Skip to content

Commit 86775f2

Browse files
committed
Address Function replay streaming review feedback
1 parent fe3d5e5 commit 86775f2

13 files changed

Lines changed: 311 additions & 71 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/cli': patch
3+
---
4+
5+
Wait for file watcher readiness in app dev and stop startup when cancelled.

‎docs/cli/json-output.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,30 @@ events from stderr; missing required input then produces a fatal error instead o
239239

240240
## Exempt only streaming commands
241241

242+
`shopify app function replay --json` writes `FunctionReplayRecord` records to stdout as JSONL in watch mode.
243+
Each record has `type` and a UTC whole-second `timestamp`. The three record types are:
244+
245+
- `status`: `watching` after the initial file scan completes, and `cancelled` when replay stops after cancellation.
246+
- `build`: `success` or `failed` after a rebuild. A failed build includes its failure message in `error`.
247+
- `execution`: the native Function runner `result` and the `exitCode` of that Function run. Native fields, numeric
248+
tokens, and strings are preserved. `result.success: false` reports a completed Function failure.
249+
250+
Rebuild records and executions wait for `watching`. The first execution uses the latest successful startup rebuild,
251+
or the original extension if no rebuild occurred. A failed startup rebuild waits for a successful rebuild before execution.
252+
Build failures and completed Function failures are recoverable: replay keeps watching and does not set the process
253+
exit code from the Function run. Build output and runner diagnostics use command diagnostic events on stderr.
254+
The failed build message is also available in the stdout build record; it does not create a second stderr diagnostic.
255+
256+
Fatal setup, watcher, or runner failures stop replay, write the standard `{error: {...}}` envelope to stdout, and exit
257+
nonzero. They do not emit `cancelled`. On `SIGINT`, `SIGTERM`, or `SIGQUIT`, replay emits `cancelled` and exits with
258+
code 130, 143, or 131, respectively. When stdout closes with `EPIPE`, replay cancels and stops writing records so a
259+
pipeline can finish. There is no final success result. With `--no-watch --json`, replay returns one native
260+
`FunctionRunResult` document and uses that run's exit code instead.
261+
262+
Setup prompts use stderr. Use `--json --no-input` for unattended replay with JSON diagnostics on stderr.
263+
`app function replay --json-schema` describes finite results, stdout records, fatal errors, and stderr side events.
264+
The stdout record definition is `FunctionReplayRecord`; `Event` describes stderr side events.
265+
242266
`shopify app dev --json` writes dev stream records as identical JSONL bytes to stdout and the session log. Each record
243267
has `timestamp`, `source`, and `message`, with `level` when available. The log contains process output, preview URLs,
244268
and session status messages. There is no final success result. Preview errors remain log records so that the

