← Public packages
@kentcdodds/cloudflare
Cloudflare helpers: API v4, static site publishing to Pages, analytics, WAF rules, logs, and docs.
src/api-v4-list.ts
163 lines · 5.8 KB · TypeScriptimport cloudflareApiV4, { type CloudflareApiV4Result } from './api-v4.ts'
import type { CloudflareAuthOptions } from './auth.ts'
export type CloudflareApiV4ListInput = CloudflareAuthOptions & {
/** List endpoint path starting with `/client/v4/`. */
path: string
/** Extra query params. `page`, `per_page`, and `cursor` are managed here. */
query?: Record<string, string | number | boolean | null | undefined>
/**
* Page size. Omit to use the endpoint's default (safest: Pages project
* lists reject `per_page` above 10 with error 8000024).
*/
perPage?: number
/** Stop after this many requests (default 50). */
maxPages?: number
/** Stop after collecting this many items (default unlimited). */
maxItems?: number
/**
* Property holding the array when `result` is an object, e.g. `buckets`
* for R2. Auto-detected when `result` has exactly one array property.
*/
itemsKey?: string
}
export type CloudflareApiV4ListResult<T = unknown> = {
ok: boolean
items: T[]
/** Requests made. */
pages: number
/** `result_info.total_count` when the endpoint reports it. */
totalCount: number | null
/** True when maxPages / maxItems stopped the walk before the end. */
truncated: boolean
httpStatus: number | null
errors: unknown[]
}
type ResultInfo = {
page?: number
per_page?: number
total_pages?: number
total_count?: number
count?: number
cursor?: string
cursors?: { after?: string }
}
const MANAGED_QUERY_KEYS = ['page', 'per_page', 'cursor']
/** Pull the item array out of a list response (`result` array or one array property). */
export function extractListItems(result: unknown, itemsKey?: string): unknown[] {
if (Array.isArray(result)) return result
if (!result || typeof result !== 'object') return []
const record = result as Record<string, unknown>
if (itemsKey) {
const value = record[itemsKey]
if (!Array.isArray(value)) {
throw new Error(`result.${itemsKey} is not an array.`)
}
return value
}
const arrays = Object.values(record).filter(Array.isArray)
return arrays.length === 1 ? (arrays[0] as unknown[]) : []
}
/**
* Decide the next request from `result_info`: a cursor, the next page number,
* or `null` when the list is done.
*/
export function nextPageQuery(
info: ResultInfo | null | undefined,
state: { page: number; received: number; perPage?: number },
): { cursor: string } | { page: number } | null {
if (!info || typeof info !== 'object') return null
const cursor = info.cursor || info.cursors?.after
if (typeof cursor === 'string' && cursor) {
return state.received === 0 ? null : { cursor }
}
if (typeof info.total_pages === 'number') {
const current = typeof info.page === 'number' ? info.page : state.page
return current < info.total_pages ? { page: current + 1 } : null
}
const size = state.perPage ?? info.per_page
if (typeof info.page === 'number' && typeof size === 'number' && state.received >= size) {
return { page: info.page + 1 }
}
return null
}
/**
* Fetch every page of a Cloudflare API v4 list endpoint into one array.
* Handles page-number (`result_info.total_pages`) and cursor
* (`result_info.cursor`) pagination. Read-only (`GET`).
*
* @param params.path - List path under `/client/v4/`.
* @param params.perPage - Page size; omit for the endpoint default (Pages max is 10).
* @param params.maxPages - Request cap (default 50).
* @param params.account - Token alias (`default`, `kody`, `pages`).
* @returns `{ ok, items, pages, totalCount, truncated, errors }`.
* @example
* import listAll from 'kody:@kentcdodds/cloudflare/api-v4-list'
* const { items } = await listAll({
* account: 'pages',
* path: '/client/v4/accounts/ACCOUNT_ID/pages/projects',
* })
* // => items: [{ name: 'my-site', subdomain: 'my-site.pages.dev', ... }, ...]
*/
export default async function cloudflareApiV4List<T = unknown>(
params: CloudflareApiV4ListInput,
): Promise<CloudflareApiV4ListResult<T>> {
if (!params || typeof params.path !== 'string') {
throw new Error('path is required (starting with /client/v4/).')
}
const query = { ...(params.query ?? {}) }
for (const key of MANAGED_QUERY_KEYS) {
if (key in query) throw new Error(`Do not pass "${key}" in query; the list helper manages it.`)
}
if (params.perPage !== undefined && (!Number.isInteger(params.perPage) || params.perPage < 1)) {
throw new Error('perPage must be a positive integer.')
}
const maxPages = params.maxPages ?? 50
const maxItems = params.maxItems ?? Number.POSITIVE_INFINITY
const items: T[] = []
let pages = 0
let totalCount: number | null = null
let httpStatus: number | null = null
let next: { cursor: string } | { page: number } | null = { page: 1 }
let truncated = false
while (next) {
if (pages >= maxPages || items.length >= maxItems) {
truncated = true
break
}
const pageQuery: Record<string, string | number | boolean | null | undefined> = { ...query }
if ('cursor' in next) pageQuery.cursor = next.cursor
else if (next.page > 1) pageQuery.page = next.page
if (params.perPage !== undefined) pageQuery.per_page = params.perPage
const response: CloudflareApiV4Result = await cloudflareApiV4({
account: params.account,
apiTokenSecret: params.apiTokenSecret,
method: 'GET',
path: params.path,
query: pageQuery,
})
pages += 1
httpStatus = response.httpStatus
if (!response.success) {
return { ok: false, items, pages, totalCount, truncated: false, httpStatus, errors: response.errors }
}
const batch = extractListItems(response.result, params.itemsKey) as T[]
items.push(...batch)
const info = (response.resultInfo ?? null) as ResultInfo | null
if (info && typeof info.total_count === 'number') totalCount = info.total_count
next = nextPageQuery(info, {
page: 'page' in next ? next.page : pages,
received: batch.length,
perPage: params.perPage,
})
}
if (items.length > maxItems) items.length = maxItems
return { ok: true, items, pages, totalCount, truncated, httpStatus, errors: [] }
}