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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/guide/edge/redirects.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,28 @@ Path-only sources apply to every domain on the project. Host-prefixed sources ap

The source must use `https://` and a literal hostname without wildcards or a port. Add the domain to your project with `void domain add` before deploying the rule. A domain awaiting certificate verification starts redirecting once it becomes active.

## HTTP to HTTPS

Set `routing.alwaysUseHttps` to redirect HTTP requests to HTTPS:

```ts
import { defineConfig } from 'void/config';

export default defineConfig({
routing: {
alwaysUseHttps: true,
},
});
```

Void answers with a permanent `308` redirect to the same URL over HTTPS. The path, query string, and request method stay the same. Omitted or `false` means off. `localhost` and loopback addresses such as `127.0.0.1` stay on HTTP, so local development and `vite preview` are unchanged.

It works on the Void platform and for Void apps on `void deploy --platform cloudflare`. It needs the Cloudflare target: setting it with `target: 'node'`, `'bun'`, or `'deno'` is a config error.

On `void deploy --platform cloudflare`, built files with a content hash in their name, such as `/assets/index-abc123.js`, can be served by Cloudflare before Void runs, so they may still answer over HTTP. Pages always redirect, so browsers load these files over HTTPS. To redirect every request, also turn on [Always Use HTTPS](https://developers.cloudflare.com/ssl/edge-certificates/additional-options/always-use-https/) for your Cloudflare zone.

SvelteKit, Nuxt, Astro, Analog, TanStack Start, React Router, and vinext apps run their own Worker, so `void deploy --platform cloudflare` stops with an error when the option is on. Static builds of these frameworks, deployed with `--dir` or `inference.appType`, redirect as usual. For the others, redirect to HTTPS in the framework, or turn on [Always Use HTTPS](https://developers.cloudflare.com/ssl/edge-certificates/additional-options/always-use-https/) for your Cloudflare zone.

## Framework `_redirects` files

Void reads `_redirects` files produced by supported frameworks. You can also place one in Vite's `publicDir`:
Expand Down
10 changes: 10 additions & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,16 @@ This choice applies to subsequent `void deploy` runs. Atomic deployment sends tr

Routing and edge configuration for headers, redirects, rewrites, and caching.

#### `routing.alwaysUseHttps`

Redirect HTTP requests to HTTPS with a permanent `308` redirect. Omitted or `false` means off. Requires the Cloudflare target. `localhost` stays on HTTP. See [HTTP to HTTPS](../guide/edge/redirects#http-to-https) for where it works.

```ts
routing: {
alwaysUseHttps: true,
}
```

#### `routing.headers`

Custom response headers. Keys are URL patterns, values are arrays of `"Name: value"` strings. See [Custom Headers](../guide/edge/headers) for details.
Expand Down
2 changes: 2 additions & 0 deletions packages/deploy-cloudflare/src/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HeaderRule>;
redirectRules?: ReadonlyArray<RedirectRule>;
fallbackRules?: ReadonlyArray<RedirectRule>;
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 } : {}),
Expand Down
11 changes: 11 additions & 0 deletions packages/deploy-cloudflare/test/unit/static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions packages/deploy-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ export type DeployManifest = {
hashedAssetsPrefix?: string;
headerRules?: Array<HeaderRule>;
redirectRules?: Array<RedirectRule>;
/** Omitted values inherit the platform default; preserve explicit false. */
alwaysUseHttps?: boolean;
fallbackRules?: Array<RedirectRule>;
websocket?: WebSocketManifestMeta;
sandbox?: SandboxManifestMeta;
Expand Down
4 changes: 4 additions & 0 deletions packages/deploy-core/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions packages/deploy-core/test/unit/validate.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
32 changes: 32 additions & 0 deletions packages/edge/src/https.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/** Upgrade public HTTP requests without involving assets or application code. */
export function redirectToHttps(request: Request): Response | undefined {
const url = new URL(request.url);
if (
url.protocol !== 'http:' ||
url.hostname === 'localhost' ||
url.hostname.endsWith('.localhost') ||
isIpv4Loopback(url.hostname) ||
url.hostname === '[::1]' ||
url.pathname.startsWith('/.well-known/acme-challenge/') ||
url.pathname.startsWith('/.well-known/pki-validation/')
) {
return undefined;
}
url.protocol = 'https:';
// Cloudflare's HTTP ports do not accept TLS. Upgrade to the default HTTPS port.
url.port = '';
return Response.redirect(url.href, 308);
}

/**
* `URL` already rewrites every IPv4 form (`127.1`, `0x7f.0.0.1`) to four
* decimal parts and rejects parts above 255, so only the shape is checked.
*/
function isIpv4Loopback(hostname: string): boolean {
const parts = hostname.split('.');
return (
parts.length === 4 &&
parts[0] === '127' &&
parts.every((part) => part !== '' && [...part].every((char) => char >= '0' && char <= '9'))
);
}
1 change: 1 addition & 0 deletions packages/edge/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export type { ProjectZeroTrustRouting } from './access.ts';
export { redirectToHttps } from './https.ts';
export {
applyHeaderRules,
parseClearSiteData,
Expand Down
8 changes: 8 additions & 0 deletions packages/edge/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -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<{
Expand All @@ -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;
/**
Expand Down Expand Up @@ -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;

Expand Down
45 changes: 45 additions & 0 deletions packages/edge/test/unit/https.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vite-plus/test';
import { redirectToHttps } from '../../src/index.ts';

describe('redirectToHttps', () => {
it.each([
'127.example.com',
'127.0.0.1.example.com',
'128.0.0.1',
'[2606:4700::1]',
'192.168.1.10',
'[fd12::1]',
])('does not treat %s as a local host', (host) => {
expect(redirectToHttps(new Request(`http://${host}/`))?.headers.get('Location')).toBe(
`https://${host}/`,
);
});

it.each(['GET', 'HEAD', 'POST'])('preserves the URL and unconsumed %s body', (method) => {
const request = new Request('http://oxc.rs:8080/docs/a%2Fb?search=a%2Fb&tag=1&tag=2', {
method,
...(method === 'POST' ? { body: 'payload' } : {}),
});
const response = redirectToHttps(request)!;
expect(response.status).toBe(308);
expect(response.headers.get('Location')).toBe(
'https://oxc.rs/docs/a%2Fb?search=a%2Fb&tag=1&tag=2',
);
expect(request.bodyUsed).toBe(false);
});

it.each([
'https://oxc.rs/',
'http://localhost:8787/',
'http://app.localhost:8787/',
'http://127.0.0.1:8787/',
'http://127.0.0.2:8787/',
'http://127.1.2.3/',
'http://127.1/',
'http://[::1]:8787/',
'http://oxc.rs/.well-known/acme-challenge/token',
'http://oxc.rs/.well-known/pki-validation/token.txt',
])('does not redirect %s', (url) => {
expect(redirectToHttps(new Request(url))).toBeUndefined();
});
});
13 changes: 13 additions & 0 deletions packages/edge/test/unit/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ function assets(responses: Record<string, Response>) {
}

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(
Expand Down
4 changes: 4 additions & 0 deletions packages/void/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@
"type": "object",
"description": "Routing configuration — headers, redirects, rewrites, fallbacks, revalidation, prerendering.",
"properties": {
"alwaysUseHttps": {
"type": "boolean",
"description": "Redirect HTTP requests to HTTPS with a permanent 308 redirect. Omitted or false means off. Requires the Cloudflare target; localhost stays on HTTP."
},
"isr": {
"type": "boolean",
"description": "Enable inferred ISR caching. Set false to disable ISR and edge prerendering, including per-page prerender and revalidate policies. Does not disable output: static build-time prerendering."
Expand Down
2 changes: 1 addition & 1 deletion packages/void/skills/void/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
2 changes: 2 additions & 0 deletions packages/void/src/asset-worker-bypass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/void/src/cli/deploy-cloudflare-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions packages/void/src/cli/deploy-cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/void/src/cli/deploy-managed-framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export async function runFrameworkDeploy(

const manifest = buildFrameworkManifest({
frameworkName: detected.name,
alwaysUseHttps: voidConfig.routing?.alwaysUseHttps,
workerMain: actualWorkerMain,
bindings,
migrations: validatedMigrations,
Expand Down
4 changes: 3 additions & 1 deletion packages/void/src/cli/deploy-managed-full.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/void/src/cli/deploy-managed-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export async function runStaticDeploy(
* call site hands the value over directly. Wins over `routing.notFound`.
*/
notFound?: NotFoundHandling,
alwaysUseHttps?: boolean,
): Promise<void> {
const typeLabel = preset.appType === 'spa' ? 'Static SPA' : 'Static Site';
log.info(`${typeLabel} deploy`);
Expand Down Expand Up @@ -140,6 +141,7 @@ export async function runStaticDeploy(

const manifest = buildStaticManifest({
appType: preset.appType,
alwaysUseHttps: alwaysUseHttps ?? routing?.alwaysUseHttps,
hashedAssetsPrefix: hashedAssetsPrefix ?? 'assets',
headerRules,
redirectRules,
Expand Down
Loading
Loading