Skip to content

Commit 1ada349

Browse files
committed
App Preview heartbeat prototype
1 parent 91cd896 commit 1ada349

File tree

8 files changed

+188
-2
lines changed

8 files changed

+188
-2
lines changed
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/* eslint-disable @typescript-eslint/consistent-type-definitions */
2+
import * as Types from './types.js'
3+
import {JsonMapType} from '@shopify/cli-kit/node/toml'
4+
5+
import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core'
6+
7+
export type DevSessionHeartbeatMutationVariables = Types.Exact<{
8+
appId: Types.Scalars['String']['input']
9+
buildStatus?: Types.InputMaybe<Types.Scalars['String']['input']>
10+
tunnelUrl?: Types.InputMaybe<Types.Scalars['String']['input']>
11+
}>
12+
13+
export type DevSessionHeartbeatMutation = {
14+
devSessionHeartbeat?: {
15+
userErrors: {message: string; on: JsonMapType; field?: string[] | null; category: string}[]
16+
} | null
17+
}
18+
19+
export const DevSessionHeartbeat = {
20+
kind: 'Document',
21+
definitions: [
22+
{
23+
kind: 'OperationDefinition',
24+
operation: 'mutation',
25+
name: {kind: 'Name', value: 'DevSessionHeartbeat'},
26+
variableDefinitions: [
27+
{
28+
kind: 'VariableDefinition',
29+
variable: {kind: 'Variable', name: {kind: 'Name', value: 'appId'}},
30+
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}},
31+
},
32+
{
33+
kind: 'VariableDefinition',
34+
variable: {kind: 'Variable', name: {kind: 'Name', value: 'buildStatus'}},
35+
type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}},
36+
},
37+
{
38+
kind: 'VariableDefinition',
39+
variable: {kind: 'Variable', name: {kind: 'Name', value: 'tunnelUrl'}},
40+
type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}},
41+
},
42+
],
43+
selectionSet: {
44+
kind: 'SelectionSet',
45+
selections: [
46+
{
47+
kind: 'Field',
48+
name: {kind: 'Name', value: 'devSessionHeartbeat'},
49+
arguments: [
50+
{
51+
kind: 'Argument',
52+
name: {kind: 'Name', value: 'appId'},
53+
value: {kind: 'Variable', name: {kind: 'Name', value: 'appId'}},
54+
},
55+
{
56+
kind: 'Argument',
57+
name: {kind: 'Name', value: 'buildStatus'},
58+
value: {kind: 'Variable', name: {kind: 'Name', value: 'buildStatus'}},
59+
},
60+
{
61+
kind: 'Argument',
62+
name: {kind: 'Name', value: 'tunnelUrl'},
63+
value: {kind: 'Variable', name: {kind: 'Name', value: 'tunnelUrl'}},
64+
},
65+
],
66+
selectionSet: {
67+
kind: 'SelectionSet',
68+
selections: [
69+
{
70+
kind: 'Field',
71+
name: {kind: 'Name', value: 'userErrors'},
72+
selectionSet: {
73+
kind: 'SelectionSet',
74+
selections: [
75+
{kind: 'Field', name: {kind: 'Name', value: 'message'}},
76+
{kind: 'Field', name: {kind: 'Name', value: 'on'}},
77+
{kind: 'Field', name: {kind: 'Name', value: 'field'}},
78+
{kind: 'Field', name: {kind: 'Name', value: 'category'}},
79+
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
80+
],
81+
},
82+
},
83+
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
84+
],
85+
},
86+
},
87+
],
88+
},
89+
},
90+
],
91+
} as unknown as DocumentNode<DevSessionHeartbeatMutation, DevSessionHeartbeatMutationVariables>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
mutation DevSessionHeartbeat($appId: String!, $buildStatus: String, $tunnelUrl: String) {
2+
devSessionHeartbeat(appId: $appId, buildStatus: $buildStatus, tunnelUrl: $tunnelUrl) {
3+
userErrors {
4+
message
5+
on
6+
field
7+
category
8+
}
9+
}
10+
}

