diff --git a/packages/deploy-cloudflare/test/unit/static.test.ts b/packages/deploy-cloudflare/test/unit/static.test.ts index aa746e7ef..32a2a8bc5 100644 --- a/packages/deploy-cloudflare/test/unit/static.test.ts +++ b/packages/deploy-cloudflare/test/unit/static.test.ts @@ -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'); diff --git a/packages/void/src/cli/deploy-cloudflare-static.ts b/packages/void/src/cli/deploy-cloudflare-static.ts index d6bd48da1..65523610e 100644 --- a/packages/void/src/cli/deploy-cloudflare-static.ts +++ b/packages/void/src/cli/deploy-cloudflare-static.ts @@ -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'; @@ -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 }); diff --git a/packages/void/src/cli/package.ts b/packages/void/src/cli/package.ts index 069a79547..4a6238e65 100644 --- a/packages/void/src/cli/package.ts +++ b/packages/void/src/cli/package.ts @@ -640,6 +640,27 @@ export function collectWorkerFiles(workerDir: string): Map { return out; } +const WRANGLER_HTML_HANDLING = new Set([ + '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, +): AssetConfig['html_handling'] | undefined { + const assets = rootConfig.assets; + if (typeof assets !== 'object' || assets === null || Array.isArray(assets)) { + return undefined; + } + const value = (assets as Record).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. */ @@ -651,20 +672,32 @@ export function buildStaticManifest(opts: { fallbackRules?: Array; /** 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) { diff --git a/packages/void/src/config.ts b/packages/void/src/config.ts index dfa475d8f..3cf522fac 100644 --- a/packages/void/src/config.ts +++ b/packages/void/src/config.ts @@ -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, @@ -156,6 +156,7 @@ export type CloudflareConfig = Record & { directory?: string; binding?: string; not_found_handling?: NotFoundHandling; + html_handling?: AssetConfig['html_handling']; run_worker_first?: boolean | Array; }>; triggers?: CloudflareField<{ crons?: Array }>; diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 2b711ae10..549f5bd43 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -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': ``, + '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'), '

Prerendered

'); + }, + }); + + 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' }), diff --git a/packages/void/test/unit/package.test.ts b/packages/void/test/unit/package.test.ts index 46e704f7a..85a8312a8 100644 --- a/packages/void/test/unit/package.test.ts +++ b/packages/void/test/unit/package.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; import { buildFrameworkManifest, buildStaticManifest, + readWranglerHtmlHandling, buildVoidManifest, computeAssetHash, collectAndHashAssets, @@ -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', () => {