@shopify/cli-hydrogen 5.2.2 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/commands/hydrogen/build.js +49 -25
  2. package/dist/commands/hydrogen/deploy.js +171 -0
  3. package/dist/commands/hydrogen/deploy.test.js +185 -0
  4. package/dist/commands/hydrogen/dev.js +27 -14
  5. package/dist/commands/hydrogen/init.js +10 -6
  6. package/dist/commands/hydrogen/init.test.js +16 -1
  7. package/dist/commands/hydrogen/preview.js +27 -11
  8. package/dist/generator-templates/starter/app/root.tsx +6 -4
  9. package/dist/generator-templates/starter/app/routes/account.tsx +1 -1
  10. package/dist/generator-templates/starter/app/routes/cart.$lines.tsx +70 -0
  11. package/dist/generator-templates/starter/app/routes/cart.tsx +1 -1
  12. package/dist/generator-templates/starter/app/routes/discount.$code.tsx +43 -0
  13. package/dist/generator-templates/starter/app/routes/products.$handle.tsx +3 -1
  14. package/dist/generator-templates/starter/package.json +4 -4
  15. package/dist/generator-templates/starter/remix.env.d.ts +12 -3
  16. package/dist/generator-templates/starter/server.ts +22 -19
  17. package/dist/generator-templates/starter/tsconfig.json +1 -1
  18. package/dist/lib/bundle/analyzer.js +56 -0
  19. package/dist/lib/bundle/bundle-analyzer.html +2045 -0
  20. package/dist/lib/flags.js +4 -0
  21. package/dist/lib/get-oxygen-token.js +47 -0
  22. package/dist/lib/get-oxygen-token.test.js +104 -0
  23. package/dist/lib/graphql/admin/oxygen-token.js +21 -0
  24. package/dist/lib/live-reload.js +2 -1
  25. package/dist/lib/log.js +56 -13
  26. package/dist/lib/mini-oxygen/common.js +58 -0
  27. package/dist/lib/mini-oxygen/index.js +12 -0
  28. package/dist/lib/mini-oxygen/node.js +110 -0
  29. package/dist/lib/mini-oxygen/types.js +1 -0
  30. package/dist/lib/mini-oxygen/workerd-inspector.js +392 -0
  31. package/dist/lib/mini-oxygen/workerd.js +182 -0
  32. package/dist/lib/onboarding/common.js +24 -13
  33. package/dist/lib/onboarding/local.js +1 -1
  34. package/dist/lib/remix-config.js +12 -2
  35. package/dist/lib/remix-version-check.js +7 -4
  36. package/dist/lib/remix-version-check.test.js +1 -1
  37. package/dist/lib/render-errors.js +1 -1
  38. package/dist/lib/request-events.js +84 -0
  39. package/dist/lib/setups/routes/generate.js +3 -3
  40. package/dist/lib/transpile-ts.js +21 -23
  41. package/dist/lib/virtual-routes.js +11 -9
  42. package/dist/virtual-routes/components/FlameChartWrapper.jsx +125 -0
  43. package/dist/virtual-routes/routes/debug-network.jsx +289 -0
  44. package/dist/virtual-routes/routes/index.jsx +4 -4
  45. package/dist/virtual-routes/virtual-root.jsx +7 -4
  46. package/oclif.manifest.json +81 -3
  47. package/package.json +35 -12
  48. package/dist/lib/mini-oxygen.js +0 -108
@@ -1,18 +1,21 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import Command from '@shopify/cli-kit/node/base-command';
3
3
  import { outputInfo, outputContent, outputToken, outputWarn } from '@shopify/cli-kit/node/output';
4
- import { rmdir, fileSize, glob, removeFile, fileExists, copyFile } from '@shopify/cli-kit/node/fs';
5
- import { resolvePath, relativePath, joinPath } from '@shopify/cli-kit/node/path';
4
+ import { rmdir, fileSize, fileExists, copyFile } from '@shopify/cli-kit/node/fs';
5
+ import { resolvePath, relativePath } from '@shopify/cli-kit/node/path';
6
6
  import { getPackageManager } from '@shopify/cli-kit/node/node-package-manager';
7
7
  import colors from '@shopify/cli-kit/node/colors';
8
- import { getProjectPaths, getRemixConfig, assertOxygenChecks } from '../../lib/remix-config.js';
8
+ import { getProjectPaths, getRemixConfig, handleRemixImportFail, assertOxygenChecks } from '../../lib/remix-config.js';
9
9
  import { commonFlags, deprecated, flagsToCamelObject } from '../../lib/flags.js';