packages/app/src/cli/models/app/app.test-data.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
DeveloperPlatformClient,
3939
DevSessionCreateOptions,
4040
DevSessionDeleteOptions,
41+
DevSessionHeartbeatOptions,
4142
DevSessionUpdateOptions,
4243
} from '../../utilities/developer-platform-client.js'
4344
import {AllAppExtensionRegistrationsQuerySchema} from '../../api/graphql/all_app_extension_registrations.js'
@@ -1492,6 +1493,8 @@ export function testDeveloperPlatformClient(stubs: Partial<DeveloperPlatformClie
14921493
Promise.resolve(`https://test.shopify.com/${app.organizationId}/apps/${app.id}`),
14931494
devSessionCreate: (_input: DevSessionCreateOptions) => Promise.resolve({devSessionCreate: {userErrors: []}}),
14941495
devSessionUpdate: (_input: DevSessionUpdateOptions) => Promise.resolve({devSessionUpdate: {userErrors: []}}),
1496+
devSessionHeartbeat: (_input: DevSessionHeartbeatOptions) =>
1497+
Promise.resolve({devSessionHeartbeat: {userErrors: []}}),
14951498
devSessionDelete: (_input: DevSessionDeleteOptions) => Promise.resolve({devSessionDelete: {userErrors: []}}),
14961499
getCreateDevStoreLink: (org: Organization) =>
14971500
Promise.resolve(

packages/app/src/cli/models/extensions/extension-instance.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
161161
this.idEnvironmentVariableName = `SHOPIFY_${constantize(this.localIdentifier)}_ID`
162162
this.outputPath = this.directory
163163
this.uid = this.buildUIDFromStrategy()
164-
this.devUUID = `dev-${this.uid}`
164+
this.devUUID = this.uid
165165

166166
if (this.features.includes('esbuild') || this.type === 'tax_calculation') {
167167
this.outputPath = joinPath(this.directory, 'dist', this.outputFileName)

packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import {DevSessionStatusManager} from './dev-session-status-manager.js'
33
import {DevSessionProcessOptions} from './dev-session-process.js'
44
import {AppEvent, AppEventWatcher, ExtensionEvent} from '../../app-events/app-event-watcher.js'
55
import {compressBundle, getUploadURL, uploadToGCS, writeManifestToBundle} from '../../../bundle.js'
6-
import {DevSessionCreateOptions, DevSessionUpdateOptions} from '../../../../utilities/developer-platform-client.js'
6+
import {
7+
DevSessionCreateOptions,
8+
DevSessionHeartbeatOptions,
9+
DevSessionUpdateOptions,
10+
} from '../../../../utilities/developer-platform-client.js'
711
import {AppManifest} from '../../../../models/app/app.js'
812
import {endHRTimeInMs, startHRTime} from '@shopify/cli-kit/node/hrtime'
913
import {ClientError} from 'graphql-request'
@@ -41,6 +45,7 @@ export class DevSession {
4145
private readonly bundlePath: string
4246
private readonly appEventsProcessor: SerialBatchProcessor<AppEvent>
4347
private failedEvents: AppEvent[] = []
48+
private heartbeatInterval?: NodeJS.Timeout
4449

4550
private constructor(processOptions: DevSessionProcessOptions, stdout: Writable) {
4651
this.statusManager = processOptions.devSessionStatusManager
@@ -51,6 +56,16 @@ export class DevSession {
5156
this.appEventsProcessor = new SerialBatchProcessor((events: AppEvent[]) => this.processEvents(events))
5257
}
5358

59+
/**
60+
* Stop the heartbeat process
61+
*/
62+
public stopHeartbeat() {
63+
if (this.heartbeatInterval) {
64+
clearInterval(this.heartbeatInterval)
65+
this.heartbeatInterval = undefined
66+
}
67+
}
68+
5469
private async start() {
5570
await this.logger.info(`Preparing app preview on ${this.options.storeFqdn}`)
5671
this.statusManager.setMessage('LOADING')
@@ -217,6 +232,7 @@ export class DevSession {
217232
await this.logger.success(`✅ Ready, watching for changes in your app `)
218233
await this.logger.logExtensionUpdateMessages(event)
219234
this.statusManager.setMessage('READY')
235+
this.startHeartbeat()
220236
} else if (result.status === 'aborted') {
221237
await this.logger.debug('❌ App preview update aborted (new change detected or error during update)')
222238
} else if (result.status === 'remote-error' || result.status === 'unknown-error') {
@@ -389,6 +405,7 @@ export class DevSession {
389405
* @param payload - The payload to update the dev session with
390406
*/
391407
private async devSessionUpdateWithRetry(payload: DevSessionUpdateOptions): Promise<DevSessionResult> {
408+
console.log(JSON.stringify(payload.manifest, null, 2))
392409
const result = await this.options.developerPlatformClient.devSessionUpdate(payload)
393410
const errors = result.devSessionUpdate?.userErrors ?? []
394411
if (errors.length) return {status: 'remote-error', error: errors}
@@ -407,4 +424,37 @@ export class DevSession {
407424
if (errors.length) return {status: 'remote-error', error: errors}
408425
return {status: 'created'}
409426
}
427+
428+
/**
429+
* Start the heartbeat process that sends periodic updates to the server
430+
* The heartbeat runs every 3 seconds after the dev session is created
431+
*/
432+
private startHeartbeat() {
433+
// Clear any existing interval
434+
if (this.heartbeatInterval) {
435+
clearInterval(this.heartbeatInterval)
436+
}
437+
438+
// Send heartbeat every 3 seconds
439+
this.heartbeatInterval = setInterval(() => {
440+
this.sendHeartbeat().catch(async (error) => {
441+
await this.logger.debug(`Heartbeat error: ${error.message}`)
442+
})
443+
}, 2000)
444+
}
445+
446+
/**
447+
* Send a heartbeat to the server with the current status
448+
*/
449+
private async sendHeartbeat() {
450+
const payload: DevSessionHeartbeatOptions = {
451+
shopFqdn: this.options.storeFqdn,
452+
appId: this.options.appId,
453+
tunnelUrl: this.options.appLocalProxyURL,
454+
buildStatus: this.statusManager.status.isReady ? 'ready' : 'building',
455+
}
456+
457+
// await this.logger.info(`Sending heartbeat with payload: ${JSON.stringify(payload)}`)
458+
await this.options.developerPlatformClient.devSessionHeartbeat(payload)
459+
}
410460
}

packages/app/src/cli/utilities/developer-platform-client.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
} from '../api/graphql/partners/generated/update-draft.js'
5151
import {DevSessionCreateMutation} from '../api/graphql/app-dev/generated/dev-session-create.js'
5252
import {DevSessionUpdateMutation} from '../api/graphql/app-dev/generated/dev-session-update.js'
53+
import {DevSessionHeartbeatMutation} from '../api/graphql/app-dev/generated/dev-session-heartbeat.js'
5354
import {DevSessionDeleteMutation} from '../api/graphql/app-dev/generated/dev-session-delete.js'
5455
import {AppLogsOptions} from '../services/app-logs/utils.js'
5556
import {AppLogData} from '../services/app-logs/types.js'
@@ -166,6 +167,11 @@ export interface DevSessionUpdateOptions extends DevSessionSharedOptions {
166167
inheritedModuleUids: string[]
167168
}
168169

170+
export interface DevSessionHeartbeatOptions extends DevSessionSharedOptions {
171+
buildStatus?: string
172+
tunnelUrl?: string
173+
}
174+
169175
export type DevSessionDeleteOptions = DevSessionSharedOptions
170176

171177
type WithUserErrors<T> = T & {
@@ -290,6 +296,7 @@ export interface DeveloperPlatformClient {
290296
appDeepLink: (app: MinimalAppIdentifiers) => Promise<string>
291297
devSessionCreate: (input: DevSessionCreateOptions) => Promise<DevSessionCreateMutation>
292298
devSessionUpdate: (input: DevSessionUpdateOptions) => Promise<DevSessionUpdateMutation>
299+
devSessionHeartbeat: (input: DevSessionHeartbeatOptions) => Promise<DevSessionHeartbeatMutation>
293300
devSessionDelete: (input: DevSessionSharedOptions) => Promise<DevSessionDeleteMutation>
294301
getCreateDevStoreLink: (org: Organization) => Promise<TokenItem>
295302
}

packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
createUnauthorizedHandler,
2323
DevSessionUpdateOptions,
2424
DevSessionCreateOptions,
25+
DevSessionHeartbeatOptions,
2526
DevSessionDeleteOptions,
2627
UserError,
2728
} from '../developer-platform-client.js'
@@ -86,6 +87,10 @@ import {
8687
DevSessionUpdateMutation,
8788
DevSessionUpdateMutationVariables,
8889
} from '../../api/graphql/app-dev/generated/dev-session-update.js'
90+
import {
91+
DevSessionHeartbeat,
92+
DevSessionHeartbeatMutation,
93+
} from '../../api/graphql/app-dev/generated/dev-session-heartbeat.js'
8994
import {DevSessionDelete, DevSessionDeleteMutation} from '../../api/graphql/app-dev/generated/dev-session-delete.js'
9095
import {
9196
FetchDevStoreByDomain,
@@ -1028,6 +1033,20 @@ export class AppManagementClient implements DeveloperPlatformClient {
10281033
return this.appDevRequest({query: DevSessionUpdate, shopFqdn, variables})
10291034
}
10301035

1036+
async devSessionHeartbeat({
1037+
appId,
1038+
shopFqdn,
1039+
buildStatus,
1040+
tunnelUrl,
1041+
}: DevSessionHeartbeatOptions): Promise<DevSessionHeartbeatMutation> {
1042+
const appIdNumber = String(numberFromGid(appId))
1043+
return this.appDevRequest({
1044+
query: DevSessionHeartbeat,
1045+
shopFqdn,
1046+
variables: {appId: appIdNumber, buildStatus, tunnelUrl},
1047+
})
1048+
}
1049+
10311050
async devSessionDelete({appId, shopFqdn}: DevSessionDeleteOptions): Promise<DevSessionDeleteMutation> {
10321051
const appIdNumber = String(numberFromGid(appId))
10331052
return this.appDevRequest({query: DevSessionDelete, shopFqdn, variables: {appId: appIdNumber}})

packages/app/src/cli/utilities/developer-platform-client/partners-client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,12 @@ export class PartnersClient implements DeveloperPlatformClient {
652652
throw new Error('Unsupported operation')
653653
}
654654

655+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
656+
async devSessionHeartbeat(_input: unknown): Promise<any> {
657+
// Dev Sessions are not supported in partners client.
658+
return Promise.resolve()
659+
}
660+
655661
// eslint-disable-next-line @typescript-eslint/no-explicit-any
656662
async devSessionDelete(_input: unknown): Promise<any> {
657663
// Dev Sessions are not supported in partners client.

0 commit comments

Comments
 (0)