diff --git a/.changeset/pre/local-https-vite-plugin.md b/.changeset/pre/local-https-vite-plugin.md index 59a33e324e..bcbee47354 100644 --- a/.changeset/pre/local-https-vite-plugin.md +++ b/.changeset/pre/local-https-vite-plugin.md @@ -5,3 +5,7 @@ Add `localHttps()` under `@shopify/hydrogen/vite` for portable local HTTPS development with Customer Account API flows. Frameworks that terminate HTTPS outside Vite can use `localHttps(...).api.getDevServerConfig()`. Certificates can be provisioned by the plugin (after confirmation on `vite dev`), the `provisionLocalHttps()` helper, or the `hydrogen certs install` CLI command. Each path downloads a pinned, checksum-verified mkcert release for macOS, Linux, or Windows, installs the local certificate authority, and generates the certificate files. The plugin skips automatic provisioning in CI environments; the explicit paths remain available there. The paired `hydrogen certs uninstall` command removes Hydrogen's files and can remove the shared mkcert CA when passed `--remove-ca`. + +When a local HTTPS server starts outside CI, the plugin uses Shopify CLI to link an unlinked project and push the callback, portless JavaScript origin, and logout URLs to the Customer Account API configuration. Shopify CLI must include `@shopify/cli-hydrogen` 13.0.4 or later. CI, missing CLI support, cancelled linking, and push failures fall back to printing the values for manual configuration without stopping the development server. + +Framework templates and examples expose local HTTPS through the `dev:https` package script, which the Vite configuration detects through `npm_lifecycle_event`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fa9eb1964..a99bd38570 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,3 +54,55 @@ jobs: - name: Test run: pnpm run test + + local-https: + name: Local HTTPS (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + env: + npm_config_registry: https://registry.npmjs.org/ + TURBO_TELEMETRY_DISABLED: "1" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 + with: + version: 10.33.0 + + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version-file: package.json + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Provision local HTTPS certificate + if: runner.os != 'Windows' + run: pnpm https:setup + + - name: Provision local HTTPS certificate on Windows + if: runner.os == 'Windows' + shell: pwsh + env: + # GitHub-hosted runners cannot accept Windows' root-store prompt. + # Generate the same CA and provide it directly to Node's TLS verifier. + TRUST_STORES: none + run: | + pnpm https:setup + $rootCertificate = Join-Path $env:LOCALAPPDATA 'mkcert\rootCA.pem' + if (!(Test-Path $rootCertificate)) { + throw "mkcert root CA was not created at $rootCertificate" + } + Add-Content -Path $env:GITHUB_ENV -Value "NODE_EXTRA_CA_CERTS=$rootCertificate" + + - name: Verify trusted local HTTPS + run: pnpm run test:local-https diff --git a/.oxlintrc.json b/.oxlintrc.json index 238d6805d1..95190e5b4e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -24,6 +24,7 @@ "scripts/**", "!scripts/copy-hydrogen-to-preview*.ts", "!scripts/preview-template-dist*.ts", + "!scripts/test-local-https.ts", "examples/**", "!examples/shared/local-cdn-assets-plugin/**/*.ts", "examples/hydrogen/**", diff --git a/AGENTS.md b/AGENTS.md index 1dbf7a8efb..e6bffb4b50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,5 +18,6 @@ When designing or adjusting APIs for the `hydrogen` package, closely follow the - Account-enabled framework examples use `https://local.tryhydrogen.dev:5173` for Customer Account OAuth callback testing. - Vite-based examples consume Hydrogen's default certificates. Certificates are provisioned automatically on `dev:https` startup. This downloads a pinned, checksum-verified mkcert release, trusts the local certificate authority, and creates the certificates under `~/.shopify/hydrogen/certs/`. Nuxt and SolidStart may need one restart after first-run provisioning so their outer dev servers can load the certificate files. +- Outside CI, the local HTTPS plugin uses Shopify CLI to link an unlinked Hydrogen storefront and push the Customer Account callback, JavaScript origin, and logout URLs. Failures fall back to printing the values for manual configuration. - The Next.js example provisions its own certificate. The Hydrogen example uses the Shopify CLI tunnel flow. - Run the relevant example with `pnpm --filter @shopify/hydrogen-example- dev:https` when that example provides the script. diff --git a/examples/astro/README.md b/examples/astro/README.md index 322a3adb1d..f4c87d2216 100644 --- a/examples/astro/README.md +++ b/examples/astro/README.md @@ -37,12 +37,14 @@ Port of the canonical `examples/core` design to [Astro](https://astro.build/) ru The account flow uses `createCustomerSession` and `createCustomerAccountServerHandlers` from `@shopify/hydrogen/customer-account`, Customer Account values from `examples/shared/config.ts`, and an encrypted HttpOnly `__Host-` cookie adapter from `examples/shared/customer-session.ts`. -Customer Account OAuth requires a public HTTPS origin. To test locally without a tunnel, register `https://local.tryhydrogen.dev:5173/account/authorize` as the callback URI and run: +Customer Account OAuth requires a public HTTPS origin. To test locally without a tunnel, run: ```sh pnpm --filter @shopify/hydrogen-example-astro dev:https ``` +The local HTTPS plugin provisions the certificate, links an unlinked Hydrogen storefront, and pushes the Customer Account callback, JavaScript origin, and logout URLs. If automatic setup is unavailable, it prints the values for manual configuration. + ## Run ```sh diff --git a/examples/solid-start/README.md b/examples/solid-start/README.md index af2d335cf1..3e04396aed 100644 --- a/examples/solid-start/README.md +++ b/examples/solid-start/README.md @@ -47,6 +47,8 @@ pnpm --filter @shopify/hydrogen-example-solid-start dev:https On the first run, restart the command after the Vite plugin provisions the certificate so Vinxi can load it. +When the server starts, the local HTTPS plugin links an unlinked Hydrogen storefront and pushes the Customer Account callback, JavaScript origin, and logout URLs. If automatic setup is unavailable, it prints the values for manual configuration. + ## Run ```sh diff --git a/examples/sveltekit/README.md b/examples/sveltekit/README.md index 92e85f720c..9a2d65a4a2 100644 --- a/examples/sveltekit/README.md +++ b/examples/sveltekit/README.md @@ -39,12 +39,14 @@ Port of the canonical `examples/core` design to [SvelteKit 2](https://svelte.dev The account flow uses `createCustomerSession` and `createCustomerAccountServerHandlers` from `@shopify/hydrogen/customer-account`, Customer Account values from `examples/shared/config.ts`, and an encrypted HttpOnly `__Host-` cookie adapter from `examples/shared/customer-session.ts`. -Customer Account OAuth requires a public HTTPS origin. To test locally without a tunnel, register `https://local.tryhydrogen.dev:5173/account/authorize` as the callback URI and run: +Customer Account OAuth requires a public HTTPS origin. To test locally without a tunnel, run: ```sh pnpm --filter @shopify/hydrogen-example-sveltekit dev:https ``` +The local HTTPS plugin provisions the certificate, links an unlinked Hydrogen storefront, and pushes the Customer Account callback, JavaScript origin, and logout URLs. If automatic setup is unavailable, it prints the values for manual configuration. + ## Run ```sh diff --git a/package.json b/package.json index b9bcc08e66..be470dd466 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dev:svelte": "turbo run dev --filter=@shopify/hydrogen-example-sveltekit...", "dev:hydrogen": "pnpm --dir examples/hydrogen dev", "dev:hub": "node scripts/examples-dev.ts", + "https:setup": "turbo run build --filter=@shopify/hydrogen && node packages/hydrogen/bin/hydrogen.mjs certs install", "download:standard-types": "node scripts/download-standard-types.ts", "prepare:preview-dist": "node scripts/preview-template-dist.ts prepare", "validate:preview-dist": "node scripts/preview-template-dist.ts validate", @@ -25,14 +26,15 @@ "benchmark:harness": "node scripts/storefront-benchmark-harness/run-opencode-docker.ts", "typecheck": "turbo run typecheck", "libcheck": "turbo run libcheck --filter='./packages/*'", - "lint": "oxlint --max-warnings=0 packages/ examples/ templates/ scripts/preview-template-dist*.ts", - "lint:ci": "oxlint --format github --max-warnings=0 packages/ examples/ templates/ scripts/preview-template-dist*.ts", + "lint": "oxlint --max-warnings=0 packages/ examples/ templates/ scripts/preview-template-dist*.ts scripts/test-local-https.ts", + "lint:ci": "oxlint --format github --max-warnings=0 packages/ examples/ templates/ scripts/preview-template-dist*.ts scripts/test-local-https.ts", "format": "oxfmt", "format:check": "oxfmt --check", "changeset": "changeset", "version-packages": "changeset version", "test": "pnpm run test:scripts && turbo run test", "test:scripts": "node --test scripts/*.test.ts templates/*/__test__/*.test.ts", + "test:local-https": "node --use-system-ca scripts/test-local-https.ts", "test:benchmark-harness": "tsc -p scripts/storefront-benchmark-harness/tsconfig.json && node --test scripts/storefront-benchmark-harness/*.test.ts", "check": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test", "test:e2e:storefront": "turbo run test:e2e --filter @shopify/storefront-e2e" diff --git a/packages/hydrogen/README.md b/packages/hydrogen/README.md index 7428223c50..8d29ba3a2b 100644 --- a/packages/hydrogen/README.md +++ b/packages/hydrogen/README.md @@ -69,7 +69,7 @@ const isLoggedIn = await customerSession.isLoggedIn( ); ``` -Customer Account OAuth methods require a public HTTPS origin. The writable session manager should expose the request origin; explicit `origin` options are only needed as overrides. For local development, use a tunnel or trusted local HTTPS through `localHttps` from `@shopify/hydrogen/vite`, and pass the framework's canonical request URL rather than an untrusted forwarded host. +Customer Account OAuth methods require a public HTTPS origin. The writable session manager should expose the request origin; explicit `origin` options are only needed as overrides. For local development, use a tunnel or trusted local HTTPS through `localHttps` from `@shopify/hydrogen/vite`, which provisions certificates and uses Shopify CLI to push Customer Account URLs outside CI. Pass the framework's canonical request URL rather than an untrusted forwarded host. Pass `customerSession` to `createCartServerHandlers({customerSession})` to associate newly created carts with the current customer when the session has a usable access token or successfully refreshed access token, and mark checkout URLs in authenticated cart GET responses with `logged_in=true`. diff --git a/packages/hydrogen/package.json b/packages/hydrogen/package.json index ebb41baf5c..5c18e5f743 100644 --- a/packages/hydrogen/package.json +++ b/packages/hydrogen/package.json @@ -93,6 +93,7 @@ "postcodegen": "node scripts/postprocess-tada-env.ts && oxfmt src/graphql/generated/*.d.ts src/graphql/generated/*.json" }, "dependencies": { + "cross-spawn": "7.0.6", "gql.tada": "1.9.2" }, "devDependencies": { @@ -100,6 +101,7 @@ "@graphql-codegen/introspection": "^6.0.0", "@graphql-codegen/typescript": "^6.0.0", "@testing-library/react": "^16.3.2", + "@types/cross-spawn": "6.0.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vue/test-utils": "^2.4.0", diff --git a/packages/hydrogen/skills/hydrogen-customer-account/SKILL.md b/packages/hydrogen/skills/hydrogen-customer-account/SKILL.md index a5cd93e3a2..3a12f55f0b 100644 --- a/packages/hydrogen/skills/hydrogen-customer-account/SKILL.md +++ b/packages/hydrogen/skills/hydrogen-customer-account/SKILL.md @@ -104,4 +104,4 @@ The same `@shopify/hydrogen/ts-plugin` and `hydrogen gql check` setup from the ` ## Local OAuth -Customer Account OAuth needs a public HTTPS callback origin. For local examples, use a trusted local HTTPS hostname and register the exact `/account/authorize` callback URL in the Customer Account app configuration. +Customer Account OAuth needs a public HTTPS origin. For local development, follow the `hydrogen-local-https` skill; its Vite plugin provisions a trusted certificate and pushes the callback, JavaScript origin, and logout URLs through Shopify CLI outside CI. diff --git a/packages/hydrogen/src/vite/customer-account.test.ts b/packages/hydrogen/src/vite/customer-account.test.ts new file mode 100644 index 0000000000..f7e9e8b745 --- /dev/null +++ b/packages/hydrogen/src/vite/customer-account.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + configureCustomerAccountUrls, + formatCustomerAccountSettings, + resolveCustomerAccountUrls, +} from "./customer-account"; + +const ROOT = "/project"; +const URLS = resolveCustomerAccountUrls("local.tryhydrogen.dev", 5_173); + +describe("resolveCustomerAccountUrls", () => { + it("keeps the JavaScript origin portless", () => { + expect(URLS).toEqual({ + callbackUri: "https://local.tryhydrogen.dev:5173/account/authorize", + devOrigin: "https://local.tryhydrogen.dev:5173", + javascriptOrigin: "https://local.tryhydrogen.dev", + logoutUri: "https://local.tryhydrogen.dev:5173", + }); + }); +}); + +describe("formatCustomerAccountSettings", () => { + it("prints all manual Customer Account API values", () => { + const output = formatCustomerAccountSettings(URLS); + + expect(output).toContain(URLS.callbackUri); + expect(output).toContain(`JavaScript origin(s): ${URLS.javascriptOrigin}\n`); + expect(output).toContain(`Logout URI: ${URLS.logoutUri}`); + }); +}); + +describe("configureCustomerAccountUrls", () => { + it("skips all Shopify CLI commands in CI and prints manual values", async () => { + const { logger, runShopifyCommand } = setup(); + + await configureCustomerAccountUrls( + { logger, root: ROOT, urls: URLS }, + { isCI: () => true, runShopifyCommand }, + ); + + expect(runShopifyCommand).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining(URLS.callbackUri)); + }); + + it("instructs users to update Shopify CLI when Hydrogen CLI is too old", async () => { + const { logger, runShopifyCommand } = setup({ version: "13.0.3" }); + + await configureCustomerAccountUrls( + { logger, root: ROOT, urls: URLS }, + { isCI: () => false, runShopifyCommand }, + ); + + expect(runShopifyCommand).toHaveBeenCalledOnce(); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("13.0.4 or later")); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("@shopify/cli@latest")); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining(URLS.callbackUri)); + }); + + it("links an unlinked project before pushing the derived origins", async () => { + let linked = false; + const hasLinkedStorefront = vi.fn(async () => linked); + const { logger, runShopifyCommand } = setup({ + onCommand(args) { + if (args[1] === "link") linked = true; + }, + }); + + await configureCustomerAccountUrls( + { logger, root: ROOT, urls: URLS }, + { hasLinkedStorefront, isCI: () => false, runShopifyCommand }, + ); + + expect(runShopifyCommand.mock.calls.map(([args]) => args)).toEqual([ + ["plugins", "--core", "--json"], + ["hydrogen", "link", "--path", ROOT], + [ + "hydrogen", + "customer-account-push", + "--path", + ROOT, + "--dev-origin", + URLS.devOrigin, + "--javascript-origin", + URLS.javascriptOrigin, + ], + ]); + expect(hasLinkedStorefront).toHaveBeenCalledTimes(2); + expect(logger.info).toHaveBeenCalledWith( + `Customer Account API settings updated for ${URLS.devOrigin}.`, + ); + }); + + it("pushes without linking when the project is already linked", async () => { + const { logger, runShopifyCommand } = setup(); + + await configureCustomerAccountUrls( + { logger, root: ROOT, urls: URLS }, + { + hasLinkedStorefront: async () => true, + isCI: () => false, + runShopifyCommand, + }, + ); + + expect(runShopifyCommand.mock.calls.map(([args]) => args)).toEqual([ + ["plugins", "--core", "--json"], + expect.arrayContaining(["customer-account-push"]), + ]); + }); + + it("warns with manual values and keeps going when linking is cancelled", async () => { + const { logger, runShopifyCommand } = setup(); + + await configureCustomerAccountUrls( + { logger, root: ROOT, urls: URLS }, + { + hasLinkedStorefront: async () => false, + isCI: () => false, + runShopifyCommand, + }, + ); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("finished without linking a Hydrogen storefront"), + ); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining(URLS.callbackUri)); + }); + + it("warns with manual values when the push fails", async () => { + const { logger, runShopifyCommand } = setup({ + onCommand(args) { + if (args.includes("customer-account-push")) throw new Error("access denied"); + }, + }); + + await configureCustomerAccountUrls( + { logger, root: ROOT, urls: URLS }, + { + hasLinkedStorefront: async () => true, + isCI: () => false, + runShopifyCommand, + }, + ); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("access denied")); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining(URLS.callbackUri)); + }); +}); + +function setup({ + version = "13.0.4", + onCommand, +}: { version?: string; onCommand?: (args: string[]) => void } = {}) { + const logger = { info: vi.fn(), warn: vi.fn() }; + const runShopifyCommand = vi.fn(async (args: string[]) => { + onCommand?.(args); + if (args[0] !== "plugins") return ""; + + return JSON.stringify([ + { + pjson: { + name: "@shopify/cli", + devDependencies: { "@shopify/cli-hydrogen": version }, + }, + }, + ]); + }); + + return { logger, runShopifyCommand }; +} diff --git a/packages/hydrogen/src/vite/customer-account.ts b/packages/hydrogen/src/vite/customer-account.ts new file mode 100644 index 0000000000..9fbbed232c --- /dev/null +++ b/packages/hydrogen/src/vite/customer-account.ts @@ -0,0 +1,266 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import spawn from "cross-spawn"; + +const CUSTOMER_ACCOUNT_AUTHORIZE_PATH = "/account/authorize"; +const MINIMUM_HYDROGEN_CLI_VERSION = "13.0.4"; +const SUCCESS_EXIT_CODE = 0; + +type Logger = { + info(message: string): void; + warn(message: string): void; +}; + +type RunShopifyCommand = ( + args: string[], + options: { captureOutput?: boolean; cwd: string }, +) => Promise; + +type CustomerAccountSetupDependencies = { + hasLinkedStorefront?: (root: string) => Promise; + isCI?: () => boolean; + runShopifyCommand?: RunShopifyCommand; +}; + +export type CustomerAccountUrls = { + callbackUri: string; + devOrigin: string; + javascriptOrigin: string; + logoutUri: string; +}; + +export function resolveCustomerAccountUrls(host: string, port: number): CustomerAccountUrls { + const javascriptOrigin = `https://${host}`; + const devOrigin = `${javascriptOrigin}:${port}`; + + return { + callbackUri: `${devOrigin}${CUSTOMER_ACCOUNT_AUTHORIZE_PATH}`, + devOrigin, + javascriptOrigin, + logoutUri: devOrigin, + }; +} + +export function formatCustomerAccountSettings(urls: CustomerAccountUrls) { + return [ + "", + "Customer Account API - configure these values for your storefront:", + "", + ` Callback URI(s) (required): ${urls.callbackUri}`, + ` JavaScript origin(s): ${urls.javascriptOrigin}`, + ` Logout URI: ${urls.logoutUri}`, + "", + ].join("\n"); +} + +export function isContinuousIntegration() { + const ci = process.env.CI; + return ci !== undefined && ci !== "" && ci !== "false" && ci !== "0"; +} + +export async function configureCustomerAccountUrls( + { + logger, + root, + urls, + }: { + logger: Logger; + root: string; + urls: CustomerAccountUrls; + }, + dependencies: CustomerAccountSetupDependencies = {}, +) { + const isCI = dependencies.isCI ?? isContinuousIntegration; + if (isCI()) { + logger.info(formatCustomerAccountSettings(urls)); + return; + } + + const runCommand = dependencies.runShopifyCommand ?? runShopifyCommand; + + try { + await requireCompatibleShopifyCli(root, runCommand); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + logger.warn( + [ + "Automatic Customer Account API setup was skipped.", + reason, + "Install the latest Shopify CLI and restart the development server:", + " npm install -g @shopify/cli@latest", + formatCustomerAccountSettings(urls), + ].join("\n"), + ); + return; + } + + const hasLinkedStorefront = dependencies.hasLinkedStorefront ?? projectHasLinkedStorefront; + + try { + if (!(await hasLinkedStorefront(root))) { + logger.info("No linked Hydrogen storefront found. Starting Shopify CLI linking..."); + await runCommand(["hydrogen", "link", "--path", root], { cwd: root }); + + if (!(await hasLinkedStorefront(root))) { + throw new Error("Shopify CLI finished without linking a Hydrogen storefront."); + } + } + + logger.info("Updating Customer Account API settings with Shopify CLI..."); + await runCommand( + [ + "hydrogen", + "customer-account-push", + "--path", + root, + "--dev-origin", + urls.devOrigin, + "--javascript-origin", + urls.javascriptOrigin, + ], + { cwd: root }, + ); + logger.info(`Customer Account API settings updated for ${urls.devOrigin}.`); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + logger.warn( + [ + "Local HTTPS is ready, but Customer Account API setup could not be completed:", + ` ${reason}`, + formatCustomerAccountSettings(urls), + ].join("\n"), + ); + } +} + +class ShopifyCliRequirementError extends Error {} + +async function requireCompatibleShopifyCli(root: string, runCommand: RunShopifyCommand) { + const hydrogenCliVersion = await getHydrogenCliVersion(root, runCommand); + if (!isVersionAtLeast(hydrogenCliVersion, MINIMUM_HYDROGEN_CLI_VERSION)) { + throw new ShopifyCliRequirementError( + `Found @shopify/cli-hydrogen ${hydrogenCliVersion}; ${MINIMUM_HYDROGEN_CLI_VERSION} or later is required.`, + ); + } +} + +async function getHydrogenCliVersion(root: string, runCommand: RunShopifyCommand) { + let output: string; + try { + output = await runCommand(["plugins", "--core", "--json"], { + captureOutput: true, + cwd: root, + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new ShopifyCliRequirementError( + `A compatible Shopify CLI installation was not found. ${reason}`, + ); + } + + let plugins: unknown; + try { + plugins = JSON.parse(output); + } catch { + throw new ShopifyCliRequirementError( + "Shopify CLI did not return valid plugin version information.", + ); + } + + if (!Array.isArray(plugins)) { + throw new ShopifyCliRequirementError("Shopify CLI did not return plugin version information."); + } + + for (const plugin of plugins) { + if (!isRecord(plugin)) continue; + const packageJson = plugin.pjson; + if (!isRecord(packageJson) || packageJson.name !== "@shopify/cli") continue; + + const devDependencies = packageJson.devDependencies; + if (!isRecord(devDependencies)) break; + + const version = devDependencies["@shopify/cli-hydrogen"]; + if (typeof version === "string") return version; + break; + } + + throw new ShopifyCliRequirementError( + "The installed Shopify CLI does not include @shopify/cli-hydrogen.", + ); +} + +function isVersionAtLeast(version: string, minimum: string) { + const parsedVersion = parseVersion(version); + const parsedMinimum = parseVersion(minimum); + if (!parsedVersion || !parsedMinimum) return false; + + for (let index = 0; index < parsedMinimum.numbers.length; index += 1) { + const difference = parsedVersion.numbers[index] - parsedMinimum.numbers[index]; + if (difference !== 0) return difference > 0; + } + + return parsedVersion.prerelease === undefined; +} + +function parseVersion(version: string) { + const match = /^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+.+)?$/.exec(version); + if (!match) return; + + return { + numbers: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: match[4], + }; +} + +async function projectHasLinkedStorefront(root: string) { + try { + const project = JSON.parse(await readFile(join(root, ".shopify", "project.json"), "utf8")); + return ( + isRecord(project) && + isRecord(project.storefront) && + typeof project.storefront.id === "string" && + project.storefront.id !== "" + ); + } catch { + return false; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function runShopifyCommand( + args: string[], + { captureOutput = false, cwd }: { captureOutput?: boolean; cwd: string }, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn("shopify", args, { + cwd, + shell: false, + stdio: captureOutput ? ["ignore", "pipe", "pipe"] : "inherit", + }); + let output = ""; + let errorOutput = ""; + + const collect = (target: "output" | "errorOutput") => (chunk: Buffer) => { + if (target === "output") output += chunk.toString(); + else errorOutput += chunk.toString(); + }; + + child.stdout?.on("data", collect("output")); + child.stderr?.on("data", collect("errorOutput")); + child.on("error", reject); + child.on("close", (code, signal) => { + if (code === SUCCESS_EXIT_CODE) { + resolve(output); + return; + } + + const reason = signal ? `was killed by ${signal}` : `exited with code ${code}`; + const detail = errorOutput.trim(); + reject(new Error(`shopify ${args.join(" ")} ${reason}${detail ? `\n${detail}` : ""}`)); + }); + }); +} diff --git a/packages/hydrogen/src/vite/index.test.ts b/packages/hydrogen/src/vite/index.test.ts index 7e385629dc..5d46f3c571 100644 --- a/packages/hydrogen/src/vite/index.test.ts +++ b/packages/hydrogen/src/vite/index.test.ts @@ -445,6 +445,7 @@ describe("localHttps", () => { }); it("logs derived Customer Account settings when the server starts listening", () => { + vi.stubEnv("CI", "true"); fs.writeFileSync(certPath, "certificate"); fs.writeFileSync(keyPath, "private-key"); const { info, listening } = configurePlugin({ certPath, keyPath }); @@ -460,6 +461,7 @@ describe("localHttps", () => { }); it("logs the port the server actually bound instead of the configured port", () => { + vi.stubEnv("CI", "true"); fs.writeFileSync(certPath, "certificate"); fs.writeFileSync(keyPath, "private-key"); const { info, listening, middleware } = configurePlugin( @@ -479,6 +481,7 @@ describe("localHttps", () => { }); it("logs Customer Account settings once across Vite server instances", () => { + vi.stubEnv("CI", "true"); fs.writeFileSync(certPath, "certificate"); fs.writeFileSync(keyPath, "private-key"); const first = configurePlugin({ certPath, keyPath }, { port: 4_322 }); @@ -492,6 +495,7 @@ describe("localHttps", () => { }); it("logs settings immediately when Vite has no HTTP server", () => { + vi.stubEnv("CI", "true"); fs.writeFileSync(certPath, "certificate"); fs.writeFileSync(keyPath, "private-key"); const info = vi.fn(); @@ -500,7 +504,7 @@ describe("localHttps", () => { configureServer({ middlewares: { use: vi.fn() }, - config: { logger: { info } }, + config: { logger: { info, warn: vi.fn() }, root: directory }, httpServer: null, } as any); @@ -516,6 +520,7 @@ describe("localHttps", () => { | undefined; let listening: (() => void) | undefined; const info = vi.fn(); + const warn = vi.fn(); const plugin = localHttps({ enabled: true, host: "custom.test", @@ -530,7 +535,7 @@ describe("localHttps", () => { middleware = handler; }, }, - config: { logger: { info } }, + config: { logger: { info, warn }, root: directory }, httpServer: { address: () => (options.boundPort ? { port: options.boundPort } : null), once(event: string, listener: () => void) { diff --git a/packages/hydrogen/src/vite/local-https.ts b/packages/hydrogen/src/vite/local-https.ts index 064692547d..df57e907f8 100644 --- a/packages/hydrogen/src/vite/local-https.ts +++ b/packages/hydrogen/src/vite/local-https.ts @@ -6,8 +6,12 @@ import { fileURLToPath } from "node:url"; import type { ConfigEnv, Plugin, ViteDevServer } from "vite"; -import { CUSTOMER_ACCOUNT_PATHS } from "../core/url"; import { confirmCertificateInstallation } from "./certificate-prompt"; +import { + configureCustomerAccountUrls, + isContinuousIntegration, + resolveCustomerAccountUrls, +} from "./customer-account"; import { provisionCertificates } from "./mkcert"; export const LOCAL_HTTPS_DEFAULTS = { @@ -27,7 +31,7 @@ const HTTP1_ONLY_RESPONSE_HEADERS = new Set([ "upgrade", ]); const emittedMissingCertificateWarnings = new Set(); -const loggedCustomerAccountSettings = new Set(); +const startedCustomerAccountSetups = new Set(); /** Options for Hydrogen's local HTTPS Vite plugin. */ export type LocalHttpsOptions = { @@ -219,11 +223,6 @@ async function ensureCertificateFiles(settings: LocalHttpsSettings): Promise !existsSync(path)); if (missingPaths.length === 0) return true; @@ -326,37 +325,26 @@ function configureLocalHttpsServer(server: ViteDevServer, settings: LocalHttpsSe next(); }); - const logSettings = () => { + const configureCustomerAccounts = () => { const port = resolveBoundPort(); - const settingsKey = `${settings.host}:${port}`; - if (loggedCustomerAccountSettings.has(settingsKey)) return; - loggedCustomerAccountSettings.add(settingsKey); - server.config.logger.info(formatCustomerAccountSettings({ host: settings.host, port })); + const settingsKey = `${server.config.root}:${settings.host}:${port}`; + if (startedCustomerAccountSetups.has(settingsKey)) return; + startedCustomerAccountSetups.add(settingsKey); + + void configureCustomerAccountUrls({ + logger: server.config.logger, + root: server.config.root, + urls: resolveCustomerAccountUrls(settings.host, port), + }); }; if (server.httpServer) { - server.httpServer.once("listening", logSettings); + server.httpServer.once("listening", configureCustomerAccounts); } else { - logSettings(); + configureCustomerAccounts(); } } -function formatCustomerAccountSettings({ host, port }: Pick) { - const origin = `https://${host}`; - const portfulOrigin = `${origin}:${port}`; - - return [ - "", - "Customer Account API — make sure these values are configured for your storefront:", - "", - ` Callback URI(s) (required): ${portfulOrigin}${CUSTOMER_ACCOUNT_PATHS.authorize}`, - // Shopify's server-side validation rejects JavaScript origins containing a port. - ` JavaScript origin(s): ${origin}`, - ` Logout URI: ${portfulOrigin}`, - "", - ].join("\n"); -} - function stripHttp1OnlyResponseHeaders(response: ServerResponse) { const originalWriteHead = response.writeHead.bind(response); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 284faee019..b61a5f880c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,6 +262,9 @@ importers: packages/hydrogen: dependencies: + cross-spawn: + specifier: 7.0.6 + version: 7.0.6 gql.tada: specifier: 1.9.2 version: 1.9.2(graphql@16.13.2)(typescript@5.9.3) @@ -281,6 +284,9 @@ importers: '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@types/cross-spawn': + specifier: 6.0.6 + version: 6.0.6 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -4310,6 +4316,9 @@ packages: '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==, tarball: https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz} + '@types/cross-spawn@6.0.6': + resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==, tarball: https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==, tarball: https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz} @@ -13894,6 +13903,10 @@ snapshots: '@types/cookie@0.6.0': {} + '@types/cross-spawn@6.0.6': + dependencies: + '@types/node': 25.8.0 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 diff --git a/scripts/test-local-https.ts b/scripts/test-local-https.ts new file mode 100644 index 0000000000..9d466ea84b --- /dev/null +++ b/scripts/test-local-https.ts @@ -0,0 +1,166 @@ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { access } from "node:fs/promises"; +import { connect } from "node:net"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +const HOST = "local.tryhydrogen.dev"; +const PORT = 5_173; +const REQUEST_TIMEOUT_MS = 5_000; +const READINESS_TIMEOUT_MS = 60_000; +const SHUTDOWN_TIMEOUT_MS = 5_000; +const SERVER_URL = `https://${HOST}:${PORT}/favicon.svg`; + +const packageManagerCli = process.env.npm_execpath; +if (!packageManagerCli) { + throw new Error("npm_execpath is missing. Run this test through the package manager script."); +} + +const certificateDirectory = join(homedir(), ".shopify", "hydrogen", "certs"); +await Promise.all([ + access(join(certificateDirectory, `${HOST}.pem`)), + access(join(certificateDirectory, `${HOST}-key.pem`)), +]); +await assertPortAvailable(); + +const server = spawn( + process.execPath, + [packageManagerCli, "--filter", "@shopify/hydrogen-template-react-router", "dev:https"], + { + cwd: process.cwd(), + detached: process.platform !== "win32", + env: { ...process.env, CI: "true" }, + stdio: ["ignore", "pipe", "pipe"], + }, +); +let stopping = false; +let serverClosed = false; +const serverClose = new Promise((resolve) => { + server.once("close", () => { + serverClosed = true; + resolve(); + }); +}); + +server.stdout?.on("data", (chunk: Buffer) => { + if (!stopping) process.stdout.write(chunk); +}); +server.stderr?.on("data", (chunk: Buffer) => { + if (!stopping) process.stderr.write(chunk); +}); + +try { + await Promise.race([waitForTrustedHttps(), rejectIfServerStops(server)]); + console.log(`Trusted local HTTPS verified at ${SERVER_URL}`); +} finally { + stopping = true; + await stopProcessTree(server, serverClose, () => serverClosed); +} + +async function waitForTrustedHttps() { + const deadline = Date.now() + READINESS_TIMEOUT_MS; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const response = await fetch(SERVER_URL, { + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (!response.ok) throw new Error(`Received HTTP ${response.status}`); + await response.body?.cancel(); + + return; + } catch (error) { + lastError = error; + await delay(500); + } + } + + throw new Error(`Trusted HTTPS server was not ready after ${READINESS_TIMEOUT_MS}ms`, { + cause: lastError, + }); +} + +async function assertPortAvailable() { + const portInUse = await new Promise((resolve) => { + const socket = connect({ host: HOST, port: PORT }); + let settled = false; + const finish = (result: boolean) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(result); + }; + + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.setTimeout(REQUEST_TIMEOUT_MS, () => finish(false)); + }); + + if (portInUse) throw new Error(`${HOST}:${PORT} is already in use`); +} + +function rejectIfServerStops(child: ChildProcess) { + return new Promise((_resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => { + reject( + new Error( + signal + ? `Local HTTPS server was killed by ${signal}` + : `Local HTTPS server exited with code ${code}`, + ), + ); + }); + }); +} + +async function stopProcessTree( + child: ChildProcess, + closePromise: Promise, + isServerClosed: () => boolean, +) { + if (!child.pid || isServerClosed()) return; + + if (process.platform === "win32") { + if (child.exitCode !== null || child.signalCode !== null) { + await requireServerClose(closePromise, isServerClosed); + return; + } + + const result = spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + stdio: "ignore", + }); + if (result.error) throw result.error; + if (result.status !== 0 && !isServerClosed()) { + throw new Error(`taskkill failed with exit code ${result.status}`); + } + await requireServerClose(closePromise, isServerClosed); + return; + } + + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + await requireServerClose(closePromise, isServerClosed); + return; + } + + await Promise.race([closePromise, delay(SHUTDOWN_TIMEOUT_MS)]); + + try { + process.kill(-child.pid, 0); + process.kill(-child.pid, "SIGKILL"); + } catch { + // The process group exited during the graceful shutdown period. + } + + await requireServerClose(closePromise, isServerClosed); +} + +async function requireServerClose(closePromise: Promise, isServerClosed: () => boolean) { + await Promise.race([closePromise, delay(SHUTDOWN_TIMEOUT_MS)]); + if (!isServerClosed()) throw new Error("Local HTTPS server did not close after termination"); +} diff --git a/skills/create-vercel-template/SKILL.md b/skills/create-vercel-template/SKILL.md index 337829c4e8..3f2b07f572 100644 --- a/skills/create-vercel-template/SKILL.md +++ b/skills/create-vercel-template/SKILL.md @@ -277,7 +277,7 @@ repository root, which is not a deployable Next.js project. Before finishing: 1. Install with `CI=true` from the repository root. -2. Run `rg -n "@shared/|examples/shared|localCdnAssets|localHttps|lru-cache|catalog:|file:" templates/ -g '!pnpm-lock.yaml' -g '!node_modules'` — expect no matches. (`process.env` and `workspace:*` are expected in the source Next.js template.) +2. Run `rg -n "@shared/|examples/shared|localCdnAssets|localHttps|lru-cache|catalog:|file:" templates/ -g '!pnpm-lock.yaml' -g '!node_modules' -g '!.agents/**'` — expect no matches. (`process.env` and `workspace:*` are expected in the source Next.js template.) 3. Run the template lint and typecheck (`eslint`, then `tsc --noEmit && hydrogen gql check --fail-on-warn`). Note: the GraphQL check passes without emitting the `*-graphql-env.d.ts` files on disk (they're gitignored, generated on demand) — that is expected. 4. Run `next build`. The source build can infer the repository workspace root; the standalone distribution should infer the template directory after installing its generated lockfile. diff --git a/templates/nextjs/README.md b/templates/nextjs/README.md index 1722294833..e4e3c8c108 100644 --- a/templates/nextjs/README.md +++ b/templates/nextjs/README.md @@ -28,6 +28,8 @@ pnpm dev:https Next.js provisions and reuses a trusted development certificate under `certificates/`. On first run, it may prompt to install the local certificate authority. +Next.js does not use Hydrogen's Vite plugin, so configure the Customer Account callback, JavaScript origin, and logout URLs manually. The `hydrogen-local-https` skill lists the exact values. + ## Environment Variables Copy `.env.example` to `.env` when you are ready to connect a real store: