Skip to content

Commit b459837

Browse files
committed
fix(deploy): preserve framework asset header metadata with active secrets
Fixes #647
1 parent d95b95f commit b459837

4 files changed

Lines changed: 103 additions & 41 deletions

File tree

‎docs/guide/edge/headers.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,6 @@ During local development, with `void deploy --platform cloudflare`, and on [othe
4747

4848
## Framework `_headers` files
4949

50-
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.
50+
On a Void platform, Void reads `_headers` files produced by supported frameworks. Your `routing.headers` rules run afterward, so they can override framework defaults.
51+
52+
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.

‎docs/guide/edge/static-assets.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Supported meta-frameworks use their own asset directories automatically.
3333

3434
### Native Cloudflare deployments
3535

36-
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`.
36+
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`.
3737

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

‎packages/void/src/cli/cf-version-api.ts‎

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import type { CloudflareCommandResult } from '@void/deploy-cloudflare';
1717
import ignore from 'ignore';
1818
import mime from 'mime';
1919
import { extname, join, relative } from 'pathslash';
20-
import { isGeneratedAssetHeaders } from '../asset-worker-bypass.ts';
2120
import type { CfBuildOutput } from './cf-build-output.ts';
2221
import { record as object } from './cf-config-migrate.ts';
2322
import { getVoidUserAgent } from './cf-user-agent.ts';
@@ -982,25 +981,18 @@ async function ensureContainerApplications(options: {
982981
}
983982
}
984983

985-
/**
986-
* The `_headers` file a native build generated for its fingerprinted files.
987-
* This API takes it as a `_headers` module, as cf does on its JSON version
988-
* uploads. A user-authored `_headers` is not sent on this path.
989-
*/
990-
function readGeneratedAssetHeaders(output: CfBuildOutput): string | undefined {
984+
function readAssetHeaders(output: CfBuildOutput): string | undefined {
991985
if (!output.assetsDirectory) {
992986
return undefined;
993987
}
994-
let text: string;
995988
try {
996-
text = readFileSync(join(getWorkerAssetsDir(output.directory), '_headers'), 'utf8');
989+
return readFileSync(join(getWorkerAssetsDir(output.directory), '_headers'), 'utf8');
997990
} catch (cause) {
998991
if (cause instanceof Error && 'code' in cause && cause.code === 'ENOENT') {
999992
return undefined;
1000993
}
1001994
throw cause;
1002995
}
1003-
return isGeneratedAssetHeaders(text) ? text : undefined;
1004996
}
1005997

1006998
async function uploadAssets(options: {
@@ -1016,16 +1008,7 @@ async function uploadAssets(options: {
10161008
const directory = getWorkerAssetsDir(options.output.directory);
10171009
const manifest: Record<string, { hash: string; size: number }> = {};
10181010
const byHash = new Map<string, string>();
1019-
const ignored = ignore().add([
1020-
'/.assetsignore',
1021-
'/_headers',
1022-
'/_redirects',
1023-
'.DS_Store',
1024-
'.env',
1025-
'.env.*',
1026-
'.dev.vars',
1027-
'.dev.vars.*',
1028-
]);
1011+
const ignored = ignore().add(['.DS_Store', '.env', '.env.*', '.dev.vars', '.dev.vars.*']);
10291012
const ignorePath = join(directory, '.assetsignore');
10301013
try {
10311014
ignored.add(readFileSync(ignorePath, 'utf8'));
@@ -1034,6 +1017,7 @@ async function uploadAssets(options: {
10341017
throw cause;
10351018
}
10361019
}
1020+
ignored.add(['/.assetsignore', '/_headers', '/_redirects']);
10371021
for (const entry of readdirSync(directory, { recursive: true, withFileTypes: true })) {
10381022
if (entry.isDirectory()) {
10391023
continue;
@@ -1243,12 +1227,12 @@ export async function uploadPinnedCloudflareVersion(options: {
12431227
).toString('base64'),
12441228
});
12451229
}
1246-
const generatedHeaders = readGeneratedAssetHeaders(options.output);
1247-
if (generatedHeaders !== undefined) {
1230+
const assetHeaders = readAssetHeaders(options.output);
1231+
if (assetHeaders !== undefined) {
12481232
modules.push({
12491233
name: '_headers',
12501234
content_type: 'text/plain',
1251-
content_base64: Buffer.from(generatedHeaders).toString('base64'),
1235+
content_base64: Buffer.from(assetHeaders).toString('base64'),
12521236
});
12531237
}
12541238
await preflightWorkflowExports({

‎packages/void/test/unit/cf-version-runner.test.ts‎

Lines changed: 92 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1358,29 +1358,51 @@ it(
13581358
REAL_CF_TIMEOUT_MS,
13591359
);
13601360

1361-
it.each([
1362-
[
1363-
'Void-generated',
1364-
`${GENERATED_ASSET_HEADERS_MARKER}\n/assets/a-12345678.js\n Cache-Control: public, max-age=31536000, immutable\n`,
1365-
],
1366-
['user-authored', '/assets/*\n X-Frame-Options: DENY\n'],
1367-
['missing', undefined],
1368-
] as const)(
1369-
'uploads only a Void-generated _headers file with pinned secrets (%s)',
1370-
async (_kind, headers) => {
1361+
const authoredAssetHeaders =
1362+
'# Déploiement headers\n\n/assets/*\n Cache-Control: public, max-age=31536000, immutable\n X-Frame-Options: DENY\n';
1363+
1364+
it.each(
1365+
(
1366+
[
1367+
[
1368+
'Void-generated',
1369+
`${GENERATED_ASSET_HEADERS_MARKER}\n/assets/a-12345678.js\n Cache-Control: public, max-age=31536000, immutable\n`,
1370+
],
1371+
['user-authored', authoredAssetHeaders],
1372+
['empty', ''],
1373+
['custom ignore negation', authoredAssetHeaders],
1374+
['missing', undefined],
1375+
] as const
1376+
).flatMap(([kind, headers]) =>
1377+
[false, true].map((workerFirst) => ({ kind, headers, workerFirst })),
1378+
),
1379+
)(
1380+
'uploads generated and user-authored _headers files with pinned secrets ($kind, worker-first=$workerFirst)',
1381+
async ({ kind, headers, workerFirst }) => {
1382+
const runWorkerFirst = workerFirst ? ['/**', '!/assets/*'] : undefined;
13711383
const prepared = await output({
13721384
secrets: { required: ['AUTH_SECRET'] },
13731385
assets: {
13741386
directory: '../client',
13751387
binding: 'ASSETS',
13761388
not_found_handling: 'none',
1377-
run_worker_first: ['/**', '!/assets/*'],
1389+
...(runWorkerFirst === undefined ? {} : { run_worker_first: runWorkerFirst }),
13781390
},
13791391
});
13801392
try {
13811393
if (headers !== undefined) {
13821394
writeFileSync(join(getWorkerAssetsDir(prepared.directory), '_headers'), headers);
13831395
}
1396+
if (kind === 'custom ignore negation') {
1397+
writeFileSync(
1398+
join(getWorkerAssetsDir(prepared.directory), '.assetsignore'),
1399+
'private.txt\n!/_headers\n!/_redirects\n!/.assetsignore\n',
1400+
);
1401+
writeFileSync(
1402+
join(getWorkerAssetsDir(prepared.directory), '_redirects'),
1403+
'/old /new 302\n',
1404+
);
1405+
}
13841406
await pinActiveWorkerSecrets({
13851407
output: prepared,
13861408
activeVersionId: '11111111-1111-4111-8111-111111111111',
@@ -1427,18 +1449,21 @@ it.each([
14271449
const body = JSON.parse(posts.find(({ url }) => url.endsWith('/versions'))!.body);
14281450
expect(body.assets).toEqual({
14291451
jwt: 'asset-token',
1430-
config: { not_found_handling: 'none', run_worker_first: ['/**', '!/assets/*'] },
1452+
config: {
1453+
not_found_handling: 'none',
1454+
...(runWorkerFirst === undefined ? {} : { run_worker_first: runWorkerFirst }),
1455+
},
14311456
});
1432-
const modules = (body.modules as Array<{ name: string }>).filter(
1433-
({ name }) => name === '_headers',
1457+
const modules = (body.modules as Array<{ name: string }>).filter(({ name }) =>
1458+
['_headers', '_redirects', '.assetsignore'].includes(name),
14341459
);
14351460
expect(modules).toEqual(
1436-
_kind === 'Void-generated'
1461+
headers !== undefined
14371462
? [
14381463
{
14391464
name: '_headers',
14401465
content_type: 'text/plain',
1441-
content_base64: Buffer.from(headers!).toString('base64'),
1466+
content_base64: Buffer.from(headers).toString('base64'),
14421467
},
14431468
]
14441469
: [],
@@ -1449,6 +1474,57 @@ it.each([
14491474
},
14501475
);
14511476

1477+
it('fails a pinned upload when prepared _headers cannot be read', async () => {
1478+
const prepared = await output({
1479+
secrets: { required: ['AUTH_SECRET'] },
1480+
assets: { directory: '../client', binding: 'ASSETS' },
1481+
});
1482+
try {
1483+
mkdirSync(join(getWorkerAssetsDir(prepared.directory), '_headers'));
1484+
await pinActiveWorkerSecrets({
1485+
output: prepared,
1486+
activeVersionId: '11111111-1111-4111-8111-111111111111',
1487+
bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }],
1488+
requiredNames: ['AUTH_SECRET'],
1489+
});
1490+
const posts: Array<string> = [];
1491+
const server = createServer((request, response) => {
1492+
request.resume();
1493+
const url = request.url ?? '';
1494+
if (request.method === 'POST') {
1495+
posts.push(url);
1496+
}
1497+
response.writeHead(200, { 'Content-Type': 'application/json' });
1498+
response.end(
1499+
JSON.stringify({
1500+
success: true,
1501+
errors: [],
1502+
result: url.endsWith('/assets-upload-session')
1503+
? { jwt: 'asset-token', buckets: [] }
1504+
: {
1505+
id: '11111111-1111-4111-8111-111111111111',
1506+
bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }],
1507+
},
1508+
}),
1509+
);
1510+
});
1511+
await expect(
1512+
uploadPinnedCloudflareVersion({
1513+
output: prepared,
1514+
accountId: 'account-id',
1515+
apiBaseUrl: await listen(server),
1516+
workerName: 'void-test',
1517+
token: 'synthetic-token',
1518+
atomic: false,
1519+
verifyUploader: () => true,
1520+
}),
1521+
).rejects.toMatchObject({ code: 'EISDIR' });
1522+
expect(posts.some((url) => url.endsWith('/versions'))).toBe(false);
1523+
} finally {
1524+
prepared.dispose();
1525+
}
1526+
});
1527+
14521528
it('uploads Worker limits, placement and cache with pinned secrets', async () => {
14531529
const prepared = await output({
14541530
secrets: { required: ['AUTH_SECRET'] },

0 commit comments

Comments
 (0)