@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
package/dist/lib/flags.js CHANGED
@@ -17,6 +17,10 @@ const commonFlags = {
17
17
  env: "SHOPIFY_HYDROGEN_FLAG_PORT",
18
18
  default: DEFAULT_PORT
19
19
  }),
20
+ workerRuntime: Flags.boolean({
21
+ description: "Run the app in a worker environment closer to Oxygen production instead of a Node.js sandbox. This flag is unstable and may change without notice.",
22
+ env: "SHOPIFY_HYDROGEN_FLAG_WORKER_UNSTABLE"
23
+ }),
20
24
  force: Flags.boolean({
21
25
  description: "Overwrite the destination directory and files if they already exist.",
22
26
  env: "SHOPIFY_HYDROGEN_FLAG_FORCE",
@@ -0,0 +1,47 @@
1
+ import { renderConfirmationPrompt } from '@shopify/cli-kit/node/ui';
2
+ import { outputWarn } from '@shopify/cli-kit/node/output';
3
+ import { linkStorefront } from '../commands/hydrogen/link.js';
4
+ import { login } from './auth.js';
5
+ import { getCliCommand } from './shell.js';
6
+ import { renderMissingLink, renderMissingStorefront } from './render-errors.js';
7
+ import { getOxygenToken } from './graphql/admin/oxygen-token.js';
8
+
9
+ async function getOxygenDeploymentToken({
10
+ root
11
+ }) {
12
+ const [{ session, config }, cliCommand] = await Promise.all([
13
+ login(root),
14
+ getCliCommand()
15
+ ]);
16
+ if (!config.storefront?.id) {
17
+ renderMissingLink({ session, cliCommand });
18
+ const runLink = await renderConfirmationPrompt({
19
+ message: ["Run", { command: `${cliCommand} link` }]
20
+ });
21
+ if (!runLink) {
22
+ return;
23
+ }
24
+ config.storefront = await linkStorefront(root, session, config, {
25
+ cliCommand
26
+ });
27
+ }
28
+ if (!config.storefront) {
29
+ return;
30
+ }
31
+ const { storefront } = await getOxygenToken(session, config.storefront.id);
32
+ if (!storefront) {
33
+ renderMissingStorefront({
34
+ session,
35
+ storefront: config.storefront,
36
+ cliCommand
37
+ });
38
+ return;
39
+ }
40
+ if (!storefront.oxygenDeploymentToken) {
41
+ outputWarn(`Could not retrieve a deployment token.`);
42
+ return;
43
+ }
44
+ return storefront.oxygenDeploymentToken;
45
+ }
46
+
47
+ export { getOxygenDeploymentToken };
@@ -0,0 +1,104 @@
1
+ import { vi, describe, beforeEach, afterEach, it, expect } from 'vitest';
2
+ import { renderConfirmationPrompt } from '@shopify/cli-kit/node/ui';
3
+ import { getOxygenDeploymentToken } from './get-oxygen-token.js';
4
+ import { login } from './auth.js';
5
+ import { getConfig } from './shopify-config.js';
6
+ import { renderMissingLink, renderMissingStorefront } from './render-errors.js';
7
+ import { linkStorefront } from '../commands/hydrogen/link.js';
8
+ import { getOxygenToken } from './graphql/admin/oxygen-token.js';
9
+
10
+ vi.mock("@shopify/cli-kit/node/ui", async () => {
11
+ const original = await vi.importActual("@shopify/cli-kit/node/ui");
12
+ return {
13
+ ...original,
14
+ renderConfirmationPrompt: vi.fn()
15
+ };
16
+ });
17
+ vi.mock("./auth.js");
18
+ vi.mock("./admin-session.js");
19
+ vi.mock("./shopify-config.js");
20
+ vi.mock("./render-errors.js");
21
+ vi.mock("../commands/hydrogen/link.js");
22
+ vi.mock("./graphql/admin/oxygen-token.js");
23
+ describe("getOxygenDeploymentToken", () => {
24
+ const OXYGEN_DEPLOYMENT_TOKEN = "a-lovely-token";
25
+ beforeEach(() => {
26
+ vi.mocked(login).mockResolvedValue({
27
+ session: {
28
+ token: "123",
29
+ storeFqdn: "www.snowdevil.com"
30
+ },
31
+ config: {
32
+ shop: "snowdevil.myshopify.com",
33
+ shopName: "Snowdevil",
34
+ email: "merchant@shop.com",
35
+ storefront: {
36
+ id: "1",
37
+ title: "Snowboards"
38
+ }
39
+ }
40
+ });
41
+ vi.mocked(getConfig).mockResolvedValue({
42
+ storefront: { id: "storefront-id", title: "Existing Link" }
43
+ });
44
+ vi.mocked(getOxygenToken).mockResolvedValue({
45
+ storefront: { oxygenDeploymentToken: OXYGEN_DEPLOYMENT_TOKEN }
46
+ });
47
+ });
48
+ afterEach(() => {
49
+ vi.resetAllMocks();
50
+ });
51
+ it("returns the oxygen deployment token", async () => {
52
+ const token = await getOxygenDeploymentToken({ root: "test-root" });
53
+ expect(token).toBe(OXYGEN_DEPLOYMENT_TOKEN);
54
+ });
55
+ describe("when there is no linked storefront", () => {
56
+ beforeEach(() => {
57
+ vi.mocked(login).mockResolvedValue({
58
+ session: {
59
+ token: "123",
60
+ storeFqdn: "www.snowdevil.com"
61
+ },
62
+ config: {
63
+ shop: "snowdevil.myshopify.com",
64
+ shopName: "Snowdevil",
65
+ email: "merchant@shop.com",
66
+ storefront: void 0
67
+ }
68
+ });
69
+ });
70
+ it("calls renderMissingLink and prompts the user to create a link", async () => {
71
+ vi.mocked(renderConfirmationPrompt).mockResolvedValue(true);
72
+ await getOxygenDeploymentToken({ root: "test-root" });
73
+ expect(renderMissingLink).toHaveBeenCalled();
74
+ expect(renderConfirmationPrompt).toHaveBeenCalled();
75
+ expect(linkStorefront).toHaveBeenCalled();
76
+ });
77
+ it("returns nothing if the user does not create a new link", async () => {
78
+ vi.mocked(renderConfirmationPrompt).mockResolvedValue(false);
79
+ const token = await getOxygenDeploymentToken({ root: "test-root" });
80
+ expect(token).toEqual(void 0);
81
+ });
82
+ });
83
+ describe("when there is no matching storefront in the shop", () => {
84
+ beforeEach(() => {
85
+ vi.mocked(getOxygenToken).mockResolvedValue({ storefront: null });
86
+ });
87
+ it("calls renderMissingStorefront and returns nothing", async () => {
88
+ const token = await getOxygenDeploymentToken({ root: "test-root" });
89
+ expect(renderMissingStorefront).toHaveBeenCalled();
90
+ expect(token).toEqual(void 0);
91
+ });
92
+ });
93
+ describe("when the storefront does not have an oxygen deployment token", () => {
94
+ beforeEach(() => {
95
+ vi.mocked(getOxygenToken).mockResolvedValue({
96
+ storefront: { oxygenDeploymentToken: "" }
97
+ });
98
+ });
99
+ it("returns nothing", async () => {
100
+ const token = await getOxygenDeploymentToken({ root: "test-root" });
101
+ expect(token).toEqual(void 0);
102
+ });
103
+ });
104
+ });
@@ -0,0 +1,21 @@
1
+ import { adminRequest } from './client.js';
2
+
3
+ const GetDeploymentTokenQuery = `#graphql
4
+ query GetDeploymentToken($id: ID!) {
5
+ hydrogenStorefront(id: $id) {
6
+ oxygenDeploymentToken
7
+ }
8
+ }
9
+ `;
10
+ async function getOxygenToken(adminSession, storefrontId) {
11
+ const { hydrogenStorefront } = await adminRequest(
12
+ GetDeploymentTokenQuery,
13
+ adminSession,
14
+ {
15
+ id: storefrontId
16
+ }
17
+ );
18
+ return { storefront: hydrogenStorefront };
19
+ }
20
+
21
+ export { GetDeploymentTokenQuery, getOxygenToken };
@@ -1,4 +1,5 @@
1
1
  import http from 'node:http';
2
+ import { handleRemixImportFail } from './remix-config.js';
2
3
 
3
4
  async function setupLiveReload(devServerPort) {
4
5
  try {
@@ -7,7 +8,7 @@ async function setupLiveReload(devServerPort) {
7
8
  import('@remix-run/dev/dist/devServer_unstable/socket.js'),
8
9
  import('@remix-run/dev/dist/devServer_unstable/hdr.js'),
9
10
  import('@remix-run/dev/dist/result.js')
10
- ]);
11
+ ]).catch(handleRemixImportFail);
11
12
  const state = {};
12
13
  const server = http.createServer(function(req, res) {
13
14
  res.writeHead(200);
package/dist/lib/log.js CHANGED
@@ -37,10 +37,17 @@ function injectLogReplacer(method, debouncer) {
37
37
  console[method] = (...args) => {
38
38
  if (debounceMessage(args, debouncer?.(args)))
39
39
  return;
40
- const replacer = messageReplacers.find(([matcher]) => matcher(args))?.[1];
41
- if (!replacer)
40
+ const replacers = messageReplacers.reduce((acc, [matcher, replacer]) => {
41
+ if (matcher(args))
42
+ acc.push(replacer);
43
+ return acc;
44
+ }, []);
45
+ if (replacers.length === 0)
42
46
  return originalConsole[method](...args);
43
- const result = replacer(args);
47
+ const result = replacers.reduce(
48
+ (resultArgs, replacer) => resultArgs && replacer(resultArgs),
49
+ args
50
+ );
44
51
  if (result)
45
52
  return originalConsole[method](...result);
46
53
  };
@@ -48,8 +55,10 @@ function injectLogReplacer(method, debouncer) {
48
55
  }
49
56
  function muteDevLogs({ workerReload } = {}) {
50
57
  injectLogReplacer("log");
58
+ injectLogReplacer("error");
59
+ injectLogReplacer("warn");
51
60
  let isFirstWorkerReload = true;
52
- addMessageReplacers("dev", [
61
+ addMessageReplacers("dev-node", [
53
62
  ([first]) => typeof first === "string" && first.includes("[mf:"),
54
63
  (args) => {
55
64
  const first = args[0];
@@ -65,6 +74,29 @@ function muteDevLogs({ workerReload } = {}) {
65
74
  }
66
75
  }
67
76
  ]);
77
+ addMessageReplacers(
78
+ "dev-workerd",
79
+ [
80
+ // Workerd logs
81
+ ([first]) => typeof first === "string" && /^\x1B\[31m(workerd\/|stack:)/.test(first),
82
+ () => {
83
+ }
84
+ ],
85
+ // Non-actionable warnings/errors:
86
+ [
87
+ ([first]) => typeof first === "string" && /^A promise rejection/i.test(first),
88
+ () => {
89
+ }
90
+ ],
91
+ [
92
+ ([first]) => {
93
+ const message = first?.message ?? first;
94
+ return typeof message === "string" && /^Network connection lost/i.test(message);
95
+ },
96
+ () => {
97
+ }
98
+ ]
99
+ );
68
100
  }
69
101
  const originalWrite = process.stdout.write;
70
102
  function muteAuthLogs({
@@ -76,12 +108,17 @@ function muteAuthLogs({
76
108
  process.stdout.write = (item, cb) => {
77
109
  if (typeof item !== "string")
78
110
  return write(item, cb);
79
- const replacer = messageReplacers.find(
80
- ([matcher]) => matcher([item])
81
- )?.[1];
82
- if (!replacer)
111
+ const replacers = messageReplacers.reduce((acc, [matcher, replacer]) => {
112
+ if (matcher([item]))
113
+ acc.push(replacer);
114
+ return acc;
115
+ }, []);
116
+ if (replacers.length === 0)
83
117
  return write(item, cb);
84
- const result = replacer([item]);
118
+ const result = replacers.reduce(
119
+ (resultArgs, replacer) => resultArgs && replacer(resultArgs),
120
+ [item]
121
+ );
85
122
  if (result)
86
123
  return write(result[0], cb);
87
124
  };
@@ -129,13 +166,13 @@ function enhanceH2Logs(options) {
129
166
  addMessageReplacers("h2-warn", [
130
167
  ([first]) => {
131
168
  const message = first?.message ?? first;
132
- return typeof message === "string" && message.startsWith("[h2:");
169
+ return typeof message === "string" && message.includes("[h2:");
133
170
  },
134
171
  (args) => {
135
172
  const firstArg = args[0];
136
173
  const errorObject = typeof firstArg === "object" && !!firstArg.stack ? firstArg : void 0;
137
174
  const stringArg = errorObject?.message ?? firstArg;
138
- const [, type, scope, message] = stringArg.match(/^\[h2:([^:]+):([^\]]+)\]\s+(.*)$/ims) || [];
175
+ const [, type, scope, message] = stringArg.match(/\[h2:([^:]+):([^\]]+)\]\s+(.*)$/ims) || [];
139
176
  if (!type || !scope || !message)
140
177
  return args;
141
178
  const headline = `In Hydrogen's \`${scope.trim()}\`:
@@ -149,8 +186,14 @@ function enhanceH2Logs(options) {
149
186
  if (type === "error" || errorObject) {
150
187
  let tryMessage = hasLinks ? lastLine : void 0;
151
188
  let stack = errorObject?.stack;
152
- const cause = errorObject?.cause;
153
- if (!!cause?.graphql?.query) {
189
+ let cause = errorObject?.cause;
190
+ if (typeof cause === "string") {
191
+ try {
192
+ cause = JSON.parse(cause);
193
+ } catch {
194
+ }
195
+ }
196
+ if (typeof cause !== "string" && !!cause?.graphql?.query) {
154
197
  const { query, variables } = cause.graphql;
155
198
  const link = `${options.graphiqlUrl}?query=${encodeURIComponent(
156
199
  query
@@ -0,0 +1,58 @@
1
+ import { outputToken, outputInfo, outputContent } from '@shopify/cli-kit/node/output';
2
+ import colors from '@shopify/cli-kit/node/colors';
3
+ import { DEV_ROUTES } from '../request-events.js';
4
+
5
+ function logRequestLine(request, {
6
+ responseStatus = 200,
7
+ durationMs = 0
8
+ } = {}) {
9
+ try {
10
+ const url = new URL(request.url);
11
+ if (DEV_ROUTES.has(url.pathname))
12
+ return;
13
+ const isDataRequest = url.searchParams.has("_data");
14
+ let route = request.url.replace(url.origin, "");
15
+ let info = "";
16
+ let type = "render";
17
+ if (isDataRequest) {
18
+ type = request.method === "GET" ? "loader" : "action";
19
+ const dataParam = url.searchParams.get("_data")?.replace("routes/", "");
20
+ route = url.pathname;
21
+ info = `[${dataParam}]`;
22
+ }
23
+ const colorizeStatus = responseStatus < 300 ? outputToken.green : responseStatus < 400 ? outputToken.cyan : outputToken.errorText;
24
+ outputInfo(
25
+ outputContent`${request.method.padStart(6)} ${colorizeStatus(
26
+ String(responseStatus)
27
+ )} ${outputToken.italic(type.padEnd(7, " "))} ${route} ${durationMs > 0 ? colors.dim(` ${durationMs}ms`) : ""}${info ? " " + colors.dim(info) : ""}${request.headers.get("purpose") === "prefetch" ? outputToken.italic(colors.dim(" prefetch")) : ""}`
28
+ );
29
+ } catch {
30
+ if (request && responseStatus) {
31
+ outputInfo(`${request.method} ${responseStatus} ${request.url}`);
32
+ }
33
+ }
34
+ }
35
+ const OXYGEN_HEADERS_MAP = {
36
+ ip: { name: "oxygen-buyer-ip", defaultValue: "127.0.0.1" },
37
+ longitude: { name: "oxygen-buyer-longitude", defaultValue: "-122.40140" },
38
+ latitude: { name: "oxygen-buyer-latitude", defaultValue: "37.78855" },
39
+ continent: { name: "oxygen-buyer-continent", defaultValue: "NA" },
40
+ country: { name: "oxygen-buyer-country", defaultValue: "US" },
41
+ region: { name: "oxygen-buyer-region", defaultValue: "California" },
42
+ regionCode: { name: "oxygen-buyer-region-code", defaultValue: "CA" },
43
+ city: { name: "oxygen-buyer-city", defaultValue: "San Francisco" },
44
+ isEuCountry: { name: "oxygen-buyer-is-eu-country", defaultValue: "" },
45
+ timezone: {
46
+ name: "oxygen-buyer-timezone",
47
+ defaultValue: "America/Los_Angeles"
48
+ },
49
+ // Not documented but available in Oxygen:
50
+ deploymentId: { name: "oxygen-buyer-deployment-id", defaultValue: "local" },
51
+ shopId: { name: "oxygen-buyer-shop-id", defaultValue: "development" },
52
+ storefrontId: {
53
+ name: "oxygen-buyer-storefront-id",
54
+ defaultValue: "development"
55
+ }
56
+ };
57
+
58
+ export { OXYGEN_HEADERS_MAP, logRequestLine };
@@ -0,0 +1,12 @@
1
+ async function startMiniOxygen(options, useWorkerd = false) {
2
+ if (useWorkerd) {
3
+ const { startWorkerdServer } = await import('./workerd.js');
4
+ return startWorkerdServer(options);
5
+ } else {
6
+ process.env.MINIFLARE_SUBREQUEST_LIMIT = 100;
7
+ const { startNodeServer } = await import('./node.js');
8
+ return startNodeServer(options);
9
+ }
10
+ }
11
+
12
+ export { startMiniOxygen };
@@ -0,0 +1,110 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
+ import { resolvePath } from '@shopify/cli-kit/node/path';
4
+ import { readFile } from '@shopify/cli-kit/node/fs';
5
+ import { renderSuccess } from '@shopify/cli-kit/node/ui';
6
+ import { startServer, Request } from '@shopify/mini-oxygen';
7
+ import { DEFAULT_PORT } from '../flags.js';
8
+ import { OXYGEN_HEADERS_MAP, logRequestLine } from './common.js';
9
+ import { clearHistory, streamRequestEvents, logRequestEvent } from '../request-events.js';
10
+
11
+ async function startNodeServer({
12
+ root,
13
+ port = DEFAULT_PORT,
14
+ watch = false,
15
+ buildPathWorkerFile,
16
+ buildPathClient,
17
+ env
18
+ }) {
19
+ resolvePath(root, ".env");
20
+ const oxygenHeaders = Object.fromEntries(
21
+ Object.entries(OXYGEN_HEADERS_MAP).map(([key, value]) => {
22
+ return [key, value.defaultValue];
23
+ })
24
+ );
25
+ const asyncLocalStorage = new AsyncLocalStorage();
26
+ const serviceBindings = {
27
+ H2O_LOG_EVENT: {
28
+ fetch: (request) => logRequestEvent(
29
+ new Request(request.url, {
30
+ headers: {
31
+ ...Object.fromEntries(request.headers.entries()),
32
+ // Merge some headers from the parent request
33
+ ...asyncLocalStorage.getStore()
34
+ }
35
+ })
36
+ )
37
+ }
38
+ };
39
+ const miniOxygen = await startServer({
40
+ script: await readFile(buildPathWorkerFile),
41
+ workerFile: buildPathWorkerFile,
42
+ assetsDir: buildPathClient,
43
+ publicPath: "",
44
+ port,
45
+ watch,
46
+ autoReload: watch,
47
+ modules: true,
48
+ env: {
49
+ ...env,
50
+ ...process.env,
51
+ ...serviceBindings
52
+ },
53
+ log: () => {
54
+ },
55
+ oxygenHeaders,
56
+ async onRequest(request, defaultDispatcher) {
57
+ const url = new URL(request.url);
58
+ if (url.pathname === "/debug-network-server") {
59
+ return request.method === "DELETE" ? clearHistory() : streamRequestEvents(request);
60
+ }
61
+ let requestId = request.headers.get("request-id");
62
+ if (!requestId) {
63
+ requestId = randomUUID();
64
+ request.headers.set("request-id", requestId);
65
+ }
66
+ const startTimeMs = Date.now();
67
+ const response = await asyncLocalStorage.run(
68
+ { "request-id": requestId, purpose: request.headers.get("purpose") },
69
+ () => defaultDispatcher(request)
70
+ );
71
+ logRequestLine(request, {
72
+ responseStatus: response.status,
73
+ durationMs: startTimeMs > 0 ? Date.now() - startTimeMs : 0
74
+ });
75
+ return response;
76
+ }
77
+ });
78
+ const listeningAt = `http://localhost:${miniOxygen.port}`;
79
+ return {
80
+ listeningAt,
81
+ port: miniOxygen.port,
82
+ async reload(options) {
83
+ const nextOptions = {};
84
+ if (options?.env) {
85
+ nextOptions.env = {
86
+ ...options.env,
87
+ ...process.env
88
+ };
89
+ }
90
+ nextOptions.script = await readFile(buildPathWorkerFile);
91
+ await miniOxygen.reload(nextOptions);
92
+ },
93
+ showBanner(options) {
94
+ console.log("");
95
+ renderSuccess({
96
+ headline: `${options?.headlinePrefix ?? ""}MiniOxygen ${options?.mode ?? "development"} server running.`,
97
+ body: [
98
+ `View ${options?.appName ?? "Hydrogen"} app: ${listeningAt}`,
99
+ ...options?.extraLines ?? []
100
+ ]
101
+ });
102
+ console.log("");
103
+ },
104
+ async close() {
105
+ await miniOxygen.close();
106
+ }
107
+ };
108
+ }
109
+
110
+ export { startNodeServer };
@@ -0,0 +1 @@
1
+