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': '
Hi
', 'logo.png': { base64 } }`. Overrides archive entries. */
files?: Record
/** 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
/** `:`, 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(['default', 'kody', 'pages'])
function assertKeys(value: Record, allowed: Set, 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, 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, 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, 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, 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) {
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': 'Hello
',
* '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 {
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.`,
}
}