/** * Pure helpers for assembling a static site before publishing it: path * normalization, content types, content hashing, `_headers` / `_redirects` * rendering, and upload planning. No network access lives here. */ /** One site file after decoding (bytes plus metadata). */ export type SiteFile = { /** Site path without a leading slash, e.g. `index.html`, `assets/app.js`. */ path: string bytes: Uint8Array contentType: string } /** Caller-facing file value: UTF-8 text, or an object with `text` or `base64`. */ export type StaticFileInput = | string | { text: string; contentType?: string } | { base64: string; contentType?: string } export type HeadersInput = string | Record> export type RedirectRule = { from: string; to: string; status?: number } export type RedirectsInput = string | Array /** Files Pages reads as configuration (sent as form fields, never as assets). */ export const PAGES_CONFIG_FILES = ['_headers', '_redirects', '_routes.json'] as const export type PagesConfigFileName = (typeof PAGES_CONFIG_FILES)[number] /** Same junk wrangler skips (`.DS_Store`, `node_modules`, `.git`, `.wrangler`). */ const IGNORED_SEGMENTS = new Set(['.DS_Store', 'node_modules', '.git', '.wrangler']) /** Wrangler limits for Pages Direct Upload. */ export const MAX_ASSET_SIZE_BYTES = 25 * 1024 * 1024 export const MAX_ASSET_COUNT_DEFAULT = 20_000 const CONTENT_TYPES: Record = { html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript', mjs: 'application/javascript', cjs: 'application/javascript', json: 'application/json', map: 'application/json', webmanifest: 'application/manifest+json', xml: 'application/xml', rss: 'application/rss+xml', atom: 'application/atom+xml', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', ics: 'text/calendar', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', ico: 'image/x-icon', bmp: 'image/bmp', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject', pdf: 'application/pdf', zip: 'application/zip', wasm: 'application/wasm', mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4', vtt: 'text/vtt', } const TEXT_CONTENT_TYPE = /^(text\/|application\/(javascript|json|manifest\+json|xml|rss\+xml|atom\+xml)|image\/svg\+xml)/ /** Lowercase extension without the dot (`app.min.js` → `js`), or `''`. */ export function extensionOf(path: string): string { const base = path.slice(path.lastIndexOf('/') + 1) const dot = base.lastIndexOf('.') return dot > 0 ? base.slice(dot + 1).toLowerCase() : '' } /** Content type from the file extension (wrangler-style, no charset). */ export function contentTypeFor(path: string): string { return CONTENT_TYPES[extensionOf(path)] ?? 'application/octet-stream' } /** True for content types the forbidden-content scan reads as UTF-8 text. */ export function isTextContentType(contentType: string): boolean { return TEXT_CONTENT_TYPE.test(contentType) } /** * Normalize a caller- or archive-supplied site path: forward slashes, no * leading `/` or `./`, no `..`, no empty segments. Throws on unsafe paths. */ export function normalizeSitePath(input: string): string { const raw = String(input ?? '').replace(/\\/g, '/').trim() const trimmed = raw.replace(/^(\.\/)+/, '').replace(/^\/+/, '') if (!trimmed) throw new Error(`Invalid site path "${input}": empty.`) if (trimmed.endsWith('/')) { throw new Error(`Invalid site path "${input}": looks like a directory.`) } const segments = trimmed.split('/') for (const segment of segments) { if (segment === '' || segment === '.' || segment === '..') { throw new Error(`Invalid site path "${input}": empty, "." or ".." segment.`) } if (/[\u0000-\u001f]/.test(segment)) { throw new Error(`Invalid site path "${input}": control characters.`) } } return segments.join('/') } /** True when wrangler would skip this path (`.DS_Store`, `node_modules/`, `.git/`, …). */ export function isIgnoredSitePath(path: string): boolean { return path.split('/').some((segment) => IGNORED_SEGMENTS.has(segment)) } export function bytesToBase64(bytes: Uint8Array): string { let binary = '' const chunk = 0x8000 for (let i = 0; i < bytes.length; i += chunk) { binary += String.fromCharCode(...bytes.subarray(i, i + chunk)) } return btoa(binary) } export function base64ToBytes(value: string): Uint8Array { const binary = atob(String(value).replace(/\s+/g, '')) const out = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) return out } function toHex(buffer: ArrayBuffer): string { return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, '0')).join('') } /** * Pages asset key: same shape as wrangler (`hash(base64 + extension)`, 32 hex * chars) but SHA-256 instead of blake3 so it runs on WebCrypto with no deps. * Keys are content addresses inside the project's asset store, so the hash * function only has to be stable, not identical to wrangler's. */ export async function hashAsset(base64: string, path: string): Promise { const data = new TextEncoder().encode(base64 + extensionOf(path)) return toHex(await crypto.subtle.digest('SHA-256', data)).slice(0, 32) } /** Decode one caller file value into bytes + content type. */ export function decodeFileInput(path: string, value: StaticFileInput): SiteFile { if (typeof value === 'string') { return { path, bytes: new TextEncoder().encode(value), contentType: contentTypeFor(path) } } if (!value || typeof value !== 'object') { throw new Error(`files["${path}"] must be a string, { text }, or { base64 }.`) } const keys = Object.keys(value) const unknown = keys.filter((key) => !['text', 'base64', 'contentType'].includes(key)) if (unknown.length) { throw new Error( `files["${path}"] has unknown key(s) ${unknown.join(', ')}; use { text } or { base64 } plus optional contentType.`, ) } const hasText = typeof (value as { text?: unknown }).text === 'string' const hasBase64 = typeof (value as { base64?: unknown }).base64 === 'string' if (hasText === hasBase64) { throw new Error(`files["${path}"] needs exactly one of text or base64.`) } const contentType = typeof value.contentType === 'string' && value.contentType.trim() ? value.contentType.trim() : contentTypeFor(path) const bytes = hasText ? new TextEncoder().encode((value as { text: string }).text) : base64ToBytes((value as { base64: string }).base64) return { path, bytes, contentType } } /** Render `_headers` text from a `{ pattern: { Header: value } }` map (strings pass through). */ export function renderHeaders(input: HeadersInput): string { if (typeof input === 'string') return input.trim() if (!input || typeof input !== 'object' || Array.isArray(input)) { throw new Error('headers must be a _headers string or { "/path": { Header: "value" } }.') } const blocks: string[] = [] for (const [pattern, headers] of Object.entries(input)) { if (!headers || typeof headers !== 'object' || Array.isArray(headers)) { throw new Error(`headers["${pattern}"] must be an object of header names to values.`) } const lines = Object.entries(headers).map(([name, value]) => { if (/[\r\n:]/.test(name) || /[\r\n]/.test(String(value))) { throw new Error(`headers["${pattern}"]["${name}"] contains a newline or colon.`) } return ` ${name}: ${String(value)}` }) blocks.push([pattern, ...lines].join('\n')) } return blocks.join('\n') } /** Render `_redirects` text from rule objects (strings pass through). */ export function renderRedirects(input: RedirectsInput): string { if (typeof input === 'string') return input.trim() if (!Array.isArray(input)) { throw new Error('redirects must be a _redirects string or an array of { from, to, status? }.') } return input .map((rule, index) => { if (!rule || typeof rule.from !== 'string' || typeof rule.to !== 'string') { throw new Error(`redirects[${index}] needs string from and to.`) } if (/\s/.test(rule.from) || /\s/.test(rule.to)) { throw new Error(`redirects[${index}] from/to must not contain whitespace.`) } const status = rule.status ?? 302 if (![200, 301, 302, 303, 307, 308].includes(status)) { throw new Error(`redirects[${index}].status must be 200, 301, 302, 303, 307, or 308.`) } return `${rule.from} ${rule.to} ${status}` }) .join('\n') } /** * Merge `_headers` text so each URL pattern appears once (header lines from * repeated blocks are concatenated in order). Cloudflare Pages keeps only one * block per repeated pattern, so `/*` from a file plus `/*` from `noindex` * would otherwise drop rules. Comments and blank lines are dropped. */ export function mergeHeadersText(text: string): string { const order: string[] = [] const blocks = new Map() let current: string[] | null = null for (const rawLine of text.split(/\r?\n/)) { if (!rawLine.trim() || rawLine.trim().startsWith('#')) continue if (/^\s/.test(rawLine)) { if (!current) throw new Error(`_headers line "${rawLine.trim()}" has no URL pattern above it.`) current.push(' ' + rawLine.trim()) continue } const pattern = rawLine.trim() if (!blocks.has(pattern)) { blocks.set(pattern, []) order.push(pattern) } current = blocks.get(pattern)! } return order.map((pattern) => [pattern, ...blocks.get(pattern)!].join('\n')).join('\n') } /** Join non-empty config text blocks with a newline. */ export function joinConfig(...parts: Array): string | undefined { const kept = parts.map((part) => (part ?? '').trim()).filter(Boolean) return kept.length ? kept.join('\n') + '\n' : undefined } export type HashedAsset = { /** Manifest path with a leading slash, e.g. `/index.html`. */ manifestPath: string hash: string base64: string contentType: string size: number } /** Hash every asset (base64 is kept for upload). */ export async function hashAssets(files: SiteFile[]): Promise { const out: HashedAsset[] = [] for (const file of files) { const base64 = bytesToBase64(file.bytes) out.push({ manifestPath: '/' + file.path, hash: await hashAsset(base64, file.path), base64, contentType: file.contentType, size: file.bytes.length, }) } return out } /** Pages deployment manifest: `{ "/path": hash }`, sorted by path. */ export function buildManifest(assets: Array>): Record { const sorted = [...assets].sort((a, b) => a.manifestPath.localeCompare(b.manifestPath)) return Object.fromEntries(sorted.map((asset) => [asset.manifestPath, asset.hash])) } /** * Split assets into upload batches (largest first) so each request stays well * under Worker memory limits. Duplicate hashes are uploaded once. */ export function planUploadBatches( assets: T[], options: { maxBatchBytes?: number; maxBatchFiles?: number } = {}, ): T[][] { const maxBytes = options.maxBatchBytes ?? 8 * 1024 * 1024 const maxFiles = options.maxBatchFiles ?? 500 const seen = new Set() const unique = assets.filter((asset) => { if (seen.has(asset.hash)) return false seen.add(asset.hash) return true }) unique.sort((a, b) => b.size - a.size) const batches: Array<{ items: T[]; bytes: number }> = [] for (const asset of unique) { const batch = batches.find( (candidate) => candidate.items.length < maxFiles && candidate.bytes + asset.size <= maxBytes, ) if (batch) { batch.items.push(asset) batch.bytes += asset.size } else { batches.push({ items: [asset], bytes: asset.size }) } } return batches.map((batch) => batch.items) } /** Decode a JWT payload without verifying it (expiry / limits only). */ export function decodeJwtPayload(jwt: string): Record { const part = String(jwt).split('.')[1] if (!part) throw new Error('Malformed JWT.') const base64 = part.replace(/-/g, '+').replace(/_/g, '/') const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4) return JSON.parse(new TextDecoder().decode(base64ToBytes(padded))) } /** True when the JWT expires within `skewMs` (default 60s) or has no readable `exp`. */ export function isJwtExpiring(jwt: string, nowMs = Date.now(), skewMs = 60_000): boolean { try { const exp = decodeJwtPayload(jwt).exp if (typeof exp !== 'number') return false return exp * 1000 - nowMs <= skewMs } catch { return true } } export type SiteConfigFiles = Partial> export type AssembleSiteInput = { /** Files from an archive (sourceUrl), applied first. */ archiveFiles?: SiteFile[] /** Caller files; override archive files at the same path. */ files?: Record headers?: HeadersInput redirects?: RedirectsInput spa?: boolean noindex?: boolean } export type AssembledSite = { /** Uploadable assets (config files removed). */ assets: SiteFile[] /** `_headers`, `_redirects`, `_routes.json` text sent as deployment form fields. */ config: SiteConfigFiles warnings: string[] } export const SPA_REDIRECT_RULE = '/* /index.html 200' export const NOINDEX_HEADERS_RULE = '/*\n X-Robots-Tag: noindex' const GENERATED_404 = 'Not found' + '' + '

404 — Not found

\n' /** * Merge archive files + caller files into uploadable assets and Pages config * files, applying `headers`, `redirects`, `spa`, and `noindex`. Pure. * * Order: archive files, then caller `files` (same path wins). Option text is * appended after any `_headers` / `_redirects` file content; the SPA rule goes * last so explicit redirects match first. */ export function assembleSite(input: AssembleSiteInput): AssembledSite { const warnings: string[] = [] const byPath = new Map() for (const file of input.archiveFiles ?? []) byPath.set(file.path, file) let overridden = 0 if (input.files !== undefined) { if (!input.files || typeof input.files !== 'object' || Array.isArray(input.files)) { throw new Error('files must be an object mapping site paths to file values.') } for (const [rawPath, value] of Object.entries(input.files)) { const path = normalizeSitePath(rawPath) if (isIgnoredSitePath(path)) { warnings.push(`Skipped ignored path ${path}.`) continue } if (byPath.has(path)) overridden += 1 byPath.set(path, decodeFileInput(path, value)) } } if (overridden) warnings.push(`${overridden} file(s) from files overrode archive entries.`) const config: SiteConfigFiles = {} const decoder = new TextDecoder() for (const name of PAGES_CONFIG_FILES) { const file = byPath.get(name) if (file) { config[name] = decoder.decode(file.bytes) byPath.delete(name) } } for (const path of byPath.keys()) { if (path === '_worker.js' || path.startsWith('_worker.js/')) { throw new Error( '_worker.js (Pages advanced mode / Functions) is not supported: publishStatic deploys static assets only. Remove it.', ) } } if ([...byPath.keys()].some((path) => path.startsWith('functions/'))) { warnings.push('functions/ will be published as static files (Pages Functions are not compiled).') } if (input.spa === true && !byPath.has('index.html')) { throw new Error('spa: true needs an index.html at the site root.') } if (input.spa === false && !byPath.has('404.html')) { byPath.set('404.html', { path: '404.html', bytes: new TextEncoder().encode(GENERATED_404), contentType: 'text/html', }) warnings.push('spa: false added a minimal 404.html so unknown paths return 404.') } if (input.spa === true && byPath.has('404.html')) { warnings.push('spa: true with a 404.html present: the SPA rewrite wins for unmatched paths.') } const joinedHeaders = joinConfig( config._headers, input.headers !== undefined ? renderHeaders(input.headers) : undefined, input.noindex ? NOINDEX_HEADERS_RULE : undefined, ) const headers = joinedHeaders ? joinConfig(mergeHeadersText(joinedHeaders)) : undefined const redirects = joinConfig( config._redirects, input.redirects !== undefined ? renderRedirects(input.redirects) : undefined, input.spa === true ? SPA_REDIRECT_RULE : undefined, ) if (headers) config._headers = headers else delete config._headers if (redirects) config._redirects = redirects else delete config._redirects const assets = [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path)) if (assets.length === 0) throw new Error('No files to publish (pass files and/or sourceUrl).') if (!byPath.has('index.html')) warnings.push('No index.html at the site root; / will 404.') if (assets.length > MAX_ASSET_COUNT_DEFAULT) { throw new Error(`Too many files (${assets.length}); Pages allows ${MAX_ASSET_COUNT_DEFAULT}.`) } const tooBig = assets.filter((asset) => asset.bytes.length > MAX_ASSET_SIZE_BYTES) if (tooBig.length) { throw new Error( `Files over 25 MiB are not allowed on Pages: ${tooBig.map((f) => f.path).join(', ')}.`, ) } return { assets, config, warnings } }