Skip to content

src/static-site.ts

472 lines · 17.0 KB · TypeScript
Raw
/**
 * 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 }
}