Skip to content

src/publish-static.ts

393 lines · 15.4 KB · TypeScript
Raw
import { loadSiteArchive, type ArchiveFormat } from './archive.ts'
import { resolveAccountId } from './accounts-resolve.ts'
import { pickAuthOptions, type CloudflareAccount, type CloudflareAuthOptions } from './auth.ts'
import {
	assertDirectUploadProject,
	assertPagesProjectName,
	buildDeploymentForm,
	createAssetsClient,
	createDeployment,
	createPagesProject,
	deploymentState,
	getPagesProject,
	uploadMissingAssets,
	waitForDeployment,
	type PagesContext,
	type PagesDeployment,
	type PagesProject,
} from './pages-direct-upload.ts'
import { formatScanRefusal, scanSiteFiles, type ScanInput, type ScanResult } from './scan.ts'
import {
	assembleSite,
	buildManifest,
	hashAssets,
	type HeadersInput,
	type RedirectsInput,
	type SiteFile,
	type StaticFileInput,
} from './static-site.ts'

export type { ForbidPattern, ScanInput } from './scan.ts'
export type { HeadersInput, RedirectRule, RedirectsInput, StaticFileInput } from './static-site.ts'

export type PublishStaticTarget = {
	/** Hosting target. Only `pages` (Cloudflare Pages Direct Upload) today. */
	kind: 'pages'
	/** Pages project name (lowercase letters, digits, dashes; ≤58 chars). */
	project: string
	/**
	 * Branch to deploy. Omit (or pass the project's production branch) for a
	 * production deploy; any other branch makes a preview deployment.
	 */
	branch?: string
	/** Production branch when `createIfMissing` creates the project (default `main`). */
	productionBranch?: string
}

export type PublishStaticInput = CloudflareAuthOptions & {
	target: PublishStaticTarget
	/** Cloudflare account id. Optional when the alias pins one (`pages`, `kody`) or the token sees one account. */
	accountId?: string
	/** Site files: `{ 'index.html': '<h1>Hi</h1>', 'logo.png': { base64 } }`. Overrides archive entries. */
	files?: Record<string, StaticFileInput>
	/** Public https URL of a .zip / .tar / .tar.gz of the built site. */
	sourceUrl?: string
	/** Folder inside the archive to publish (`dist`, or `* /dist` for a GitHub tarball). */
	sourceRoot?: string
	/** Extra `_headers` rules (text, or `{ '/path': { Header: 'value' } }`). */
	headers?: HeadersInput
	/** Extra `_redirects` rules (text, or `[{ from, to, status? }]`). */
	redirects?: RedirectsInput
	/** `true`: unknown paths serve index.html. `false`: unknown paths 404. Omit for Pages default. */
	spa?: boolean
	/** Add `X-Robots-Tag: noindex` to every path (public but unlisted). */
	noindex?: boolean
	/** Create the Pages project when it does not exist (default `false`). */
	createIfMissing?: boolean
	/** Wait until the deployment is live (default `true`; `{ timeoutMs }` default 120000). */
	wait?: boolean | { timeoutMs?: number }
	/** Assemble, scan, hash, and check the project without uploading or deploying. */
	dryRun?: boolean
	/** Forbidden-content preflight. Built-in credential patterns always run unless `defaults: false`. */
	scan?: ScanInput
	/** Deployment message shown in the Pages dashboard. */
	message?: string
}

export type PublishStaticResult = {
	ok: boolean
	dryRun: boolean
	target: 'pages'
	project: string
	accountId: string
	accountIdSource: 'param' | 'alias' | 'discovered'
	projectExists: boolean
	projectCreated: boolean
	/** Production URL (or branch alias for previews). Null in dryRun when the project does not exist yet. */
	url: string | null
	/** Unique per-deployment URL. */
	deploymentUrl: string | null
	deploymentId: string | null
	environment: string | null
	branch: string | null
	/** `<stage>:<status>`, e.g. `deploy:success`. */
	stage: string | null
	live: boolean
	files: number
	bytes: number
	/** Files sent this time (dryRun: would send). Null when unknown (dryRun on a new project). */
	uploaded: number | null
	/** Files the project already had. */
	skipped: number | null
	config: { headers: boolean; redirects: boolean; routes: boolean }
	/** Custom + pages.dev domains on the project. */
	domains: string[]
	source: { files: number; archive: { format: ArchiveFormat; root: string; bytes: number } | null }
	scan: ScanResult
	warnings: string[]
	/** What happens next / what to do (dryRun and edge cases). */
	guidance: string
}

