← Public packages
@kentcdodds/cloudflare
Cloudflare helpers: API v4, static site publishing to Pages, analytics, WAF rules, logs, and docs.
src/static-site.ts
472 lines · 17.0 KB · TypeScript/**
* 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<string, Record<string, string>>
export type RedirectRule = { from: string; to: string; status?: number }
export type RedirectsInput = string | Array<RedirectRule>
/** 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<string, string> = {
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<string> {
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<string, string[]>()
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>): 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<HashedAsset[]> {
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<Pick<HashedAsset, 'manifestPath' | 'hash'>>): Record<string, string> {
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<T extends { hash: string; size: number }>(
assets: T[],
options: { maxBatchBytes?: number; maxBatchFiles?: number } = {},
): T[][] {
const maxBytes = options.maxBatchBytes ?? 8 * 1024 * 1024
const maxFiles = options.maxBatchFiles ?? 500
const seen = new Set<string>()
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<string, unknown> {
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<Record<PagesConfigFileName, string>>
export type AssembleSiteInput = {
/** Files from an archive (sourceUrl), applied first. */
archiveFiles?: SiteFile[]
/** Caller files; override archive files at the same path. */
files?: Record<string, StaticFileInput>
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 =
'<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Not found</title>' +
'<meta name="viewport" content="width=device-width,initial-scale=1"></head>' +
'<body><h1>404 — Not found</h1></body></html>\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<string, SiteFile>()
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 }
}