Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/local-https-vite-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
---

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`.
3 changes: 1 addition & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ When designing or adjusting APIs for the `hydrogen` package, closely follow the
## Local HTTPS for Examples

- Account-enabled framework examples use `https://local.tryhydrogen.dev:5173` for Customer Account OAuth callback testing.
- On macOS, install `mkcert` with Homebrew before running those examples: `brew install mkcert`.
- Vite-based examples consume Hydrogen's default certificates. From the repository root, run `pnpm https:setup` once to trust the local certificate authority and create them under `~/.shopify/hydrogen/certs/`.
- Vite-based examples consume Hydrogen's default certificates. Certificates are provisioned automatically on `https:dev` startup, or run `pnpm https:setup` once from the repository root (`npx hydrogen certs install` in a consumer project). Both download a pinned, checksum-verified mkcert release, trust the local certificate authority, and create the certificates under `~/.shopify/hydrogen/certs/`.
- The Next.js example provisions its own certificate. The Hydrogen example uses the Shopify CLI tunnel flow.
- After setup, run the relevant example with `pnpm --filter @shopify/hydrogen-example-<name> https:dev` when that example provides the script.
5 changes: 2 additions & 3 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,13 @@ From the repository root:
- `pnpm --filter @shopify/hydrogen-example-<name> dev` — run one example.
- `pnpm https:setup` then `pnpm --filter @shopify/hydrogen-example-<name> https:dev` — run an account-enabled example on `https://local.tryhydrogen.dev:5173` when it provides an `https:dev` script. The Hydrogen example uses `--customer-account-push` instead of local certificates.

Local HTTPS requires `mkcert`. On macOS:
Local HTTPS certificates are provisioned automatically the first time an `https:dev` script starts, or explicitly with:

```sh
brew install mkcert
pnpm https:setup
```

