Skip to content

src/pages-direct-upload.ts

322 lines · 10.8 KB · TypeScript
Raw
/**
 * Cloudflare Pages Direct Upload flow (mirrors `wrangler pages deploy`):
 * project → upload JWT → check-missing → upload (base64 JSON) → upsert-hashes →
 * multipart deployment → optional wait. Internal to `publish-static`.
 */

import cloudflareApiV4 from './api-v4.ts'
import type { CloudflareAuthOptions } from './auth.ts'
import {
	decodeJwtPayload,
	isJwtExpiring,
	MAX_ASSET_COUNT_DEFAULT,
	planUploadBatches,
	type HashedAsset,
	type SiteConfigFiles,
} from './static-site.ts'

const API_BASE = 'https://api.cloudflare.com/client/v4'

export type PagesContext = {
	auth: CloudflareAuthOptions
	accountId: string
	projectName: string
}

export type PagesProject = {
	name: string
	subdomain?: string
	production_branch?: string
	domains?: string[]
	source?: { type?: string } | null
	created_on?: string
}

export type PagesDeployment = {
	id: string
	url?: string
	environment?: string
	aliases?: string[] | null
	latest_stage?: { name?: string; status?: string; ended_on?: string | null }
	deployment_trigger?: { metadata?: { branch?: string } }
}

type CfError = { code?: number; message?: string }

const PROJECT_NAME = /^[a-z0-9](?:[a-z0-9-]{0,56}[a-z0-9])?$/

export function assertPagesProjectName(name: unknown): string {
	const value = String(name ?? '').trim()
	if (!PROJECT_NAME.test(value)) {
		throw new Error(
			`Invalid Pages project name "${value}": use 1–58 lowercase letters, digits, or dashes (no leading/trailing dash).`,
		)
	}
	return value
}

export function formatCfErrors(errors: unknown): string {
	if (!Array.isArray(errors) || errors.length === 0) return 'no error details'
	return (errors as CfError[])
		.map((error) => `${error?.code ?? '?'}: ${error?.message ?? 'unknown'}`)
		.join('; ')
}

function projectPath(ctx: PagesContext): string {
	return `/client/v4/accounts/${ctx.accountId}/pages/projects/${ctx.projectName}`
}

/** GET the project; `null` when it does not exist. Throws on auth/other errors. */
export async function getPagesProject(ctx: PagesContext): Promise<PagesProject | null> {
	const res = await cloudflareApiV4({ ...ctx.auth, path: projectPath(ctx) })
	if (res.success) return res.result as PagesProject
	const notFound =
		res.httpStatus === 404 ||
		(res.errors as CfError[]).some((error) => error?.code === 8000007)
	if (notFound) return null
	throw new Error(
		`Could not read Pages project "${ctx.projectName}" on account ${ctx.accountId} ` +
			`(HTTP ${res.httpStatus}: ${formatCfErrors(res.errors)}). ` +
			(res.httpStatus === 403
				? 'The token lacks Pages access on this account — check account / accountId.'
				: ''),
	)
}

/**
 * Create a Direct Upload project (no Git source; production branch required).
 * Retries Cloudflare 5xx responses (seen intermittently on create), checking
 * whether the project appeared in between.
 */
export async function createPagesProject(
	ctx: PagesContext,
	productionBranch: string,
): Promise<PagesProject> {
	let last = ''
	for (let attempt = 0; attempt < 3; attempt++) {
		if (attempt > 0) {
			await sleep(1500 * attempt)
			const existing = await getPagesProject(ctx)
			if (existing) return existing
		}
		const res = await cloudflareApiV4({
			...ctx.auth,
			method: 'POST',
			path: `/client/v4/accounts/${ctx.accountId}/pages/projects`,
			body: { name: ctx.projectName, production_branch: productionBranch },
		})
		if (res.success) return res.result as PagesProject
		last = `HTTP ${res.httpStatus}: ${formatCfErrors(res.errors)}`
		if (res.httpStatus < 500) break
	}
	throw new Error(`Creating Pages project "${ctx.projectName}" failed (${last}).`)
}

