← Public packages
@kentcdodds/cloudflare
Cloudflare helpers: API v4, static site publishing to Pages, analytics, WAF rules, logs, and docs.
src/pages-direct-upload.ts
322 lines · 10.8 KB · TypeScript/**
* 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
}
}