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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ Local development needs no secret setup. To preview a production build locally,

Responses to signed-in requests get `Cache-Control: no-store`, so neither the browser cache nor a shared cache keeps a user's data. A route keeps its own policy when it sets `no-store`, or `private` together with `no-cache`. To answer repeat requests with `304`, use `private, no-cache` with Hono's [`etag()`](https://hono.dev/docs/middleware/builtin/etag) middleware. If you set the `ETag` yourself, make it differ per user, so the `304` doesn't reuse another account's page. The browser cache keeps a copy of a `private, no-cache` page, so the Back button can show it again after the user signs out. Keep the default `no-store` for pages that must not stay in the browser cache.

Static files from your build and `public/` keep their own cache policy for signed-in requests, so the browser reuses cached JavaScript and CSS. When your app handles the request, the policy is marked `private`, and a request that refreshes the session cookie gets `no-store`.

`POST`, `PUT`, `PATCH` and `DELETE` requests and WebSocket connections from another origin don't use the signed-in session unless that origin is in `trustedOrigins`. If a separate frontend calls your app, add its origin there (see [Customization](#customization)). If your routes use their own cookies, check the request `Origin` yourself, for example with Hono's [`csrf()`](https://hono.dev/docs/middleware/builtin/csrf) middleware.

Deployed apps on Cloudflare limit auth requests per client IP with Better Auth's defaults, for example 3 sign-in attempts per 10 seconds. Each Worker instance keeps its own count. Change or turn off the limits with `rateLimit` in `auth.ts`. To share one count across instances, set `rateLimit: { storage: 'database' }`, then run `void db generate` for a direct Cloudflare deploy.
Expand Down
10 changes: 10 additions & 0 deletions docs/guide/edge/static-assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ With `void deploy --platform cloudflare`, generated JavaScript bundles with cont

Use [Custom Headers](./headers) to choose a cache policy for other files. Apply immutable caching only to URLs that change whenever their content changes.

Cloudflare serves these fingerprinted JavaScript bundles without starting your Worker, so they do not count as Worker requests, when your app:

- has no global middleware
- has no header, redirect, rewrite or fallback rules, and no `routing.notFound` setting
- uses the default `base` of `/`
- has no route or page that matches paths in the assets directory (`assets/` by default)
- builds at most 99 fingerprinted bundles

Otherwise these bundles go through your Worker and keep the same caching.

## Non-hashed assets

Other static files, such as `index.html` and `favicon.ico`, are cached until the next deploy. You do not need to purge them manually.
Expand Down
200 changes: 200 additions & 0 deletions packages/void/src/asset-worker-bypass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* Build-time decision for letting Cloudflare serve fingerprinted build files
* without starting the Worker. Native Cloudflare builds of apps with no global
* middleware and no edge rules gain nothing from running the Worker for those
* files: the Worker would only copy the asset response. Skipping it makes
* those requests free static requests and keeps auth and its session read off
* them. Each file gets its own negative `run_worker_first` rule, because a
* 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.
*
* The asset layer does not know which files are fingerprinted, so a generated
* `_headers` file gives every bypassed chunk the immutable policy the Worker
* would have set.
*/

export const GENERATED_ASSET_HEADERS_MARKER =
'# Generated by Void: immutable caching for fingerprinted build files. Do not edit.';
/** Cloudflare accepts at most 100 `run_worker_first` rules, `/**` included. */
const MAX_BYPASS_FILES = 99;
/** Cloudflare limits each `run_worker_first` rule to 100 characters. */
const MAX_RUN_WORKER_FIRST_RULE_LENGTH = 100;

const IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable';
// Void's own Worker paths. They are not in the route scan.
const INTERNAL_PATTERNS = ['/__void/*'];
const AUTH_PATTERNS = ['/api/auth/*'];

export type AssetWorkerBypassFacts = {
/** `assets.run_worker_first` from the built Worker config. */
runWorkerFirst: unknown;
/** `assets.not_found_handling` from the built Worker config. */
notFoundHandling: unknown;
/** Vite's resolved `base`. */
base: string;
hasMiddleware: boolean;
authEnabled: boolean;
headerRuleCount: number;
redirectRuleCount: number;
fallbackRuleCount: number;
/** Whether `_headers` or `_redirects` exists in Vite's `publicDir`. */
publicEdgeFiles: boolean;
/** Every route, page, WebSocket, prerender and revalidate pattern. */
patterns: Array<string>;
/** Vite's `build.assetsDir`. */
assetsDir: string;
/** Fingerprinted files the manifest marks immutable, relative to the client root. */
immutableFiles: Array<string>;
};

export type AssetWorkerBypass = {
/** The new `assets.run_worker_first`. */
runWorkerFirst: Array<string>;
/** The generated `_headers` text. */
headers: string;
};

/** Trim surrounding slashes; null when nothing is left. */
export function normalizeAssetsDir(dir: string): string | null {
let start = 0;
let end = dir.length;
while (start < end && dir[start] === '/') {
start++;
}
while (end > start && dir[end - 1] === '/') {
end--;
}
const trimmed = dir.slice(start, end);
return trimmed === '' || trimmed === '.' ? null : trimmed;
}

/**
* Whether a route pattern can match a path below `/<dir>/`. Parameters,
* wildcards and patterns with more segments than the directory count as a
* match, so the answer errs towards keeping the Worker in front.
*/
export function patternCanReachUnder(pattern: string, dir: string): boolean {
const patternSegments = (pattern.startsWith('/') ? pattern.slice(1) : pattern).split('/');
const dirSegments = (dir.startsWith('/') ? dir.slice(1) : dir).split('/');
for (let i = 0; i < dirSegments.length; i++) {
const segment = patternSegments[i];
if (segment === undefined) {
return false;
}
if (segment.includes('*') || segment.includes('{')) {
return true;
}
if (segment.startsWith(':')) {
continue;
}
if (segment.toLowerCase() !== dirSegments[i].toLowerCase()) {
return false;
}
}
return patternSegments.length > dirSegments.length;
}

/**
* Characters that are plain text in both a `_headers` path and a
* `run_worker_first` rule. `*`, `:` and `!` have meaning there, and anything
* that needs URL encoding would not match the request path as written.
*/
function isPlainPath(path: string): boolean {
for (const char of path) {
const plain =
(char >= 'a' && char <= 'z') ||
(char >= 'A' && char <= 'Z') ||
(char >= '0' && char <= '9') ||
char === '-' ||
char === '_' ||
char === '.' ||
char === '~' ||
char === '/';
if (!plain) {
return false;
}
}
return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');
}

/**
* Whether `run_worker_first` is the rule an earlier bypass wrote. A watch
* rebuild of only the client still finds it, because the Worker build that
* would write `['/**']` again did not run.
*/
export function isAssetBypassRule(runWorkerFirst: unknown, assetsDir: string): boolean {
const dir = normalizeAssetsDir(assetsDir);
return (
dir !== null &&
Array.isArray(runWorkerFirst) &&
runWorkerFirst.length > 1 &&
runWorkerFirst[0] === '/**' &&
runWorkerFirst
.slice(1)
.every((rule) => typeof rule === 'string' && rule.startsWith('!/' + dir + '/'))
);
}

/** The bypass this build can use, or null when any rule keeps the Worker in front. */
export function resolveAssetWorkerBypass(facts: AssetWorkerBypassFacts): AssetWorkerBypass | null {
const { runWorkerFirst } = facts;
const allPaths =
(Array.isArray(runWorkerFirst) && runWorkerFirst.length === 1 && runWorkerFirst[0] === '/**') ||
isAssetBypassRule(runWorkerFirst, facts.assetsDir);
if (
!allPaths ||
facts.notFoundHandling !== 'none' ||
facts.base !== '/' ||
facts.hasMiddleware ||
facts.headerRuleCount > 0 ||
facts.redirectRuleCount > 0 ||
facts.fallbackRuleCount > 0 ||
facts.publicEdgeFiles
) {
return null;
}
const assetsDir = normalizeAssetsDir(facts.assetsDir);
if (!assetsDir || !isPlainPath(assetsDir)) {
return null;
}
const patterns = [
...facts.patterns,
...INTERNAL_PATTERNS,
...(facts.authEnabled ? AUTH_PATTERNS : []),
];
if (patterns.some((pattern) => patternCanReachUnder(pattern, assetsDir))) {
return null;
}
const prefix = assetsDir + '/';
const files = [...new Set(facts.immutableFiles)].filter((file) => file.startsWith(prefix)).sort();
const rules = files.map((file) => '!/' + file);
if (
files.length === 0 ||
files.length > MAX_BYPASS_FILES ||
files.some((file) => !isPlainPath(file)) ||
rules.some((rule) => rule.length > MAX_RUN_WORKER_FIRST_RULE_LENGTH)
) {
return null;
}
return { runWorkerFirst: ['/**', ...rules], headers: renderGeneratedAssetHeaders(files) };
}

/** One immutable rule per file after the marker line. */
function renderGeneratedAssetHeaders(files: Array<string>): string {
const lines = [GENERATED_ASSET_HEADERS_MARKER];
for (const file of files) {
lines.push('/' + file, ' Cache-Control: ' + IMMUTABLE_CACHE_CONTROL);
}
return lines.join('\n') + '\n';
}

/** Whether a `_headers` file is the one Void generated. */
export function isGeneratedAssetHeaders(text: string): boolean {
const newline = text.indexOf('\n');
let firstLine = newline === -1 ? text : text.slice(0, newline);
if (firstLine.endsWith('\r')) {
firstLine = firstLine.slice(0, -1);
}
return firstLine === GENERATED_ASSET_HEADERS_MARKER;
}
31 changes: 31 additions & 0 deletions packages/void/src/cli/cf-version-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import type { CloudflareCommandResult } from '@void/deploy-cloudflare';
import ignore from 'ignore';
import mime from 'mime';
import { extname, join, relative } from 'pathslash';
import { isGeneratedAssetHeaders } from '../asset-worker-bypass.ts';
import type { CfBuildOutput } from './cf-build-output.ts';
import { record as object } from './cf-config-migrate.ts';
import { getVoidUserAgent } from './cf-user-agent.ts';
import { Buffer } from './node-buffer.ts';

type Json = Record<string, unknown>;
export type CfCommand = (
Expand Down Expand Up @@ -923,6 +925,27 @@ async function ensureContainerApplications(options: {
}
}

/**
* The `_headers` file a native build generated for its fingerprinted files.
* This API takes it as a `_headers` module, as cf does on its JSON version
* uploads. A user-authored `_headers` is not sent on this path.
*/
function readGeneratedAssetHeaders(output: CfBuildOutput): string | undefined {
if (!output.assetsDirectory) {
return undefined;
}
let text: string;
try {
text = readFileSync(join(getWorkerAssetsDir(output.directory), '_headers'), 'utf8');
} catch (cause) {
if (cause instanceof Error && 'code' in cause && cause.code === 'ENOENT') {
return undefined;
}
throw cause;
}
return isGeneratedAssetHeaders(text) ? text : undefined;
}

async function uploadAssets(options: {
output: CfBuildOutput;
accountUrl: string;
Expand Down Expand Up @@ -1162,6 +1185,14 @@ export async function uploadPinnedCloudflareVersion(options: {
).toString('base64'),
});
}
const generatedHeaders = readGeneratedAssetHeaders(options.output);
if (generatedHeaders !== undefined) {
modules.push({
name: '_headers',
content_type: 'text/plain',
content_base64: Buffer.from(generatedHeaders).toString('base64'),
});
}
await preflightWorkflowExports({
exports,
accountUrl,
Expand Down
12 changes: 9 additions & 3 deletions packages/void/src/client-asset-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ import type { Rolldown } from 'vite';
import type { ClientManifest } from './runtime/routing-assets.ts';
import type { RoutingHeaderRule } from './runtime/routing.ts';

/** Attach cache provenance to the existing manifest without changing its entry inventory. */
/**
* Attach cache provenance to the existing manifest without changing its entry inventory.
* Returns the stamped immutable files, sorted, relative to the client root.
*/
export function stampClientAssetCachePolicy(
bundle: Rolldown.OutputBundle,
manifestPath: string,
headerRules: Array<RoutingHeaderRule> = [],
): void {
): Array<string> {
const asset = bundle[manifestPath];
if (asset?.type !== 'asset') {
return;
return [];
}
const fingerprinted = new Set<string>();
const authoredCachePatterns = headerRules
Expand All @@ -36,12 +39,15 @@ export function stampClientAssetCachePolicy(
}
}
const manifest = JSON.parse(String(asset.source)) as ClientManifest;
const stamped = new Set<string>();
for (const entry of Object.values(manifest)) {
if (entry.file && fingerprinted.has(entry.file)) {
entry.immutableFiles = [entry.file];
stamped.add(entry.file);
} else {
delete entry.immutableFiles;
}
}
asset.source = JSON.stringify(manifest, null, 2);
return [...stamped].sort();
}
2 changes: 2 additions & 0 deletions packages/void/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,8 @@ export function voidPlugin(options?: {
durableObjects,
websocketTombstones,
onDevAssetRoutingPolicyChange: updateDevAssetRoutingPolicy,
// Native builds of eligible apps let Cloudflare serve hashed build files directly.
assetWorkerBypass: true,
}),
drizzlePlugin(root, true, dialect, {
d1BindingName: bindingNames?.d1,
Expand Down
Loading
Loading