Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/steady-build-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli-hydrogen': minor
---

Add typed JSONL output for hydrogen build --watch.
5 changes: 2 additions & 3 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,9 @@ CLI-owned fields. Storefront and environment lists contain all fetched resources
{"storeDomain": "example.myshopify.com", "storefronts": []}
```

`dev`, `preview`, and `debug cpu` are streaming commands. The finite JSON result
format also excludes `build --watch`.
`dev`, `preview`, and `debug cpu` are streaming commands.

`codegen --json --watch` emits typed JSONL records for each generation cycle.
`codegen --json --watch` and `build --json --watch` emit typed JSONL records for each cycle.

## Contributing

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"hydrogen:build": {
"aliases": [],
"args": {},
"description": "Builds a Hydrogen storefront for production. The client and app worker files are compiled to a `/dist` folder in your Hydrogen project directory.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenBuildResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"directory\": {\n \"$ref\": \"#/definitions/AbsolutePath\"\n },\n \"clientDirectory\": {\n \"$ref\": \"#/definitions/AbsolutePath\"\n },\n \"serverDirectory\": {\n \"$ref\": \"#/definitions/AbsolutePath\"\n },\n \"serverPath\": {\n \"$ref\": \"#/definitions/AbsolutePath\"\n }\n },\n \"required\": [\n \"directory\",\n \"clientDirectory\",\n \"serverDirectory\",\n \"serverPath\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenBuildResult\",\n \"definitions\": {\n \"AbsolutePath\": {\n \"type\": \"string\"\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```",
"description": "Builds a Hydrogen storefront for production. The client and app worker files are compiled to a `/dist` folder in your Hydrogen project directory. With `--json --watch`, emits JSONL lifecycle and build records to stdout and diagnostics to stderr until stopped.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenBuildOutput` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/HydrogenBuildResult\"\n },\n {\n \"$ref\": \"#/definitions/HydrogenBuildWatchRecord\"\n }\n ],\n \"title\": \"HydrogenBuildOutput\",\n \"definitions\": {\n \"HydrogenBuildResult\": {\n \"type\": \"object\",\n \"properties\": {\n \"directory\": {\n \"type\": \"string\"\n },\n \"clientDirectory\": {\n \"$ref\": \"#/definitions/HydrogenBuildResult/properties/directory\"\n },\n \"serverDirectory\": {\n \"$ref\": \"#/definitions/HydrogenBuildResult/properties/directory\"\n },\n \"serverPath\": {\n \"$ref\": \"#/definitions/HydrogenBuildResult/properties/directory\"\n }\n },\n \"required\": [\n \"directory\",\n \"clientDirectory\",\n \"serverDirectory\",\n \"serverPath\"\n ],\n \"additionalProperties\": false\n },\n \"HydrogenBuildWatchRecord\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"watch-started\"\n },\n \"directory\": {\n \"$ref\": \"#/definitions/HydrogenBuildResult/properties/directory\"\n }\n },\n \"required\": [\n \"type\",\n \"directory\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"build-started\"\n },\n \"cycle\": {\n \"type\": \"integer\",\n \"exclusiveMinimum\": 0\n }\n },\n \"required\": [\n \"type\",\n \"cycle\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"build-completed\"\n },\n \"cycle\": {\n \"$ref\": \"#/definitions/HydrogenBuildWatchRecord/anyOf/1/properties/cycle\"\n },\n \"directory\": {\n \"$ref\": \"#/definitions/HydrogenBuildResult/properties/directory\"\n },\n \"clientDirectory\": {\n \"$ref\": \"#/definitions/HydrogenBuildResult/properties/directory\"\n },\n \"serverDirectory\": {\n \"$ref\": \"#/definitions/HydrogenBuildResult/properties/directory\"\n },\n \"serverPath\": {\n \"$ref\": \"#/definitions/HydrogenBuildResult/properties/directory\"\n }\n },\n \"required\": [\n \"type\",\n \"cycle\",\n \"directory\",\n \"clientDirectory\",\n \"serverDirectory\",\n \"serverPath\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"build-failed\"\n },\n \"cycle\": {\n \"$ref\": \"#/definitions/HydrogenBuildWatchRecord/anyOf/1/properties/cycle\"\n },\n \"error\": {\n \"type\": \"object\",\n \"properties\": {\n \"message\": {\n \"type\": \"string\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"message\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"type\",\n \"cycle\",\n \"error\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"watch-stopped\"\n }\n },\n \"required\": [\n \"type\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```",
"flags": {
"json-schema": {
"description": "Print the command's JSON schemas.",
Expand Down Expand Up @@ -128,7 +128,7 @@
"pluginType": "core",
"strict": true,
"enableJsonFlag": false,
"descriptionWithMarkdown": "Builds a Hydrogen storefront for production. The client and app worker files are compiled to a `/dist` folder in your Hydrogen project directory.",
"descriptionWithMarkdown": "Builds a Hydrogen storefront for production. The client and app worker files are compiled to a `/dist` folder in your Hydrogen project directory. With `--json --watch`, emits JSONL lifecycle and build records to stdout and diagnostics to stderr until stopped.",
"isESM": true,
"relativePath": [
"dist",
Expand Down
13 changes: 0 additions & 13 deletions packages/cli/src/commands/hydrogen/build-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,19 +134,6 @@ it.each([Build, Codegen, Check])(
},
);

it.each([Build])(
'rejects JSON watch mode before execution: %s',
async (Command) => {
const command = new Command([], {} as any);
vi.spyOn(command as any, 'parse').mockResolvedValue({
flags: {json: true, watch: true},
});
await expect(command.run()).rejects.toThrow(
'--json cannot be combined with --watch',
);
},
);

it('streams Codegen watch lifecycle and repeated results without a finite result', async () => {
vi.mocked(getRemixConfig).mockResolvedValue({
rootDirectory: '/project',
Expand Down
332 changes: 332 additions & 0 deletions packages/cli/src/commands/hydrogen/build-watch.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
import {expect, it, vi} from 'vitest';
import {
inTemporaryDirectory,
readFile,
writeFile,
writeFileSync,
} from '@shopify/cli-kit/node/fs';
import {joinPath} from '@shopify/cli-kit/node/path';
import {captureJsonOutput} from '../../../tests/output.js';
import {runBuildWatch} from './build.js';
import {
buildCommandOutputSchema,
type BuildWatchRecord,
} from '../../lib/build-tooling/types.js';
import {writeWatchRecord} from '../../lib/watch.js';

async function writeBuildFixture(
directory: string,
clientSource = 'console.log("client");',
clientPlugin = '',
serverPlugin = '',
) {
await writeFile(
joinPath(directory, 'vite.config.mjs'),
`import {readFileSync, writeFileSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
export default ({isSsrBuild}) => ({
plugins: isSsrBuild ? [${serverPlugin}] : [${clientPlugin}],
build: {
outDir: isSsrBuild ? 'dist/server' : 'dist/client',
ssr: isSsrBuild ? 'server.js' : false,
rollupOptions: {input: isSsrBuild ? 'server.js' : 'index.html', output: {entryFileNames: 'index.js'}},
},
});`,
);
await writeFile(
joinPath(directory, 'index.html'),
'<script type="module" src="/client.js"></script>',
);
const clientPath = joinPath(directory, 'client.js');
await writeFile(clientPath, clientSource);
const serverPath = joinPath(directory, 'server.js');
await writeFile(
serverPath,
'export default {fetch() {return new Response("first");}};',
);
return {clientPath, serverPath};
}

async function editUntilBuildStarts(
records: BuildWatchRecord[],
path: string,
source: string,
) {
const previousCycle = records.reduce(
(cycle, record) => (record.type === 'build-started' ? record.cycle : cycle),
0,
);
const started = () =>
records.some(
(record) =>
record.type === 'build-started' && record.cycle > previousCycle,
);
await vi.waitFor(
() => {
// Keep truncation and writing in one turn so Vite cannot read a partial edit.
if (!started()) writeFileSync(path, source);
expect(started()).toBe(true);
},
{timeout: 10000, interval: 250},
);
return previousCycle;
}

it('completes client-only rebuilds and recovers after a server source error', async () => {
await inTemporaryDirectory(async (directory) => {
const {clientPath, serverPath} = await writeBuildFixture(
directory,
undefined,
'',
`{
name: 'capture-client-trigger',
watchChange(id) {
if (id.includes('hydrogen-client-build-')) {
writeFileSync(new URL('./trigger-path.txt', import.meta.url), id);
}
},
}`,
);
const controller = new AbortController();
const records: BuildWatchRecord[] = [];
const {stdout} = await captureJsonOutput(async () => {
const running = runBuildWatch(
{directory, lockfileCheck: false, bundleStats: false},
controller.signal,
(record) => {
records.push(record);
writeWatchRecord(buildCommandOutputSchema, record);
},
);
try {
await vi.waitFor(
() => expect(records.at(-1)?.type).toBe('build-completed'),
{timeout: 10000},
);
for (const cycle of [2, 3]) {
await editUntilBuildStarts(
records,
clientPath,
`console.log("client ${cycle}");`,
);
await vi.waitFor(
() =>
expect(records.at(-1)).toMatchObject({
type: 'build-completed',
cycle,
}),
{timeout: 10000},
);
expect(
await readFile(joinPath(directory, 'dist/client/index.js')),
).toContain(`client ${cycle}`);
}
const triggerPath = await readFile(
joinPath(directory, 'trigger-path.txt'),
);
const completedRecords = records.length;
for (const value of ['duplicate event', 'another duplicate event']) {
await writeFile(triggerPath, value);
await new Promise((resolve) => setTimeout(resolve, 400));
}
expect(records).toHaveLength(completedRecords);
await editUntilBuildStarts(
records,
serverPath,
'export default { broken syntax',
);
await vi.waitFor(
() => expect(records.at(-1)?.type).toBe('build-failed'),
{timeout: 10000},
);
await editUntilBuildStarts(
records,
serverPath,
'export default {fetch() {return new Response("second");}};',
);
await vi.waitFor(
() => expect(records.at(-1)?.type).toBe('build-completed'),
{timeout: 10000},
);
expect(
await readFile(joinPath(directory, 'dist/server/index.js')),
).toContain('second');
} finally {
controller.abort();
await running;
}
});
expect(
stdout
.trim()
.split('\n')
.map((line) => JSON.parse(line).type),
).toEqual([
'watch-started',
'build-started',
'build-completed',
'build-started',
'build-completed',
'build-started',
'build-completed',
'build-started',
'build-failed',
'build-started',
'build-completed',
'watch-stopped',
]);
expect(JSON.parse(stdout.trim().split('\n')[2]!)).toMatchObject({
serverPath: joinPath(directory, 'dist/server/index.js'),
clientDirectory: joinPath(directory, 'dist/client'),
});
});
}, 30000);

it.each(['console.log("first");', 'broken syntax'])(
Comment thread
fredericoo marked this conversation as resolved.
'watches plugin-only client inputs and recovers from errors with initial source %s',
async (initialSource) => {
await inTemporaryDirectory(async (directory) => {
await writeBuildFixture(
directory,
'import "virtual:client-content";',
`{
name: 'client-content',
resolveId(id) {
if (id === 'virtual:client-content') return '\\0client-content';
},
load(id) {
if (id !== '\\0client-content') return;
const path = fileURLToPath(new URL('./client-content.txt', import.meta.url));
this.addWatchFile(path);
return readFileSync(path, 'utf8');
},
}`,
);
const contentPath = joinPath(directory, 'client-content.txt');
await writeFile(contentPath, initialSource);
const controller = new AbortController();
const records: BuildWatchRecord[] = [];
await captureJsonOutput(async () => {
const running = runBuildWatch(
{directory, lockfileCheck: false, bundleStats: false},
controller.signal,
(record) => records.push(record),
);
try {
await vi.waitFor(
() =>
expect(records.at(-1)?.type).toBe(
initialSource === 'broken syntax'
? 'build-failed'
: 'build-completed',
),
{timeout: 10000},
);
for (const [source, expectedOutput] of [
['console.log("second");', 'second'],
['broken syntax', undefined],
['console.log("recovered");', 'recovered'],
] as const) {
const previousCycle = await editUntilBuildStarts(
records,
contentPath,
source,
);
await vi.waitFor(
async () => {
const record = records.at(-1);
expect(record).toMatchObject({
type:
source === 'broken syntax'
? 'build-failed'
: 'build-completed',
});
expect(
record && 'cycle' in record ? record.cycle : 0,
).toBeGreaterThan(previousCycle);
if (expectedOutput) {
expect(
await readFile(joinPath(directory, 'dist/client/index.js')),
).toContain(expectedOutput);
}
},
{timeout: 10000},
);
}
expect(
await readFile(joinPath(directory, 'dist/client/index.js')),
).toContain('recovered');
} finally {
controller.abort();
await running;
}
});
expect(records[0]).toEqual({type: 'watch-started', directory});
expect(records.at(-1)).toEqual({type: 'watch-stopped'});
const cycles = records.slice(1, -1);
expect(cycles.length).toBeGreaterThanOrEqual(8);
expect(cycles.length % 2).toBe(0);
// Filesystem backends can report the same edit more than once.
for (let index = 0; index < cycles.length; index += 2) {
expect(cycles[index]).toEqual({
type: 'build-started',
cycle: index / 2 + 1,
});
expect(cycles[index + 1]).toMatchObject({
type: expect.stringMatching(/^build-(completed|failed)$/),
cycle: index / 2 + 1,
});
}
});
},
30000,
);

it('recovers from an initial client error and watches client-only changes', async () => {
await inTemporaryDirectory(async (directory) => {
const {clientPath} = await writeBuildFixture(directory, 'broken syntax');
const controller = new AbortController();
const records: BuildWatchRecord[] = [];
await captureJsonOutput(async () => {
const running = runBuildWatch(
{directory, lockfileCheck: false, bundleStats: false},
controller.signal,
(record) => records.push(record),
);
try {
await vi.waitFor(
() => expect(records.at(-1)?.type).toBe('build-failed'),
{timeout: 10000},
);
for (const cycle of [2, 3]) {
await editUntilBuildStarts(
records,
clientPath,
`console.log("recovered ${cycle}");`,
);
await vi.waitFor(
() =>
expect(records.at(-1)).toMatchObject({
type: 'build-completed',
cycle,
}),
{timeout: 10000},
);
}
} finally {
controller.abort();
await running;
}
});
expect(records.map(({type}) => type)).toEqual([
'watch-started',
'build-started',
'build-failed',
'build-started',
'build-completed',
'build-started',
'build-completed',
'watch-stopped',
]);
});
}, 30000);
Loading
Loading