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.`, } }