Skip to content
Draft
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
32 changes: 31 additions & 1 deletion packages/cli/src/commands/hydrogen/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,42 @@ import {
import {describe, it, expect, vi, beforeAll, afterAll} from 'vitest';
import {joinPath} from '@shopify/cli-kit/node/path';
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output';
import {runBuild} from './build.js';
import {runBuild, rethrowAsUserError} from './build.js';
import {AbortError} from '@shopify/cli-kit/node/error';
import {setupTemplate} from '../../lib/onboarding/index.js';
import {BUNDLE_ANALYZER_HTML_FILE} from '../../lib/bundle/analyzer.js';
import path from 'node:path';
import {mkdirSync} from 'node:fs';

describe('rethrowAsUserError', () => {
it('converts known user-code Vite errors to AbortError', () => {
const error = new Error(
'Failed to parse source for import analysis because the content contains invalid JS syntax. If you are using JSX, make sure to name the file with the .jsx or .tsx extension.',
);
error.stack = 'Error: ...\n at something.js:1:1';

expect(() => rethrowAsUserError(error)).toThrow(AbortError);
try {
rethrowAsUserError(error);
} catch (e) {
expect(e).toBeInstanceOf(AbortError);
expect((e as AbortError).message).toBe(error.message);
expect((e as AbortError).stack).toBe(error.stack);
}
});

it('re-throws unknown errors unchanged', () => {
const error = new Error('some unexpected vite internal error');

try {
rethrowAsUserError(error);
} catch (e) {
expect(e).toBe(error);
expect(e).not.toBeInstanceOf(AbortError);
}
});
});

describe('build', () => {
const outputMock = mockAndCaptureOutput();

Expand Down
216 changes: 119 additions & 97 deletions packages/cli/src/commands/hydrogen/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ import {deferPromise, type DeferredPromise} from '../../lib/defer.js';
import {setupResourceCleanup} from '../../lib/resource-cleanup.js';
import {AbortError} from '@shopify/cli-kit/node/error';

const USER_CODE_ERROR_PATTERNS = ['Failed to parse source for import analysis'];

export function rethrowAsUserError(error: unknown): never {
if (
error instanceof Error &&
USER_CODE_ERROR_PATTERNS.some((pattern) => error.message.includes(pattern))
) {
const abortError = new AbortError(error.message);
abortError.stack = error.stack;
throw abortError;
}
throw error;
}

export default class Build extends Command {
static descriptionWithMarkdown = `Builds a Hydrogen storefront for production. The client and app worker files are compiled to a \`/dist\` folder in your Hydrogen project directory.`;

Expand Down Expand Up @@ -168,111 +182,119 @@ export async function runBuild({
let clientBuildStatus: DeferredPromise;

// Client build first
const clientBuild = await vite.build({
...commonConfig,
build: {
emptyOutDir: true,
copyPublicDir: true,
// Disable client sourcemaps in production by default
sourcemap:
forceClientSourcemap ??
(process.env.NODE_ENV !== 'production' && sourcemap),
watch: watch ? {} : null,
},
server: {
watch: watch ? {} : null,
},
plugins: [
{
name: 'hydrogen:cli:client',
buildStart() {
clientBuildStatus?.resolve();
clientBuildStatus = deferPromise();
},
buildEnd(error) {
if (error) clientBuildStatus.reject(error);
},
writeBundle() {
clientBuildStatus.resolve();
},
closeWatcher() {
// End build process if watcher is closed
this.error(new Error('Process exited before client build finished.'));
},
const clientBuild = await vite
.build({
...commonConfig,
build: {
emptyOutDir: true,
copyPublicDir: true,
// Disable client sourcemaps in production by default
sourcemap:
forceClientSourcemap ??
(process.env.NODE_ENV !== 'production' && sourcemap),
watch: watch ? {} : null,
},
server: {
watch: watch ? {} : null,
},
],
});
plugins: [
{
name: 'hydrogen:cli:client',
buildStart() {
clientBuildStatus?.resolve();
clientBuildStatus = deferPromise();
},
buildEnd(error) {
if (error) clientBuildStatus.reject(error);
},
writeBundle() {
clientBuildStatus.resolve();
},
closeWatcher() {
// End build process if watcher is closed
this.error(
new Error('Process exited before client build finished.'),
);
},
},
],
})
.catch(rethrowAsUserError);

console.log('');

let serverBuildStatus: DeferredPromise;

// Server/SSR build
const serverBuild = await vite.build({
...commonConfig,
build: {
sourcemap,
ssr: ssrEntry ?? true,
emptyOutDir: false,
copyPublicDir: false,
minify: serverMinify,
// Ensure the server rebuild start after the client one
watch: watch ? {buildDelay: 100} : null,
},
server: {
watch: watch ? {} : null,
},
plugins: [
{
name: 'hydrogen:cli:server',
async buildStart() {
// Wait for the client build to finish in watch mode
// before starting the server build to access the
// Remix manifest from file disk.
await clientBuildStatus.promise;

// Keep track of server builds to wait for them to finish
// before cleaning up resources in watch mode. Otherwise,
// it might complain about missing files and loop infinitely.
serverBuildStatus?.resolve();
serverBuildStatus = deferPromise();
await onServerBuildStart?.();
},
async writeBundle() {
if (serverBuildStatus?.state !== 'rejected') {
await onServerBuildFinish?.();
}

serverBuildStatus.resolve();
},
closeWatcher() {
// End build process if watcher is closed
this.error(new Error('Process exited before server build finished.'));
},
const serverBuild = await vite
.build({
...commonConfig,
build: {
sourcemap,
ssr: ssrEntry ?? true,
emptyOutDir: false,
copyPublicDir: false,
minify: serverMinify,
// Ensure the server rebuild start after the client one
watch: watch ? {buildDelay: 100} : null,
},
...(bundleStats
? [
hydrogenBundleAnalyzer({
minify: serverMinify
? (code, filepath) =>
vite
.transformWithEsbuild(code, filepath, {
minify: true,
minifyWhitespace: true,
minifySyntax: true,
minifyIdentifiers: true,
sourcemap: false,
treeShaking: false, // Tree-shaking would drop most exports in routes
legalComments: 'none',
target: 'esnext',
})
.then((result) => result.code)
: undefined,
}),
]
: []),
],
});
server: {
watch: watch ? {} : null,
},
plugins: [
{
name: 'hydrogen:cli:server',
async buildStart() {
// Wait for the client build to finish in watch mode
// before starting the server build to access the
// Remix manifest from file disk.
await clientBuildStatus.promise;

// Keep track of server builds to wait for them to finish
// before cleaning up resources in watch mode. Otherwise,
// it might complain about missing files and loop infinitely.
serverBuildStatus?.resolve();
serverBuildStatus = deferPromise();
await onServerBuildStart?.();
},
async writeBundle() {
if (serverBuildStatus?.state !== 'rejected') {
await onServerBuildFinish?.();
}

serverBuildStatus.resolve();
},
closeWatcher() {
// End build process if watcher is closed
this.error(
new Error('Process exited before server build finished.'),
);
},
},
...(bundleStats
? [
hydrogenBundleAnalyzer({
minify: serverMinify
? (code, filepath) =>
vite
.transformWithEsbuild(code, filepath, {
minify: true,
minifyWhitespace: true,
minifySyntax: true,
minifyIdentifiers: true,
sourcemap: false,
treeShaking: false, // Tree-shaking would drop most exports in routes
legalComments: 'none',
target: 'esnext',
})
.then((result) => result.code)
: undefined,
}),
]
: []),
],
})
.catch(rethrowAsUserError);

if (!watch) {
await Promise.all([
Expand Down
Loading