Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/pre/local-https-vite-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name> dev:https` when that example provides the script.
4 changes: 3 additions & 1 deletion examples/astro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions examples/solid-start/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion examples/sveltekit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/hydrogen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 2 additions & 0 deletions packages/hydrogen/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,15 @@
"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": {
"@graphql-codegen/cli": "^7.0.0",
"@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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
171 changes: 171 additions & 0 deletions packages/hydrogen/src/vite/customer-account.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading