/** * 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 { 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 { 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 { 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 { 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 { if (!jwt || isJwtExpiring(jwt)) return refreshJwt() return jwt } async function post(path: string, payload: unknown, attempts = 5): Promise { 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 { 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 { return post('/pages/assets/check-missing', { hashes }) }, upload(batch: HashedAsset[]): Promise { return post('/pages/assets/upload', batch.map((asset) => ({ key: asset.hash, value: asset.base64, metadata: { contentType: asset.contentType }, base64: true, }))) }, upsertHashes(hashes: string[]): Promise { return post('/pages/assets/upsert-hashes', { hashes }, 2) }, } } export type AssetsClient = ReturnType /** 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 branch?: string message?: string config: SiteConfigFiles }): Record { const form: Record = { 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, ): Promise { 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 } }