Skip to content
Open
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
4 changes: 3 additions & 1 deletion docs/guide/edge/headers.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,6 @@ During local development, with `void deploy --platform cloudflare`, and on [othe

## Framework `_headers` files

Void reads `_headers` files produced by supported frameworks. Your `routing.headers` rules run afterward, so they can override framework defaults. No additional setup is needed.
On a Void platform, Void reads `_headers` files produced by supported frameworks. Your `routing.headers` rules run afterward, so they can override framework defaults.

For native Cloudflare framework deployments, put a `_headers` file in your framework's public asset directory to configure static asset responses. Void includes the built file when it uploads a Worker version, including deployments that keep existing Worker secrets. These rules do not apply to responses generated by a Worker.
2 changes: 1 addition & 1 deletion docs/guide/edge/static-assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Supported meta-frameworks use their own asset directories automatically.

### Native Cloudflare deployments

With `void deploy --platform cloudflare`, generated JavaScript bundles with content fingerprints receive immutable caching. Files copied from `public/` and custom build outputs with stable filenames keep normal revalidation, even when they live in the assets directory. CSS and other assets keep Cloudflare's default or your configured `Cache-Control`.
For apps that use Void routing, `void deploy --platform cloudflare` gives generated JavaScript bundles with content fingerprints immutable caching. Framework integrations use their asset configuration and `_headers` file. Files copied from `public/` and custom build outputs with stable filenames keep normal revalidation, even when they live in the assets directory. CSS and other assets keep Cloudflare's default or your configured `Cache-Control`.

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

Expand Down
40 changes: 17 additions & 23 deletions packages/void/src/cli/cf-version-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { extname, join, relative } from 'pathslash';
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 { readGeneratedAssetHeadersFile } from './headers.ts';
import { Buffer } from './node-buffer.ts';

type Json = Record<string, unknown>;
Expand Down Expand Up @@ -985,15 +984,18 @@ 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 {
return output.assetsDirectory
? readGeneratedAssetHeadersFile(getWorkerAssetsDir(output.directory))
: undefined;
function readAssetHeaders(output: CfBuildOutput): string | undefined {
if (!output.assetsDirectory) {
return undefined;
}
try {
return readFileSync(join(getWorkerAssetsDir(output.directory), '_headers'), 'utf8');
} catch (cause) {
if (cause instanceof Error && 'code' in cause && cause.code === 'ENOENT') {
return undefined;
}
throw cause;
}
}

async function uploadAssets(options: {
Expand All @@ -1009,16 +1011,7 @@ async function uploadAssets(options: {
const directory = getWorkerAssetsDir(options.output.directory);
const manifest: Record<string, { hash: string; size: number }> = {};
const byHash = new Map<string, string>();
const ignored = ignore().add([
'/.assetsignore',
'/_headers',
'/_redirects',
'.DS_Store',
'.env',
'.env.*',
'.dev.vars',
'.dev.vars.*',
]);
const ignored = ignore().add(['.DS_Store', '.env', '.env.*', '.dev.vars', '.dev.vars.*']);
const ignorePath = join(directory, '.assetsignore');
try {
ignored.add(readFileSync(ignorePath, 'utf8'));
Expand All @@ -1027,6 +1020,7 @@ async function uploadAssets(options: {
throw cause;
}
}
ignored.add(['/.assetsignore', '/_headers', '/_redirects']);
for (const entry of readdirSync(directory, { recursive: true, withFileTypes: true })) {
if (entry.isDirectory()) {
continue;
Expand Down Expand Up @@ -1236,12 +1230,12 @@ export async function uploadPinnedCloudflareVersion(options: {
).toString('base64'),
});
}
const generatedHeaders = readGeneratedAssetHeaders(options.output);
if (generatedHeaders !== undefined) {
const assetHeaders = readAssetHeaders(options.output);
if (assetHeaders !== undefined) {
modules.push({
name: '_headers',
content_type: 'text/plain',
content_base64: Buffer.from(generatedHeaders).toString('base64'),
content_base64: Buffer.from(assetHeaders).toString('base64'),
});
}
await preflightWorkflowExports({
Expand Down
108 changes: 92 additions & 16 deletions packages/void/test/unit/cf-version-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1497,29 +1497,51 @@ it(
REAL_CF_TIMEOUT_MS,
);

it.each([
[
'Void-generated',
`${GENERATED_ASSET_HEADERS_MARKER}\n/assets/a-12345678.js\n Cache-Control: public, max-age=31536000, immutable\n`,
],
['user-authored', '/assets/*\n X-Frame-Options: DENY\n'],
['missing', undefined],
] as const)(
'uploads only a Void-generated _headers file with pinned secrets (%s)',
async (_kind, headers) => {
const authoredAssetHeaders =
'# Déploiement headers\n\n/assets/*\n Cache-Control: public, max-age=31536000, immutable\n X-Frame-Options: DENY\n';

it.each(
(
[
[
'Void-generated',
`${GENERATED_ASSET_HEADERS_MARKER}\n/assets/a-12345678.js\n Cache-Control: public, max-age=31536000, immutable\n`,
],
['user-authored', authoredAssetHeaders],
['empty', ''],
['custom ignore negation', authoredAssetHeaders],
['missing', undefined],
] as const
).flatMap(([kind, headers]) =>
[false, true].map((workerFirst) => ({ kind, headers, workerFirst })),
),
)(
'uploads generated and user-authored _headers files with pinned secrets ($kind, worker-first=$workerFirst)',
async ({ kind, headers, workerFirst }) => {
const runWorkerFirst = workerFirst ? ['/**', '!/assets/*'] : undefined;
const prepared = await output({
secrets: { required: ['AUTH_SECRET'] },
assets: {
directory: '../client',
binding: 'ASSETS',
not_found_handling: 'none',
run_worker_first: ['/**', '!/assets/*'],
...(runWorkerFirst === undefined ? {} : { run_worker_first: runWorkerFirst }),
},
});
try {
if (headers !== undefined) {
writeFileSync(join(getWorkerAssetsDir(prepared.directory), '_headers'), headers);
}
if (kind === 'custom ignore negation') {
writeFileSync(
join(getWorkerAssetsDir(prepared.directory), '.assetsignore'),
'private.txt\n!/_headers\n!/_redirects\n!/.assetsignore\n',
);
writeFileSync(
join(getWorkerAssetsDir(prepared.directory), '_redirects'),
'/old /new 302\n',
);
}
await pinActiveWorkerSecrets({
output: prepared,
activeVersionId: '11111111-1111-4111-8111-111111111111',
Expand Down Expand Up @@ -1566,18 +1588,21 @@ it.each([
const body = JSON.parse(posts.find(({ url }) => url.endsWith('/versions'))!.body);
expect(body.assets).toEqual({
jwt: 'asset-token',
config: { not_found_handling: 'none', run_worker_first: ['/**', '!/assets/*'] },
config: {
not_found_handling: 'none',
...(runWorkerFirst === undefined ? {} : { run_worker_first: runWorkerFirst }),
},
});
const modules = (body.modules as Array<{ name: string }>).filter(
({ name }) => name === '_headers',
const modules = (body.modules as Array<{ name: string }>).filter(({ name }) =>
['_headers', '_redirects', '.assetsignore'].includes(name),
);
expect(modules).toEqual(
_kind === 'Void-generated'
headers !== undefined
? [
{
name: '_headers',
content_type: 'text/plain',
content_base64: Buffer.from(headers!).toString('base64'),
content_base64: Buffer.from(headers).toString('base64'),
},
]
: [],
Expand All @@ -1588,6 +1613,57 @@ it.each([
},
);

it('fails a pinned upload when prepared _headers cannot be read', async () => {
const prepared = await output({
secrets: { required: ['AUTH_SECRET'] },
assets: { directory: '../client', binding: 'ASSETS' },
});
try {
mkdirSync(join(getWorkerAssetsDir(prepared.directory), '_headers'));
await pinActiveWorkerSecrets({
output: prepared,
activeVersionId: '11111111-1111-4111-8111-111111111111',
bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }],
requiredNames: ['AUTH_SECRET'],
});
const posts: Array<string> = [];
const server = createServer((request, response) => {
request.resume();
const url = request.url ?? '';
if (request.method === 'POST') {
posts.push(url);
}
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(
JSON.stringify({
success: true,
errors: [],
result: url.endsWith('/assets-upload-session')
? { jwt: 'asset-token', buckets: [] }
: {
id: '11111111-1111-4111-8111-111111111111',
bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }],
},
}),
);
});
await expect(
uploadPinnedCloudflareVersion({
output: prepared,
accountId: 'account-id',
apiBaseUrl: await listen(server),
workerName: 'void-test',
token: 'synthetic-token',
atomic: false,
verifyUploader: () => true,
}),
).rejects.toMatchObject({ code: 'EISDIR' });
expect(posts.some((url) => url.endsWith('/versions'))).toBe(false);
} finally {
prepared.dispose();
}
});

it('uploads Worker limits, placement and cache with pinned secrets', async () => {
const prepared = await output({
secrets: { required: ['AUTH_SECRET'] },
Expand Down