const ALLOWED_KEYS = new Set([
	'account', 'apiTokenSecret', 'target', 'accountId', 'files', 'sourceUrl', 'sourceRoot',
	'headers', 'redirects', 'spa', 'noindex', 'createIfMissing', 'wait', 'dryRun', 'scan', 'message',
])
const TARGET_KEYS = new Set(['kind', 'project', 'branch', 'productionBranch'])
const SCAN_KEYS = new Set(['forbid', 'defaults'])
const ALIASES = new Set<CloudflareAccount>(['default', 'kody', 'pages'])

function assertKeys(value: Record<string, unknown>, allowed: Set<string>, label: string): void {
	const unknown = Object.keys(value).filter((key) => !allowed.has(key))
	if (unknown.length) {
		throw new Error(
			`${label} has unknown key(s): ${unknown.join(', ')}. Allowed: ${[...allowed].join(', ')}.`,
		)
	}
}

function assertBranch(value: unknown, label: string): string | undefined {
	if (value === undefined) return undefined
	if (typeof value !== 'string' || !value.trim() || /\s/.test(value) || value.length > 255) {
		throw new Error(`${label} must be a non-empty branch name without spaces.`)
	}
	return value.trim()
}

function validateInput(raw: unknown): PublishStaticInput & { target: PublishStaticTarget } {
	if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
		throw new Error('publishStatic expects an object.')
	}
	const input = raw as PublishStaticInput
	assertKeys(input as Record<string, unknown>, ALLOWED_KEYS, 'publishStatic input')
	if (input.account !== undefined && !ALIASES.has(input.account)) {
		throw new Error('account must be "default", "kody", or "pages".')
	}
	const target = input.target as PublishStaticTarget | undefined
	if (!target || typeof target !== 'object') {
		throw new Error('target is required, e.g. { kind: "pages", project: "my-site" }.')
	}
	assertKeys(target as Record<string, unknown>, TARGET_KEYS, 'target')
	if (target.kind !== 'pages') {
		throw new Error(
			`target.kind "${String(target.kind)}" is not supported yet. Supported: "pages" (Cloudflare Pages Direct Upload).`,
		)
	}
	assertPagesProjectName(target.project)
	assertBranch(target.branch, 'target.branch')
	assertBranch(target.productionBranch, 'target.productionBranch')
	if (input.files === undefined && input.sourceUrl === undefined) {
		throw new Error('Pass files and/or sourceUrl.')
	}
	if (input.sourceRoot !== undefined && input.sourceUrl === undefined) {
		throw new Error('sourceRoot only applies with sourceUrl.')
	}
	for (const key of ['spa', 'noindex', 'createIfMissing', 'dryRun'] as const) {
		if (input[key] !== undefined && typeof input[key] !== 'boolean') {
			throw new Error(`${key} must be a boolean.`)
		}
	}
	if (input.wait !== undefined && typeof input.wait !== 'boolean') {
		if (!input.wait || typeof input.wait !== 'object') throw new Error('wait must be a boolean or { timeoutMs }.')
		assertKeys(input.wait as Record<string, unknown>, new Set(['timeoutMs']), 'wait')
		const ms = input.wait.timeoutMs
		if (ms !== undefined && (!Number.isFinite(ms) || ms < 0 || ms > 600_000)) {
			throw new Error('wait.timeoutMs must be between 0 and 600000.')
		}
	}
	if (input.scan !== undefined) {
		if (!input.scan || typeof input.scan !== 'object') throw new Error('scan must be { forbid?, defaults? }.')
		assertKeys(input.scan as Record<string, unknown>, SCAN_KEYS, 'scan')
	}
	if (input.message !== undefined && typeof input.message !== 'string') {
		throw new Error('message must be a string.')
	}
	return input as PublishStaticInput & { target: PublishStaticTarget }
}

function configFlags(config: Record<string, string | undefined>) {
	return { headers: !!config._headers, redirects: !!config._redirects, routes: !!config['_routes.json'] }
}

