diff --git a/docs/guide/edge/headers.md b/docs/guide/edge/headers.md index 07a9e17e5..1ae6a83e5 100644 --- a/docs/guide/edge/headers.md +++ b/docs/guide/edge/headers.md @@ -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. diff --git a/docs/guide/edge/static-assets.md b/docs/guide/edge/static-assets.md index aa90a73c2..a428326fd 100644 --- a/docs/guide/edge/static-assets.md +++ b/docs/guide/edge/static-assets.md @@ -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. diff --git a/packages/void/src/cli/cf-version-api.ts b/packages/void/src/cli/cf-version-api.ts index c8cdeff61..6678a722b 100644 --- a/packages/void/src/cli/cf-version-api.ts +++ b/packages/void/src/cli/cf-version-api.ts @@ -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; @@ -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: { @@ -1009,16 +1011,7 @@ async function uploadAssets(options: { const directory = getWorkerAssetsDir(options.output.directory); const manifest: Record = {}; const byHash = new Map(); - 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')); @@ -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; @@ -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({ diff --git a/packages/void/test/unit/cf-version-runner.test.ts b/packages/void/test/unit/cf-version-runner.test.ts index e35f4e7a8..a5ba77c46 100644 --- a/packages/void/test/unit/cf-version-runner.test.ts +++ b/packages/void/test/unit/cf-version-runner.test.ts @@ -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', @@ -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'), }, ] : [], @@ -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 = []; + 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'] },