@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,34 +1,50 @@
1
1
  import Command from '@shopify/cli-kit/node/base-command';
2
2
  import { muteDevLogs } from '../../lib/log.js';
3
3
  import { getProjectPaths } from '../../lib/remix-config.js';
4
- import { commonFlags, DEFAULT_PORT } from '../../lib/flags.js';
5
- import { startMiniOxygen } from '../../lib/mini-oxygen.js';
4
+ import { commonFlags, flagsToCamelObject, DEFAULT_PORT } from '../../lib/flags.js';
5
+ import { startMiniOxygen } from '../../lib/mini-oxygen/index.js';
6
+ import { getAllEnvironmentVariables } from '../../lib/environment-variables.js';
7
+ import { getConfig } from '../../lib/shopify-config.js';
6
8
 
7
9
  class Preview extends Command {
8
10
  static description = "Runs a Hydrogen storefront in an Oxygen worker for production.";
9
11
  static flags = {
10
12
  path: commonFlags.path,
11
- port: commonFlags.port
13
+ port: commonFlags.port,
14
+ ["worker-unstable"]: commonFlags.workerRuntime,
15
+ ["env-branch"]: commonFlags.envBranch
12
16
  };
13
17
  async run() {
14
18
  const { flags } = await this.parse(Preview);
15
- await runPreview({ ...flags });
19
+ await runPreview({
20
+ ...flagsToCamelObject(flags),
21
+ workerRuntime: flags["worker-unstable"]
22
+ });
16
23
  }
17
24
  }
18
25
  async function runPreview({
19
26
  port = DEFAULT_PORT,
20
- path: appPath
27
+ path: appPath,
28
+ workerRuntime = false,
29
+ envBranch
21
30
  }) {
22
31
  if (!process.env.NODE_ENV)
23
32
  process.env.NODE_ENV = "production";
24
33
  muteDevLogs({ workerReload: false });
25
34
  const { root, buildPathWorkerFile, buildPathClient } = getProjectPaths(appPath);
26
- const miniOxygen = await startMiniOxygen({
27
- root,
28
- port,
29
- buildPathClient,
30
- buildPathWorkerFile
31
- });
35
+ const { shop, storefront } = await getConfig(root);
36
+ const fetchRemote = !!shop && !!storefront?.id;
37
+ const env = await getAllEnvironmentVariables({ root, fetchRemote, envBranch });
38
+ const miniOxygen = await startMiniOxygen(
39
+ {
40
+ root,
41
+ port,
42
+ buildPathClient,
43
+ buildPathWorkerFile,
44
+ env
45
+ },
46
+ workerRuntime
47
+ );
32
48
  miniOxygen.showBanner({ mode: "preview" });
33
49
  }
34
50
 
@@ -63,8 +63,8 @@ export async function loader({context}: LoaderArgs) {
63
63
 
64
64
  // validate the customer access token is valid
65
65
  const {isLoggedIn, headers} = await validateCustomerAccessToken(
66
- customerAccessToken,
67
66
  session,
67
+ customerAccessToken,
68
68
  );
69
69
 
70
70
  // defer the cart query by not awaiting it
@@ -198,17 +198,19 @@ export function CatchBoundary() {
198
198
  * ```
199
199
  * */
200
200
  async function validateCustomerAccessToken(
201
- customerAccessToken: CustomerAccessToken,
202
201
  session: HydrogenSession,
202
+ customerAccessToken?: CustomerAccessToken,
203
203
  ) {
204
204
  let isLoggedIn = false;
205
205
  const headers = new Headers();
206
206
  if (!customerAccessToken?.accessToken || !customerAccessToken?.expiresAt) {
207
207
  return {isLoggedIn, headers};
208
208
  }
209
- const expiresAt = new Date(customerAccessToken.expiresAt);
210
- const dateNow = new Date();
209
+
210
+ const expiresAt = new Date(customerAccessToken.expiresAt).getTime();
211
+ const dateNow = Date.now();
211
212
  const customerAccessTokenExpired = expiresAt < dateNow;
213
+
212
214
  if (customerAccessTokenExpired) {
213
215
  session.unset('customerAccessToken');
214
216
  headers.append('Set-Cookie', await session.commit());
@@ -10,7 +10,7 @@ export async function loader({request, context}: LoaderArgs) {
10
10
  const {session, storefront} = context;
11
11
  const {pathname} = new URL(request.url);
12
12
  const customerAccessToken = await session.get('customerAccessToken');
13
- const isLoggedIn = Boolean(customerAccessToken?.accessToken);
13
+ const isLoggedIn = !!customerAccessToken?.accessToken;
14
14
  const isAccountHome = pathname === '/account' || pathname === '/account/';
15
15
  const isPrivateRoute =
16
16
  /^\/account\/(orders|orders\/.*|profile|addresses|addresses\/.*)$/.test(
@@ -0,0 +1,70 @@
1
+ import {redirect, type LoaderArgs} from '@shopify/remix-oxygen';
2
+
3
+ /**
4
+ * Automatically creates a new cart based on the URL and redirects straight to checkout.
5
+ * Expected URL structure:
6
+ * ```ts
7
+ * /cart/<variant_id>:<quantity>
8
+ *
9
+ * ```
10
+ * More than one `<variant_id>:<quantity>` separated by a comma, can be supplied in the URL, for
11
+ * carts with more than one product variant.
12
+ *
13
+ * @param `?discount` an optional discount code to apply to the cart
14
+ * @example
15
+ * Example path creating a cart with two product variants, different quantities, and a discount code:
16
+ * ```ts
17
+ * /cart/41007289663544:1,41007289696312:2?discount=HYDROBOARD
18
+ *
19
+ * ```
20
+ * @preserve
21
+ */
22
+ export async function loader({request, context, params}: LoaderArgs) {
23
+ const {cart} = context;
24
+ const {lines} = params;
25
+ if (!lines) return redirect('/cart');
26
+ const linesMap = lines.split(',').map((line) => {
27
+ const lineDetails = line.split(':');
28
+ const variantId = lineDetails[0];
29
+ const quantity = parseInt(lineDetails[1], 10);
30
+
31
+ return {
32
+ merchandiseId: `gid://shopify/ProductVariant/${variantId}`,
33
+ quantity,
34
+ };
35
+ });
36
+
37
+ const url = new URL(request.url);
38
+ const searchParams = new URLSearchParams(url.search);
39
+
40
+ const discount = searchParams.get('discount');
41
+ const discountArray = discount ? [discount] : [];
42
+
43
+ // create a cart
44
+ const result = await cart.create({
45
+ lines: linesMap,
46
+ discountCodes: discountArray,
47
+ });
48
+
49
+ const cartResult = result.cart;
50
+
51
+ if (result.errors?.length || !cartResult) {
52
+ throw new Response('Link may be expired. Try checking the URL.', {
53
+ status: 410,
54
+ });
55
+ }
56
+
57
+ // Update cart id in cookie
58
+ const headers = cart.setCartId(cartResult.id);
59
+
60
+ // redirect to checkout
61
+ if (cartResult.checkoutUrl) {
62
+ return redirect(cartResult.checkoutUrl, {headers});
63
+ } else {
64
+ throw new Error('No checkout URL found');
65
+ }
66
+ }
67
+
68
+ export default function Component() {
69
+ return null;
70
+ }
@@ -54,7 +54,7 @@ export async function action({request, context}: ActionArgs) {
54
54
  case CartForm.ACTIONS.BuyerIdentityUpdate: {
55
55
  result = await cart.updateBuyerIdentity({
56
56
  ...inputs.buyerIdentity,
57
- customerAccessToken,
57
+ customerAccessToken: customerAccessToken?.accessToken,
58
58
  });
59
59
  break;
60
60
  }
@@ -0,0 +1,43 @@
1
+ import {redirect, type LoaderArgs} from '@shopify/remix-oxygen';
2
+
3
+ /**
4
+ * Automatically applies a discount found on the url
5
+ * If a cart exists it's updated with the discount, otherwise a cart is created with the discount already applied
6
+ * @param ?redirect an optional path to return to otherwise return to the home page
7
+ * @example
8
+ * Example path applying a discount and redirecting
9
+ * ```ts
10
+ * /discount/FREESHIPPING?redirect=/products
11
+ *
12
+ * ```
13
+ * @preserve
14
+ */
15
+ export async function loader({request, context, params}: LoaderArgs) {
16
+ const {cart} = context;
17
+ const {code} = params;
18
+
19
+ const url = new URL(request.url);
20
+ const searchParams = new URLSearchParams(url.search);
21
+ const redirectParam =
22
+ searchParams.get('redirect') || searchParams.get('return_to') || '/';
23
+
24
+ searchParams.delete('redirect');
25
+ searchParams.delete('return_to');
26
+
27
+ const redirectUrl = `${redirectParam}?${searchParams}`;
28
+
29
+ if (!code) {
30
+ return redirect(redirectUrl);
31
+ }
32
+
33
+ const result = await cart.updateDiscountCodes([code]);
34
+ const headers = cart.setCartId(result.cart.id);
35
+
36
+ // Using set-cookie on a 303 redirect will not work if the domain origin have port number (:3000)
37
+ // If there is no cart id and a new cart id is created in the progress, it will not be set in the cookie
38
+ // on localhost:3000
39
+ return redirect(redirectUrl, {
40
+ status: 303,
41
+ headers,
42
+ });
43
+ }
@@ -42,7 +42,9 @@ export async function loader({params, request, context}: LoaderArgs) {
42
42
  !option.name.startsWith('_pos') &&
43
43
  !option.name.startsWith('_psq') &&
44
44
  !option.name.startsWith('_ss') &&
45
- !option.name.startsWith('_v'),
45
+ !option.name.startsWith('_v') &&
46
+ // Filter out third party tracking params
47
+ !option.name.startsWith('fbclid'),
46
48
  );
47
49
 
48
50
  if (!handle) {
@@ -14,10 +14,10 @@
14
14
  "prettier": "@shopify/prettier-config",
15
15
  "dependencies": {
16
16
  "@remix-run/react": "1.19.1",
17
- "@shopify/cli": "3.48.0",
18
- "@shopify/cli-hydrogen": "^5.2.2",
19
- "@shopify/hydrogen": "^2023.7.6",
20
- "@shopify/remix-oxygen": "^1.1.3",
17
+ "@shopify/cli": "3.49.2",
18
+ "@shopify/cli-hydrogen": "^5.3.0",
19
+ "@shopify/hydrogen": "^2023.7.8",
20
+ "@shopify/remix-oxygen": "^1.1.4",
21
21
  "graphql": "^16.6.0",
22
22
  "graphql-tag": "^2.12.6",
23
23
  "isbot": "^3.6.6",
@@ -6,6 +6,7 @@
6
6
  import '@total-typescript/ts-reset';
7
7
 
8
8
  import type {Storefront, HydrogenCart} from '@shopify/hydrogen';
9
+ import type {CustomerAccessToken} from '@shopify/hydrogen/storefront-api-types';
9
10
  import type {HydrogenSession} from './server';
10
11
 
11
12
  declare global {
@@ -26,14 +27,22 @@ declare global {
26
27
  }
27
28
  }
28
29
 
29
- /**
30
- * Declare local additions to `AppLoadContext` to include the session utilities we injected in `server.ts`.
31
- */
32
30
  declare module '@shopify/remix-oxygen' {
31
+ /**
32
+ * Declare local additions to the Remix loader context.
33
+ */
33
34
  export interface AppLoadContext {
34
35
  env: Env;
35
36
  cart: HydrogenCart;
36
37
  storefront: Storefront;
37
38
  session: HydrogenSession;
39
+ waitUntil: ExecutionContext['waitUntil'];
40
+ }
41
+
42
+ /**
43
+ * Declare the data we expect to access via `context.session`.
44
+ */
45
+ export interface SessionData {
46
+ customerAccessToken: CustomerAccessToken;
38
47
  }
39
48
  }
@@ -32,7 +32,7 @@ export default {
32
32
  throw new Error('SESSION_SECRET environment variable is not set');
33
33
  }
34
34
 
35
- const waitUntil = (p: Promise<any>) => executionContext.waitUntil(p);
35
+ const waitUntil = executionContext.waitUntil.bind(executionContext);
36
36
  const [cache, session] = await Promise.all([
37
37
  caches.open('hydrogen'),
38
38
  HydrogenSession.init(request, [env.SESSION_SECRET]),
@@ -70,7 +70,7 @@ export default {
70
70
  const handleRequest = createRequestHandler({
71
71
  build: remixBuild,
72
72
  mode: process.env.NODE_ENV,
73
- getLoadContext: () => ({session, storefront, env, cart}),
73
+ getLoadContext: () => ({session, storefront, cart, env, waitUntil}),
74
74
  });
75
75
 
76
76
  const response = await handleRequest(request);
@@ -99,10 +99,13 @@ export default {
99
99
  * swap out the cookie-based implementation with something else!
100
100
  */
101
101
  export class HydrogenSession {
102
- constructor(
103
- private sessionStorage: SessionStorage,
104
- private session: Session,
105
- ) {}
102
+ #sessionStorage;
103
+ #session;
104
+
105
+ constructor(sessionStorage: SessionStorage, session: Session) {
106
+ this.#sessionStorage = sessionStorage;
107
+ this.#session = session;
108
+ }
106
109
 
107
110
  static async init(request: Request, secrets: string[]) {
108
111
  const storage = createCookieSessionStorage({
@@ -120,32 +123,32 @@ export class HydrogenSession {
120
123
  return new this(storage, session);
121
124
  }
122
125
 
123
- has(key: string) {
124
- return this.session.has(key);
126
+ get has() {
127
+ return this.#session.has;
125
128
  }
126
129
 
127
- get(key: string) {
128
- return this.session.get(key);
130
+ get get() {
131
+ return this.#session.get;
129
132
  }
130
133
 
131
- destroy() {
132
- return this.sessionStorage.destroySession(this.session);
134
+ get flash() {
135
+ return this.#session.flash;
133
136
  }
134
137
 
135
- flash(key: string, value: any) {
136
- this.session.flash(key, value);
138
+ get unset() {
139
+ return this.#session.unset;
137
140
  }
138
141
 
139
- unset(key: string) {
140
- this.session.unset(key);
142
+ get set() {
143
+ return this.#session.set;
141
144
  }
142
145
 
143
- set(key: string, value: any) {
144
- this.session.set(key, value);
146
+ destroy() {
147
+ return this.#sessionStorage.destroySession(this.#session);
145
148
  }
146
149
 
147
150
  commit() {
148
- return this.sessionStorage.commitSession(this.session);
151
+ return this.#sessionStorage.commitSession(this.#session);
149
152
  }
150
153
  }
151
154
 
@@ -5,7 +5,7 @@
5
5
  "isolatedModules": true,
6
6
  "esModuleInterop": true,
7
7
  "jsx": "react-jsx",
8
- "moduleResolution": "node",
8
+ "moduleResolution": "Bundler",
9
9
  "resolveJsonModule": true,
10
10
  "module": "ES2022",
11
11
  "target": "ES2022",
@@ -0,0 +1,56 @@
1
+ import { joinPath, dirname } from '@shopify/cli-kit/node/path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { readFile, writeFile } from '@shopify/cli-kit/node/fs';
4
+ import colors from '@shopify/cli-kit/node/colors';
5
+
6
+ async function buildBundleAnalysis(buildPath) {
7
+ await Promise.all([
8
+ writeBundleAnalyzerFile(
9
+ buildPath,
10
+ "metafile.server.json",
11
+ "worker-bundle-analyzer.html"
12
+ ),
13
+ writeBundleAnalyzerFile(
14
+ buildPath,
15
+ "metafile.js.json",
16
+ "client-bundle-analyzer.html"
17
+ )
18
+ ]);
19
+ return "file://" + joinPath(buildPath, "worker", "worker-bundle-analyzer.html");
20
+ }
21
+ async function writeBundleAnalyzerFile(buildPath, metafileName, outputFile) {
22
+ const metafile = await readFile(joinPath(buildPath, "worker", metafileName), {
23
+ encoding: "utf8"
24
+ });
25
+ const metafile64 = Buffer.from(metafile, "utf-8").toString("base64");
26
+ const analysisTemplate = await readFile(
27
+ fileURLToPath(
28
+ new URL(`../../lib/bundle/bundle-analyzer.html`, import.meta.url)
29
+ )
30
+ );
31
+ const templateWithMetafile = analysisTemplate.replace(
32
+ `globalThis.METAFILE = '';`,
33
+ `globalThis.METAFILE = '${metafile64}';`
34
+ );
35
+ await writeFile(
36
+ joinPath(buildPath, "worker", outputFile),
37
+ templateWithMetafile
38
+ );
39
+ }
40
+ async function getBundleAnalysisSummary(bundlePath) {
41
+ const esbuild = await import('esbuild').catch(() => {
42
+ });
43
+ if (esbuild) {
44
+ const metafilePath = joinPath(dirname(bundlePath), "metafile.server.json");
45
+ return " \u2502\n " + (await esbuild.analyzeMetafile(await readFile(metafilePath), {
46
+ color: true
47
+ })).split("\n").filter((line) => {
48
+ const match = line.match(
49
+ /(.*)\/node_modules\/(react-dom|@remix-run|@shopify\/hydrogen|react-router|react-router-dom)\/(.*)/g
50
+ );
51
+ return !match;
52
+ }).slice(2, 12).join("\n").replace(/dist\/worker\/_assets\/.*$/ms, "\n").replace(/\n/g, "\n ").replace(/(\.\.\/)+node_modules\//g, (match) => colors.dim(match));
53
+ }
54
+ }
55
+
56
+ export { buildBundleAnalysis, getBundleAnalysisSummary };