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
20 changes: 20 additions & 0 deletions packages/deploy-cloudflare/test/unit/static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,26 @@ describe('Cloudflare static deployment', () => {
});
});

it('writes authored html_handling instead of the default fallback', () => {
const root = mkdtempSync(join(tmpdir(), 'void-static-html-handling-'));
const configPath = join(root, 'wrangler.jsonc');
writeFileSync(configPath, '{}\n');

writeCloudflareStaticDeployConfig({
configPath,
name: 'site',
accountId: 'account-id',
main: 'static.mjs',
assetsDirectory: 'dist',
compatibilityDate: '2026-09-11',
assetConfig: { not_found_handling: '404-page', html_handling: 'drop-trailing-slash' },
});

expect(parseRootWranglerConfig(configPath).assets).toMatchObject({
html_handling: 'drop-trailing-slash',
});
});

it('rejects duplicate keys before an edit can silently target the wrong member', () => {
const root = mkdtempSync(join(tmpdir(), 'void-static-duplicate-'));
const configPath = join(root, 'wrangler.jsonc');
Expand Down
3 changes: 2 additions & 1 deletion packages/void/src/cli/deploy-cloudflare-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import {
import { type RunDeployOptions, type CloudflareDeployDeps } from './deploy-types.ts';
import { type CliLogger } from './log-file.ts';
import { getErrorMessage, note } from './output.ts';
import { buildStaticManifest } from './package.ts';
import { buildStaticManifest, readWranglerHtmlHandling } from './package.ts';
import { detectPreset, type StaticPreset, type FrameworkPreset } from './preset.ts';
import { deleteEnvVarCaseAware } from './provision.ts';
import { readWranglerManagedQueueCleanupCandidates } from './wrangler.ts';
Expand Down Expand Up @@ -342,6 +342,7 @@ export async function runCloudflareStaticDeploy(
redirectRules: merged.redirectRules,
fallbackRules: merged.fallbackRules,
notFound: voidConfig.routing?.notFound,
html_handling: readWranglerHtmlHandling(rootConfig),
});
const entryPath = join(root, '.void', 'deploy', 'cloudflare', 'static-edge.mjs');
mkdirSync(dirname(entryPath), { recursive: true });
Expand Down
37 changes: 35 additions & 2 deletions packages/void/src/cli/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,27 @@ export function collectWorkerFiles(workerDir: string): Map<string, Buffer> {
return out;
}

const WRANGLER_HTML_HANDLING = new Set<AssetConfig['html_handling']>([
'auto-trailing-slash',
'force-trailing-slash',
'drop-trailing-slash',
'none',
]);

/** Read `assets.html_handling` from a materialized root Wrangler config object. */
export function readWranglerHtmlHandling(
rootConfig: Record<string, unknown>,
): AssetConfig['html_handling'] | undefined {
const assets = rootConfig.assets;
if (typeof assets !== 'object' || assets === null || Array.isArray(assets)) {
return undefined;
}
const value = (assets as Record<string, unknown>).html_handling;
return typeof value === 'string' && WRANGLER_HTML_HANDLING.has(value as AssetConfig['html_handling'])
? (value as AssetConfig['html_handling'])
: undefined;
}

/**
* Build a static-deploy manifest. Pure shape transformation — no I/O.
*/
Expand All @@ -651,20 +672,32 @@ export function buildStaticManifest(opts: {
fallbackRules?: Array<DevRedirectRule>;
/** Explicit `routing.notFound` — overrides the appType-inferred handling. */
notFound?: NotFoundHandling;
/** Authored `cloudflare.assets.html_handling` from the root Wrangler config. */
html_handling?: AssetConfig['html_handling'];
}): DeployManifest {
const { appType, hashedAssetsPrefix, headerRules, redirectRules, fallbackRules, notFound } = opts;
const {
appType,
hashedAssetsPrefix,
headerRules,
redirectRules,
fallbackRules,
notFound,
html_handling,
} = opts;
const { assetConfig, fallbackRules: resolvedFallbackRules } = resolveSpaFallbackChain(
appType,
fallbackRules,
notFound,
);
const resolvedAssetConfig =
html_handling === undefined ? assetConfig : { ...assetConfig, html_handling };

const manifest: DeployManifest = {
version: 3,
assetManifestVersion: 2,
bindings: {},
type: appType,
assetConfig,
assetConfig: resolvedAssetConfig,
};

if (hashedAssetsPrefix) {
Expand Down
3 changes: 2 additions & 1 deletion packages/void/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isRecord } from '@void/deploy-core';
import { isRecord, type AssetConfig } from '@void/deploy-core';
import type { RewriteDestination } from 'void/routes';
import {
VOID_AUTH_PROVIDERS,
Expand Down Expand Up @@ -156,6 +156,7 @@ export type CloudflareConfig = Record<string, unknown> & {
directory?: string;
binding?: string;
not_found_handling?: NotFoundHandling;
html_handling?: AssetConfig['html_handling'];
run_worker_first?: boolean | Array<string>;
}>;
triggers?: CloudflareField<{ crons?: Array<string> }>;
Expand Down
32 changes: 32 additions & 0 deletions packages/void/test/unit/cloudflare-deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2680,6 +2680,38 @@ describe('runCloudflareDeploy — static, SPA, and SSG apps', () => {
expect(trace.indexOf('postgres-migrate')).toBeLessThan(trace.indexOf('wrangler:deploy'));
});

it('preserves authored assets.html_handling in the generated static Wrangler config', async () => {
const root = makeProject({
'package.json': JSON.stringify({ name: 'ssgapp' }),
'void.json': JSON.stringify({ output: 'static' }),
'wrangler.jsonc': JSON.stringify({
account_id: 'acc123',
assets: { html_handling: 'drop-trailing-slash' },
}),
'pages/index.vue': `<template><div>hi</div></template>`,
'pages/index.server.ts': `export const loader = () => ({});`,
});
const { deps, trace, exitCalls } = harness({
runStaticBuild: () => {
trace.push('static-build');
mkdirSync(join(root, 'dist/client'), { recursive: true });
writeFileSync(join(root, 'dist/client/index.html'), '<h1>Prerendered</h1>');
},
});

await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps);

expect(exitCalls).toEqual([]);
expect(trace).toEqual(['wrangler:whoami', 'static-build', 'wrangler:deploy']);
expect(parseRootWranglerConfig(generatedStaticConfig(root))).toMatchObject({
assets: {
directory: '../../../dist/client',
not_found_handling: '404-page',
html_handling: 'drop-trailing-slash',
},
});
});

it('deploys a fully prerenderable output:"static" Pages app through the thin edge Worker', async () => {
const root = makeProject({
'package.json': JSON.stringify({ name: 'ssgapp' }),
Expand Down
25 changes: 25 additions & 0 deletions packages/void/test/unit/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test';
import {
buildFrameworkManifest,
buildStaticManifest,
readWranglerHtmlHandling,
buildVoidManifest,
computeAssetHash,
collectAndHashAssets,
Expand Down Expand Up @@ -315,6 +316,30 @@ describe('buildStaticManifest', () => {
{ source: '/docs/*', destination: '/docs.html', status: 200 },
]);
});

it('carries authored html_handling into assetConfig', () => {
expect(
buildStaticManifest({ appType: 'static', html_handling: 'drop-trailing-slash' }).assetConfig,
).toEqual({
not_found_handling: '404-page',
html_handling: 'drop-trailing-slash',
});
});
});

describe('readWranglerHtmlHandling', () => {
it('reads a supported assets.html_handling value', () => {
expect(
readWranglerHtmlHandling({
assets: { html_handling: 'drop-trailing-slash' },
}),
).toBe('drop-trailing-slash');
});

it('ignores missing or invalid values', () => {
expect(readWranglerHtmlHandling({})).toBeUndefined();
expect(readWranglerHtmlHandling({ assets: { html_handling: 'bogus' } })).toBeUndefined();
});
});

describe('collectAndHashAssets', () => {
Expand Down