This installs the local certificate authority and creates trusted `local.tryhydrogen.dev` certificates under `~/.shopify/hydrogen/certs/` so Customer Account OAuth can redirect to `https://local.tryhydrogen.dev:5173/account/authorize`.
Both download a pinned, checksum-verified [mkcert](https://github.com/FiloSottile/mkcert) release, install the local certificate authority (this may prompt for your password), and create trusted `local.tryhydrogen.dev` certificates under `~/.shopify/hydrogen/certs/` so Customer Account OAuth can redirect to `https://local.tryhydrogen.dev:5173/account/authorize`.

The Next.js template provisions its own development certificate and does not use the Hydrogen certificates.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +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": "mkcert -install && mkdir -p \"$HOME/.shopify/hydrogen/certs\" && mkcert -cert-file \"$HOME/.shopify/hydrogen/certs/local.tryhydrogen.dev.pem\" -key-file \"$HOME/.shopify/hydrogen/certs/local.tryhydrogen.dev-key.pem\" local.tryhydrogen.dev",
"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 Down
22 changes: 10 additions & 12 deletions packages/hydrogen/skills/hydrogen-local-https/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,19 @@ description: >

Customer Account login, logout, and OAuth callbacks require an HTTPS, non-`localhost` origin that exactly matches the URLs registered in Shopify admin. `local.tryhydrogen.dev` is a Shopify-owned domain that resolves publicly to `127.0.0.1`, so it provides a stable local hostname.

## Vite Certificate Setup
## Certificates

Vite-based frameworks need manual certificates until automatic provisioning is available. Install and trust [mkcert](https://github.com/FiloSottile/mkcert), then create the default certificate files:
The `localHttps` Vite plugin provisions missing certificates automatically when `vite dev` starts: it downloads a pinned, checksum-verified [mkcert](https://github.com/FiloSottile/mkcert) release, installs the local certificate authority (this may prompt for your password), and generates the certificate files under `~/.shopify/hydrogen/certs/`. To provision ahead of time — or for frameworks that read certificate paths before Vite starts (Nuxt, SolidStart) — run:

```sh
brew install mkcert
mkcert -install
mkdir -p ~/.shopify/hydrogen/certs
mkcert \
-cert-file ~/.shopify/hydrogen/certs/local.tryhydrogen.dev.pem \
-key-file ~/.shopify/hydrogen/certs/local.tryhydrogen.dev-key.pem \
local.tryhydrogen.dev
npx hydrogen certs install
```

Pass `certPath` and `keyPath` to use another location. Certificate generation is intentionally separate from the plugin.
Remove Hydrogen's generated certificate files and cached mkcert binary with `npx hydrogen certs uninstall`. This leaves mkcert's shared local certificate authority trusted because other projects may use it. Pass `--remove-ca` to remove that shared CA from the system trust stores too.

Pass `certPath` and `keyPath` to use another location. When automatic download is unavailable for a platform, install mkcert manually and generate the files at the paths the warning prints.

The plugin skips automatic provisioning when the `CI` environment variable is set, because installing the certificate authority needs an interactive trust prompt. Run `npx hydrogen certs install` explicitly when a CI job genuinely needs local HTTPS.

## Vite

Expand Down Expand Up @@ -72,7 +70,7 @@ export default defineConfig({

## Nuxt

Nitro terminates TLS, so provide certificate paths to both Nitro and Vite:
Nitro terminates TLS, so provide certificate paths to both Nitro and Vite. Nitro reads the paths when the config is evaluated, so provision certificates with `npx hydrogen certs install` (or restart once after the plugin provisions them):

```ts
import { localHttps } from "@shopify/hydrogen/vite";
Expand All @@ -95,7 +93,7 @@ export default defineNuxtConfig({

## SolidStart/Vinxi

Vinxi terminates TLS outside Vite:
Vinxi terminates TLS outside Vite and reads certificate paths when the config is evaluated, so provision certificates with `npx hydrogen certs install` (or restart once after the plugin provisions them):

```ts
import { defineConfig } from "@solidjs/start/config";
Expand Down
86 changes: 86 additions & 0 deletions packages/hydrogen/src/cli/__tests__/certs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { homedir } from "node:os";
import { join } from "node:path";

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { uninstallLocalHttpsCertificates } from "../certs";

const fsCalls = vi.hoisted(() => ({ rm: vi.fn(async () => {}) }));
const mkcertCalls = vi.hoisted(() => ({
resolveMkcertBinary: vi.fn((): { assetName: string } | undefined => ({
assetName: "mkcert-pinned-platform",
})),
uninstallCertificateAuthority: vi.fn(async () => {}),
}));

vi.mock("node:fs/promises", async (importOriginal) => ({
...(await importOriginal<typeof import("node:fs/promises")>()),
rm: fsCalls.rm,
}));

vi.mock("../../vite/mkcert", () => ({
resolveMkcertBinary: mkcertCalls.resolveMkcertBinary,
uninstallCertificateAuthority: mkcertCalls.uninstallCertificateAuthority,
}));

describe("uninstallLocalHttpsCertificates", () => {
beforeEach(() => {
fsCalls.rm.mockClear();
mkcertCalls.resolveMkcertBinary.mockClear();
mkcertCalls.uninstallCertificateAuthority.mockClear();
});

afterEach(() => {
vi.restoreAllMocks();
});

it("removes Hydrogen files but keeps the shared certificate authority by default", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const hydrogenDirectory = join(homedir(), ".shopify", "hydrogen");

await uninstallLocalHttpsCertificates();

expect(fsCalls.rm.mock.calls).toEqual([
[join(hydrogenDirectory, "certs", "local.tryhydrogen.dev.pem"), { force: true }],
[join(hydrogenDirectory, "certs", "local.tryhydrogen.dev-key.pem"), { force: true }],
[join(hydrogenDirectory, "mkcert", "mkcert-pinned-platform"), { force: true }],
]);
expect(mkcertCalls.resolveMkcertBinary).toHaveBeenCalledWith(process.platform, process.arch);
expect(mkcertCalls.uninstallCertificateAuthority).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(
"The shared mkcert CA remains trusted. Pass --remove-ca to remove it.",
);
});

it("removes the shared certificate authority when requested", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

await uninstallLocalHttpsCertificates(["--remove-ca"]);

expect(warn).toHaveBeenCalledWith(expect.stringContaining("other projects"));
expect(mkcertCalls.uninstallCertificateAuthority).toHaveBeenCalledOnce();
});

it("does not remove another mkcert binary on an unsupported platform", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
mkcertCalls.resolveMkcertBinary.mockReturnValueOnce(undefined);
const certificateDirectory = join(homedir(), ".shopify", "hydrogen", "certs");

await uninstallLocalHttpsCertificates();

expect(fsCalls.rm.mock.calls).toEqual([
[join(certificateDirectory, "local.tryhydrogen.dev.pem"), { force: true }],
[join(certificateDirectory, "local.tryhydrogen.dev-key.pem"), { force: true }],
]);
});

it("rejects unknown arguments without removing anything", async () => {
await expect(uninstallLocalHttpsCertificates(["--unknown"])).rejects.toThrow(
"Unknown argument: --unknown",
);

expect(fsCalls.rm).not.toHaveBeenCalled();
expect(mkcertCalls.uninstallCertificateAuthority).not.toHaveBeenCalled();
});
});
57 changes: 57 additions & 0 deletions packages/hydrogen/src/cli/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { runCli } from "../index";

const commandCalls = vi.hoisted(() => ({
checkGraphQL: vi.fn(async () => {}),
installLocalHttpsCertificates: vi.fn(async () => {}),
setupHydrogen: vi.fn(async () => {}),
uninstallLocalHttpsCertificates: vi.fn(async () => {}),
}));

vi.mock("../certs", () => ({
installLocalHttpsCertificates: commandCalls.installLocalHttpsCertificates,
uninstallLocalHttpsCertificates: commandCalls.uninstallLocalHttpsCertificates,
}));
vi.mock("../gql", () => ({ checkGraphQL: commandCalls.checkGraphQL }));
vi.mock("../setup", () => ({ setupHydrogen: commandCalls.setupHydrogen }));

const originalArgv = process.argv;

function runWithArguments(args: string[]): void {
process.argv = ["node", "hydrogen", ...args];
runCli();
}

describe("runCli", () => {
afterEach(() => {
process.argv = originalArgv;
vi.clearAllMocks();
});

it("dispatches certs install to local HTTPS installation", () => {
runWithArguments(["certs", "install"]);

expect(commandCalls.installLocalHttpsCertificates).toHaveBeenCalledOnce();
expect(commandCalls.setupHydrogen).not.toHaveBeenCalled();
});

it("dispatches certs uninstall with trailing arguments", () => {
runWithArguments(["certs", "uninstall", "--remove-ca"]);

expect(commandCalls.uninstallLocalHttpsCertificates).toHaveBeenCalledWith(["--remove-ca"]);
});

it("dispatches setup to the skills setup", () => {
runWithArguments(["setup"]);

expect(commandCalls.setupHydrogen).toHaveBeenCalledOnce();
expect(commandCalls.installLocalHttpsCertificates).not.toHaveBeenCalled();
});

it("dispatches gql check with trailing arguments", () => {
runWithArguments(["gql", "check", "src/**/*.ts"]);

expect(commandCalls.checkGraphQL).toHaveBeenCalledWith({ args: ["src/**/*.ts"] });
});
});
49 changes: 49 additions & 0 deletions packages/hydrogen/src/cli/certs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { rm } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";

import { LOCAL_HTTPS_DEFAULTS, provisionLocalHttps } from "../vite";
import { resolveMkcertBinary, uninstallCertificateAuthority } from "../vite/mkcert";

const REMOVE_CA_FLAG = "--remove-ca";

export async function installLocalHttpsCertificates(): Promise<void> {
const { host, certPath, keyPath } = await provisionLocalHttps();

console.log(`Local HTTPS is ready for https://${host}`);
console.log(` Certificate: ${certPath}`);
console.log(` Private key: ${keyPath}`);
}

export async function uninstallLocalHttpsCertificates(args: string[] = []): Promise<void> {
const unknownArgument = args.find((argument) => argument !== REMOVE_CA_FLAG);
if (unknownArgument) {
throw new Error(`Unknown argument: ${unknownArgument}`);
}

const removeCertificateAuthority = args.includes(REMOVE_CA_FLAG);
if (removeCertificateAuthority) {
console.warn(
"Warning: removing mkcert's shared local CA will make certificates created by other projects untrusted.",
);
await uninstallCertificateAuthority();
}

const hydrogenDirectory = join(homedir(), ".shopify", "hydrogen");
const certificateDirectory = join(hydrogenDirectory, "certs");
const host = LOCAL_HTTPS_DEFAULTS.host;
const binary = resolveMkcertBinary(process.platform, process.arch);
const removals = [
rm(join(certificateDirectory, `${host}.pem`), { force: true }),
rm(join(certificateDirectory, `${host}-key.pem`), { force: true }),
];
if (binary) {
removals.push(rm(join(hydrogenDirectory, "mkcert", binary.assetName), { force: true }));
}
await Promise.all(removals);

console.log("Hydrogen's local HTTPS certificate files were removed.");
if (!removeCertificateAuthority) {
console.log(`The shared mkcert CA remains trusted. Pass ${REMOVE_CA_FLAG} to remove it.`);
}
}
6 changes: 6 additions & 0 deletions packages/hydrogen/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";

import { installLocalHttpsCertificates, uninstallLocalHttpsCertificates } from "./certs";
import { checkGraphQL } from "./gql";
import { setupHydrogen } from "./setup";

const CLI_ARGUMENTS_INDEX = 2;
const FAILURE_EXIT_CODE = 1;

const COMMANDS = [
{ path: ["certs", "install"], run: async (_args: string[]) => installLocalHttpsCertificates() },
{
path: ["certs", "uninstall"],
run: async (args: string[]) => uninstallLocalHttpsCertificates(args),
},
{ path: ["setup"], run: async (_args: string[]) => setupHydrogen() },
{ path: ["gql", "check"], run: async (args: string[]) => checkGraphQL({ args }) },
] as const;
Expand Down
15 changes: 15 additions & 0 deletions packages/hydrogen/src/vite/certificate-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";

import { acceptsCertificateInstallation } from "./certificate-prompt";

describe("acceptsCertificateInstallation", () => {
it.each([
["", true],
["y", true],
["YES", true],
["n", false],
["no", false],
])("parses %j as %s", (answer, expected) => {
expect(acceptsCertificateInstallation(answer)).toBe(expected);
});
});
22 changes: 22 additions & 0 deletions packages/hydrogen/src/vite/certificate-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { createInterface } from "node:readline/promises";

export async function confirmCertificateInstallation(host: string): Promise<boolean> {
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;

const readline = createInterface({ input: process.stdin, output: process.stdout });
try {
const answer = await readline.question(
`To run HTTPS in development, Hydrogen needs to download mkcert, add a local certificate authority to your system trust store, and create a certificate for "${host}". Continue? [Y/n] `,
);

return acceptsCertificateInstallation(answer);
} finally {
readline.close();
}
}

/** @internal Exported for tests. */
export function acceptsCertificateInstallation(answer: string): boolean {
const response = answer.trim().toLowerCase();
return response === "" || response === "y" || response === "yes";
}
Loading
Loading