‎packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,25 @@ describe('app-event-watcher', () => {
689689
})
690690
})
691691
})
692+
test('does not notify startup listeners when cancelled during the initial file scan', async () => {
693+
await inTemporaryDirectory(async (directory) => {
694+
const controller = new AbortController()
695+
const app = testAppLinked({directory})
696+
const fileWatcher = new FileWatcher(app, {...outputOptions, signal: controller.signal})
697+
vi.spyOn(fileWatcher, 'start').mockImplementation(async () => {
698+
controller.abort()
699+
})
700+
const watcher = new AppEventWatcher(app, undefined, undefined, fileWatcher)
701+
const onStart = vi.fn()
702+
const onLateStart = vi.fn()
703+
watcher.onStart(onStart)
704+
await watcher.start({...outputOptions, signal: controller.signal}, false)
705+
watcher.onStart(onLateStart)
706+
expect(onStart).not.toHaveBeenCalled()
707+
expect(onLateStart).not.toHaveBeenCalled()
708+
})
709+
})
710+
692711
test('keeps command output context when processing native file events', async () => {
693712
await inTemporaryDirectory(async (directory) => {
694713
const nativeCallback = new AsyncResource('app-file-watcher')

‎packages/app/src/cli/services/dev/app-events/app-event-watcher.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export class AppEventWatcher extends EventEmitter {
156156
})
157157
})
158158
await this.fileWatcher.start()
159+
if (this.options.signal.aborted) return
159160

160161
this.ready = true
161162
this.emit('ready', {app: this.app, extensionEvents: this.initialEvents})
Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
11
import {watchReplay, type WatchReplayOptions, type ReplayWatchEvent} from './watch.js'
2-
import {functionReplayEventSchema} from './types.js'
3-
import {renderCommandEventAsJson} from '@shopify/cli-kit/node/command-events'
2+
import {functionReplayRecordSchema} from './types.js'
3+
import {emitCommandEvent} from '@shopify/cli-kit/node/command-events'
44
import {formatJsonOutputTimestamp} from '@shopify/cli-kit/node/json-output-schema'
55
import {outputResult, unstyled} from '@shopify/cli-kit/node/output'
6+
import {AsyncResource} from 'node:async_hooks'
67

78
export async function watchReplayJson(options: Omit<WatchReplayOptions, 'onEvent'>): Promise<void> {
9+
let stdoutClosed = false
10+
const onStdoutError = (error: NodeJS.ErrnoException) => {
11+
if (error.code !== 'EPIPE') return
12+
stdoutClosed = true
13+
options.abortController.abort()
14+
}
815
const onSignal = (receivedSignal: 'SIGINT' | 'SIGTERM' | 'SIGQUIT') => {
916
process.exitCode = {SIGINT: 130, SIGTERM: 143, SIGQUIT: 131}[receivedSignal]
1017
options.abortController.abort()
1118
}
1219
process.on('shopify:signal', onSignal)
20+
process.stdout.on('error', onStdoutError)
21+
// Native watcher callbacks can run outside the command event context.
22+
const onEvent = AsyncResource.bind((event: ReplayWatchEvent) => {
23+
if (!stdoutClosed) writeEvent(event)
24+
})
1325
try {
14-
await watchReplay({...options, onEvent: writeEvent})
26+
await watchReplay({...options, onEvent})
1527
} finally {
1628
process.removeListener('shopify:signal', onSignal)
29+
process.stdout.removeListener('error', onStdoutError)
1730
}
1831
}
1932

@@ -23,12 +36,12 @@ function writeEvent(event: ReplayWatchEvent): void {
2336
if (event.type === 'diagnostic') {
2437
const message = unstyled(event.message)
2538
if (message.trim().length > 0) {
26-
renderCommandEventAsJson({type: 'diagnostic', timestamp, level: 'warning', message})
39+
emitCommandEvent({type: 'diagnostic', level: 'warning', message})
2740
}
2841
return
2942
}
3043
if (event.type === 'execution') {
31-
functionReplayEventSchema.validate({
44+
functionReplayRecordSchema.validate({
3245
type: 'execution',
3346
timestamp,
3447
result: JSON.parse(event.resultJson),
@@ -43,10 +56,14 @@ function writeEvent(event: ReplayWatchEvent): void {
4356
)
4457
} else {
4558
outputResult(
46-
JSON.stringify(functionReplayEventSchema.validate({type: event.type, timestamp, status: event.status})),
59+
JSON.stringify(
60+
functionReplayRecordSchema.validate({
61+
type: event.type,
62+
timestamp,
63+
status: event.status,
64+
...(event.type === 'build' && event.status === 'failed' ? {error: unstyled(event.error)} : {}),
65+
}),
66+
),
4767
)
48-
if (event.type === 'build' && event.status === 'failed') {
49-
renderCommandEventAsJson({type: 'diagnostic', timestamp, level: 'error', message: event.error})
50-
}
5168
}
5269
}
Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
1-
import {functionReplayEventSchema} from './types.js'
1+
import {functionReplayRecordSchema, functionReplayJsonOutputSchema} from './types.js'
22
import {expect, test} from 'vitest'
33

44
test.each([
55
{type: 'status', timestamp: '2026-10-26T09:00:00.123Z', status: 'watching'},
66
{type: 'status', timestamp: '2026-10-26T09:00:00Z', status: 'finished'},
77
{type: 'build', timestamp: '2026-10-26T09:00:00Z', status: 'success', extra: true},
8+
{type: 'build', timestamp: '2026-10-26T09:00:00Z', status: 'failed'},
9+
{type: 'build', timestamp: '2026-10-26T09:00:00Z', status: 'success', error: 'Unexpected error'},
810
])('rejects invalid CLI-owned record fields: %j', (record) => {
9-
expect(() => functionReplayEventSchema.encode(record as never)).toThrow()
11+
expect(() => functionReplayRecordSchema.encode(record as never)).toThrow()
12+
})
13+
14+
test('describes stdout records and Function execution outcomes', () => {
15+
const schema = functionReplayJsonOutputSchema.jsonSchema
16+
expect(schema.definitions).toHaveProperty('FunctionReplayRecord')
17+
expect(schema.definitions).not.toHaveProperty('FunctionReplayEvent')
18+
const serialized = JSON.stringify(schema)
19+
expect(serialized).toContain('The exit code of this Function run, not the replay process.')
20+
expect(serialized).toContain('False for a completed Function execution that failed.')
21+
expect(serialized).not.toContain('the command exits nonzero')
1022
})

‎packages/app/src/cli/services/function/replay/types.ts‎

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,33 +10,43 @@ const lifecycleSchema = zod
1010
})
1111
.strict()
1212

13-
const buildSchema = zod
14-
.object({
15-
type: zod.literal('build'),
16-
timestamp: jsonOutputTimestampSchema,
17-
status: zod.enum(['success', 'failed']),
18-
})
19-
.strict()
13+
const buildSchema = zod.discriminatedUnion('status', [
14+
zod
15+
.object({
16+
type: zod.literal('build'),
17+
timestamp: jsonOutputTimestampSchema,
18+
status: zod.literal('success'),
19+
})
20+
.strict(),
21+
zod
22+
.object({
23+
type: zod.literal('build'),
24+
timestamp: jsonOutputTimestampSchema,
25+
status: zod.literal('failed'),
26+
error: zod.string().describe('The rebuild failure message.'),
27+
})
28+
.strict(),
29+
])
2030

2131
const executionSchema = zod
2232
.object({
2333
type: zod.literal('execution'),
2434
timestamp: jsonOutputTimestampSchema,
2535
result: functionRunJsonOutputSchema.schema,
26-
exitCode: zod.number().int().nonnegative(),
36+
exitCode: zod.number().int().nonnegative().describe('The exit code of this Function run, not the replay process.'),
2737
})
2838
.strict()
2939

30-
export const functionReplayEventSchema = defineJsonOutputSchema({
31-
name: 'FunctionReplayEvent',
32-
schema: zod.discriminatedUnion('type', [lifecycleSchema, buildSchema, executionSchema]),
40+
export const functionReplayRecordSchema = defineJsonOutputSchema({
41+
name: 'FunctionReplayRecord',
42+
schema: zod.union([lifecycleSchema, buildSchema, executionSchema]),
3343
})
3444

3545
export const functionReplayJsonOutputSchema = defineJsonOutputSchema({
3646
name: 'FunctionReplayOutput',
37-
schema: zod.union([functionRunJsonOutputSchema.schema, functionReplayEventSchema.schema]),
47+
schema: zod.union([functionRunJsonOutputSchema.schema, functionReplayRecordSchema.schema]),
3848
definitions: {
3949
FunctionRunResult: functionRunJsonOutputSchema.schema,
40-
FunctionReplayEvent: functionReplayEventSchema.schema,
50+
FunctionReplayRecord: functionReplayRecordSchema.schema,
4151
},
4252
})

‎packages/app/src/cli/services/function/replay/watch-events.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,55 @@ test.each(['watching', 'running', 'diagnostic'])(
276276
},
277277
)
278278

279+
test.each([false, true])('defers startup rebuilds and uses the latest extension (build failed: %s)', async (failed) => {
280+
const {options, events, emit, startWatcher, start} = await fixture()
281+
const updatedExtension = await testFunctionExtension({
282+
config: {
283+
...options.extension.configuration,
284+
build: {...options.extension.configuration.build, path: 'dist/updated.wasm', wasm_opt: true},
285+
},
286+
})
287+
let finishStartup!: () => void
288+
startWatcher.mockReturnValue(
289+
new Promise((resolve) => {
290+
finishStartup = resolve
291+
}),
292+
)
293+
const running = start()
294+
try {
295+
await flushPromises()
296+
emit([
297+
{
298+
type: EventType.Updated,
299+
extension: updatedExtension,
300+
buildResult: failed
301+
? {status: 'error', error: 'Compilation failed', uid: updatedExtension.uid}
302+
: {status: 'ok', uid: updatedExtension.uid},
303+
},
304+
])
305+
await flushPromises()
306+
expect(events).toEqual([])
307+
expect(captureFunctionRun).not.toHaveBeenCalled()
308+
finishStartup()
309+
await flushPromises()
310+
expect(events.slice(0, 2)).toEqual([
311+
{type: 'status', status: 'watching'},
312+
failed ? {type: 'build', status: 'failed', error: 'Compilation failed'} : {type: 'build', status: 'success'},
313+
])
314+
if (failed) {
315+
expect(captureFunctionRun).not.toHaveBeenCalled()
316+
emit([{type: EventType.Updated, extension: updatedExtension}])
317+
await flushPromises()
318+
}
319+
expect(captureFunctionRun).toHaveBeenCalledOnce()
320+
expect(vi.mocked(captureFunctionRun).mock.calls[0]![0].functionExtension).toBe(updatedExtension)
321+
} finally {
322+
finishStartup()
323+
options.abortController.abort()
324+
await running
325+
}
326+
})
327+
279328
test.each(['startup', 'watcher', 'runner', 'invalid-result'])('propagates a fatal %s failure', async (source) => {
280329
const {options, events, startWatcher} = await fixture()
281330
const error = new Error('Watch failed')

0 commit comments

Comments
 (0)