diff --git a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-heartbeat.ts b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-heartbeat.ts new file mode 100644 index 00000000000..4cdf03cd436 --- /dev/null +++ b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-heartbeat.ts @@ -0,0 +1,91 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' +import {JsonMapType} from '@shopify/cli-kit/node/toml' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type DevSessionHeartbeatMutationVariables = Types.Exact<{ + appId: Types.Scalars['String']['input'] + buildStatus?: Types.InputMaybe + tunnelUrl?: Types.InputMaybe +}> + +export type DevSessionHeartbeatMutation = { + devSessionHeartbeat?: { + userErrors: {message: string; on: JsonMapType; field?: string[] | null; category: string}[] + } | null +} + +export const DevSessionHeartbeat = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: {kind: 'Name', value: 'DevSessionHeartbeat'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'appId'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'buildStatus'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'tunnelUrl'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'devSessionHeartbeat'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'appId'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'appId'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'buildStatus'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'buildStatus'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'tunnelUrl'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'tunnelUrl'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'userErrors'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'message'}}, + {kind: 'Field', name: {kind: 'Name', value: 'on'}}, + {kind: 'Field', name: {kind: 'Name', value: 'field'}}, + {kind: 'Field', name: {kind: 'Name', value: 'category'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-heartbeat.graphql b/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-heartbeat.graphql new file mode 100644 index 00000000000..2b4d6d91ee8 --- /dev/null +++ b/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-heartbeat.graphql @@ -0,0 +1,10 @@ +mutation DevSessionHeartbeat($appId: String!, $buildStatus: String, $tunnelUrl: String) { + devSessionHeartbeat(appId: $appId, buildStatus: $buildStatus, tunnelUrl: $tunnelUrl) { + userErrors { + message + on + field + category + } + } +} diff --git a/packages/app/src/cli/models/app/app.test-data.ts b/packages/app/src/cli/models/app/app.test-data.ts index b2342bf36de..69af2c7f757 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -38,6 +38,7 @@ import { DeveloperPlatformClient, DevSessionCreateOptions, DevSessionDeleteOptions, + DevSessionHeartbeatOptions, DevSessionUpdateOptions, } from '../../utilities/developer-platform-client.js' import {AllAppExtensionRegistrationsQuerySchema} from '../../api/graphql/all_app_extension_registrations.js' @@ -1492,6 +1493,8 @@ export function testDeveloperPlatformClient(stubs: Partial Promise.resolve({devSessionCreate: {userErrors: []}}), devSessionUpdate: (_input: DevSessionUpdateOptions) => Promise.resolve({devSessionUpdate: {userErrors: []}}), + devSessionHeartbeat: (_input: DevSessionHeartbeatOptions) => + Promise.resolve({devSessionHeartbeat: {userErrors: []}}), devSessionDelete: (_input: DevSessionDeleteOptions) => Promise.resolve({devSessionDelete: {userErrors: []}}), getCreateDevStoreLink: (org: Organization) => Promise.resolve( diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 5b1342c8097..08a3c7f1369 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -161,7 +161,7 @@ export class ExtensionInstance private failedEvents: AppEvent[] = [] + private heartbeatInterval?: NodeJS.Timeout private constructor(processOptions: DevSessionProcessOptions, stdout: Writable) { this.statusManager = processOptions.devSessionStatusManager @@ -51,6 +56,16 @@ export class DevSession { this.appEventsProcessor = new SerialBatchProcessor((events: AppEvent[]) => this.processEvents(events)) } + /** + * Stop the heartbeat process + */ + public stopHeartbeat() { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval) + this.heartbeatInterval = undefined + } + } + private async start() { await this.logger.info(`Preparing app preview on ${this.options.storeFqdn}`) this.statusManager.setMessage('LOADING') @@ -217,6 +232,7 @@ export class DevSession { await this.logger.success(`✅ Ready, watching for changes in your app `) await this.logger.logExtensionUpdateMessages(event) this.statusManager.setMessage('READY') + this.startHeartbeat() } else if (result.status === 'aborted') { await this.logger.debug('❌ App preview update aborted (new change detected or error during update)') } else if (result.status === 'remote-error' || result.status === 'unknown-error') { @@ -389,6 +405,7 @@ export class DevSession { * @param payload - The payload to update the dev session with */ private async devSessionUpdateWithRetry(payload: DevSessionUpdateOptions): Promise { + console.log(JSON.stringify(payload.manifest, null, 2)) const result = await this.options.developerPlatformClient.devSessionUpdate(payload) const errors = result.devSessionUpdate?.userErrors ?? [] if (errors.length) return {status: 'remote-error', error: errors} @@ -407,4 +424,37 @@ export class DevSession { if (errors.length) return {status: 'remote-error', error: errors} return {status: 'created'} } + + /** + * Start the heartbeat process that sends periodic updates to the server + * The heartbeat runs every 3 seconds after the dev session is created + */ + private startHeartbeat() { + // Clear any existing interval + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval) + } + + // Send heartbeat every 3 seconds + this.heartbeatInterval = setInterval(() => { + this.sendHeartbeat().catch(async (error) => { + await this.logger.debug(`Heartbeat error: ${error.message}`) + }) + }, 2000) + } + + /** + * Send a heartbeat to the server with the current status + */ + private async sendHeartbeat() { + const payload: DevSessionHeartbeatOptions = { + shopFqdn: this.options.storeFqdn, + appId: this.options.appId, + tunnelUrl: this.options.appLocalProxyURL, + buildStatus: this.statusManager.status.isReady ? 'ready' : 'building', + } + + // await this.logger.info(`Sending heartbeat with payload: ${JSON.stringify(payload)}`) + await this.options.developerPlatformClient.devSessionHeartbeat(payload) + } } diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index 42dfe1394ac..c7338a6e0e2 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -50,6 +50,7 @@ import { } from '../api/graphql/partners/generated/update-draft.js' import {DevSessionCreateMutation} from '../api/graphql/app-dev/generated/dev-session-create.js' import {DevSessionUpdateMutation} from '../api/graphql/app-dev/generated/dev-session-update.js' +import {DevSessionHeartbeatMutation} from '../api/graphql/app-dev/generated/dev-session-heartbeat.js' import {DevSessionDeleteMutation} from '../api/graphql/app-dev/generated/dev-session-delete.js' import {AppLogsOptions} from '../services/app-logs/utils.js' import {AppLogData} from '../services/app-logs/types.js' @@ -166,6 +167,11 @@ export interface DevSessionUpdateOptions extends DevSessionSharedOptions { inheritedModuleUids: string[] } +export interface DevSessionHeartbeatOptions extends DevSessionSharedOptions { + buildStatus?: string + tunnelUrl?: string +} + export type DevSessionDeleteOptions = DevSessionSharedOptions type WithUserErrors = T & { @@ -290,6 +296,7 @@ export interface DeveloperPlatformClient { appDeepLink: (app: MinimalAppIdentifiers) => Promise devSessionCreate: (input: DevSessionCreateOptions) => Promise devSessionUpdate: (input: DevSessionUpdateOptions) => Promise + devSessionHeartbeat: (input: DevSessionHeartbeatOptions) => Promise devSessionDelete: (input: DevSessionSharedOptions) => Promise getCreateDevStoreLink: (org: Organization) => Promise } diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index b15a861fe68..31774327a76 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -22,6 +22,7 @@ import { createUnauthorizedHandler, DevSessionUpdateOptions, DevSessionCreateOptions, + DevSessionHeartbeatOptions, DevSessionDeleteOptions, UserError, } from '../developer-platform-client.js' @@ -86,6 +87,10 @@ import { DevSessionUpdateMutation, DevSessionUpdateMutationVariables, } from '../../api/graphql/app-dev/generated/dev-session-update.js' +import { + DevSessionHeartbeat, + DevSessionHeartbeatMutation, +} from '../../api/graphql/app-dev/generated/dev-session-heartbeat.js' import {DevSessionDelete, DevSessionDeleteMutation} from '../../api/graphql/app-dev/generated/dev-session-delete.js' import { FetchDevStoreByDomain, @@ -1028,6 +1033,20 @@ export class AppManagementClient implements DeveloperPlatformClient { return this.appDevRequest({query: DevSessionUpdate, shopFqdn, variables}) } + async devSessionHeartbeat({ + appId, + shopFqdn, + buildStatus, + tunnelUrl, + }: DevSessionHeartbeatOptions): Promise { + const appIdNumber = String(numberFromGid(appId)) + return this.appDevRequest({ + query: DevSessionHeartbeat, + shopFqdn, + variables: {appId: appIdNumber, buildStatus, tunnelUrl}, + }) + } + async devSessionDelete({appId, shopFqdn}: DevSessionDeleteOptions): Promise { const appIdNumber = String(numberFromGid(appId)) return this.appDevRequest({query: DevSessionDelete, shopFqdn, variables: {appId: appIdNumber}}) diff --git a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts index 2b775384c76..c5a60ee5762 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts @@ -652,6 +652,12 @@ export class PartnersClient implements DeveloperPlatformClient { throw new Error('Unsupported operation') } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async devSessionHeartbeat(_input: unknown): Promise { + // Dev Sessions are not supported in partners client. + return Promise.resolve() + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any async devSessionDelete(_input: unknown): Promise { // Dev Sessions are not supported in partners client.