/** Git-connected Pages projects can never accept Direct Upload deployments. */
export function assertDirectUploadProject(project: PagesProject): void {
	const type = project.source?.type
	if (type && type !== 'direct_upload') {
		throw new Error(
			`Pages project "${project.name}" is connected to ${type}; Direct Upload cannot deploy to it. ` +
				'Pick a different project name (Git-connected projects deploy from their repo).',
		)
	}
}

/** Summarize a deployment's latest stage. Pure. */
export function deploymentState(deployment: PagesDeployment): {
	stage: string
	live: boolean
	failed: boolean
} {
	const name = deployment.latest_stage?.name ?? 'unknown'
	const status = deployment.latest_stage?.status ?? 'unknown'
	return {
		stage: `${name}:${status}`,
		live: name === 'deploy' && status === 'success',
		failed: status === 'failure' || status === 'canceled',
	}
}

function sleep(ms: number): Promise<void> {
	return new Promise((resolve) => setTimeout(resolve, ms))
}

/**
 * Client for the JWT-authenticated `/pages/assets/*` endpoints. Fetches the
 * project upload JWT itself and refreshes it when it is about to expire or
 * the API returns 401, so callers never handle the short-lived token.
 */
export function createAssetsClient(ctx: PagesContext) {
	let jwt: string | null = null

	async function refreshJwt(): Promise<string> {
		const res = await cloudflareApiV4({ ...ctx.auth, path: `${projectPath(ctx)}/upload-token` })
		const token = (res.result as { jwt?: string } | null)?.jwt
		if (!res.success || !token) {
			throw new Error(
				`Could not get a Pages upload token for "${ctx.projectName}" (HTTP ${res.httpStatus}: ${formatCfErrors(res.errors)}).`,
			)
		}
		jwt = token
		return token
	}

	async function token(): Promise<string> {
		if (!jwt || isJwtExpiring(jwt)) return refreshJwt()
		return jwt
	}

	async function post<T>(path: string, payload: unknown, attempts = 5): Promise<T> {
		const body = JSON.stringify(payload)
		let lastError = ''
		for (let attempt = 0; attempt < attempts; attempt++) {
			const bearer = await token()
			let status = 0
			let json: { success?: boolean; result?: T; errors?: unknown } | null = null
			try {
				const response = await fetch(`${API_BASE}${path}`, {
					method: 'POST',
					headers: { Authorization: `Bearer ${bearer}`, 'Content-Type': 'application/json' },
					body,
				})
				status = response.status
				json = await response.json().catch(() => null)
				if (response.ok && json?.success) return json.result as T
			} catch (error) {
				lastError = `network error: ${(error as Error).message}`
			}
			if (status) lastError = `HTTP ${status}: ${formatCfErrors(json?.errors)}`
			if (status === 401 || status === 403) {
				jwt = null // JWT expired or rejected; fetch a fresh one and retry
				continue
			}
			if (status && status < 500 && status !== 429) break
			await sleep(Math.min(1000 * 2 ** attempt, 8000))
		}
		throw new Error(`Pages ${path} failed: ${lastError}`)
	}

	return {
		async maxFileCount(): Promise<number> {
			const claims = decodeJwtPayload(await token())
			return typeof claims.max_file_count_allowed === 'number'
				? claims.max_file_count_allowed
				: MAX_ASSET_COUNT_DEFAULT
		},
		checkMissing(hashes: string[]): Promise<string[]> {
			return post<string[]>('/pages/assets/check-missing', { hashes })
		},
		upload(batch: HashedAsset[]): Promise<unknown> {
			return post('/pages/assets/upload', batch.map((asset) => ({
				key: asset.hash,
				value: asset.base64,
				metadata: { contentType: asset.contentType },
				base64: true,
			})))
		},
		upsertHashes(hashes: string[]): Promise<unknown> {
			return post('/pages/assets/upsert-hashes', { hashes }, 2)
		},
	}
}

