diff --git a/docs/guide/edge/redirects.md b/docs/guide/edge/redirects.md index 3318ff76b..99cb6c657 100644 --- a/docs/guide/edge/redirects.md +++ b/docs/guide/edge/redirects.md @@ -51,6 +51,28 @@ Path-only sources apply to every domain on the project. Host-prefixed sources ap The source must use `https://` and a literal hostname without wildcards or a port. Add the domain to your project with `void domain add` before deploying the rule. A domain awaiting certificate verification starts redirecting once it becomes active. +## HTTP to HTTPS + +Set `routing.alwaysUseHttps` to redirect HTTP requests to HTTPS: + +```ts +import { defineConfig } from 'void/config'; + +export default defineConfig({ + routing: { + alwaysUseHttps: true, + }, +}); +``` + +Void answers with a permanent `308` redirect to the same URL over HTTPS. The path, query string, and request method stay the same. Omitted or `false` means off. `localhost` and loopback addresses such as `127.0.0.1` stay on HTTP, so local development and `vite preview` are unchanged. + +It works on the Void platform and for Void apps on `void deploy --platform cloudflare`. It needs the Cloudflare target: setting it with `target: 'node'`, `'bun'`, or `'deno'` is a config error. + +On `void deploy --platform cloudflare`, built files with a content hash in their name, such as `/assets/index-abc123.js`, can be served by Cloudflare before Void runs, so they may still answer over HTTP. Pages always redirect, so browsers load these files over HTTPS. To redirect every request, also turn on [Always Use HTTPS](https://developers.cloudflare.com/ssl/edge-certificates/additional-options/always-use-https/) for your Cloudflare zone. + +SvelteKit, Nuxt, Astro, Analog, TanStack Start, React Router, and vinext apps run their own Worker, so `void deploy --platform cloudflare` stops with an error when the option is on. Static builds of these frameworks, deployed with `--dir` or `inference.appType`, redirect as usual. For the others, redirect to HTTPS in the framework, or turn on [Always Use HTTPS](https://developers.cloudflare.com/ssl/edge-certificates/additional-options/always-use-https/) for your Cloudflare zone. + ## Framework `_redirects` files Void reads `_redirects` files produced by supported frameworks. You can also place one in Vite's `publicDir`: diff --git a/docs/reference/config.md b/docs/reference/config.md index 0d9fd77b3..54cb7840d 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -366,6 +366,16 @@ This choice applies to subsequent `void deploy` runs. Atomic deployment sends tr Routing and edge configuration for headers, redirects, rewrites, and caching. +#### `routing.alwaysUseHttps` + +Redirect HTTP requests to HTTPS with a permanent `308` redirect. Omitted or `false` means off. Requires the Cloudflare target. `localhost` stays on HTTP. See [HTTP to HTTPS](../guide/edge/redirects#http-to-https) for where it works. + +```ts +routing: { + alwaysUseHttps: true, +} +``` + #### `routing.headers` Custom response headers. Keys are URL patterns, values are arrays of `"Name: value"` strings. See [Custom Headers](../guide/edge/headers) for details. diff --git a/packages/deploy-cloudflare/src/static.ts b/packages/deploy-cloudflare/src/static.ts index f15900353..6f658bff5 100644 --- a/packages/deploy-cloudflare/src/static.ts +++ b/packages/deploy-cloudflare/src/static.ts @@ -24,12 +24,14 @@ export type CloudflareStaticDeployConfig = Readonly<{ /** Generate the deterministic thin Worker entry bundled by Wrangler. */ export function renderCloudflareStaticEntry(options: { runtimeImport?: string; + alwaysUseHttps?: boolean; headerRules?: ReadonlyArray; redirectRules?: ReadonlyArray; fallbackRules?: ReadonlyArray; versionMetadataBinding?: string; }): string { const config = JSON.stringify({ + ...(options.alwaysUseHttps !== undefined ? { alwaysUseHttps: options.alwaysUseHttps } : {}), ...(options.headerRules?.length ? { headerRules: options.headerRules } : {}), ...(options.redirectRules?.length ? { redirectRules: options.redirectRules } : {}), ...(options.fallbackRules?.length ? { fallbackRules: options.fallbackRules } : {}), diff --git a/packages/deploy-cloudflare/test/unit/static.test.ts b/packages/deploy-cloudflare/test/unit/static.test.ts index 32a2a8bc5..dec3c153a 100644 --- a/packages/deploy-cloudflare/test/unit/static.test.ts +++ b/packages/deploy-cloudflare/test/unit/static.test.ts @@ -10,6 +10,17 @@ import { } from '../../src/index.ts'; describe('Cloudflare static deployment', () => { + it.each([true, false, undefined])( + 'preserves alwaysUseHttps %s in the generated entry', + (alwaysUseHttps) => { + const entry = renderCloudflareStaticEntry({ alwaysUseHttps }); + if (alwaysUseHttps === undefined) { + expect(entry).not.toContain('alwaysUseHttps'); + } else { + expect(entry).toContain(`"alwaysUseHttps":${alwaysUseHttps}`); + } + }, + ); it('renders one deterministic shared-runtime entry', () => { expect( renderCloudflareStaticEntry({ diff --git a/packages/deploy-core/src/index.ts b/packages/deploy-core/src/index.ts index 16de95a65..d3510e38b 100644 --- a/packages/deploy-core/src/index.ts +++ b/packages/deploy-core/src/index.ts @@ -278,6 +278,8 @@ export type DeployManifest = { hashedAssetsPrefix?: string; headerRules?: Array; redirectRules?: Array; + /** Omitted values inherit the platform default; preserve explicit false. */ + alwaysUseHttps?: boolean; fallbackRules?: Array; websocket?: WebSocketManifestMeta; sandbox?: SandboxManifestMeta; diff --git a/packages/deploy-core/src/validate.ts b/packages/deploy-core/src/validate.ts index f9aa73064..fcfcc7b65 100644 --- a/packages/deploy-core/src/validate.ts +++ b/packages/deploy-core/src/validate.ts @@ -376,6 +376,10 @@ export function validateManifestShape(manifest: unknown): string | null { } } + if (manifest.alwaysUseHttps !== undefined && typeof manifest.alwaysUseHttps !== 'boolean') { + return 'invalid manifest shape: alwaysUseHttps must be a boolean'; + } + const redirectRulesError = validateRoutingRules(manifest.redirectRules, 'redirectRules'); if (redirectRulesError) { return redirectRulesError; diff --git a/packages/deploy-core/test/unit/validate.test.ts b/packages/deploy-core/test/unit/validate.test.ts index e3a05b681..763d2f0e6 100644 --- a/packages/deploy-core/test/unit/validate.test.ts +++ b/packages/deploy-core/test/unit/validate.test.ts @@ -1,6 +1,17 @@ import { describe, expect, it } from 'vite-plus/test'; import { validateManifestShape, type DeployManifest } from '../../src/index.ts'; +describe('alwaysUseHttps', () => { + it.each([true, false, undefined])('accepts %s', (alwaysUseHttps) => { + expect(validateManifestShape({ version: 3, bindings: {}, alwaysUseHttps })).toBeNull(); + }); + it.each(['true', 1, null, {}])('rejects non-boolean %s', (alwaysUseHttps) => { + expect(validateManifestShape({ version: 3, bindings: {}, alwaysUseHttps })).toContain( + 'alwaysUseHttps must be a boolean', + ); + }); +}); + describe('custom Durable Object declarations', () => { const binding = { bindingName: 'WORKSPACE', diff --git a/packages/edge/src/https.ts b/packages/edge/src/https.ts new file mode 100644 index 000000000..5e3b2ba81 --- /dev/null +++ b/packages/edge/src/https.ts @@ -0,0 +1,32 @@ +/** Upgrade public HTTP requests without involving assets or application code. */ +export function redirectToHttps(request: Request): Response | undefined { + const url = new URL(request.url); + if ( + url.protocol !== 'http:' || + url.hostname === 'localhost' || + url.hostname.endsWith('.localhost') || + isIpv4Loopback(url.hostname) || + url.hostname === '[::1]' || + url.pathname.startsWith('/.well-known/acme-challenge/') || + url.pathname.startsWith('/.well-known/pki-validation/') + ) { + return undefined; + } + url.protocol = 'https:'; + // Cloudflare's HTTP ports do not accept TLS. Upgrade to the default HTTPS port. + url.port = ''; + return Response.redirect(url.href, 308); +} + +/** + * `URL` already rewrites every IPv4 form (`127.1`, `0x7f.0.0.1`) to four + * decimal parts and rejects parts above 255, so only the shape is checked. + */ +function isIpv4Loopback(hostname: string): boolean { + const parts = hostname.split('.'); + return ( + parts.length === 4 && + parts[0] === '127' && + parts.every((part) => part !== '' && [...part].every((char) => char >= '0' && char <= '9')) + ); +} diff --git a/packages/edge/src/index.ts b/packages/edge/src/index.ts index 13d339668..62ed48ed7 100644 --- a/packages/edge/src/index.ts +++ b/packages/edge/src/index.ts @@ -1,4 +1,5 @@ export type { ProjectZeroTrustRouting } from './access.ts'; +export { redirectToHttps } from './https.ts'; export { applyHeaderRules, parseClearSiteData, diff --git a/packages/edge/src/runtime.ts b/packages/edge/src/runtime.ts index f6ad56fbd..fa97c3b91 100644 --- a/packages/edge/src/runtime.ts +++ b/packages/edge/src/runtime.ts @@ -1,5 +1,6 @@ import type { AssetConfig, HeaderRule, RedirectRule } from '@void/deploy-core'; import { applyHeaderRules } from './headers.ts'; +import { redirectToHttps } from './https.ts'; import { applyRoutingRules, mergeQueryIntoLocation } from './routing-rules.ts'; export type EdgeAssetFetcher = Readonly<{ @@ -11,6 +12,7 @@ export type StaticEdgeEnv = Readonly<{ }>; export type StaticEdgeConfig = Readonly<{ + alwaysUseHttps?: boolean; /** Optional native Worker self-metadata used to identify the served version. */ versionMetadataBinding?: string; /** @@ -50,6 +52,12 @@ export function createStaticEdgeHandler(config: StaticEdgeConfig): StaticEdgeHan { headers: { 'cache-control': 'no-store' } }, ); } + if (config.alwaysUseHttps) { + const redirect = redirectToHttps(request); + if (redirect) { + return redirect; + } + } let effectiveUrl = new URL(originalUrl); let effectiveRequest = request; diff --git a/packages/edge/test/unit/https.test.ts b/packages/edge/test/unit/https.test.ts new file mode 100644 index 000000000..1c9086d1c --- /dev/null +++ b/packages/edge/test/unit/https.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { redirectToHttps } from '../../src/index.ts'; + +describe('redirectToHttps', () => { + it.each([ + '127.example.com', + '127.0.0.1.example.com', + '128.0.0.1', + '[2606:4700::1]', + '192.168.1.10', + '[fd12::1]', + ])('does not treat %s as a local host', (host) => { + expect(redirectToHttps(new Request(`http://${host}/`))?.headers.get('Location')).toBe( + `https://${host}/`, + ); + }); + + it.each(['GET', 'HEAD', 'POST'])('preserves the URL and unconsumed %s body', (method) => { + const request = new Request('http://oxc.rs:8080/docs/a%2Fb?search=a%2Fb&tag=1&tag=2', { + method, + ...(method === 'POST' ? { body: 'payload' } : {}), + }); + const response = redirectToHttps(request)!; + expect(response.status).toBe(308); + expect(response.headers.get('Location')).toBe( + 'https://oxc.rs/docs/a%2Fb?search=a%2Fb&tag=1&tag=2', + ); + expect(request.bodyUsed).toBe(false); + }); + + it.each([ + 'https://oxc.rs/', + 'http://localhost:8787/', + 'http://app.localhost:8787/', + 'http://127.0.0.1:8787/', + 'http://127.0.0.2:8787/', + 'http://127.1.2.3/', + 'http://127.1/', + 'http://[::1]:8787/', + 'http://oxc.rs/.well-known/acme-challenge/token', + 'http://oxc.rs/.well-known/pki-validation/token.txt', + ])('does not redirect %s', (url) => { + expect(redirectToHttps(new Request(url))).toBeUndefined(); + }); +}); diff --git a/packages/edge/test/unit/runtime.test.ts b/packages/edge/test/unit/runtime.test.ts index b3f167ef0..889c83923 100644 --- a/packages/edge/test/unit/runtime.test.ts +++ b/packages/edge/test/unit/runtime.test.ts @@ -11,6 +11,19 @@ function assets(responses: Record) { } describe('createStaticEdgeHandler', () => { + it.each([true, false, undefined])( + 'applies alwaysUseHttps %s before assets', + async (alwaysUseHttps) => { + const binding = assets({ '/': new Response('home') }); + const response = await createStaticEdgeHandler({ alwaysUseHttps }).fetch( + new Request('http://oxc.rs/'), + { ASSETS: binding }, + ); + expect(response.status).toBe(alwaysUseHttps ? 308 : 200); + expect(response.headers.get('Location')).toBe(alwaysUseHttps ? 'https://oxc.rs/' : null); + expect(binding.fetch).toHaveBeenCalledTimes(alwaysUseHttps ? 0 : 1); + }, + ); it('answers the deployment readiness probe without touching assets', async () => { const binding = assets({}); const response = await createStaticEdgeHandler({}).fetch( diff --git a/packages/void/schema.json b/packages/void/schema.json index 2dd55bce1..c97e3f6ca 100644 --- a/packages/void/schema.json +++ b/packages/void/schema.json @@ -144,6 +144,10 @@ "type": "object", "description": "Routing configuration — headers, redirects, rewrites, fallbacks, revalidation, prerendering.", "properties": { + "alwaysUseHttps": { + "type": "boolean", + "description": "Redirect HTTP requests to HTTPS with a permanent 308 redirect. Omitted or false means off. Requires the Cloudflare target; localhost stays on HTTP." + }, "isr": { "type": "boolean", "description": "Enable inferred ISR caching. Set false to disable ISR and edge prerendering, including per-page prerender and revalidate policies. Does not disable output: static build-time prerendering." diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index 3b380b312..d8da88959 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -49,7 +49,7 @@ the app's existing saved destination, including when the target project is new. | Email | `docs/guide/email.md`, then the matching guide under `docs/guide/email/` | | KV / storage / queues / cron jobs | `docs/guide/kv.md`, `docs/guide/storage.md`, `docs/guide/queues.md`, `docs/guide/jobs.md` | | SSR and caching | `docs/guide/ssr.md`, `docs/guide/edge/*.md` | -| Rewrites, redirects, fallbacks | `docs/guide/edge/rewrites.md`, `docs/guide/edge/redirects.md`, `docs/reference/config.md` | +| HTTPS, rewrites, redirects, fallbacks | `docs/guide/edge/rewrites.md`, `docs/guide/edge/redirects.md`, `docs/reference/config.md` | | Static site generation | `docs/guide/ssg.md` | | Connected apps and artifact preparation | `docs/guide/platform/administration/connected-apps.md`, `docs/reference/api/deploy.md` | | Deployment and CI | `docs/guide/deployment.md`, `docs/reference/cli/deploy.md` | diff --git a/packages/void/src/asset-worker-bypass.ts b/packages/void/src/asset-worker-bypass.ts index 356285048..9e289c1a5 100644 --- a/packages/void/src/asset-worker-bypass.ts +++ b/packages/void/src/asset-worker-bypass.ts @@ -8,6 +8,8 @@ * request matching a negative rule never reaches the Worker, even when the * file is missing. Misses and other paths in the folder keep the Worker's * not-found, SPA and page handling. + * `routing.alwaysUseHttps` alone does not count as an edge rule: hashed files + * are only linked from pages the Worker has already moved to HTTPS. * * The asset layer does not know which files are fingerprinted, so a generated * `_headers` file gives every bypassed chunk the immutable policy the Worker diff --git a/packages/void/src/cli/deploy-cloudflare-static.ts b/packages/void/src/cli/deploy-cloudflare-static.ts index 7d21c36ce..44f4aca38 100644 --- a/packages/void/src/cli/deploy-cloudflare-static.ts +++ b/packages/void/src/cli/deploy-cloudflare-static.ts @@ -340,6 +340,7 @@ export async function runCloudflareStaticDeploy( const merged = mergeAndLintRoutingRules(preset.outputDir, voidConfig.routing); const manifest = buildStaticManifest({ appType: preset.appType, + alwaysUseHttps: voidConfig.routing?.alwaysUseHttps, hashedAssetsPrefix: 'assets', headerRules: merged.headerRules, redirectRules: merged.redirectRules, @@ -383,6 +384,7 @@ export async function runCloudflareStaticDeploy( entryPath, renderCloudflareStaticEntry({ runtimeImport: 'void/edge', + alwaysUseHttps: manifest.alwaysUseHttps, headerRules: manifest.headerRules, redirectRules: manifest.redirectRules, fallbackRules: manifest.fallbackRules, diff --git a/packages/void/src/cli/deploy-cloudflare.ts b/packages/void/src/cli/deploy-cloudflare.ts index ae435ff6f..1fbde4762 100644 --- a/packages/void/src/cli/deploy-cloudflare.ts +++ b/packages/void/src/cli/deploy-cloudflare.ts @@ -94,6 +94,19 @@ export async function runCloudflareDeploy( ); } + // After the static branch: a static framework build is served by the thin + // edge Worker, which applies the redirect itself. + if (detectedFramework && voidConfig.routing?.alwaysUseHttps === true) { + log.error( + [ + `deploy: native Cloudflare deployment cannot apply Void routing.alwaysUseHttps to ${detectedFramework.name}.`, + 'This framework owns its Worker request handler, so the HTTPS redirect would otherwise be ignored.', + "Redirect HTTP to HTTPS in the framework, or turn on Cloudflare's Always Use HTTPS setting for your zone, then remove routing.alwaysUseHttps and retry.", + ].join('\n'), + ); + return exit(1); + } + const source = await prepareCloudflareDeploySource(root, options, voidConfig); if (!source) { return exit(1); diff --git a/packages/void/src/cli/deploy-managed-framework.ts b/packages/void/src/cli/deploy-managed-framework.ts index cf6eae8d0..b2f11611c 100644 --- a/packages/void/src/cli/deploy-managed-framework.ts +++ b/packages/void/src/cli/deploy-managed-framework.ts @@ -198,6 +198,7 @@ export async function runFrameworkDeploy( const manifest = buildFrameworkManifest({ frameworkName: detected.name, + alwaysUseHttps: voidConfig.routing?.alwaysUseHttps, workerMain: actualWorkerMain, bindings, migrations: validatedMigrations, diff --git a/packages/void/src/cli/deploy-managed-full.ts b/packages/void/src/cli/deploy-managed-full.ts index 73d659c8a..6999e2d16 100644 --- a/packages/void/src/cli/deploy-managed-full.ts +++ b/packages/void/src/cli/deploy-managed-full.ts @@ -287,10 +287,11 @@ export async function runFullDeploy( headerRules, redirectRules, fallbackRules, - // Rules were already merged above — pass only the notFound override. + // Rules were already merged above. undefined, cliLog, voidConfig.routing?.notFound, + voidConfig.routing?.alwaysUseHttps, ); } } @@ -418,6 +419,7 @@ export async function runFullDeploy( isFrameworkMode, { bindings: liveConfig ? { ...bindings, needsLive: true } : bindings, + alwaysUseHttps: voidConfig.routing?.alwaysUseHttps, hasEmailHandlers: !isFrameworkMode && routeScan.email !== undefined && hasEmailHandlers(routeScan.email), migrations: validatedMigrations, diff --git a/packages/void/src/cli/deploy-managed-static.ts b/packages/void/src/cli/deploy-managed-static.ts index 5f29d6c16..113ee785d 100644 --- a/packages/void/src/cli/deploy-managed-static.ts +++ b/packages/void/src/cli/deploy-managed-static.ts @@ -79,6 +79,7 @@ export async function runStaticDeploy( * call site hands the value over directly. Wins over `routing.notFound`. */ notFound?: NotFoundHandling, + alwaysUseHttps?: boolean, ): Promise { const typeLabel = preset.appType === 'spa' ? 'Static SPA' : 'Static Site'; log.info(`${typeLabel} deploy`); @@ -140,6 +141,7 @@ export async function runStaticDeploy( const manifest = buildStaticManifest({ appType: preset.appType, + alwaysUseHttps: alwaysUseHttps ?? routing?.alwaysUseHttps, hashedAssetsPrefix: hashedAssetsPrefix ?? 'assets', headerRules, redirectRules, diff --git a/packages/void/src/cli/package.ts b/packages/void/src/cli/package.ts index 094b2516d..5aa12a087 100644 --- a/packages/void/src/cli/package.ts +++ b/packages/void/src/cli/package.ts @@ -449,6 +449,7 @@ export function buildVoidManifest(opts: { options?: PackageOptions; hashedAssetsPrefix?: string; headerRules?: Array; + alwaysUseHttps?: boolean; redirectRules?: Array; fallbackRules?: Array; dialect?: 'sqlite' | 'postgresql' | 'mysql'; @@ -479,6 +480,9 @@ export function buildVoidManifest(opts: { const resolvedDialect = dialect ?? 'sqlite'; const manifest: DeployManifest = { version: 3, assetManifestVersion: 2, bindings: {} }; + if (opts.alwaysUseHttps !== undefined) { + manifest.alwaysUseHttps = opts.alwaysUseHttps; + } if (opts.durableObjects?.length) { manifest.version = 4; manifest.durableObjects = { @@ -669,6 +673,7 @@ export function buildStaticManifest(opts: { appType: 'spa' | 'static'; hashedAssetsPrefix?: string; headerRules?: Array; + alwaysUseHttps?: boolean; redirectRules?: Array; fallbackRules?: Array; /** Explicit `routing.notFound` — overrides the appType-inferred handling. */ @@ -698,6 +703,7 @@ export function buildStaticManifest(opts: { assetManifestVersion: 2, bindings: {}, type: appType, + ...(opts.alwaysUseHttps !== undefined ? { alwaysUseHttps: opts.alwaysUseHttps } : {}), assetConfig: resolvedAssetConfig, }; @@ -738,6 +744,7 @@ export function buildFrameworkManifest(opts: { packageOptions?: Omit; hashedAssetsPrefix?: string; headerRules?: Array; + alwaysUseHttps?: boolean; redirectRules?: Array; fallbackRules?: Array; dialect?: 'sqlite' | 'postgresql' | 'mysql'; @@ -746,6 +753,9 @@ export function buildFrameworkManifest(opts: { const dialect = opts.dialect ?? 'sqlite'; const manifest: DeployManifest = { version: 3, assetManifestVersion: 2, bindings: {} }; + if (opts.alwaysUseHttps !== undefined) { + manifest.alwaysUseHttps = opts.alwaysUseHttps; + } applyResourceBindings( manifest, opts.bindings, diff --git a/packages/void/src/config.ts b/packages/void/src/config.ts index 3cf522fac..0df00727e 100644 --- a/packages/void/src/config.ts +++ b/packages/void/src/config.ts @@ -45,6 +45,8 @@ export type NotFoundHandling = 'single-page-application' | '404-page' | 'none'; /** Routing configuration — headers, redirects, rewrites, fallbacks, revalidation, prerendering. */ export type RoutingConfig = { + /** Redirect HTTP to HTTPS with a 308. Requires the Cloudflare target. Omitted means off. */ + alwaysUseHttps?: boolean; /** Disable ISR and edge prerendering, including per-page policies. Static output is unaffected. */ isr?: boolean; revalidate?: number | Record; @@ -320,8 +322,8 @@ type ParsedRuleSource = { /** * Split an optional `https://host` prefix from a rule source. The host is * lowercased (dispatch compares `url.hostname`), and a bare `https://host` gets - * path `/`. Returns `{ error }` for `//` and `http://` sources (only HTTPS - * requests reach the runtime) and for non-literal hosts. + * path `/`. Sources scope rules by hostname and path, without matching the + * request scheme. Returns `{ error }` for unsupported schemes and non-literal hosts. */ export function parseHostPrefixedSource(raw: string): ParsedRuleSource | { error: string } { if (raw.startsWith('//')) { @@ -331,7 +333,7 @@ export function parseHostPrefixedSource(raw: string): ParsedRuleSource | { error } if (raw.startsWith('http://')) { return { - error: `must not start with "http://" (TLS terminates upstream of the worker; only "https://" requests reach the runtime — use "https://host/path" instead)`, + error: `must not start with "http://" (use "https://host/path" to scope a rule by hostname, or "routing.alwaysUseHttps" to redirect HTTP to HTTPS)`, }; } if (!raw.startsWith('https://')) { @@ -559,6 +561,15 @@ function validateConfigObject(obj: unknown): VoidConfig { } const routing = obj.routing; + if (routing.alwaysUseHttps !== undefined && typeof routing.alwaysUseHttps !== 'boolean') { + throw new Error(`config: "routing.alwaysUseHttps" in ${CONFIG_FILE} must be a boolean.`); + } + if (routing.alwaysUseHttps === true && isNodeTarget(obj.target as VoidConfig['target'])) { + throw new Error( + `config: "routing.alwaysUseHttps" in ${CONFIG_FILE} requires target: "cloudflare". It is not available with target: "${obj.target}".`, + ); + } + // Validate routing.notFound if (routing.notFound !== undefined) { if (typeof routing.notFound !== 'string' || !VALID_NOT_FOUND_HANDLING.has(routing.notFound)) { diff --git a/packages/void/src/deploy.ts b/packages/void/src/deploy.ts index e2398fdbf..e7519f44c 100644 --- a/packages/void/src/deploy.ts +++ b/packages/void/src/deploy.ts @@ -109,6 +109,7 @@ export async function prepareDeployArtifact( artifact: createDeployArtifact({ manifest: buildStaticManifest({ appType: preset.appType, + alwaysUseHttps: config.routing?.alwaysUseHttps, hashedAssetsPrefix: 'assets', notFound: config.routing?.notFound, ...rules, @@ -157,6 +158,7 @@ export async function prepareDeployArtifact( false, { bindings, + alwaysUseHttps: config.routing?.alwaysUseHttps, vars: config.worker?.vars, hasEmailHandlers: scan.email !== undefined && hasEmailHandlers(scan.email), migrations, diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index fdb7af2b8..c7282a8ca 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -901,7 +901,8 @@ export function voidPlugin(options?: { const rules = mergeRoutingRules({ dir: assetRoutingPublicDir, routing: config.routing }); const nativeOwnsEdgeRouting = !managedCloudflareBuild && - (rules.headerRules.length > 0 || + (config.routing?.alwaysUseHttps === true || + rules.headerRules.length > 0 || rules.redirectRules.length > 0 || (rules.fallbackRules?.length ?? 0) > 0); if (nativeOwnsEdgeRouting) { diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 9860cd858..f19b029d3 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -487,6 +487,8 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug assetsDir: string, userHeaders: boolean, ): { headers: string | null; wranglerPath: string; wranglerText: string | null } | null { + // `routing.alwaysUseHttps` keeps the bypass: hashed files are only linked + // from pages the Worker has already moved to HTTPS. if (!scan || !resolvedConfig || compileOptions.managedDispatch) { return null; } @@ -569,6 +571,11 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug join(outDir, 'entry.ts'), compileWorkerEntry({ versionMetadataBinding: options.getVersionMetadataBinding?.(), + // Managed dispatch enforces HTTPS before the app Worker runs. + alwaysUseHttps: + options.routing?.alwaysUseHttps === true && + resolvedConfig?.command === 'build' && + !compileOptions.managedDispatch, hasQueues: scan!.queues.length > 0, remote, assetsDir: resolvedConfig?.build.assetsDir, diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 7b0cd545f..faf78517c 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -1072,6 +1072,11 @@ const serveWithAssets = __createAssetFallback(${JSON.stringify({ assetsDir, serv export interface WorkerEntryOptions { /** Native readiness proves the actual served Worker version through this binding. */ versionMetadataBinding?: string; + /** + * Redirect public HTTP requests to HTTPS before ISR and the app. Native + * Cloudflare production builds only: managed dispatch enforces it there. + */ + alwaysUseHttps?: boolean; hasQueues?: boolean; remote?: { remotePath: string; @@ -1146,6 +1151,7 @@ export function compileWorkerEntry(options: WorkerEntryOptions = {}): string { const { hasQueues = false, versionMetadataBinding, + alwaysUseHttps = false, remote, assetsDir = 'assets', webSocketClasses = [], @@ -1244,7 +1250,7 @@ const __filterInternalBindings = (env) => __filterBindings(env, ${JSON.stringify app, assets: serveWithAssets, withRawEnv: __withRawEnv, withRuntimeEnv, filterBindings: __filterInternalBindings, internalRoute: __voidHandleInternalRoute, - collectPrerenderPaths: __voidCollectPrerenderPaths${versionMetadataBinding ? ', versionMetadataBinding: ' + JSON.stringify(versionMetadataBinding) : ''}${envSchemaModule ? ', validateEnv: _validateEnvOnce' : ''}${remotePath ? ', remoteEnv: (env) => ' + createRemoteEnvCall : ''}${hasEmail ? ', emailReceipt: __handleEmailReceipt' : ''}${isr ? ', isr: { deploymentId: __VOID_ISR_DEPLOYMENT_ID, handle: (request, env, ctx, render) => handleIsr(request, env, ctx, { revalidate: __VOID_ISR_REVALIDATE, pages: __VOID_ISR_PAGES, pathRevalidate: __VOID_ISR_PATH_REVALIDATE, deploymentId: __VOID_ISR_DEPLOYMENT_ID, queryAllowlist: ' + JSON.stringify(isr.queryAllowlist) + ' }, render) }' : ''} + collectPrerenderPaths: __voidCollectPrerenderPaths${alwaysUseHttps ? ', alwaysUseHttps: true' : ''}${versionMetadataBinding ? ', versionMetadataBinding: ' + JSON.stringify(versionMetadataBinding) : ''}${envSchemaModule ? ', validateEnv: _validateEnvOnce' : ''}${remotePath ? ', remoteEnv: (env) => ' + createRemoteEnvCall : ''}${hasEmail ? ', emailReceipt: __handleEmailReceipt' : ''}${isr ? ', isr: { deploymentId: __VOID_ISR_DEPLOYMENT_ID, handle: (request, env, ctx, render) => handleIsr(request, env, ctx, { revalidate: __VOID_ISR_REVALIDATE, pages: __VOID_ISR_PAGES, pathRevalidate: __VOID_ISR_PATH_REVALIDATE, deploymentId: __VOID_ISR_DEPLOYMENT_ID, queryAllowlist: ' + JSON.stringify(isr.queryAllowlist) + ' }, render) }' : ''} })`; const remoteEnvSwap = remotePath ? ` if (env.__VOID_REMOTE === "1") env = ${createRemoteEnvCall};\n` diff --git a/packages/void/src/runtime/routing-worker.ts b/packages/void/src/runtime/routing-worker.ts index ad8f7975f..d75cf3414 100644 --- a/packages/void/src/runtime/routing-worker.ts +++ b/packages/void/src/runtime/routing-worker.ts @@ -1,3 +1,4 @@ +import { redirectToHttps } from '@void/edge'; import type { ExecutionContext } from 'hono'; import { createManifestLoader, type ManifestLoader } from './routing-assets.ts'; @@ -172,6 +173,8 @@ export type WorkerFetchOptions = { withRuntimeEnv: (env: RuntimeEnvironment, fn: () => Promise) => Promise; filterBindings: (env: RuntimeEnvironment) => RuntimeEnvironment; validateEnv?: (env: RuntimeEnvironment) => Promise; + /** Native Cloudflare production builds redirect public HTTP requests to HTTPS. */ + alwaysUseHttps?: boolean; /** Authored/generated version metadata binding used to prove the served native version. */ versionMetadataBinding?: string; remoteEnv?: (env: RuntimeEnvironment) => RuntimeEnvironment; @@ -245,6 +248,15 @@ export function createWorkerFetch( if (discovery) { return Response.json({ paths: await options.collectPrerenderPaths(request, env, ctx) }); } + // After the Void endpoints that tools and the platform call above, and + // before ISR, so a cached page is never served over HTTP. The + // build-time prerender runner fetches localhost, which is never redirected. + if (options.alwaysUseHttps) { + const redirect = redirectToHttps(request); + if (redirect) { + return redirect; + } + } const userEnv = options.filterBindings(env); const render = async (req: Request): Promise => options.assets(req, env, await options.app.fetch(req, userEnv, ctx)); diff --git a/packages/void/test/integration/full-deploy-manifest.test.ts b/packages/void/test/integration/full-deploy-manifest.test.ts index e805fd14d..952642ff9 100644 --- a/packages/void/test/integration/full-deploy-manifest.test.ts +++ b/packages/void/test/integration/full-deploy-manifest.test.ts @@ -100,6 +100,7 @@ type Captured = { finalizeBody: { manifest?: { pages?: unknown; + alwaysUseHttps?: boolean; bindings?: Record; vars?: Record; }; @@ -337,17 +338,20 @@ describe('managed deployment manifests (built Worker)', () => { ['Class A', { name: 'vinext-pages', class: 'a' }], ['Class C', { name: 'astro', class: 'c' }], ] as const)( - 'includes configured worker.vars in the %s deployment request', + 'includes configured worker.vars and HTTPS policy in the %s deployment request', async (_, detected) => { mockDetectFramework.mockReturnValue(detected); const configPath = join(fixture, 'void.config.ts'); const original = readFileSync(configPath, 'utf8'); const dotenvPath = join(fixture, '.env'); const originalDotenv = existsSync(dotenvPath) ? readFileSync(dotenvPath) : undefined; + const alwaysUseHttps = detected?.class !== 'a'; const vars = { API_URL: 'https://api.example.test', EMPTY_SETTING: '' }; writeFileSync( configPath, - original.replace('worker: {', `worker: { vars: ${JSON.stringify(vars)},`), + original + .replace('worker: {', `worker: { vars: ${JSON.stringify(vars)},`) + .replace('routing: {', `routing: { alwaysUseHttps: ${alwaysUseHttps},`), ); writeFileSync(dotenvPath, 'API_URL=https://local.example.test\nADMIN_TOKEN=local-secret\n'); if (detected?.name === 'astro') { @@ -359,6 +363,7 @@ describe('managed deployment manifests (built Worker)', () => { const captured = installPlatformMocks(); await runDeploy(fixture, { skipBuild: true }); expect(captured.finalizeBody?.manifest?.vars).toEqual(vars); + expect(captured.finalizeBody?.manifest?.alwaysUseHttps).toBe(alwaysUseHttps); } finally { writeFileSync(configPath, original); if (originalDotenv === undefined) { diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index bf10099a6..b6679c7fc 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -2382,10 +2382,36 @@ describe('runCloudflareDeploy — static, SPA, and SSG apps', () => { not_found_handling: 'single-page-application', }, }); + const entry = readFileSync(join(root, '.void/deploy/cloudflare/static-edge.mjs'), 'utf-8'); + expect(entry).toContain('void/edge'); + expect(entry).not.toContain('alwaysUseHttps'); + expect(wranglerArgs.at(-1)).toContain(toSlash(join(root, '.void/deploy/cloudflare/assets'))); + }); + + it.each([ + { name: 'a SPA', dependencies: {} }, + { name: 'a static Astro build', dependencies: { astro: '5.0.0' } }, + ])('writes routing.alwaysUseHttps into the thin edge Worker for $name', async (testCase) => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'spaapp', dependencies: testCase.dependencies }), + 'void.json': JSON.stringify({ + inference: { appType: 'spa', outputDir: 'dist' }, + routing: { alwaysUseHttps: true }, + }), + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123' }), + 'dist/index.html': '
', + }); + const { deps, wranglerArgs, exitCalls } = harness(); + await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); + + expect(exitCalls).toEqual([]); + expect(wranglerArgs.map((args) => args[0])).toEqual(['whoami', 'deploy']); + expect(parseRootWranglerConfig(generatedStaticConfig(root))).toMatchObject({ + assets: { run_worker_first: true }, + }); expect(readFileSync(join(root, '.void/deploy/cloudflare/static-edge.mjs'), 'utf-8')).toContain( - 'void/edge', + '"alwaysUseHttps":true', ); - expect(wranglerArgs.at(-1)).toContain(toSlash(join(root, '.void/deploy/cloudflare/assets'))); }); it('preserves canonical Worker configuration when deploying static assets', async () => { @@ -3819,6 +3845,38 @@ describe('runCloudflareDeploy — full Void app queues', () => { expect(messages.join('\n')).toMatch(/cannot apply Void routing[\s\S]*framework owns/); }); + it.each([ + ['@tanstack/react-start', 'tanstack-start'], + ['@sveltejs/kit', 'sveltekit'], + ['nuxt', 'nuxt'], + ['astro', 'astro'], + ])( + 'rejects routing.alwaysUseHttps for a %s app before account mutation', + async (dependency, framework) => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'fwapp', dependencies: { [dependency]: '1.0.0' } }), + 'void.json': JSON.stringify({ routing: { alwaysUseHttps: true } }), + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: 'fwapp' }, null, 2), + }); + const messages: Array = []; + const restore = log.error; + log.error = (message: string) => messages.push(message); + try { + const { deps, trace, wranglerArgs } = harness(); + await expect( + runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(trace).toEqual([]); + expect(wranglerArgs).toEqual([]); + } finally { + log.error = restore; + } + const message = messages.join('\n'); + expect(message).toContain(`cannot apply Void routing.alwaysUseHttps to ${framework}.`); + expect(message).toContain("Cloudflare's Always Use HTTPS setting"); + }, + ); + it('deploys a Class-A framework queue through the verified artifact', async () => { const root = makeProject({ 'package.json': JSON.stringify({ diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index bf63d2ab3..5d56eb189 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -833,6 +833,11 @@ describe('default-target dev parity', () => { }); describe('compileWorkerEntry', () => { + it('passes the HTTPS redirect to the worker fetch only when enabled', () => { + expect(compileWorkerEntry({ alwaysUseHttps: true })).toContain('alwaysUseHttps: true'); + expect(compileWorkerEntry()).not.toContain('alwaysUseHttps'); + }); + it('passes the version metadata binding name to the worker fetch', () => { expect(compileWorkerEntry({ versionMetadataBinding: 'AUTHORED_VERSION' })).toContain( 'versionMetadataBinding: "AUTHORED_VERSION"', diff --git a/packages/void/test/unit/config.test.ts b/packages/void/test/unit/config.test.ts index 912a4c44e..9fa9f5cfd 100644 --- a/packages/void/test/unit/config.test.ts +++ b/packages/void/test/unit/config.test.ts @@ -41,6 +41,46 @@ function cleanup() { rmSync(tmpDir, { recursive: true, force: true }); } +describe('routing.alwaysUseHttps', () => { + it.each([true, false, undefined])('preserves %s without applying a default', (alwaysUseHttps) => { + setup(JSON.stringify({ routing: { alwaysUseHttps } })); + try { + expect(readConfig(tmpDir).routing?.alwaysUseHttps).toBe(alwaysUseHttps); + } finally { + cleanup(); + } + }); + it.each(['true', 1, null, {}])('rejects non-boolean %s', (alwaysUseHttps) => { + setup(JSON.stringify({ routing: { alwaysUseHttps } })); + try { + expect(() => readConfig(tmpDir)).toThrow('routing.alwaysUseHttps'); + } finally { + cleanup(); + } + }); + it.each(['node', 'bun', 'deno'])('rejects true with target %s', (target) => { + setup(JSON.stringify({ target, routing: { alwaysUseHttps: true } })); + try { + expect(() => readConfig(tmpDir)).toThrow( + `"routing.alwaysUseHttps" in void.json requires target: "cloudflare". It is not available with target: "${target}".`, + ); + } finally { + cleanup(); + } + }); + it.each([ + { target: 'node', routing: { alwaysUseHttps: false } }, + { target: 'cloudflare', routing: { alwaysUseHttps: true } }, + ])('accepts %j', (config) => { + setup(JSON.stringify(config)); + try { + expect(readConfig(tmpDir).routing?.alwaysUseHttps).toBe(config.routing.alwaysUseHttps); + } finally { + cleanup(); + } + }); +}); + describe('readConfig', () => { it('loads and re-reads a typed Void config', () => { setup(); diff --git a/packages/void/test/unit/deploy-artifact-preparation.test.ts b/packages/void/test/unit/deploy-artifact-preparation.test.ts index 0b1715402..522101d00 100644 --- a/packages/void/test/unit/deploy-artifact-preparation.test.ts +++ b/packages/void/test/unit/deploy-artifact-preparation.test.ts @@ -133,27 +133,34 @@ it('packages a detected Vite SPA as assets without a Worker or provider calls', expect(fetch).not.toHaveBeenCalled(); }); -it('honors static output directories and routing rules without packaging stale Worker output', async () => { - writeFileSync( - join(root, 'void.config.ts'), - `export default { inference: { appType: 'static', outputDir: 'site' }, routing: { notFound: '404-page' }, worker: { vars: { API_URL: 'https://api.example.test' } } };`, - ); - mkdirSync(join(root, 'site')); - writeFileSync(join(root, 'site/index.html'), '

