From 89467212e3311891dce8364cb6097ae251b68267 Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Tue, 27 Oct 2026 11:14:56 -0700 Subject: [PATCH 1/3] fix(platform): redirect HTTP app requests to HTTPS --- docs/guide/edge/redirects.md | 2 + platform/packages/dispatch/src/index.ts | 10 +++ .../test/unit/internal-routes.test.ts | 70 +++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/docs/guide/edge/redirects.md b/docs/guide/edge/redirects.md index 3318ff76b..f6f439a7b 100644 --- a/docs/guide/edge/redirects.md +++ b/docs/guide/edge/redirects.md @@ -4,6 +4,8 @@ outline: deep # Redirects +Apps hosted on a Void platform automatically redirect HTTP requests to HTTPS with a permanent `308` redirect, preserving the path, query string, and request method. Local development continues to support HTTP. + Define URL redirects in [`void.config.ts`](../../reference/config) using the `routing.redirects` field. Keys are source URL patterns, values are destination strings or objects with an explicit status. ```json diff --git a/platform/packages/dispatch/src/index.ts b/platform/packages/dispatch/src/index.ts index 6bb54ae20..cf6bfcd18 100644 --- a/platform/packages/dispatch/src/index.ts +++ b/platform/packages/dispatch/src/index.ts @@ -696,6 +696,16 @@ const dispatcher = { ) { return createErrorResponse(request, 404, 'Not Found', { 'Cache-Control': 'no-store' }); } + if ( + url.protocol === 'http:' && + url.hostname !== 'localhost' && + !url.hostname.endsWith('.localhost') && + url.hostname !== '127.0.0.1' && + url.hostname !== '[::1]' + ) { + url.protocol = 'https:'; + return Response.redirect(url.href, 308); + } if ( env.PLATFORM_TRAFFIC_STATE === 'staging' || env.PLATFORM_TRAFFIC_STATE === 'sandbox-drain' || diff --git a/platform/packages/dispatch/test/unit/internal-routes.test.ts b/platform/packages/dispatch/test/unit/internal-routes.test.ts index 2e5f0cb73..debeb7d57 100644 --- a/platform/packages/dispatch/test/unit/internal-routes.test.ts +++ b/platform/packages/dispatch/test/unit/internal-routes.test.ts @@ -49,6 +49,76 @@ function createEnv( } as unknown as Env; } +describe('HTTPS redirects', () => { + it.each([ + ['oxc.rs', 'GET'], + ['rolldown.rs', 'HEAD'], + ['my-app.void.app', 'POST'], + ])('redirects HTTP %s requests with method %s before routing', async (hostname, method) => { + const env = createEnv(); + 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).not.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' } }), + createEnv(), + 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/`), + createEnv(), + 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' }, + }), + createEnv(), + 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( From aca0207bceed195ff133f7b1cada212b9b642a9f Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Tue, 27 Oct 2026 11:50:44 -0700 Subject: [PATCH 2/3] feat(routing): add opt-in HTTPS redirects --- docs/guide/edge/redirects.md | 16 ++- docs/reference/config.md | 10 ++ packages/deploy-cloudflare/src/static.ts | 2 + .../test/unit/static.test.ts | 11 ++ packages/deploy-core/src/index.ts | 2 + packages/deploy-core/src/validate.ts | 4 + .../deploy-core/test/unit/validate.test.ts | 11 ++ packages/edge/src/https.ts | 19 ++++ packages/edge/src/index.ts | 1 + packages/edge/src/runtime.ts | 8 ++ packages/edge/test/unit/https.test.ts | 35 ++++++ packages/edge/test/unit/runtime.test.ts | 13 +++ packages/void/schema.json | 4 + packages/void/skills/void/SKILL.md | 2 +- .../src/cli/deploy-artifact-validation.ts | 7 +- .../void/src/cli/deploy-cloudflare-static.ts | 2 + packages/void/src/cli/deploy-cloudflare.ts | 2 +- .../void/src/cli/deploy-managed-framework.ts | 1 + packages/void/src/cli/deploy-managed-full.ts | 4 +- .../void/src/cli/deploy-managed-static.ts | 2 + packages/void/src/cli/package.ts | 10 ++ packages/void/src/config.ts | 12 +- packages/void/src/deploy.ts | 2 + packages/void/src/index.ts | 3 +- packages/void/src/plugin-routing.ts | 8 +- packages/void/src/router/compile.ts | 3 +- packages/void/src/runtime/routing.ts | 8 ++ .../integration/full-deploy-manifest.test.ts | 9 +- .../void/test/unit/cloudflare-deploy.test.ts | 53 ++++----- packages/void/test/unit/config.test.ts | 19 ++++ .../unit/deploy-artifact-preparation.test.ts | 64 +++++++---- packages/void/test/unit/package.test.ts | 20 ++++ .../void/test/unit/plugin-routing.test.ts | 1 + .../void/test/unit/routing-runtime.test.ts | 25 +++++ platform/packages/api/src/routing.ts | 2 + .../packages/api/test/unit/routing.test.ts | 28 +++++ platform/packages/dispatch/src/index.ts | 27 +++-- .../test/unit/internal-routes.test.ts | 106 +++++++++++++++--- 38 files changed, 464 insertions(+), 92 deletions(-) create mode 100644 packages/edge/src/https.ts create mode 100644 packages/edge/test/unit/https.test.ts diff --git a/docs/guide/edge/redirects.md b/docs/guide/edge/redirects.md index f6f439a7b..8572ea491 100644 --- a/docs/guide/edge/redirects.md +++ b/docs/guide/edge/redirects.md @@ -4,7 +4,21 @@ outline: deep # Redirects -Apps hosted on a Void platform automatically redirect HTTP requests to HTTPS with a permanent `308` redirect, preserving the path, query string, and request method. Local development continues to support HTTP. +Enable HTTP-to-HTTPS redirects with `routing.alwaysUseHttps` in `void.config.ts`: + +```ts +import { defineConfig } from 'void/config'; + +export default defineConfig({ + routing: { + alwaysUseHttps: true, + }, +}); +``` + +This sends a permanent `308` redirect to the same hostname over HTTPS, preserving the path, query string, and request method. Local development continues to support HTTP, and certificate validation paths remain available over HTTP. + +Set `false` to explicitly keep HTTP. Omit the setting to inherit your hosting platform's default, which is currently off. The option works on Void platforms and direct Cloudflare deployments of Void apps and static sites. Frameworks that own their Worker handler require their own HTTPS redirect configuration for direct Cloudflare deployments. Define URL redirects in [`void.config.ts`](../../reference/config) using the `routing.redirects` field. Keys are source URL patterns, values are destination strings or objects with an explicit status. diff --git a/docs/reference/config.md b/docs/reference/config.md index 0d9fd77b3..afa4906a3 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. Set `true` to enable, `false` to explicitly keep HTTP, or omit to inherit the hosting platform default (currently off). Local development continues to use HTTP. See [HTTPS redirects](../guide/edge/redirects) for deployment support and details. + +```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..be63f6da0 --- /dev/null +++ b/packages/edge/src/https.ts @@ -0,0 +1,19 @@ +/** 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') || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(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); +} 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..827ccdb7a --- /dev/null +++ b/packages/edge/test/unit/https.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { redirectToHttps } from '../../src/index.ts'; + +describe('redirectToHttps', () => { + it('does not treat a public hostname starting with 127 as loopback', () => { + expect(redirectToHttps(new Request('http://127.example.com/'))?.headers.get('Location')).toBe( + 'https://127.example.com/', + ); + }); + 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://[::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..d2bb719b8 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. Omit to inherit the hosting platform default (currently off). Local development continues to support 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/cli/deploy-artifact-validation.ts b/packages/void/src/cli/deploy-artifact-validation.ts index 3e57961dc..62ce7bc22 100644 --- a/packages/void/src/cli/deploy-artifact-validation.ts +++ b/packages/void/src/cli/deploy-artifact-validation.ts @@ -120,9 +120,10 @@ export function deduplicateGeneratedCompatibilityFlags( export function hasConfiguredVoidWorkerRoutingRules(routing: RoutingConfig | undefined): boolean { return ( routing !== undefined && - [routing.redirects, routing.rewrites, routing.fallbacks, routing.headers].some( - (rules) => rules !== undefined && Object.keys(rules).length > 0, - ) + (routing.alwaysUseHttps === true || + [routing.redirects, routing.rewrites, routing.fallbacks, routing.headers].some( + (rules) => rules !== undefined && Object.keys(rules).length > 0, + )) ); } 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..1605e1176 100644 --- a/packages/void/src/cli/deploy-cloudflare.ts +++ b/packages/void/src/cli/deploy-cloudflare.ts @@ -66,7 +66,7 @@ export async function runCloudflareDeploy( if (detectedFramework?.class === 'a' && hasConfiguredVoidWorkerRoutingRules(voidConfig.routing)) { log.error( [ - `deploy: native Cloudflare deployment cannot apply Void routing redirects, rewrites, fallbacks, or headers to ${detectedFramework.name}.`, + `deploy: native Cloudflare deployment cannot apply Void routing.alwaysUseHttps, redirects, rewrites, fallbacks, or headers to ${detectedFramework.name}.`, 'This framework owns its Worker request handler, so these rules would otherwise be ignored.', 'Move the rules into the framework or its Cloudflare Worker, then retry.', ].join('\n'), 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..4a51435a0 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. Omit to inherit the hosting platform's default (currently 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,10 @@ 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.`); + } + // 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..83de6db40 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -269,6 +269,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug auth: null, nodeTarget: !!options.target, managedDispatch: false, + alwaysUseHttps: options.routing?.alwaysUseHttps, explicitPrerenderPaths: options.prerenderPaths, bindingHandlerPath: options.target ? undefined : resolveFile('#self/remote/binding-handler'), bindingNames: options.bindingNames, @@ -487,7 +488,12 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug assetsDir: string, userHeaders: boolean, ): { headers: string | null; wranglerPath: string; wranglerText: string | null } | null { - if (!scan || !resolvedConfig || compileOptions.managedDispatch) { + if ( + !scan || + !resolvedConfig || + compileOptions.managedDispatch || + options.routing?.alwaysUseHttps + ) { return null; } let wranglerPath: string; diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 7b0cd545f..1d8b6e501 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -64,6 +64,7 @@ export type CompileOptions = { nodeTarget?: boolean; /** Managed Cloudflare build whose dispatch worker owns edge routing. */ managedDispatch?: boolean; + alwaysUseHttps?: boolean; /** Absolute path to the binding handler module (for /__void/* native binding access) */ bindingHandlerPath?: string; /** Absolute path to the migration handler module */ @@ -273,7 +274,7 @@ export function compileRouteTable( } const blockedExts = ASSET_EXTENSIONS.filter((e) => !usedExts.has(e)); lines.push( - `const __routingRuntime = __createRoutingRuntime(app, ${JSON.stringify({ dev: options.dev === true, nodeTarget: options.nodeTarget === true, managedDispatch, blockedAssetExtensions: blockedExts })});`, + `const __routingRuntime = __createRoutingRuntime(app, ${JSON.stringify({ dev: options.dev === true, nodeTarget: options.nodeTarget === true, managedDispatch, alwaysUseHttps: options.alwaysUseHttps, blockedAssetExtensions: blockedExts })});`, ); lines.push('export const requestContext = __routingRuntime.requestContext;'); lines.push('app.use(__routingRuntime.contextMiddleware);'); diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index 46c143bab..453d49eec 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -1,5 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { + redirectToHttps, compilePattern, matchPattern, matchPatternCaptures, @@ -52,6 +53,7 @@ export function createRoutingRuntime( dev?: boolean; nodeTarget?: boolean; managedDispatch?: boolean; + alwaysUseHttps?: boolean; blockedAssetExtensions?: Array; } = {}, ): RoutingRuntime { @@ -196,6 +198,12 @@ export function createRoutingRuntime( requestContext, contextMiddleware, requestMiddleware: async (c, next) => { + if (options.alwaysUseHttps && !options.dev && !options.managedDispatch) { + const redirect = redirectToHttps(c.req.raw); + if (redirect) { + return redirect; + } + } await requestContext.run( options.nodeTarget ? { request: c.req.raw } 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..50497ebfa 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -3793,31 +3793,34 @@ describe('runCloudflareDeploy — D1 migrations_dir gate', () => { }); describe('runCloudflareDeploy — full Void app queues', () => { - it('rejects configured Void routing rules for a Class-A framework before account mutation', async () => { - const root = makeProject({ - 'package.json': JSON.stringify({ - name: 'fwapp', - dependencies: { '@tanstack/react-start': '1.0.0' }, - }), - 'void.json': JSON.stringify({ routing: { rewrites: { '/old': '/new' } } }), - 'queues/jobs.ts': `export default {};`, - '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; - } - expect(messages.join('\n')).toMatch(/cannot apply Void routing[\s\S]*framework owns/); - }); + it.each([{ rewrites: { '/old': '/new' } }, { alwaysUseHttps: true }])( + 'rejects configured Void routing %j for a Class-A framework before account mutation', + async (routing) => { + const root = makeProject({ + 'package.json': JSON.stringify({ + name: 'fwapp', + dependencies: { '@tanstack/react-start': '1.0.0' }, + }), + 'void.json': JSON.stringify({ routing }), + 'queues/jobs.ts': `export default {};`, + '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; + } + expect(messages.join('\n')).toMatch(/cannot apply Void routing[\s\S]*framework owns/); + }, + ); it('deploys a Class-A framework queue through the verified artifact', async () => { const root = makeProject({ diff --git a/packages/void/test/unit/config.test.ts b/packages/void/test/unit/config.test.ts index 912a4c44e..ba5fcdfee 100644 --- a/packages/void/test/unit/config.test.ts +++ b/packages/void/test/unit/config.test.ts @@ -41,6 +41,25 @@ 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(); + } + }); +}); + 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-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index 32b8607ef..36d6e2c03 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -1159,6 +1159,7 @@ describe('routingPlugin native asset Worker bypass', () => { writeFileSync(join(root, 'pages', 'assets', '[id].tsx'), 'export default () => null;\n'); }, ], + ['HTTPS enforcement', () => {}, { routing: { alwaysUseHttps: true } }], ['a redirect rule', () => {}, { routing: { redirects: { '/old': '/new' } } }], ['a fallback rule', () => {}, { routing: { fallbacks: { '/*': '/index.html' } } }], [ diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 8ac87b374..aa847b591 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -1016,3 +1016,28 @@ describe('static asset marker', () => { expect(seen.pop()).toBeUndefined(); }); }); + +describe('native HTTPS policy', () => { + it.each([ + { alwaysUseHttps: true, status: 308 }, + { alwaysUseHttps: false, status: 200 }, + { alwaysUseHttps: undefined, status: 200 }, + { alwaysUseHttps: true, dev: true, status: 200 }, + { alwaysUseHttps: true, managedDispatch: true, status: 200 }, + ])( + 'handles $alwaysUseHttps with dev=$dev and managed=$managedDispatch', + async ({ status, ...options }) => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true, ...options }); + app.use(runtime.requestMiddleware); + const handler = vi.fn((c: Context) => c.text('app')); + app.get('*', handler); + const response = await app.request('http://oxc.rs/docs?q=1'); + expect(response.status).toBe(status); + expect(response.headers.get('Location')).toBe( + status === 308 ? 'https://oxc.rs/docs?q=1' : null, + ); + expect(handler).toHaveBeenCalledTimes(status === 308 ? 0 : 1); + }, + ); +}); 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 cf6bfcd18..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, @@ -696,16 +697,6 @@ const dispatcher = { ) { return createErrorResponse(request, 404, 'Not Found', { 'Cache-Control': 'no-store' }); } - if ( - url.protocol === 'http:' && - url.hostname !== 'localhost' && - !url.hostname.endsWith('.localhost') && - url.hostname !== '127.0.0.1' && - url.hostname !== '[::1]' - ) { - url.protocol = 'https:'; - return Response.redirect(url.href, 308); - } if ( env.PLATFORM_TRAFFIC_STATE === 'staging' || env.PLATFORM_TRAFFIC_STATE === 'sandbox-drain' || @@ -757,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' }); @@ -776,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 debeb7d57..00a911e0d 100644 --- a/platform/packages/dispatch/test/unit/internal-routes.test.ts +++ b/platform/packages/dispatch/test/unit/internal-routes.test.ts @@ -50,31 +50,103 @@ function createEnv( } describe('HTTPS redirects', () => { - it.each([ - ['oxc.rs', 'GET'], - ['rolldown.rs', 'HEAD'], - ['my-app.void.app', 'POST'], - ])('redirects HTTP %s requests with method %s before routing', async (hostname, method) => { + function createHttpsEnv(alwaysUseHttps?: boolean): Env { const env = createEnv(); - const request = new Request(`http://${hostname}/docs/a%2Fb?search=a%2Fb&tag=1&tag=2`, { - method, - ...(method === 'POST' ? { body: 'payload' } : {}), + Object.assign(env.ROUTING, { + get: vi.fn(async () => + JSON.stringify({ + workerName: 'user-app', + projectId: 'proj_1', + ...(alwaysUseHttps !== undefined ? { alwaysUseHttps } : {}), + }), + ), }); - const response = await app.fetch(request, env, createCtx()); + 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://${hostname}/docs/a%2Fb?search=a%2Fb&tag=1&tag=2`, - ); - expect(request.bodyUsed).toBe(false); - expect(env.ROUTING.get).not.toHaveBeenCalled(); + 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' } }), - createEnv(), + createHttpsEnv(true), createCtx(), ); expect(response.status).toBe(200); @@ -86,7 +158,7 @@ describe('HTTPS redirects', () => { async (hostname) => { const response = await app.fetch( new Request(`http://${hostname}:8787/`), - createEnv(), + createHttpsEnv(true), createCtx(), ); expect(response.status).toBe(200); @@ -111,7 +183,7 @@ describe('HTTPS redirects', () => { new Request('http://test.void.app/health', { headers: { 'X-Health-Check': 'void-admin' }, }), - createEnv(), + createHttpsEnv(true), createCtx(), ); expect(response.status).toBe(200); From 99c9f2f80d0ffb63e7a2c42898f87417431578b3 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 14:19:53 +0800 Subject: [PATCH 3/3] fix(routing): enforce alwaysUseHttps in the native Worker entry - Redirect in createWorkerFetch before the ISR cache, so cached pages are never served over HTTP. In-process server fetch and cron jobs call the app directly and no longer get a 308. - Reject alwaysUseHttps with node/bun/deno targets, which never redirect. - Stop native Cloudflare deploys of frameworks that own their Worker when alwaysUseHttps is set; static framework builds still redirect. - Keep the hashed asset Worker bypass so asset requests stay free on Workers Free. - Replace the loopback regex with a small parser. - Document the behaviour in the redirects guide. Co-Authored-By: Claude Opus 5.5 --- docs/guide/edge/redirects.md | 38 +++++---- docs/reference/config.md | 2 +- packages/edge/src/https.ts | 15 +++- packages/edge/test/unit/https.test.ts | 16 +++- packages/void/schema.json | 2 +- packages/void/src/asset-worker-bypass.ts | 2 + .../src/cli/deploy-artifact-validation.ts | 7 +- packages/void/src/cli/deploy-cloudflare.ts | 15 +++- packages/void/src/config.ts | 7 +- packages/void/src/plugin-routing.ts | 15 ++-- packages/void/src/router/compile.ts | 11 ++- packages/void/src/runtime/routing-worker.ts | 12 +++ packages/void/src/runtime/routing.ts | 8 -- .../void/test/unit/cloudflare-deploy.test.ts | 79 ++++++++++++++--- packages/void/test/unit/compile.test.ts | 5 ++ packages/void/test/unit/config.test.ts | 21 +++++ .../unit/plugin-dev-asset-routing.test.ts | 23 +++++ .../void/test/unit/plugin-routing.test.ts | 47 ++++++++++- .../void/test/unit/routing-runtime.test.ts | 84 ++++++++++++++----- 19 files changed, 330 insertions(+), 79 deletions(-) diff --git a/docs/guide/edge/redirects.md b/docs/guide/edge/redirects.md index 8572ea491..99cb6c657 100644 --- a/docs/guide/edge/redirects.md +++ b/docs/guide/edge/redirects.md @@ -4,22 +4,6 @@ outline: deep # Redirects -Enable HTTP-to-HTTPS redirects with `routing.alwaysUseHttps` in `void.config.ts`: - -```ts -import { defineConfig } from 'void/config'; - -export default defineConfig({ - routing: { - alwaysUseHttps: true, - }, -}); -``` - -This sends a permanent `308` redirect to the same hostname over HTTPS, preserving the path, query string, and request method. Local development continues to support HTTP, and certificate validation paths remain available over HTTP. - -Set `false` to explicitly keep HTTP. Omit the setting to inherit your hosting platform's default, which is currently off. The option works on Void platforms and direct Cloudflare deployments of Void apps and static sites. Frameworks that own their Worker handler require their own HTTPS redirect configuration for direct Cloudflare deployments. - Define URL redirects in [`void.config.ts`](../../reference/config) using the `routing.redirects` field. Keys are source URL patterns, values are destination strings or objects with an explicit status. ```json @@ -67,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 afa4906a3..54cb7840d 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -368,7 +368,7 @@ Routing and edge configuration for headers, redirects, rewrites, and caching. #### `routing.alwaysUseHttps` -Redirect HTTP requests to HTTPS with a permanent `308` redirect. Set `true` to enable, `false` to explicitly keep HTTP, or omit to inherit the hosting platform default (currently off). Local development continues to use HTTP. See [HTTPS redirects](../guide/edge/redirects) for deployment support and details. +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: { diff --git a/packages/edge/src/https.ts b/packages/edge/src/https.ts index be63f6da0..5e3b2ba81 100644 --- a/packages/edge/src/https.ts +++ b/packages/edge/src/https.ts @@ -5,7 +5,7 @@ export function redirectToHttps(request: Request): Response | undefined { url.protocol !== 'http:' || url.hostname === 'localhost' || url.hostname.endsWith('.localhost') || - /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(url.hostname) || + isIpv4Loopback(url.hostname) || url.hostname === '[::1]' || url.pathname.startsWith('/.well-known/acme-challenge/') || url.pathname.startsWith('/.well-known/pki-validation/') @@ -17,3 +17,16 @@ export function redirectToHttps(request: Request): Response | undefined { 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/test/unit/https.test.ts b/packages/edge/test/unit/https.test.ts index 827ccdb7a..1c9086d1c 100644 --- a/packages/edge/test/unit/https.test.ts +++ b/packages/edge/test/unit/https.test.ts @@ -2,11 +2,19 @@ import { describe, expect, it } from 'vite-plus/test'; import { redirectToHttps } from '../../src/index.ts'; describe('redirectToHttps', () => { - it('does not treat a public hostname starting with 127 as loopback', () => { - expect(redirectToHttps(new Request('http://127.example.com/'))?.headers.get('Location')).toBe( - 'https://127.example.com/', + 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, @@ -26,6 +34,8 @@ describe('redirectToHttps', () => { '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', diff --git a/packages/void/schema.json b/packages/void/schema.json index d2bb719b8..c97e3f6ca 100644 --- a/packages/void/schema.json +++ b/packages/void/schema.json @@ -146,7 +146,7 @@ "properties": { "alwaysUseHttps": { "type": "boolean", - "description": "Redirect HTTP requests to HTTPS with a permanent 308 redirect. Omit to inherit the hosting platform default (currently off). Local development continues to support HTTP." + "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", 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-artifact-validation.ts b/packages/void/src/cli/deploy-artifact-validation.ts index 62ce7bc22..3e57961dc 100644 --- a/packages/void/src/cli/deploy-artifact-validation.ts +++ b/packages/void/src/cli/deploy-artifact-validation.ts @@ -120,10 +120,9 @@ export function deduplicateGeneratedCompatibilityFlags( export function hasConfiguredVoidWorkerRoutingRules(routing: RoutingConfig | undefined): boolean { return ( routing !== undefined && - (routing.alwaysUseHttps === true || - [routing.redirects, routing.rewrites, routing.fallbacks, routing.headers].some( - (rules) => rules !== undefined && Object.keys(rules).length > 0, - )) + [routing.redirects, routing.rewrites, routing.fallbacks, routing.headers].some( + (rules) => rules !== undefined && Object.keys(rules).length > 0, + ) ); } diff --git a/packages/void/src/cli/deploy-cloudflare.ts b/packages/void/src/cli/deploy-cloudflare.ts index 1605e1176..1fbde4762 100644 --- a/packages/void/src/cli/deploy-cloudflare.ts +++ b/packages/void/src/cli/deploy-cloudflare.ts @@ -66,7 +66,7 @@ export async function runCloudflareDeploy( if (detectedFramework?.class === 'a' && hasConfiguredVoidWorkerRoutingRules(voidConfig.routing)) { log.error( [ - `deploy: native Cloudflare deployment cannot apply Void routing.alwaysUseHttps, redirects, rewrites, fallbacks, or headers to ${detectedFramework.name}.`, + `deploy: native Cloudflare deployment cannot apply Void routing redirects, rewrites, fallbacks, or headers to ${detectedFramework.name}.`, 'This framework owns its Worker request handler, so these rules would otherwise be ignored.', 'Move the rules into the framework or its Cloudflare Worker, then retry.', ].join('\n'), @@ -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/config.ts b/packages/void/src/config.ts index 4a51435a0..0df00727e 100644 --- a/packages/void/src/config.ts +++ b/packages/void/src/config.ts @@ -45,7 +45,7 @@ export type NotFoundHandling = 'single-page-application' | '404-page' | 'none'; /** Routing configuration — headers, redirects, rewrites, fallbacks, revalidation, prerendering. */ export type RoutingConfig = { - /** Redirect HTTP to HTTPS. Omit to inherit the hosting platform's default (currently off). */ + /** 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; @@ -564,6 +564,11 @@ function validateConfigObject(obj: unknown): VoidConfig { 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) { diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 83de6db40..f19b029d3 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -269,7 +269,6 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug auth: null, nodeTarget: !!options.target, managedDispatch: false, - alwaysUseHttps: options.routing?.alwaysUseHttps, explicitPrerenderPaths: options.prerenderPaths, bindingHandlerPath: options.target ? undefined : resolveFile('#self/remote/binding-handler'), bindingNames: options.bindingNames, @@ -488,12 +487,9 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug assetsDir: string, userHeaders: boolean, ): { headers: string | null; wranglerPath: string; wranglerText: string | null } | null { - if ( - !scan || - !resolvedConfig || - compileOptions.managedDispatch || - options.routing?.alwaysUseHttps - ) { + // `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; } let wranglerPath: string; @@ -575,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 1d8b6e501..faf78517c 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -64,7 +64,6 @@ export type CompileOptions = { nodeTarget?: boolean; /** Managed Cloudflare build whose dispatch worker owns edge routing. */ managedDispatch?: boolean; - alwaysUseHttps?: boolean; /** Absolute path to the binding handler module (for /__void/* native binding access) */ bindingHandlerPath?: string; /** Absolute path to the migration handler module */ @@ -274,7 +273,7 @@ export function compileRouteTable( } const blockedExts = ASSET_EXTENSIONS.filter((e) => !usedExts.has(e)); lines.push( - `const __routingRuntime = __createRoutingRuntime(app, ${JSON.stringify({ dev: options.dev === true, nodeTarget: options.nodeTarget === true, managedDispatch, alwaysUseHttps: options.alwaysUseHttps, blockedAssetExtensions: blockedExts })});`, + `const __routingRuntime = __createRoutingRuntime(app, ${JSON.stringify({ dev: options.dev === true, nodeTarget: options.nodeTarget === true, managedDispatch, blockedAssetExtensions: blockedExts })});`, ); lines.push('export const requestContext = __routingRuntime.requestContext;'); lines.push('app.use(__routingRuntime.contextMiddleware);'); @@ -1073,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; @@ -1147,6 +1151,7 @@ export function compileWorkerEntry(options: WorkerEntryOptions = {}): string { const { hasQueues = false, versionMetadataBinding, + alwaysUseHttps = false, remote, assetsDir = 'assets', webSocketClasses = [], @@ -1245,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/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index 453d49eec..46c143bab 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -1,6 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { - redirectToHttps, compilePattern, matchPattern, matchPatternCaptures, @@ -53,7 +52,6 @@ export function createRoutingRuntime( dev?: boolean; nodeTarget?: boolean; managedDispatch?: boolean; - alwaysUseHttps?: boolean; blockedAssetExtensions?: Array; } = {}, ): RoutingRuntime { @@ -198,12 +196,6 @@ export function createRoutingRuntime( requestContext, contextMiddleware, requestMiddleware: async (c, next) => { - if (options.alwaysUseHttps && !options.dev && !options.managedDispatch) { - const redirect = redirectToHttps(c.req.raw); - if (redirect) { - return redirect; - } - } await requestContext.run( options.nodeTarget ? { request: c.req.raw } diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 50497ebfa..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 () => { @@ -3793,16 +3819,43 @@ describe('runCloudflareDeploy — D1 migrations_dir gate', () => { }); describe('runCloudflareDeploy — full Void app queues', () => { - it.each([{ rewrites: { '/old': '/new' } }, { alwaysUseHttps: true }])( - 'rejects configured Void routing %j for a Class-A framework before account mutation', - async (routing) => { + it('rejects configured Void routing rules for a Class-A framework before account mutation', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ + name: 'fwapp', + dependencies: { '@tanstack/react-start': '1.0.0' }, + }), + 'void.json': JSON.stringify({ routing: { rewrites: { '/old': '/new' } } }), + 'queues/jobs.ts': `export default {};`, + '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; + } + 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: { '@tanstack/react-start': '1.0.0' }, - }), - 'void.json': JSON.stringify({ routing }), - 'queues/jobs.ts': `export default {};`, + '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 = []; @@ -3818,7 +3871,9 @@ describe('runCloudflareDeploy — full Void app queues', () => { } finally { log.error = restore; } - expect(messages.join('\n')).toMatch(/cannot apply Void routing[\s\S]*framework owns/); + const message = messages.join('\n'); + expect(message).toContain(`cannot apply Void routing.alwaysUseHttps to ${framework}.`); + expect(message).toContain("Cloudflare's Always Use HTTPS setting"); }, ); 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 ba5fcdfee..9fa9f5cfd 100644 --- a/packages/void/test/unit/config.test.ts +++ b/packages/void/test/unit/config.test.ts @@ -58,6 +58,27 @@ describe('routing.alwaysUseHttps', () => { 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', () => { 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 36d6e2c03..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( @@ -1159,7 +1205,6 @@ describe('routingPlugin native asset Worker bypass', () => { writeFileSync(join(root, 'pages', 'assets', '[id].tsx'), 'export default () => null;\n'); }, ], - ['HTTPS enforcement', () => {}, { routing: { alwaysUseHttps: true } }], ['a redirect rule', () => {}, { routing: { redirects: { '/old': '/new' } } }], ['a fallback rule', () => {}, { routing: { fallbacks: { '/*': '/index.html' } } }], [ diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index aa847b591..744c1ed1d 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -1018,26 +1018,70 @@ describe('static asset marker', () => { }); 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([ - { alwaysUseHttps: true, status: 308 }, - { alwaysUseHttps: false, status: 200 }, - { alwaysUseHttps: undefined, status: 200 }, - { alwaysUseHttps: true, dev: true, status: 200 }, - { alwaysUseHttps: true, managedDispatch: true, status: 200 }, - ])( - 'handles $alwaysUseHttps with dev=$dev and managed=$managedDispatch', - async ({ status, ...options }) => { - const app = new Hono(); - const runtime = createRoutingRuntime(app, { nodeTarget: true, ...options }); - app.use(runtime.requestMiddleware); - const handler = vi.fn((c: Context) => c.text('app')); - app.get('*', handler); - const response = await app.request('http://oxc.rs/docs?q=1'); - expect(response.status).toBe(status); - expect(response.headers.get('Location')).toBe( - status === 308 ? 'https://oxc.rs/docs?q=1' : null, - ); - expect(handler).toHaveBeenCalledTimes(status === 308 ? 0 : 1); + { 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'] }); + }); });