function productionUrl(project: PagesProject | null, name: string): string {
	return `https://${project?.subdomain ?? `${name}.pages.dev`}`
}

/**
 * Publish a static site to public Cloudflare hosting in one call. Today the
 * only target is Cloudflare Pages Direct Upload (same flow as
 * `wrangler pages deploy`): it resolves the account, optionally creates the
 * project, fetches/refreshes the short-lived upload JWT internally, uploads
 * only missing files, sends `_headers` / `_redirects` as deployment fields, and
 * waits until the deployment is live.
 *
 * Every call runs a forbidden-content scan first (built-in credential
 * patterns + your `scan.forbid`) and refuses to publish on a match.
 *
 * @param params.target - `{ kind: 'pages', project, branch?, productionBranch? }`.
 * @param params.account - Token alias; `pages` is the least-privilege choice for KCD Pages.
 * @param params.accountId - Cloudflare account id when the alias does not pin one.
 * @param params.files - Map of site path → text, `{ text }`, or `{ base64, contentType? }`.
 * @param params.sourceUrl - Public https zip / tar / tar.gz of the built site.
 * @param params.sourceRoot - Folder inside the archive to publish.
 * @param params.headers - Extra `_headers` rules (string or object).
 * @param params.redirects - Extra `_redirects` rules (string or rule array).
 * @param params.spa - Single-page-app fallback (`true`) or strict 404s (`false`).
 * @param params.noindex - Add `X-Robots-Tag: noindex` everywhere.
 * @param params.createIfMissing - Create the Pages project if needed (default false).
 * @param params.wait - Poll until live (default true).
 * @param params.dryRun - Preview: no uploads, no project creation, no deploy.
 * @param params.scan - `{ forbid: ['internal-only', '/password\\s*=/i'], defaults?: true }`.
 * @param params.message - Deployment message.
 * @returns URL, deployment id, upload counts, scan report, and warnings.
 * @example
 * import publishStatic from 'kody:@kentcdodds/cloudflare/publish-static'
 * const result = await publishStatic({
 *   account: 'pages',
 *   target: { kind: 'pages', project: 'my-demo-site' },
 *   files: {
 *     'index.html': '<!doctype html><h1>Hello</h1>',
 *     'styles.css': 'h1 { color: rebeccapurple }',
 *   },
 *   createIfMissing: true,
 *   scan: { forbid: ['TODO: private'] },
 * })
 * // => { ok: true, url: 'https://my-demo-site.pages.dev', live: true, uploaded: 2, skipped: 0, ... }
 */