Static

'); - writeFileSync(join(root, 'site/_headers'), '/*\n X-Content-Type-Options: nosniff\n'); - writeFileSync(join(root, 'site/_redirects'), '/old /new 301\n'); - const result = await prepareDeployArtifact(root); - expect(result.artifact.manifest).toMatchObject({ - type: 'static', - assetConfig: { not_found_handling: '404-page' }, - headerRules: [{ pattern: '/*', headers: [['X-Content-Type-Options', 'nosniff']] }], - redirectRules: [{ source: '/old', destination: '/new', status: 301 }], - }); - expect(result.artifact.workerFiles).toBeUndefined(); - expect(result.artifact.manifest).not.toHaveProperty('vars'); - expect(Object.keys(result.assets)).toEqual(['/index.html']); - expect(fetch).not.toHaveBeenCalled(); -}); +it.each([true, false, undefined])( + 'honors static output directories and HTTPS policy %s without packaging stale Worker output', + async (alwaysUseHttps) => { + writeFileSync( + join(root, 'void.config.ts'), + `export default { inference: { appType: 'static', outputDir: 'site' }, routing: { alwaysUseHttps: ${alwaysUseHttps}, notFound: '404-page' }, worker: { vars: { API_URL: 'https://api.example.test' } } };`, + ); + mkdirSync(join(root, 'site')); + writeFileSync(join(root, 'site/index.html'), '

Static

'); + writeFileSync(join(root, 'site/_headers'), '/*\n X-Content-Type-Options: nosniff\n'); + writeFileSync(join(root, 'site/_redirects'), '/old /new 301\n'); + const result = await prepareDeployArtifact(root); + expect(result.artifact.manifest).toMatchObject({ + type: 'static', + assetConfig: { not_found_handling: '404-page' }, + headerRules: [{ pattern: '/*', headers: [['X-Content-Type-Options', 'nosniff']] }], + redirectRules: [{ source: '/old', destination: '/new', status: 301 }], + }); + expect(result.artifact.workerFiles).toBeUndefined(); + expect(result.artifact.manifest.alwaysUseHttps).toBe(alwaysUseHttps); + expect(Object.hasOwn(result.artifact.manifest, 'alwaysUseHttps')).toBe( + alwaysUseHttps !== undefined, + ); + expect(result.artifact.manifest).not.toHaveProperty('vars'); + expect(Object.keys(result.assets)).toEqual(['/index.html']); + expect(fetch).not.toHaveBeenCalled(); + }, +); it('keeps SPA plus API apps on the Worker deployment path', async () => { writeFileSync(join(root, 'package.json'), JSON.stringify({ devDependencies: { vite: '*' } })); @@ -181,3 +188,18 @@ it('rejects missing or empty static build output', async () => { await expect(prepareDeployArtifact(root)).rejects.toThrow('Static build output is empty'); expect(fetch).not.toHaveBeenCalled(); }); + +it.each([true, false, undefined])( + 'preserves HTTPS policy %s in a prepared Worker artifact', + async (alwaysUseHttps) => { + writeFileSync( + join(root, 'void.config.ts'), + `export default { routing: { alwaysUseHttps: ${alwaysUseHttps} } };`, + ); + const result = await prepareDeployArtifact(root); + expect(result.artifact.manifest.alwaysUseHttps).toBe(alwaysUseHttps); + expect(Object.hasOwn(result.artifact.manifest, 'alwaysUseHttps')).toBe( + alwaysUseHttps !== undefined, + ); + }, +); diff --git a/packages/void/test/unit/package.test.ts b/packages/void/test/unit/package.test.ts index 85a8312a8..c10334049 100644 --- a/packages/void/test/unit/package.test.ts +++ b/packages/void/test/unit/package.test.ts @@ -38,6 +38,26 @@ afterEach(() => { }); describe('worker packaging', () => { + it.each([true, false, undefined])( + 'preserves alwaysUseHttps %s in every app manifest', + (value) => { + const manifests = [ + buildVoidManifest({ bindings: makeBindings(), alwaysUseHttps: value }), + buildStaticManifest({ appType: 'static', alwaysUseHttps: value }), + buildFrameworkManifest({ + frameworkName: 'nuxt', + workerMain: 'index.mjs', + bindings: makeBindings(), + alwaysUseHttps: value, + }), + ]; + for (const manifest of manifests) { + const published = JSON.parse(JSON.stringify(manifest)); + expect(published.alwaysUseHttps).toBe(value); + expect(Object.hasOwn(published, 'alwaysUseHttps')).toBe(value !== undefined); + } + }, + ); it('marks Sandbox bundles as controller-capable and omits metadata for apps without Sandbox', () => { const manifest = buildVoidManifest({ bindings: makeBindings({ needsSandbox: true }), diff --git a/packages/void/test/unit/plugin-dev-asset-routing.test.ts b/packages/void/test/unit/plugin-dev-asset-routing.test.ts index 30b044a7d..f8048d456 100644 --- a/packages/void/test/unit/plugin-dev-asset-routing.test.ts +++ b/packages/void/test/unit/plugin-dev-asset-routing.test.ts @@ -429,6 +429,29 @@ describe('voidPlugin dev asset routing', () => { expect(assets?.run_worker_first).toEqual(['/**']); }); + it.each([ + { name: 'sends every path to the Worker in native builds', managed: false, expected: ['/**'] }, + { + name: 'keeps the inferred policy in managed builds', + managed: true, + expected: ['/api', '/api/*', '/__void', '/__void/*'], + }, + ])('routing.alwaysUseHttps alone $name', async ({ managed, expected }) => { + if (managed) { + vi.stubEnv('VOID_DEPLOY_PROJECT_ID', 'managed-project'); + } + const root = createApiOnlyProjectRoot(); + writeFileSync(join(root, 'void.json'), JSON.stringify({ routing: { alwaysUseHttps: true } })); + const assets = await resolveVoidPluginAssets( + root, + {}, + { command: 'build', mode: 'production' }, + ); + + expect(assets?.run_worker_first).toEqual(expected); + expect(assets?.not_found_handling).toBe('single-page-application'); + }); + it('runs merged public routing files before existing assets in native production builds', async () => { const root = createApiOnlyProjectRoot(); mkdirSync(join(root, 'public'), { recursive: true }); diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index 32b8607ef..76359de1d 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -1009,6 +1009,36 @@ describe('routingPlugin', () => { } }, ); + + it.each([ + { name: 'a native build', command: 'build' as const, enabled: true, emitted: true }, + { name: 'the dev server', command: 'serve' as const, enabled: true, emitted: false }, + { name: 'a managed build', command: 'build' as const, enabled: true, managed: true }, + { name: 'an unset option', command: 'build' as const, enabled: false, emitted: false }, + ])('writes the HTTPS redirect into the Worker entry for $name', async (testCase) => { + const { command, enabled, managed, emitted = false } = testCase; + if (managed) { + vi.stubEnv('VOID_DEPLOY_PROJECT_ID', 'project-id'); + } + try { + const root = createTempRoot(); + const plugin = createRoutingPlugin(root, { + isAuthEnabled: () => false, + routing: enabled ? { alwaysUseHttps: true } : undefined, + }); + runConfig(plugin, { command, mode: 'production' }); + resolveConfig(plugin, command); + await runBuildStart(plugin); + + const entry = readFileSync(join(root, '.void', 'entry.ts'), 'utf-8'); + expect(entry.includes('alwaysUseHttps: true')).toBe(emitted); + // The app itself never redirects, so in-process `fetch` stays on HTTP. + const load = getHookHandler(plugin.load) as (id: string) => Promise; + expect(await load('virtual:void-routes')).not.toContain('alwaysUseHttps'); + } finally { + vi.unstubAllEnvs(); + } + }); }); describe('routingPlugin native asset Worker bypass', () => { @@ -1107,6 +1137,22 @@ describe('routingPlugin native asset Worker bypass', () => { expect(headers).toContain(`/${CHUNK}\n Cache-Control: public, max-age=31536000, immutable\n`); }); + it('keeps the bypass when HTTPS enforcement is the only rule', async () => { + const root = createBuildRoot(); + await runClientBuild(root, { routing: { alwaysUseHttps: true } }); + expect(JSON.parse(wranglerText(root)).assets.run_worker_first).toEqual(['/**', `!/${CHUNK}`]); + expect(readFileSync(headersPath(root), 'utf8')).toContain(`/${CHUNK}\n`); + }); + + it('keeps the Worker in front when HTTPS enforcement comes with a header rule', async () => { + const root = createBuildRoot(); + await runClientBuild(root, { + routing: { alwaysUseHttps: true, headers: { '/*': ['X-Frame-Options: DENY'] } }, + }); + expect(wranglerText(root)).toBe(WRANGLER); + expect(existsSync(headersPath(root))).toBe(false); + }); + it('rewrites its own _headers on the next build', async () => { const root = createBuildRoot(); writeFileSync( diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 8ac87b374..744c1ed1d 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -1016,3 +1016,72 @@ describe('static asset marker', () => { expect(seen.pop()).toBeUndefined(); }); }); + +describe('native HTTPS policy', () => { + it('redirects HTTP before ISR and the app, keeping path, query and method', async () => { + const handle = vi.fn(async () => new Response('cache')); + const app = { fetch: vi.fn(async () => new Response('app')) }; + const fetch = worker({ alwaysUseHttps: true, app, isr: { deploymentId: 'd', handle } }); + const response = await fetch( + new Request('http://oxc.rs/docs?q=1', { method: 'POST' }), + { ISR_CACHE: {} }, + context, + ); + expect(response.status).toBe(308); + expect(response.headers.get('Location')).toBe('https://oxc.rs/docs?q=1'); + expect(handle).not.toHaveBeenCalled(); + expect(app.fetch).not.toHaveBeenCalled(); + const upgrade = await fetch( + new Request('http://oxc.rs/chat', { headers: { upgrade: 'websocket' } }), + {}, + context, + ); + expect(upgrade.status).toBe(308); + }); + + it.each([ + { name: 'HTTPS', url: 'https://oxc.rs/docs', alwaysUseHttps: true }, + { name: 'the option off', url: 'http://oxc.rs/docs', alwaysUseHttps: false }, + { name: 'the option unset', url: 'http://oxc.rs/docs', alwaysUseHttps: undefined }, + { + name: 'the prerender runner on localhost', + url: 'http://localhost/docs', + alwaysUseHttps: true, + }, + ])('serves the cached page for $name', async ({ url, alwaysUseHttps }) => { + const handle = vi.fn(async () => new Response('cache')); + const fetch = worker({ alwaysUseHttps, isr: { deploymentId: 'd', handle } }); + const response = await fetch(new Request(url), { ISR_CACHE: {} }, context); + expect(response.status).toBe(200); + expect(await response.text()).toBe('cache'); + }); + + it('answers Void endpoints for tools and the platform over HTTP', async () => { + const fetch = worker({ + alwaysUseHttps: true, + emailReceipt: async () => new Response('receipt', { status: 202 }), + internalRoute: async (request) => + new URL(request.url).pathname === '/__void/migrate' ? new Response('migrated') : null, + }); + const ready = await fetch(new Request('http://oxc.rs/__void/ready'), {}, context); + expect(await ready.json()).toEqual({ ready: true }); + const receipt = await fetch( + new Request('http://oxc.rs/__void/email/receipt/v1', { method: 'POST' }), + {}, + context, + ); + expect(receipt.status).toBe(202); + const migrate = await fetch( + new Request('http://oxc.rs/__void/migrate', { method: 'POST' }), + {}, + context, + ); + expect(await migrate.text()).toBe('migrated'); + const discovery = await fetch( + new Request('http://oxc.rs/__void/prerender-paths', { method: 'POST' }), + { __VOID_PRERENDER: '1' }, + context, + ); + expect(await discovery.json()).toEqual({ paths: ['/page'] }); + }); +}); diff --git a/platform/packages/api/src/routing.ts b/platform/packages/api/src/routing.ts index 752635b5a..8f96b7b23 100644 --- a/platform/packages/api/src/routing.ts +++ b/platform/packages/api/src/routing.ts @@ -305,6 +305,7 @@ export function buildRoutingJson(opts: { : {}), ...(manifest?.hashedAssetsPrefix ? { hashedAssetsPrefix: manifest.hashedAssetsPrefix } : {}), ...(manifest?.headerRules?.length ? { headerRules: manifest.headerRules } : {}), + ...(manifest?.alwaysUseHttps !== undefined ? { alwaysUseHttps: manifest.alwaysUseHttps } : {}), ...(manifest?.redirectRules?.length ? { redirectRules: manifest.redirectRules } : {}), ...(manifest?.fallbackRules?.length ? { fallbackRules: manifest.fallbackRules } : {}), ...(manifest?.revalidateQueryAllowlist @@ -341,6 +342,7 @@ export function buildStaticRoutingJson(opts: { version: opts.deploymentId, ...(manifest?.hashedAssetsPrefix ? { hashedAssetsPrefix: manifest.hashedAssetsPrefix } : {}), ...(manifest?.headerRules?.length ? { headerRules: manifest.headerRules } : {}), + ...(manifest?.alwaysUseHttps !== undefined ? { alwaysUseHttps: manifest.alwaysUseHttps } : {}), ...(manifest?.redirectRules?.length ? { redirectRules: manifest.redirectRules } : {}), ...(manifest?.fallbackRules?.length ? { fallbackRules: manifest.fallbackRules } : {}), ...(manifest?.revalidateQueryAllowlist diff --git a/platform/packages/api/test/unit/routing.test.ts b/platform/packages/api/test/unit/routing.test.ts index 9d9849e4b..eb8e2edcd 100644 --- a/platform/packages/api/test/unit/routing.test.ts +++ b/platform/packages/api/test/unit/routing.test.ts @@ -13,6 +13,34 @@ import { PLAN_LIMITS, type PlanLimits } from '../../src/tiers.ts'; import type { DeployManifest } from '../../src/types.ts'; import * as zeroTrust from '../../src/zero-trust/routing.ts'; +describe.each([ + ['worker', buildRoutingJson], + ['static', buildStaticRoutingJson], +] as const)('%s HTTPS routing policy', (_kind, build) => { + it.each([true, false, undefined])( + 'preserves alwaysUseHttps %s in published routing', + (alwaysUseHttps) => { + const published = JSON.parse( + build({ + workerName: 'worker', + projectId: 'proj_1', + userId: 'user_1', + deploymentId: 'dep_1', + dispatchLimits: null, + assetConfig: { not_found_handling: 'none' }, + manifest: { + version: 3, + bindings: {}, + ...(alwaysUseHttps !== undefined ? { alwaysUseHttps } : {}), + }, + }), + ); + expect(published.alwaysUseHttps).toBe(alwaysUseHttps); + expect(Object.hasOwn(published, 'alwaysUseHttps')).toBe(alwaysUseHttps !== undefined); + }, + ); +}); + function createKvStore(initial: Record = {}): { store: Map; puts: Array; diff --git a/platform/packages/dispatch/src/index.ts b/platform/packages/dispatch/src/index.ts index 6bb54ae20..3221a53a9 100644 --- a/platform/packages/dispatch/src/index.ts +++ b/platform/packages/dispatch/src/index.ts @@ -1,4 +1,4 @@ -import type { ProjectZeroTrustRouting } from '@void/edge'; +import { redirectToHttps, type ProjectZeroTrustRouting } from '@void/edge'; import { clientRevalidateResponse, isEventStreamResponse } from '@void/isr/runtime'; import { PLATFORM_HEALTH_HOSTNAME_LABEL, @@ -81,6 +81,7 @@ type RoutingEntry = Readonly<{ // `redirectRules` fires pre-asset, `fallbackRules` fires post-asset. // List identity encodes the pre-vs-post-asset semantic. redirectRules?: ReadonlyArray; + alwaysUseHttps?: boolean; fallbackRules?: ReadonlyArray; // Per-pathname allowlist of query params that are part of the ISR variant // key on a rewrite. Default (no entry / no allowlist) drops every param, @@ -747,6 +748,17 @@ const dispatcher = { } const routing: RoutingEntry = JSON.parse(routingRaw); + if (selected.projectId && selected.projectId !== routing.projectId) { + return createErrorResponse(request, 404, 'Not Found'); + } + // Keep omission distinct from false so a future platform default can change + // without overriding an application's explicit opt-out. + if (routing.alwaysUseHttps ?? false) { + const redirect = redirectToHttps(request); + if (redirect) { + return redirect; + } + } if (!privatePreview && (routing.privatePreview || previewRoute)) { if (!routing.privatePreview || !env.PREVIEW_API) { return createErrorResponse(request, 404, 'Not Found', { 'Cache-Control': 'no-store' }); @@ -766,9 +778,6 @@ const dispatcher = { if (Boolean(routing.privatePreview) !== privatePreview) { return createErrorResponse(request, 404, 'Not Found', { 'Cache-Control': 'no-store' }); } - if (selected.projectId && selected.projectId !== routing.projectId) { - return createErrorResponse(request, 404, 'Not Found'); - } // Access normally rejects unauthenticated traffic before dispatch. This // independent assertion check makes an accidentally deleted or drifted diff --git a/platform/packages/dispatch/test/unit/internal-routes.test.ts b/platform/packages/dispatch/test/unit/internal-routes.test.ts index 2e5f0cb73..00a911e0d 100644 --- a/platform/packages/dispatch/test/unit/internal-routes.test.ts +++ b/platform/packages/dispatch/test/unit/internal-routes.test.ts @@ -49,6 +49,148 @@ function createEnv( } as unknown as Env; } +describe('HTTPS redirects', () => { + function createHttpsEnv(alwaysUseHttps?: boolean): Env { + const env = createEnv(); + Object.assign(env.ROUTING, { + get: vi.fn(async () => + JSON.stringify({ + workerName: 'user-app', + projectId: 'proj_1', + ...(alwaysUseHttps !== undefined ? { alwaysUseHttps } : {}), + }), + ), + }); + return env; + } + + it.each([false, undefined])('serves HTTP with alwaysUseHttps %s', async (value) => { + const env = createHttpsEnv(value); + const response = await app.fetch(new Request('http://oxc.rs/'), env, createCtx()); + expect(response.status).toBe(200); + expect(response.headers.get('Location')).toBeNull(); + expect(env.DISPATCH.get).toHaveBeenCalled(); + }); + + it('does not redirect an unknown host', async () => { + const env = createHttpsEnv(true); + Object.assign(env.ROUTING, { get: vi.fn(async () => null) }); + const response = await app.fetch(new Request('http://unknown.example/'), env, createCtx()); + expect(response.status).toBe(404); + expect(response.headers.get('Location')).toBeNull(); + }); + + it('preserves the platform staging traffic gate for opted-in apps', async () => { + const env: Env = { ...createHttpsEnv(true), PLATFORM_TRAFFIC_STATE: 'staging' }; + const response = await app.fetch(new Request('http://oxc.rs/'), env, createCtx()); + expect(response.status).toBe(503); + expect(response.headers.get('Location')).toBeNull(); + }); + + it('redirects opted-in static deployments before loading assets', async () => { + const env = createEnv(); + Object.assign(env.ROUTING, { + get: vi.fn(async () => + JSON.stringify({ projectId: 'proj_1', staticDeploymentId: 'dep_1', alwaysUseHttps: true }), + ), + }); + const response = await app.fetch(new Request('http://oxc.rs/'), env, createCtx()); + expect(response.status).toBe(308); + expect(response.headers.get('Location')).toBe('https://oxc.rs/'); + expect(env.DISPATCH.get).not.toHaveBeenCalled(); + }); + + it.each(['/.well-known/acme-challenge/token', '/.well-known/pki-validation/token.txt'])( + 'passes certificate validation path %s to the app', + async (path) => { + const env = createHttpsEnv(true); + const response = await app.fetch(new Request('http://oxc.rs' + path), env, createCtx()); + expect(response.status).toBe(200); + expect(env.DISPATCH.get).toHaveBeenCalled(); + }, + ); + + it('drops an HTTP-only port when upgrading', async () => { + const response = await app.fetch( + new Request('http://oxc.rs:8080/docs?q=1'), + createHttpsEnv(true), + createCtx(), + ); + expect(response.status).toBe(308); + expect(response.headers.get('Location')).toBe('https://oxc.rs/docs?q=1'); + }); + it.each([ + ['oxc.rs', 'GET'], + ['rolldown.rs', 'HEAD'], + ['my-app.void.app', 'POST'], + ])( + 'redirects HTTP %s requests with method %s before worker dispatch', + async (hostname, method) => { + const env = createHttpsEnv(true); + const request = new Request(`http://${hostname}/docs/a%2Fb?search=a%2Fb&tag=1&tag=2`, { + method, + ...(method === 'POST' ? { body: 'payload' } : {}), + }); + const response = await app.fetch(request, env, createCtx()); + + expect(response.status).toBe(308); + expect(response.headers.get('Location')).toBe( + `https://${hostname}/docs/a%2Fb?search=a%2Fb&tag=1&tag=2`, + ); + expect(request.bodyUsed).toBe(false); + expect(env.ROUTING.get).toHaveBeenCalled(); + expect(env.DISPATCH.get).not.toHaveBeenCalled(); + }, + ); + + it('serves HTTPS without a redirect even with an HTTP forwarding header', async () => { + const response = await app.fetch( + new Request('https://oxc.rs/', { headers: { 'X-Forwarded-Proto': 'http' } }), + createHttpsEnv(true), + createCtx(), + ); + expect(response.status).toBe(200); + expect(response.headers.get('Location')).toBeNull(); + }); + + it.each(['localhost', 'my-app.localhost', '127.0.0.1', '[::1]'])( + 'serves local HTTP on %s without a redirect', + async (hostname) => { + const response = await app.fetch( + new Request(`http://${hostname}:8787/`), + createHttpsEnv(true), + createCtx(), + ); + expect(response.status).toBe(200); + expect(response.headers.get('Location')).toBeNull(); + }, + ); + + it('preserves HTTP installer health probes while public traffic is disabled', async () => { + const response = await app.fetch( + new Request('http://test.void.app/health', { + headers: { 'X-Void-Platform-Probe': 'probe-token' }, + }), + createEnv(true, true, 'probe-token'), + createCtx(), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + }); + + it('preserves the legacy HTTP admin health probe', async () => { + const response = await app.fetch( + new Request('http://test.void.app/health', { + headers: { 'X-Health-Check': 'void-admin' }, + }), + createHttpsEnv(true), + createCtx(), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + }); +}); + describe('internal route blocking', () => { it('rejects every request while the platform is disabled', async () => { const response = await app.fetch(