export type AssetsClient = ReturnType<typeof createAssetsClient>

/** Check which hashes the project store is missing, upload those, then upsert. */
export async function uploadMissingAssets(
	client: AssetsClient,
	assets: HashedAsset[],
	warnings: string[],
): Promise<{ uploaded: number; skipped: number }> {
	const hashes = [...new Set(assets.map((asset) => asset.hash))]
	const limit = await client.maxFileCount()
	if (hashes.length > limit) {
		throw new Error(`Too many files (${hashes.length}); this project allows ${limit}.`)
	}
	const missing = new Set(await client.checkMissing(hashes))
	const toUpload = assets.filter((asset) => missing.has(asset.hash))
	const batches = planUploadBatches(toUpload)
	const queue = [...batches]
	const workers = Array.from({ length: Math.min(3, queue.length) }, async () => {
		for (let batch = queue.shift(); batch; batch = queue.shift()) {
			await client.upload(batch)
		}
	})
	await Promise.all(workers)
	try {
		await client.upsertHashes(hashes)
	} catch (error) {
		warnings.push(
			`upsert-hashes failed (${(error as Error).message}); the deploy still works but the next upload may re-send files.`,
		)
	}
	const uploadedHashes = new Set(toUpload.map((asset) => asset.hash))
	return { uploaded: uploadedHashes.size, skipped: hashes.length - uploadedHashes.size }
}

/** Build the multipart fields for POST …/deployments. Pure. */
export function buildDeploymentForm(input: {
	manifest: Record<string, string>
	branch?: string
	message?: string
	config: SiteConfigFiles
}): Record<string, string | { filename: string; content: string }> {
	const form: Record<string, string | { filename: string; content: string }> = {
		manifest: JSON.stringify(input.manifest),
	}
	if (input.branch) form.branch = input.branch
	if (input.message) form.commit_message = input.message.slice(0, 384)
	for (const [name, content] of Object.entries(input.config)) {
		if (typeof content === 'string' && content.trim()) form[name] = { filename: name, content }
	}
	return form
}

/** POST the multipart deployment (manifest + config files). */
export async function createDeployment(
	ctx: PagesContext,
	form: ReturnType<typeof buildDeploymentForm>,
): Promise<PagesDeployment> {
	let last = ''
	for (let attempt = 0; attempt < 3; attempt++) {
		const res = await cloudflareApiV4({
			...ctx.auth,
			method: 'POST',
			path: `${projectPath(ctx)}/deployments`,
			form,
		})
		if (res.success) return res.result as PagesDeployment
		last = `HTTP ${res.httpStatus}: ${formatCfErrors(res.errors)}`
		if (res.httpStatus < 500) break
		await sleep(1000 * 2 ** attempt)
	}
	throw new Error(`Creating the Pages deployment failed (${last}).`)
}

/** Poll a deployment until its deploy stage succeeds, fails, or times out. */
export async function waitForDeployment(
	ctx: PagesContext,
	deployment: PagesDeployment,
	options: { timeoutMs: number; intervalMs?: number },
): Promise<{ deployment: PagesDeployment; timedOut: boolean }> {
	const deadline = Date.now() + options.timeoutMs
	let current = deployment
	while (true) {
		const state = deploymentState(current)
		if (state.live) return { deployment: current, timedOut: false }
		if (state.failed) {
			throw new Error(
				`Pages deployment ${current.id} failed at stage ${state.stage}. ` +
					`Inspect it in the Cloudflare dashboard or via api-v4 GET ${projectPath(ctx)}/deployments/${current.id}/history/logs.`,
			)
		}
		if (Date.now() >= deadline) return { deployment: current, timedOut: true }
		await sleep(options.intervalMs ?? 2000)
		const res = await cloudflareApiV4({
			...ctx.auth,
			path: `${projectPath(ctx)}/deployments/${current.id}`,
		})
		if (res.success) current = res.result as PagesDeployment
	}
}