export default async function publishStatic(params: PublishStaticInput): Promise<PublishStaticResult> {
	const input = validateInput(params)
	const auth = pickAuthOptions(input)
	const dryRun = input.dryRun === true
	const projectName = input.target.project

	let archiveFiles: SiteFile[] = []
	let archive: PublishStaticResult['source']['archive'] = null
	if (input.sourceUrl !== undefined) {
		const loaded = await loadSiteArchive(input.sourceUrl, input.sourceRoot)
		archiveFiles = loaded.files
		archive = { format: loaded.format, root: loaded.root, bytes: loaded.bytes }
	}
	const site = assembleSite({
		archiveFiles,
		files: input.files,
		headers: input.headers,
		redirects: input.redirects,
		spa: input.spa,
		noindex: input.noindex,
	})
	const warnings = [...site.warnings]

	const configAsFiles: SiteFile[] = Object.entries(site.config).map(([path, text]) => ({
		path,
		bytes: new TextEncoder().encode(text ?? ''),
		contentType: 'text/plain',
	}))
	const scan = scanSiteFiles([...site.assets, ...configAsFiles], input.scan)
	if (!scan.ok && !dryRun) throw new Error(formatScanRefusal(scan))

	const assets = await hashAssets(site.assets)
	const manifest = buildManifest(assets)
	const bytes = assets.reduce((sum, asset) => sum + asset.size, 0)

	const { accountId, source: accountIdSource } = await resolveAccountId({ ...auth, accountId: input.accountId })
	const ctx: PagesContext = { auth, accountId, projectName }

	let project = await getPagesProject(ctx)
	const projectExists = project !== null
	if (project) assertDirectUploadProject(project)

	const base = {
		target: 'pages' as const,
		project: projectName,
		accountId,
		accountIdSource,
		projectExists,
		files: assets.length,
		bytes,
		config: configFlags(site.config),
		source: { files: input.files ? Object.keys(input.files).length : 0, archive },
		scan,
		warnings,
	}

	if (dryRun) {
		let uploaded: number | null = null
		let skipped: number | null = null
		if (project) {
			const client = createAssetsClient(ctx)
			const hashes = [...new Set(assets.map((asset) => asset.hash))]
			const missing = new Set(await client.checkMissing(hashes))
			uploaded = hashes.filter((hash) => missing.has(hash)).length
			skipped = hashes.length - uploaded
		}
		const productionBranch = project?.production_branch ?? input.target.productionBranch ?? 'main'
		const branch = input.target.branch ?? productionBranch
		const steps: string[] = []
		if (!scan.ok) steps.push(`REFUSE: scan found ${scan.findings.length} forbidden match(es) — a real run would stop here`)
		if (!project) {
			steps.push(
				input.createIfMissing
					? `create Direct Upload project "${projectName}" (production branch ${productionBranch})`
					: `STOP: project "${projectName}" does not exist — pass createIfMissing: true to create it`,
			)
		}
		steps.push(`upload ${uploaded ?? assets.length} file(s), deploy ${branch === productionBranch ? 'production' : `preview branch ${branch}`}`)
		return {
			...base,
			ok: scan.ok && (projectExists || input.createIfMissing === true),
			dryRun: true,
			projectCreated: false,
			url: project ? productionUrl(project, projectName) : null,
			deploymentUrl: null,
			deploymentId: null,
			environment: branch === productionBranch ? 'production' : 'preview',
			branch,
			stage: null,
			live: false,
			uploaded,
			skipped,
			domains: project?.domains ?? [],
			guidance: `Dry run (nothing uploaded). Would: ${steps.join('; ')}.`,
		}
	}

	let projectCreated = false
	if (!project) {
		if (!input.createIfMissing) {
			throw new Error(
				`Pages project "${projectName}" does not exist on account ${accountId}. ` +
					'Check the name (typos create new sites), or pass createIfMissing: true to create a Direct Upload project.',
			)
		}
		project = await createPagesProject(ctx, input.target.productionBranch ?? 'main')
		projectCreated = true
	}

	const client = createAssetsClient(ctx)
	const { uploaded, skipped } = await uploadMissingAssets(client, assets, warnings)
	const productionBranch = project.production_branch ?? 'main'
	const branch = input.target.branch ?? productionBranch
	const form = buildDeploymentForm({
		manifest,
		branch: input.target.branch,
		message: input.message,
		config: site.config,
	})
	let deployment: PagesDeployment = await createDeployment(ctx, form)

	let timedOut = false
	if (input.wait !== false) {
		const timeoutMs = typeof input.wait === 'object' ? input.wait.timeoutMs ?? 120_000 : 120_000
		const waited = await waitForDeployment(ctx, deployment, { timeoutMs })
		deployment = waited.deployment
		timedOut = waited.timedOut
		if (timedOut) warnings.push(`Deployment not live after ${timeoutMs}ms; it may still finish.`)
	}
	const state = deploymentState(deployment)
	const isProduction = (deployment.environment ?? (branch === productionBranch ? 'production' : 'preview')) === 'production'
	const url = isProduction
		? productionUrl(project, projectName)
		: deployment.aliases?.[0] ?? deployment.url ?? null

	return {
		...base,
		ok: true,
		dryRun: false,
		projectCreated,
		url,
		deploymentUrl: deployment.url ?? null,
		deploymentId: deployment.id,
		environment: deployment.environment ?? (isProduction ? 'production' : 'preview'),
		branch,
		stage: state.stage,
		live: state.live,
		uploaded,
		skipped,
		domains: project.domains ?? [],
		guidance: state.live
			? `Live at ${url}.`
			: input.wait === false
				? `Deployment ${deployment.id} queued (${state.stage}); pass wait: true to poll until live.`
				: `Deployment ${deployment.id} still ${state.stage}; check again shortly.`,
	}
}