10
10
  import { checkLockfileStatus } from '../../lib/check-lockfile.js';
11
11
  import { findMissingRoutes } from '../../lib/missing-routes.js';
12
12
  import { muteRemixLogs, createRemixLogger } from '../../lib/log.js';
13
13
  import { codegen } from '../../lib/codegen.js';
14
+ import { buildBundleAnalysis, getBundleAnalysisSummary } from '../../lib/bundle/analyzer.js';
15
+ import { AbortError } from '@shopify/cli-kit/node/error';
14
16
 
15
17
  const LOG_WORKER_BUILT = "\u{1F4E6} Worker built";
18
+ const MAX_WORKER_BUNDLE_SIZE = 10;
16
19
  class Build extends Command {
17
20
  static description = "Builds a Hydrogen storefront for production.";
18
21
  static flags = {
@@ -20,7 +23,13 @@ class Build extends Command {
20
23
  sourcemap: Flags.boolean({
21
24
  description: "Generate sourcemaps for the build.",
22
25
  env: "SHOPIFY_HYDROGEN_FLAG_SOURCEMAP",
23
- default: false
26
+ allowNo: true,
27
+ default: true
28
+ }),
29
+ ["bundle-stats"]: Flags.boolean({
30
+ description: "Show a bundle size summary after building.",
31
+ default: true,
32
+ allowNo: true
24
33
  }),
25
34
  "disable-route-warning": Flags.boolean({
26
35
  description: "Disable warning about missing standard routes.",
@@ -51,21 +60,28 @@ async function runBuild({
51
60
  useCodegen = false,
52
61
  codegenConfigPath,
53
62
  sourcemap = false,
54
- disableRouteWarning = false
63
+ disableRouteWarning = false,
64
+ bundleStats = true,
65
+ assetPath
55
66
  }) {
56
67
  if (!process.env.NODE_ENV) {
57
68
  process.env.NODE_ENV = "production";
58
69
  }
70
+ if (assetPath) {
71
+ process.env.HYDROGEN_ASSET_BASE_URL = assetPath;
72
+ }
59
73
  const { root, buildPath, buildPathClient, buildPathWorkerFile, publicPath } = getProjectPaths(directory);
60
74
  await Promise.all([checkLockfileStatus(root), muteRemixLogs()]);
61
75
  console.time(LOG_WORKER_BUILT);
62
76
  outputInfo(`
63
77
  \u{1F3D7}\uFE0F Building in ${process.env.NODE_ENV} mode...`);
64
- const [remixConfig, { build }, { logThrown }, { createFileWatchCache }] = await Promise.all([
78
+ const [remixConfig, [{ build }, { logThrown }, { createFileWatchCache }]] = await Promise.all([
65
79
  getRemixConfig(root),
66
- import('@remix-run/dev/dist/compiler/build.js'),
67
- import('@remix-run/dev/dist/compiler/utils/log.js'),
68
- import('@remix-run/dev/dist/compiler/fileWatchCache.js'),
80
+ Promise.all([
81
+ import('@remix-run/dev/dist/compiler/build.js'),
82
+ import('@remix-run/dev/dist/compiler/utils/log.js'),
83
+ import('@remix-run/dev/dist/compiler/fileWatchCache.js')
84
+ ]).catch(handleRemixImportFail),
69
85
  rmdir(buildPath, { force: true })
70
86
  ]);
71
87
  assertOxygenChecks(remixConfig);
@@ -88,29 +104,37 @@ async function runBuild({
88
104
  if (process.env.NODE_ENV !== "development") {
89
105
  console.timeEnd(LOG_WORKER_BUILT);
90
106
  const sizeMB = await fileSize(buildPathWorkerFile) / (1024 * 1024);
107
+ const bundleAnalysisPath = await buildBundleAnalysis(buildPath);
91
108
  outputInfo(
92
109
  outputContent` ${colors.dim(
93
110
  relativePath(root, buildPathWorkerFile)
94
- )} ${outputToken.yellow(sizeMB.toFixed(2))} MB\n`
111
+ )} ${outputToken.link(
112
+ colors.yellow(sizeMB.toFixed(2) + " MB"),
113
+ bundleAnalysisPath
114
+ )}\n`
95
115
  );
96
- if (sizeMB >= 1) {
116
+ if (bundleStats && sizeMB < MAX_WORKER_BUNDLE_SIZE) {
117
+ outputInfo(
118
+ outputContent`${await getBundleAnalysisSummary(buildPathWorkerFile) || "\n"}\n │\n └─── ${outputToken.link(
119
+ "Complete analysis: " + bundleAnalysisPath,
120
+ bundleAnalysisPath
121
+ )}\n\n`
122
+ );
123
+ }
124
+ if (sizeMB >= MAX_WORKER_BUNDLE_SIZE) {
125
+ throw new AbortError(
126
+ "\u{1F6A8} Worker bundle exceeds 10 MB! Oxygen has a maximum worker bundle size of 10 MB.",
127
+ outputContent`See the bundle analysis for a breakdown of what is contributing to the bundle size:\n${outputToken.link(
128
+ bundleAnalysisPath,
129
+ bundleAnalysisPath
130
+ )}`
131
+ );
132
+ } else if (sizeMB >= 5) {
97
133
  outputWarn(
98
- `\u{1F6A8} Worker bundle exceeds 1 MB! This can delay your worker response.${remixConfig.serverMinify ? "" : " Minify your bundle by adding `serverMinify: true` to remix.config.js."}
134
+ `\u{1F6A8} Worker bundle exceeds 5 MB! This can delay your worker response.${remixConfig.serverMinify ? "" : " Minify your bundle by adding `serverMinify: true` to remix.config.js."}
99
135
  `
100
136
  );
101
137
  }
102
- if (sourcemap) {
103
- if (process.env.HYDROGEN_ASSET_BASE_URL) {
104
- const filepaths = await glob(joinPath(buildPathClient, "**/*.js.map"));
105
- for (const filepath of filepaths) {
106
- await removeFile(filepath);
107
- }
108
- } else {
109
- outputWarn(
110
- "\u{1F6A8} Sourcemaps are enabled in production! Use this only for testing.\n"
111
- );
112
- }
113
- }
114
138
  }
115
139
  if (!disableRouteWarning) {
116
140
  const missingRoutes = findMissingRoutes(remixConfig);
@@ -125,7 +149,7 @@ This build is missing ${missingRoutes.length} route${missingRoutes.length > 1 ?
125
149
  );
126
150
  }
127
151
  }
128
- if (!process.env.SHOPIFY_UNIT_TEST) {
152
+ if (!process.env.SHOPIFY_UNIT_TEST && !assetPath) {
129
153
  process.exit(0);
130
154
  }
131
155
  }
@@ -0,0 +1,171 @@
1
+ import { Flags } from '@oclif/core';
2
+ import Command from '@shopify/cli-kit/node/base-command';
3
+ import colors from '@shopify/cli-kit/node/colors';
4
+ import { outputWarn, outputInfo, outputContent } from '@shopify/cli-kit/node/output';
5
+ import { AbortError } from '@shopify/cli-kit/node/error';
6
+ import { resolvePath } from '@shopify/cli-kit/node/path';
7
+ import { renderFatalError, renderSuccess, renderTasks } from '@shopify/cli-kit/node/ui';
8
+ import { parseToken, createDeploy } from '@shopify/oxygen-cli/deploy';
9
+ import { commonFlags } from '../../lib/flags.js';
10
+ import { getOxygenDeploymentToken } from '../../lib/get-oxygen-token.js';
11
+ import { runBuild } from './build.js';
12
+
13
+ const deploymentLogger = (message, level = "info") => {
14
+ if (level === "error" || level === "warn") {
15
+ outputWarn(message);
16
+ }
17
+ };
18
+ class Deploy extends Command {
19
+ static flags = {
20
+ path: commonFlags.path,
21
+ shop: commonFlags.shop,
22
+ publicDeployment: Flags.boolean({
23
+ env: "SHOPIFY_HYDROGEN_FLAG_PUBLIC_DEPLOYMENT",
24
+ description: "Marks a preview deployment as publicly accessible.",
25
+ required: false,
26
+ default: false
27
+ }),
28
+ metadataUrl: Flags.string({
29
+ description: "URL that links to the deployment. Will be saved and displayed in the Shopify admin",
30
+ required: false,
31
+ env: "SHOPIFY_HYDROGEN_FLAG_METADATA_URL"
32
+ }),
33
+ metadataUser: Flags.string({
34
+ description: "User that initiated the deployment. Will be saved and displayed in the Shopify admin",
35
+ required: false,
36
+ env: "SHOPIFY_HYDROGEN_FLAG_METADATA_USER"
37
+ }),
38
+ metadataVersion: Flags.string({
39
+ description: "A version identifier for the deployment. Will be saved and displayed in the Shopify admin",
40
+ required: false,
41
+ env: "SHOPIFY_HYDROGEN_FLAG_METADATA_VERSION"
42
+ })
43
+ };
44
+ static hidden = true;
45
+ async run() {
46
+ const { flags } = await this.parse(Deploy);
47
+ const actualPath = flags.path ? resolvePath(flags.path) : process.cwd();
48
+ await oxygenDeploy({
49
+ path: actualPath,
50
+ shop: flags.shop,
51
+ publicDeployment: flags.publicDeployment,
52
+ metadataUrl: flags.metadataUrl,
53
+ metadataUser: flags.metadataUser,
54
+ metadataVersion: flags.metadataVersion
55
+ }).catch((error) => {
56
+ renderFatalError(error);
57
+ process.exit(1);
58
+ }).finally(() => {
59
+ process.exit(0);
60
+ });
61
+ }
62
+ }
63
+ async function oxygenDeploy(options) {
64
+ const {
65
+ path,
66
+ shop,
67
+ publicDeployment,
68
+ metadataUrl,
69
+ metadataUser,
70
+ metadataVersion
71
+ } = options;
72
+ const token = await getOxygenDeploymentToken({
73
+ root: path,
74
+ flagShop: shop
75
+ });
76
+ if (!token) {
77
+ throw new AbortError("Could not obtain Oxygen deployment token");
78
+ }
79
+ const config = {
80
+ assetsDir: "dist/client",
81
+ deploymentUrl: "https://oxygen.shopifyapps.com",
82
+ deploymentToken: parseToken(token),
83
+ healthCheckMaxDuration: 180,
84
+ metadata: {
85
+ ...metadataUrl ? { url: metadataUrl } : {},
86
+ ...metadataUser ? { user: metadataUser } : {},
87
+ ...metadataVersion ? { version: metadataVersion } : {}
88
+ },
89
+ publicDeployment,
90
+ skipHealthCheck: false,
91
+ rootPath: path,
92
+ skipBuild: false,
93
+ workerOnly: false,
94
+ workerDir: "dist/worker"
95
+ };
96
+ let resolveUpload;
97
+ const uploadPromise = new Promise((resolve) => {
98
+ resolveUpload = resolve;
99
+ });
100
+ let resolveHealthCheck;
101
+ const healthCheckPromise = new Promise((resolve) => {
102
+ resolveHealthCheck = resolve;
103
+ });
104
+ let deployError = null;
105
+ let resolveDeploy;
106
+ let rejectDeploy;
107
+ const deployPromise = new Promise((resolve, reject) => {
108
+ resolveDeploy = resolve;
109
+ rejectDeploy = reject;
110
+ });
111
+ const hooks = {
112
+ buildFunction: async (assetPath) => {
113
+ outputInfo(
114
+ outputContent`${colors.whiteBright("Building project...")}`.value
115
+ );
116
+ await runBuild({
117
+ directory: path,
118
+ assetPath,
119
+ sourcemap: false,
120
+ useCodegen: false
121
+ });
122
+ },
123
+ onHealthCheckComplete: () => resolveHealthCheck(),
124
+ onUploadFilesStart: () => uploadStart(),
125
+ onUploadFilesComplete: () => resolveUpload(),
126
+ onHealthCheckError: (error) => {
127
+ deployError = new AbortError(
128
+ error.message,
129
+ "Please verify the deployment status in the Shopify Admin and retry deploying if necessary."
130
+ );
131
+ },
132
+ onUploadFilesError: (error) => {
133
+ deployError = new AbortError(
134
+ error.message,
135
+ "Check your connection and try again. If the problem persists, try again later or contact support."
136
+ );
137
+ }
138
+ };
139
+ const uploadStart = async () => {
140
+ outputInfo(
141
+ outputContent`${colors.whiteBright("Deploying to Oxygen..\n")}`.value
142
+ );
143
+ await renderTasks([
144
+ {
145
+ title: "Uploading files",
146
+ task: async () => await uploadPromise
147
+ },
148
+ {
149
+ title: "Performing health check",
150
+ task: async () => await healthCheckPromise
151
+ }
152
+ ]);
153
+ };
154
+ await createDeploy({ config, hooks, logger: deploymentLogger }).then((url) => {
155
+ const deploymentType = config.publicDeployment ? "public" : "private";
156
+ renderSuccess({
157
+ body: ["Successfully deployed to Oxygen"],
158
+ nextSteps: [
159
+ [
160
+ `Open ${url} in your browser to view your ${deploymentType} deployment`
161
+ ]
162
+ ]
163
+ });
164
+ resolveDeploy();
165
+ }).catch((error) => {
166
+ rejectDeploy(deployError || error);
167
+ });
168
+ return deployPromise;
169
+ }
170
+
171
+ export { Deploy as default, deploymentLogger, oxygenDeploy };
@@ -0,0 +1,185 @@
1
+ import { vi, describe, beforeEach, afterEach, it, expect } from 'vitest';
2
+ import { login } from '../../lib/auth.js';
3
+ import { getStorefronts } from '../../lib/graphql/admin/link-storefront.js';
4
+ import { AbortError } from '@shopify/cli-kit/node/error';
5
+ import { renderSelectPrompt, renderSuccess, renderFatalError } from '@shopify/cli-kit/node/ui';
6
+ import { oxygenDeploy, deploymentLogger } from './deploy.js';
7
+ import { getOxygenDeploymentToken } from '../../lib/get-oxygen-token.js';
8
+ import { createDeploy, parseToken } from '@shopify/oxygen-cli/deploy';
9
+
10
+ vi.mock("../../lib/get-oxygen-token.js");
11
+ vi.mock("@shopify/oxygen-cli/deploy");
12
+ vi.mock("../../lib/auth.js");
13
+ vi.mock("../../lib/shopify-config.js");
14
+ vi.mock("../../lib/graphql/admin/link-storefront.js");
15
+ vi.mock("../../lib/graphql/admin/create-storefront.js");
16
+ vi.mock("../../lib/graphql/admin/fetch-job.js");
17
+ vi.mock("../../lib/shell.js", () => ({ getCliCommand: () => "h2" }));
18
+ vi.mock("@shopify/cli-kit/node/output", async () => {
19
+ return {
20
+ outputContent: () => ({ value: "" }),
21
+ outputInfo: () => {
22
+ }
23
+ };
24
+ });
25
+ vi.mock("@shopify/cli-kit/node/ui", async () => {
26
+ return {
27
+ renderFatalError: vi.fn(),
28
+ renderSelectPrompt: vi.fn(),
29
+ renderSuccess: vi.fn(),
30
+ renderTasks: vi.fn()
31
+ };
32
+ });
33
+ describe("deploy", () => {
34
+ const ADMIN_SESSION = {
35
+ token: "abc123",
36
+ storeFqdn: "my-shop.myshopify.com"
37
+ };
38
+ const FULL_SHOPIFY_CONFIG = {
39
+ shop: "my-shop.myshopify.com",
40
+ shopName: "My Shop",
41
+ email: "email",
42
+ storefront: {
43
+ id: "gid://shopify/HydrogenStorefront/1",
44
+ title: "Hydrogen"
45
+ }
46
+ };
47
+ const UNLINKED_SHOPIFY_CONFIG = {
48
+ ...FULL_SHOPIFY_CONFIG,
49
+ storefront: void 0
50
+ };
51
+ const originalExit = process.exit;
52
+ const deployParams = {
53
+ path: "./",
54
+ shop: "snowdevil.myshopify.com",
55
+ publicDeployment: false,
56
+ metadataUrl: "https://example.com",
57
+ metadataUser: "user",
58
+ metadataVersion: "1.0.0"
59
+ };
60
+ const mockToken = {
61
+ accessToken: "some-token",
62
+ allowedResource: "some-resource",
63
+ appId: "1",
64
+ client: "1",
65
+ expiresAt: "some-time",
66
+ namespace: "some-namespace",
67
+ namespaceId: "1"
68
+ };
69
+ beforeEach(async () => {
70
+ process.exit = vi.fn();
71
+ vi.mocked(login).mockResolvedValue({
72
+ session: ADMIN_SESSION,
73
+ config: UNLINKED_SHOPIFY_CONFIG
74
+ });
75
+ vi.mocked(getStorefronts).mockResolvedValue([
76
+ {
77
+ ...FULL_SHOPIFY_CONFIG.storefront,
78
+ parsedId: "1",
79
+ productionUrl: "https://example.com"
80
+ }
81
+ ]);
82
+ vi.mocked(renderSelectPrompt).mockResolvedValue(FULL_SHOPIFY_CONFIG.shop);
83
+ vi.mocked(createDeploy).mockResolvedValue(
84
+ "https://a-lovely-deployment.com"
85
+ );
86
+ vi.mocked(getOxygenDeploymentToken).mockResolvedValue("some-encoded-token");
87
+ vi.mocked(parseToken).mockReturnValue(mockToken);
88
+ });
89
+ afterEach(() => {
90
+ vi.resetAllMocks();
91
+ process.exit = originalExit;
92
+ });
93
+ it("calls getOxygenDeploymentToken with the correct parameters", async () => {
94
+ await oxygenDeploy(deployParams);
95
+ expect(getOxygenDeploymentToken).toHaveBeenCalledWith({
96
+ root: "./",
97
+ flagShop: "snowdevil.myshopify.com"
98
+ });
99
+ expect(getOxygenDeploymentToken).toHaveBeenCalledTimes(1);
100
+ });
101
+ it("calls createDeploy with the correct parameters", async () => {
102
+ await oxygenDeploy(deployParams);
103
+ const expectedConfig = {
104
+ assetsDir: "dist/client",
105
+ deploymentUrl: "https://oxygen.shopifyapps.com",
106
+ deploymentToken: mockToken,
107
+ healthCheckMaxDuration: 180,
108
+ metadata: {
109
+ url: deployParams.metadataUrl,
110
+ user: deployParams.metadataUser,
111
+ version: deployParams.metadataVersion
112
+ },
113
+ publicDeployment: deployParams.publicDeployment,
114
+ skipHealthCheck: false,
115
+ rootPath: deployParams.path,
116
+ skipBuild: false,
117
+ workerOnly: false,
118
+ workerDir: "dist/worker"
119
+ };
120
+ expect(vi.mocked(createDeploy)).toHaveBeenCalledWith({
121
+ config: expectedConfig,
122
+ hooks: {
123
+ buildFunction: expect.any(Function),
124
+ onHealthCheckComplete: expect.any(Function),
125
+ onUploadFilesStart: expect.any(Function),
126
+ onUploadFilesComplete: expect.any(Function),
127
+ onHealthCheckError: expect.any(Function),
128
+ onUploadFilesError: expect.any(Function)
129
+ },
130
+ logger: deploymentLogger
131
+ });
132
+ expect(vi.mocked(renderSuccess)).toHaveBeenCalled;
133
+ });
134
+ it("handles error during uploadFiles", async () => {
135
+ const mockRenderFatalError = vi.fn();
136
+ vi.mocked(renderFatalError).mockImplementation(mockRenderFatalError);
137
+ const error = new Error("Wonky internet!");
138
+ vi.mocked(createDeploy).mockImplementation((options) => {
139
+ options.hooks?.onUploadFilesStart?.();
140
+ options.hooks?.onUploadFilesError?.(error);
141
+ return new Promise((_resolve, reject) => {
142
+ reject(error);
143
+ });
144
+ });
145
+ try {
146
+ await oxygenDeploy(deployParams);
147
+ expect(true).toBe(false);
148
+ } catch (err) {
149
+ if (err instanceof AbortError) {
150
+ expect(err.message).toBe(error.message);
151
+ expect(err.tryMessage).toBe(
152
+ "Check your connection and try again. If the problem persists, try again later or contact support."
153
+ );
154
+ } else {
155
+ expect(true).toBe(false);
156
+ }
157
+ }
158
+ });
159
+ it("handles error during health check", async () => {
160
+ const mockRenderFatalError = vi.fn();
161
+ vi.mocked(renderFatalError).mockImplementation(mockRenderFatalError);
162
+ const error = new Error("Cloudflare is down!");
163
+ vi.mocked(createDeploy).mockImplementation((options) => {
164
+ options.hooks?.onUploadFilesStart?.();
165
+ options.hooks?.onUploadFilesComplete?.();
166
+ options.hooks?.onHealthCheckError?.(error);
167
+ return new Promise((_resolve, reject) => {
168
+ reject(error);
169
+ });
170
+ });
171
+ try {
172
+ await oxygenDeploy(deployParams);
173
+ expect(true).toBe(false);
174
+ } catch (err) {
175
+ if (err instanceof AbortError) {
176
+ expect(err.message).toBe(error.message);
177
+ expect(err.tryMessage).toBe(
178
+ "Please verify the deployment status in the Shopify Admin and retry deploying if necessary."
179
+ );
180
+ } else {
181
+ expect(true).toBe(false);
182
+ }
183
+ }
184
+ });
185
+ });
@@ -5,12 +5,12 @@ import { fileExists } from '@shopify/cli-kit/node/fs';
5
5
  import { renderFatalError } from '@shopify/cli-kit/node/ui';
6
6
  import colors from '@shopify/cli-kit/node/colors';
7
7
  import { copyPublicFiles } from './build.js';
8
- import { getProjectPaths, assertOxygenChecks, getRemixConfig } from '../../lib/remix-config.js';
8
+ import { getProjectPaths, assertOxygenChecks, handleRemixImportFail, getRemixConfig } from '../../lib/remix-config.js';
9
9
  import { muteDevLogs, createRemixLogger, enhanceH2Logs } from '../../lib/log.js';
10
10
  import { commonFlags, deprecated, flagsToCamelObject, DEFAULT_PORT } from '../../lib/flags.js';
11
11
  import Command from '@shopify/cli-kit/node/base-command';
12
12
  import { Flags } from '@oclif/core';
13
- import { startMiniOxygen } from '../../lib/mini-oxygen.js';
13
+ import { startMiniOxygen } from '../../lib/mini-oxygen/index.js';
14
14
  import { checkHydrogenVersion } from '../../lib/check-version.js';
15
15
  import { addVirtualRoutes } from '../../lib/virtual-routes.js';
16
16
  import { spawnCodegenProcess } from '../../lib/codegen.js';
@@ -26,6 +26,7 @@ class Dev extends Command {
26
26
  static flags = {
27
27
  path: commonFlags.path,
28
28
  port: commonFlags.port,
29
+ ["worker-unstable"]: commonFlags.workerRuntime,
29
30
  ["codegen-unstable"]: Flags.boolean({
30
31
  description: "Generate types for the Storefront API queries found in your project. It updates the types on file save.",
31
32
  required: false,
@@ -52,6 +53,7 @@ class Dev extends Command {
52
53
  await runDev({
53
54
  ...flagsToCamelObject(flags),
54
55
  useCodegen: flags["codegen-unstable"],
56
+ workerRuntime: flags["worker-unstable"],
55
57
  path: directory
56
58
  });
57
59
  }
@@ -60,6 +62,7 @@ async function runDev({
60
62
  port: portFlag = DEFAULT_PORT,
61
63
  path: appPath,
62
64
  useCodegen = false,
65
+ workerRuntime = false,
63
66
  codegenConfigPath,
64
67
  disableVirtualRoutes,
65
68
  envBranch,
@@ -98,7 +101,7 @@ async function runDev({
98
101
  const [{ watch }, { createFileWatchCache }] = await Promise.all([
99
102
  import('@remix-run/dev/dist/compiler/watch.js'),
100
103
  import('@remix-run/dev/dist/compiler/fileWatchCache.js')
101
- ]);
104
+ ]).catch(handleRemixImportFail);
102
105
  let isInitialBuild = true;
103
106
  let initialBuildDurationMs = 0;
104
107
  let initialBuildStartTimeMs = Date.now();
@@ -107,22 +110,32 @@ async function runDev({
107
110
  async function safeStartMiniOxygen() {
108
111
  if (miniOxygen)
109
112
  return;
110
- miniOxygen = await startMiniOxygen({
111
- root,
112
- port: portFlag,
113
- watch: !liveReload,
114
- buildPathWorkerFile,
115
- buildPathClient,
116
- env: await envPromise
117
- });
113
+ miniOxygen = await startMiniOxygen(
114
+ {
115
+ root,
116
+ port: portFlag,
117
+ watch: !liveReload,
118
+ buildPathWorkerFile,
119
+ buildPathClient,
120
+ env: await envPromise
121
+ },
122
+ workerRuntime
123
+ );
118
124
  const graphiqlUrl = `${miniOxygen.listeningAt}/graphiql`;
125
+ const debugNetworkUrl = `${miniOxygen.listeningAt}/debug-network`;
119
126
  enhanceH2Logs({ graphiqlUrl, ...remixConfig });
120
127
  miniOxygen.showBanner({
121
128
  appName: storefront ? colors.cyan(storefront?.title) : void 0,
122
129
  headlinePrefix: initialBuildDurationMs > 0 ? `Initial build: ${initialBuildDurationMs}ms
123
130
  ` : "",
124
- extraLines: [colors.dim(`
125
- View GraphiQL API browser: ${graphiqlUrl}`)]
131
+ extraLines: [
132
+ colors.dim(`
133
+ View GraphiQL API browser: ${graphiqlUrl}`),
134
+ workerRuntime ? "" : colors.dim(
135
+ `
136
+ View server-side network requests: ${debugNetworkUrl}`
137
+ )
138
+ ].filter(Boolean)
126
139
  });
127
140
  if (useCodegen) {
128
141
  spawnCodegenProcess({ ...remixConfig, configFilePath: codegenConfigPath });
@@ -177,7 +190,7 @@ View GraphiQL API browser: ${graphiqlUrl}`)]
177
190
  if (!miniOxygen) {
178
191
  await safeStartMiniOxygen();
179
192
  } else if (liveReload) {
180
- await miniOxygen.reload({ worker: true });
193
+ await miniOxygen.reload();
181
194
  }
182
195
  liveReload?.onAppReady(context);
183
196
  }
@@ -1,6 +1,6 @@
1
1
  import Command from '@shopify/cli-kit/node/base-command';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { packageManagerUsedForCreating } from '@shopify/cli-kit/node/node-package-manager';
3
+ import { packageManagerFromUserAgent } from '@shopify/cli-kit/node/node-package-manager';
4
4
  import { Flags } from '@oclif/core';
5
5
  import { AbortError } from '@shopify/cli-kit/node/error';
6
6
  import { AbortController } from '@shopify/cli-kit/node/abort';
@@ -42,7 +42,8 @@ class Init extends Command {
42
42
  routes: Flags.boolean({
43
43
  description: "Generate routes for all pages.",
44
44
  env: "SHOPIFY_HYDROGEN_FLAG_ROUTES",
45
- hidden: true
45
+ hidden: true,
46
+ allowNo: true
46
47
  }),
47
48
  git: Flags.boolean({
48
49
  description: "Init Git and create initial commits.",
@@ -52,10 +53,13 @@ class Init extends Command {
52
53
  })
53
54
  };
54
55
  async run() {
55
- const { flags } = await this.parse(Init);
56
- if (flags.markets && !I18N_CHOICES.includes(flags.markets)) {
56
+ const {
57
+ flags: { markets, ..._flags }
58
+ } = await this.parse(Init);
59
+ const flags = { ..._flags, i18n: markets };
60
+ if (flags.i18n && !I18N_CHOICES.includes(flags.i18n)) {
57
61
  throw new AbortError(
58
- `Invalid URL structure strategy: ${flags.markets}. Must be one of ${I18N_CHOICES.join(", ")}`
62
+ `Invalid URL structure strategy: ${flags.i18n}. Must be one of ${I18N_CHOICES.join(", ")}`
59
63
  );
60
64
  }
61
65
  if (flags.styling && !STYLING_CHOICES.includes(flags.styling)) {
@@ -77,7 +81,7 @@ async function runInit(options = parseProcessFlags(process.argv, FLAG_MAP)) {
77
81
  "cli"
78
82
  );
79
83
  if (showUpgrade) {
80
- const packageManager = await packageManagerUsedForCreating();
84
+ const packageManager = packageManagerFromUserAgent();
81
85
  showUpgrade(
82
86
  packageManager === "unknown" ? "" : `Please use the latest version with \`${packageManager} create @shopify/hydrogen@latest\``
83
87
  );
@@ -46,7 +46,7 @@ vi.mock(
46
46
  return {
47
47
  ...original,
48
48
  getPackageManager: () => Promise.resolve("npm"),
49
- packageManagerUsedForCreating: () => Promise.resolve("npm"),
49
+ packageManagerFromUserAgent: () => "npm",
50
50
  installNodeModules: vi.fn(async ({ directory }) => {
51
51
  renderTasksHook.mockImplementationOnce(async () => {
52
52
  await writeFile(`${directory}/package-lock.json`, "{}");
@@ -519,6 +519,21 @@ describe("init", () => {
519
519
  );
520
520
  expect(mb).toBeGreaterThan(0);
521
521
  expect(mb).toBeLessThan(1);
522
+ expect(output).toMatch("Complete analysis: file://");
523
+ const clientAnalysisPath = "dist/worker/client-bundle-analyzer.html";
524
+ const workerAnalysisPath = "dist/worker/worker-bundle-analyzer.html";
525
+ expect(
526
+ fileExists(joinPath(tmpDir, clientAnalysisPath))
527
+ ).resolves.toBeTruthy();
528
+ expect(
529
+ fileExists(joinPath(tmpDir, workerAnalysisPath))
530
+ ).resolves.toBeTruthy();
531
+ expect(await readFile(joinPath(tmpDir, clientAnalysisPath))).toMatch(
532
+ /globalThis\.METAFILE = '.+';/g
533
+ );
534
+ expect(await readFile(joinPath(tmpDir, workerAnalysisPath))).toMatch(
535
+ /globalThis\.METAFILE = '.+';/g
536
+ );
522
537
  });
523
538
  });
524
539
  });