UNPKG

run402-mcp

Version:

MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.

765 lines • 36.1 kB
/** * `functions` namespace — serverless function lifecycle. * * `deploy` routes through the unified apply engine (`/apply/v1`, a * `functions.patch.set` release) — the legacy `POST /projects/v1/admin/:id/ * functions` route was removed gateway-side. invoke/logs/list/delete/update * cover `/projects/v1/admin/:id/functions*` and `/functions/v1/:name`, plus * the client-side `logsByRequestId` fan-out (no project-wide logs route * exists) and the `origin` tagging/filter on every log entry, plus * the opt-in runtime rebuild/rebuildAll against `/projects/v1/:id/functions*` * (wallet-authed, capability `function-runtime-rebuild`). */ import { LocalError } from "../errors.js"; import { requireProjectCredentials } from "../project-credentials.js"; import { Deploy } from "./deploy.js"; const FUNCTION_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,127}$/; const FUNCTION_LOG_REQUEST_ID_RE = /^(?:req|fnrun|fnatt)_[A-Za-z0-9_-]{4,128}$/; const ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/; const FUNCTION_LOG_TAIL_MAX = 1000; const FUNCTION_DEP_MAX = 30; const FUNCTION_DEP_SPEC_MAX = 200; const DISALLOWED_FUNCTION_DEPS = new Set([ "@run402/functions", "run402-functions", "sharp", "canvas", "bcrypt", ]); /** * Lambda runtime control lines. Every pattern anchors at the start of the * (trimmed) CloudWatch message; nothing a function writes through `console.*` * can start this way because the Node runtime's text log format prefixes app * output with `<iso-timestamp>\t<request-uuid>\t<LEVEL>\t`. * * INIT_START / INIT_REPORT cold-start init phase * RESTORE_START / RESTORE_REPORT SnapStart restore phase (`class: "ssr"`) * START / END / REPORT RequestId per-invocation bookends (billed duration…) * EXTENSION / LOGS / TELEMETRY extension registration banners * * Deliberately NOT platform: `Task timed out after …`, `RequestId: … Error: * Runtime exited …`, and the wrapper's own `{"event":"function.error",…}` * diagnostics — those describe what happened to the app code and stay `app`. */ const PLATFORM_LOG_LINE_RE = /^(?:INIT_START\b|INIT_REPORT\b|RESTORE_START\b|RESTORE_REPORT\b|START RequestId:|END RequestId:|REPORT RequestId:|EXTENSION\t|LOGS\t|TELEMETRY\t)/; /** * Classify one CloudWatch log line as a Lambda runtime control line * (`platform`) or function output (`app`). Pure; safe on any string. */ export function classifyFunctionLogLine(message) { const line = typeof message === "string" ? message.trimStart() : ""; return PLATFORM_LOG_LINE_RE.test(line) ? "platform" : "app"; } const FUNCTION_LOG_ORIGIN_FILTERS = new Set(["app", "platform", "all"]); function validateFunctionLogOriginFilter(value, context) { if (value === undefined) return "all"; if (typeof value !== "string" || !FUNCTION_LOG_ORIGIN_FILTERS.has(value)) { throw new LocalError(`Invalid functions.logs origin: ${String(value)} (expected "app", "platform", or "all")`, context); } return value; } /** * Tag every entry with its origin and apply the client-side filter. The wire * response never carries `origin`; it is computed here so CLI/MCP never * re-derive it. */ function applyFunctionLogOrigin(logs, origin) { const tagged = (Array.isArray(logs) ? logs : []).map((entry) => ({ ...entry, origin: classifyFunctionLogLine(entry?.message ?? ""), })); if (origin === "all") return { logs: tagged }; const hidden = { platform: 0, app: 0 }; const kept = []; for (const entry of tagged) { if (entry.origin === origin) kept.push(entry); else hidden[entry.origin] += 1; } return { logs: kept, hidden }; } function compareFunctionLogEntries(a, b) { const ta = a.timestamp ? Date.parse(a.timestamp) : 0; const tb = b.timestamp ? Date.parse(b.timestamp) : 0; return (Number.isNaN(ta) ? 0 : ta) - (Number.isNaN(tb) ? 0 : tb); } export class FunctionRunTerminalError extends Error { run; code; constructor(run) { const message = run.last_error?.message ?? `Function run ${run.run_id} reached terminal status ${run.status}`; super(message); this.name = "FunctionRunTerminalError"; this.run = run; this.code = run.last_error?.code; } } export class Functions { client; runs; retry = { standard: (opts = {}) => ({ preset: "standard", ...(opts.maxAttempts !== undefined ? { maxAttempts: opts.maxAttempts } : {}), ...(opts.minDelaySeconds !== undefined ? { minDelaySeconds: opts.minDelaySeconds } : {}), ...(opts.maxDelaySeconds !== undefined ? { maxDelaySeconds: opts.maxDelaySeconds } : {}), }), }; constructor(client) { this.client = client; this.runs = new FunctionRuns(client); } /** * Deploy a serverless function through the unified apply engine. Builds a * one-function `functions.patch.set` {@link ReleaseSpec} (additive — never * touches coexisting functions) and runs it through the same `/apply/v1` * state machine as `r.project(id).apply`. The legacy * `POST /projects/v1/admin/:id/functions` route was removed gateway-side. * * Deployed functions can `import { db, adminDb, email, ai } from * "@run402/functions"` — the in-function helper library is auto-bundled by * the platform. * * `opts.deps` lists additional npm packages to install and bundle (capability * `apply-v1-function-deps`). Bare names resolve to the latest published * version at deploy time; pinned or range specs (`"lodash@4.17.21"`, * `"date-fns@^3.0.0"`) are honored verbatim. `@run402/functions` and native * binary modules are rejected by the gateway. The actually-installed concrete * versions land on the function record (read via {@link Functions.list}) — * the apply/deploy result does not carry them, so * {@link FunctionDeployResult.deps_resolved} and `runtime_version` are `null`. * * Authorizes through the standard apply credential — a SIWX wallet, or the * write-approval `project.deploy` gate for a wallet-less person — NOT the * project service key. Non-fatal deploy issues (bundle size warnings, esbuild * advisories) surface in {@link FunctionDeployResult.warnings}. * * @throws {ProjectNotFound} when the project is absent from the local keystore. * @throws {Run402DeployError} on any apply state-machine failure. * @throws {PaymentRequired} when the project lease has expired. */ async deploy(projectId, opts) { validateFunctionName(opts.name, "name", "deploying function"); validateFunctionCode(opts.code, "code", "deploying function"); validateFunctionConfig(opts.config, "config", "deploying function"); validateFunctionDeps(opts.deps, "deps", "deploying function"); validateFunctionSchedule(opts.schedule, "schedule", "deploying function"); // Fast-fail on an unknown project id before planning (and the spec needs // the id). The deploy authorizes via the apply credential, not the // service key — the legacy service-key route no longer exists. const project = await requireProjectCredentials(this.client, projectId, "deploying function"); const fn = { runtime: "node22", source: { data: opts.code, contentType: "text/javascript; charset=utf-8" }, }; if (opts.config !== undefined) { fn.config = { timeoutSeconds: opts.config.timeout, memoryMb: opts.config.memory }; } if (opts.deps !== undefined) fn.deps = opts.deps; if (opts.schedule !== undefined) fn.schedule = opts.schedule; const spec = { project_id: projectId, functions: { patch: { set: { [opts.name]: fn } } }, }; const result = await new Deploy(this.client).apply(spec, {}); // Map the release-level `DeployResult` back to the stable // `FunctionDeployResult`. The apply result carries urls + warnings, not // per-function build metadata, so `runtime_version` / `deps_resolved` are // `null` (the function record holds them — read via `list`). `runtime` / // `timeout` / `memory` echo the request (or the documented defaults). const warnings = result.warnings.map((w) => w.message); return { name: opts.name, url: result.urls[opts.name] ?? `${this.client.apiBase}/functions/v1/${opts.name}`, status: "deployed", runtime: "node22", timeout: opts.config?.timeout ?? 15, memory: opts.config?.memory ?? 128, schedule: opts.schedule ?? null, created_at: new Date().toISOString(), runtime_version: null, deps_resolved: null, ...(warnings.length > 0 ? { warnings } : {}), }; } /** * Invoke a deployed function via HTTP. Uses the project's service key as * the API key. The returned `body` is parsed JSON when the response was * JSON, otherwise the raw text. */ async invoke(projectId, name, opts = {}) { const project = await requireProjectCredentials(this.client, projectId, "invoking function"); const method = opts.method ?? "POST"; const headers = { apikey: project.service_key, ...(opts.headers ?? {}), }; if (opts.idempotencyKey) headers["Idempotency-Key"] = opts.idempotencyKey; const requestOpts = { method, headers, context: "invoking function", }; if (method !== "GET" && method !== "HEAD" && opts.body !== undefined) { if (typeof opts.body === "string") { requestOpts.rawBody = opts.body; } else { requestOpts.body = opts.body; } } const start = Date.now(); let response = await this.client.requestWithResponse(`/functions/v1/${name}`, requestOpts); const runId = paidFacadeRunId(response.body); if (opts.wait && response.status === 202 && runId) { await this.runs.wait(projectId, runId, opts.wait); if (opts.idempotencyKey) { response = await this.client.requestWithResponse(`/functions/v1/${name}`, requestOpts); } } return { status: response.status, body: response.body, duration_ms: Date.now() - start, }; } /** * Get recent logs for a function. Default tail 50; `since` accepts an ISO * 8601 timestamp or epoch milliseconds for incremental polling. Pass * `requestId` to retrieve logs correlated to a routed request failure. */ async logs(projectId, name, opts = {}) { // The project's own service key when cached; otherwise the client's // principal credential (SIWX / session / grant key), which the gateway // authorizes with project.read — the agent a Buzz page addresses runs // the logs drill-down an error fingerprint hands it without a key. const project = await this.client.getProjectCredentials(projectId); const tail = opts.tail ?? 50; validatePositiveJsonInteger(tail, "tail", "fetching function logs", { max: FUNCTION_LOG_TAIL_MAX }); const origin = validateFunctionLogOriginFilter(opts.origin, "fetching function logs"); const search = new URLSearchParams({ tail: String(tail) }); if (opts.since !== undefined) { search.set("since", String(parseLogSince(opts.since))); } if (opts.requestId !== undefined) { validateFunctionLogRequestId(opts.requestId, "requestId", "fetching function logs"); search.set("request_id", opts.requestId); } const path = `/projects/v1/admin/${projectId}/functions/${encodeURIComponent(name)}/logs?${search.toString()}`; const wire = await this.client.request(path, { ...(project ? { headers: { Authorization: `Bearer ${project.service_key}` }, withAuth: false } : { withAuth: true }), context: "fetching function logs", }); const filtered = applyFunctionLogOrigin(wire?.logs, origin); return { logs: filtered.logs, origin, ...(filtered.hidden ? { hidden: filtered.hidden } : {}), }; } /** * Project-wide request-id search. The gateway has no cross-function logs * route, so this fans `logs()` out across every function in the project * (or just `functionName`) and merges the matches oldest-first. A function * whose read failed lands in `errors[]` by name; the others still answer. * Accepts `req_…` (the `x-run402-request-id` response header), `fnrun_…`, * and `fnatt_…` ids. */ async logsByRequestId(projectId, requestId, opts = {}) { const context = "searching function logs by request id"; validateFunctionLogRequestId(requestId, "requestId", context); const tail = opts.tail ?? 100; validatePositiveJsonInteger(tail, "tail", context, { max: FUNCTION_LOG_TAIL_MAX }); const origin = validateFunctionLogOriginFilter(opts.origin, context); if (opts.since !== undefined) parseLogSince(opts.since); let names; if (opts.functionName !== undefined) { validateNonEmptyString(opts.functionName, "functionName", context); names = [opts.functionName]; } else { const list = await this.list(projectId); names = (list?.functions ?? []).map((fn) => fn.name).filter((n) => typeof n === "string" && n.length > 0); } const results = await Promise.allSettled(names.map((name) => this.logs(projectId, name, { tail, since: opts.since, requestId, origin }).then((result) => ({ name, result })))); const scanned = []; const entries = []; const errors = []; const hidden = { platform: 0, app: 0 }; results.forEach((settled, index) => { const name = names[index]; if (settled.status === "fulfilled") { scanned.push(name); for (const entry of settled.value.result.logs) entries.push({ function: name, ...entry }); if (settled.value.result.hidden) { hidden.platform += settled.value.result.hidden.platform; hidden.app += settled.value.result.hidden.app; } return; } const reason = settled.reason; const message = reason instanceof Error ? reason.message : String(reason); const code = reason && typeof reason === "object" && typeof reason.code === "string" ? reason.code : undefined; errors.push({ function: name, message, ...(code ? { code } : {}) }); }); entries.sort(compareFunctionLogEntries); return { request_id: requestId, scanned, entries, errors, origin, ...(origin !== "all" ? { hidden } : {}), }; } /** List deployed functions for a project. */ async list(projectId) { const project = await requireProjectCredentials(this.client, projectId, "listing functions"); return this.client.request(`/projects/v1/admin/${projectId}/functions`, { headers: { Authorization: `Bearer ${project.service_key}` }, context: "listing functions", }); } /** Delete a deployed function. */ async delete(projectId, name) { const project = await requireProjectCredentials(this.client, projectId, "deleting function"); return this.client.request(`/projects/v1/admin/${projectId}/functions/${encodeURIComponent(name)}`, { method: "DELETE", headers: { Authorization: `Bearer ${project.service_key}` }, context: "deleting function", }); } /** * Update a function's schedule / timeout / memory without re-deploying. * Pass `schedule: null` to remove an existing schedule; `undefined` * leaves it unchanged. */ async update(projectId, name, opts) { validatePositiveJsonInteger(opts.timeout, "timeout", "updating function"); validatePositiveJsonInteger(opts.memory, "memory", "updating function"); validateFunctionSchedule(opts.schedule, "schedule", "updating function"); if (opts.schedule === undefined && opts.timeout === undefined && opts.memory === undefined) { throw new LocalError("Provide at least one supported update field: schedule, timeout, or memory", "updating function"); } const project = await requireProjectCredentials(this.client, projectId, "updating function"); const body = {}; if (opts.schedule !== undefined) body.schedule = opts.schedule; if (opts.timeout !== undefined || opts.memory !== undefined) { const config = {}; if (opts.timeout !== undefined) config.timeout = opts.timeout; if (opts.memory !== undefined) config.memory = opts.memory; body.config = config; } return this.client.request(`/projects/v1/admin/${projectId}/functions/${encodeURIComponent(name)}`, { method: "PATCH", headers: { Authorization: `Bearer ${project.service_key}` }, body, context: "updating function", }); } /** * Refresh a single runtime-stale function onto the gateway's current entry * wrapper + bundled runtime, WITHOUT changing its source (capability * `function-runtime-rebuild`, gateway v1.69+). * * A gateway-side fix to the function entry wrapper (e.g. the SSR `auth.*` * fixes) only reaches a deployed function when it is re-bundled — a plain * redeploy with unchanged source skips it (apply's release diff keys on the * source `code_hash`, not the wrapper). This re-bundles from the function's * STORED source with dependencies pinned to the recorded `deps_resolved` * exact versions, so the only change is the wrapper/runtime: `code_hash` is * unchanged and no new release is created. Strictly opt-in — the platform * never auto-rebuilds. * * Wallet-authed (the caller must own the project) and allowed during billing * grace (`past_due` / `frozen` / `dormant`); it adds no capacity. No service * key is required — the gateway derives it from the wallet-owned project. * * @throws {ApiError} HTTP 404 when the function does not exist; HTTP 403 when * the wallet does not own the project; HTTP 409 with code * `CANNOT_REBUILD_UNLOCKED_DEPS` for functions deployed before dependency * locking (`deps_resolved` is NULL) — redeploy from source to refresh. */ async rebuild(projectId, name) { validateFunctionName(name, "name", "rebuilding function"); return this.client.request(`/projects/v1/${encodeURIComponent(projectId)}/functions/${encodeURIComponent(name)}/rebuild`, { method: "POST", context: "rebuilding function", }); } /** * Refresh ALL of a project's functions onto the gateway's current entry * wrapper + bundled runtime (capability `function-runtime-rebuild`). Same * deterministic, deps-locked, release-agnostic semantics as * {@link Functions.rebuild} applied per function. * * Per-function failures are isolated and never abort the batch: a function * that fails to rebuild (bundle/upload error, or the * `CANNOT_REBUILD_UNLOCKED_DEPS` refusal) keeps its previously-deployed * artifact and is reported as `{ rebuilt: false, error, code? }` in * `results`. Wallet-authed (project ownership) and allowed during billing * grace. */ async rebuildAll(projectId) { return this.client.request(`/projects/v1/${encodeURIComponent(projectId)}/functions/rebuild`, { method: "POST", context: "rebuilding functions", }); } } function paidFacadeRunId(body) { if (!body || typeof body !== "object" || Array.isArray(body)) return null; const runId = body.run_id; return typeof runId === "string" && /^fnrun_[A-Za-z0-9_-]+$/.test(runId) ? runId : null; } export class FunctionRuns { client; constructor(client) { this.client = client; } async create(projectId, functionName, opts) { validateFunctionName(functionName, "functionName", "creating function run"); const project = await requireProjectCredentials(this.client, projectId, "creating function run"); const body = normalizeFunctionRunCreate(opts, "creating function run"); return this.client.request(`/functions/v1/${encodeURIComponent(functionName)}/runs`, { method: "POST", headers: { Authorization: `Bearer ${project.service_key}`, "Idempotency-Key": body.idempotency_key, }, body, context: "creating function run", }); } async list(projectId, functionName, opts = {}) { validateFunctionName(functionName, "functionName", "listing function runs"); const project = await requireProjectCredentials(this.client, projectId, "listing function runs"); const search = new URLSearchParams(); if (opts.status !== undefined) { validateFunctionRunStatus(opts.status, "status", "listing function runs"); search.set("status", opts.status); } if (opts.eventType !== undefined) { validateNonEmptyString(opts.eventType, "eventType", "listing function runs"); search.set("event_type", opts.eventType); } if (opts.since !== undefined) search.set("since", isoOrEpochString(opts.since, "since", "listing function runs")); if (opts.until !== undefined) search.set("until", isoOrEpochString(opts.until, "until", "listing function runs")); if (opts.limit !== undefined) { validatePositiveJsonInteger(opts.limit, "limit", "listing function runs", { max: 100 }); search.set("limit", String(opts.limit)); } if (opts.cursor !== undefined) { validateNonEmptyString(opts.cursor, "cursor", "listing function runs"); search.set("cursor", opts.cursor); } const qs = search.toString(); return this.client.request(`/functions/v1/${encodeURIComponent(functionName)}/runs${qs ? `?${qs}` : ""}`, { headers: { Authorization: `Bearer ${project.service_key}` }, context: "listing function runs", }); } async get(projectId, runId) { const project = await requireProjectCredentials(this.client, projectId, "fetching function run"); validateFunctionRunId(runId, "runId", "fetching function run"); return this.client.request(`/functions/v1/runs/${encodeURIComponent(runId)}`, { headers: { Authorization: `Bearer ${project.service_key}` }, context: "fetching function run", }); } async logs(projectId, runId, opts = {}) { const project = await requireProjectCredentials(this.client, projectId, "fetching function run logs"); validateFunctionRunId(runId, "runId", "fetching function run logs"); const tail = opts.tail ?? 50; validatePositiveJsonInteger(tail, "tail", "fetching function run logs", { max: FUNCTION_LOG_TAIL_MAX }); const search = new URLSearchParams({ tail: String(tail) }); if (opts.since !== undefined) search.set("since", isoOrEpochString(opts.since, "since", "fetching function run logs")); const wire = await this.client.request(`/functions/v1/runs/${encodeURIComponent(runId)}/logs?${search.toString()}`, { headers: { Authorization: `Bearer ${project.service_key}` }, context: "fetching function run logs", }); return { logs: applyFunctionLogOrigin(wire?.logs, "all").logs, origin: "all" }; } async cancel(projectId, runId) { const project = await requireProjectCredentials(this.client, projectId, "cancelling function run"); validateFunctionRunId(runId, "runId", "cancelling function run"); return this.client.request(`/functions/v1/runs/${encodeURIComponent(runId)}/cancel`, { method: "POST", headers: { Authorization: `Bearer ${project.service_key}` }, context: "cancelling function run", }); } async redrive(projectId, runId, opts = {}) { const project = await requireProjectCredentials(this.client, projectId, "redriving function run"); validateFunctionRunId(runId, "runId", "redriving function run"); const body = {}; if (opts.retry !== undefined) body.retry = normalizeRetry(opts.retry); return this.client.request(`/functions/v1/runs/${encodeURIComponent(runId)}/redrive`, { method: "POST", headers: { Authorization: `Bearer ${project.service_key}` }, body, context: "redriving function run", }); } async wait(projectId, runId, opts = {}) { const deadline = Date.now() + (opts.timeoutMs ?? 300_000); const intervalMs = opts.intervalMs ?? 1000; const throwOnFailure = opts.throwOnFailure ?? true; for (;;) { const run = await this.get(projectId, runId); if (run.terminal) { if (throwOnFailure && run.status !== "succeeded") { throw new FunctionRunTerminalError(run); } return run; } if (Date.now() >= deadline) { throw new LocalError(`Timed out waiting for function run ${runId}`, "waiting for function run"); } await sleep(Math.max(0, intervalMs)); } } } function parseLogSince(since) { const raw = since.trim(); const ms = /^\d+$/.test(raw) ? Number(raw) : ISO_DATE_TIME_RE.test(raw) ? Date.parse(raw) : Number.NaN; if (!Number.isSafeInteger(ms) || ms < 0) { throw new LocalError(`Invalid functions.logs since timestamp: ${since}`, "fetching function logs"); } return ms; } function normalizeFunctionRunCreate(opts, context) { if (!opts || typeof opts !== "object") { throw new LocalError("function run options must be an object", context); } validateNonEmptyString(opts.eventType, "eventType", context); if (typeof opts.idempotencyKey !== "string" || opts.idempotencyKey.trim() === "") { throw new LocalError("idempotencyKey is required for function run creation", context); } const hasDelay = opts.delay !== undefined || opts.delaySeconds !== undefined; if (hasDelay && opts.runAt !== undefined) { throw new LocalError("runAt and delay are mutually exclusive for function run creation", context); } if (opts.delay !== undefined && opts.delaySeconds !== undefined) { throw new LocalError("delay and delaySeconds are mutually exclusive for function run creation", context); } const body = { event_type: opts.eventType, idempotency_key: opts.idempotencyKey, }; if (opts.payload !== undefined) body.payload = validateJsonObject(opts.payload, "payload", context); if (opts.runAt !== undefined) body.run_at = isoFromDateInput(opts.runAt, "runAt", context); if (opts.delay !== undefined) body.delay_seconds = parseDurationSeconds(opts.delay, "delay", context); if (opts.delaySeconds !== undefined) body.delay_seconds = parseDurationSeconds(opts.delaySeconds, "delaySeconds", context); if (opts.expiresAt !== undefined) body.expires_at = isoFromDateInput(opts.expiresAt, "expiresAt", context); if (opts.expiresAfter !== undefined) { const seconds = parseDurationSeconds(opts.expiresAfter, "expiresAfter", context); body.expires_at = new Date(Date.now() + seconds * 1000).toISOString(); } if (body.expires_at !== undefined) { const runAtMs = body.run_at ? Date.parse(String(body.run_at)) : Date.now() + Number(body.delay_seconds ?? 0) * 1000; if (Date.parse(String(body.expires_at)) <= runAtMs) { throw new LocalError("expiresAt/expiresAfter must be after the function run time", context); } } if (opts.retry !== undefined) body.retry = normalizeRetry(opts.retry); return body; } function normalizeRetry(retry) { return Object.fromEntries(Object.entries({ ...retry, ...(retry.maxAttempts !== undefined ? { max_attempts: retry.maxAttempts } : {}), ...(retry.minDelaySeconds !== undefined ? { min_delay_seconds: retry.minDelaySeconds } : {}), ...(retry.maxDelaySeconds !== undefined ? { max_delay_seconds: retry.maxDelaySeconds } : {}), }).filter(([key]) => key !== "maxAttempts" && key !== "minDelaySeconds" && key !== "maxDelaySeconds")); } function validateJsonObject(value, resource, context) { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new LocalError(`${resource} must be a JSON object`, context); } return value; } function validateNonEmptyString(value, resource, context) { if (typeof value !== "string" || value.trim() === "") { throw new LocalError(`${resource} must be a non-empty string`, context); } } function isoFromDateInput(value, resource, context) { const date = value instanceof Date ? value : new Date(value); if (!Number.isFinite(date.getTime())) { throw new LocalError(`${resource} must be an ISO-8601 timestamp`, context); } return date.toISOString(); } function isoOrEpochString(value, resource, context) { const raw = value.trim(); if (/^\d+$/.test(raw)) { const epoch = Number(raw); if (Number.isSafeInteger(epoch) && epoch >= 0) return raw; } if (ISO_DATE_TIME_RE.test(raw) && Number.isFinite(Date.parse(raw))) return raw; throw new LocalError(`${resource} must be an ISO-8601 timestamp or epoch millisecond integer`, context); } function parseDurationSeconds(value, resource, context) { if (typeof value === "number") { if (!Number.isFinite(value) || value < 0) { throw new LocalError(`${resource} must be a non-negative duration`, context); } return Math.ceil(value); } const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(ms|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?)$/i); if (!match) { throw new LocalError(`${resource} must be a duration such as "10m", "1h", or "3d"`, context); } const amount = Number(match[1]); const unit = match[2].toLowerCase(); const multiplier = unit.startsWith("ms") ? 0.001 : unit.startsWith("s") ? 1 : unit.startsWith("m") ? 60 : unit.startsWith("h") ? 3600 : 86400; return Math.ceil(amount * multiplier); } function validateFunctionRunId(value, resource, context) { if (typeof value !== "string" || !/^fnrun_[A-Za-z0-9_-]{4,128}$/.test(value)) { throw new LocalError(`${resource} must match fnrun_<4-128 url-safe chars>`, context); } } function validateFunctionRunStatus(value, resource, context) { const statuses = new Set([ "scheduled", "queued", "running", "retrying", "blocked", "succeeded", "failed", "cancelled", "expired", ]); if (typeof value !== "string" || !statuses.has(value)) { throw new LocalError(`${resource} must be a function run status`, context); } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function validateFunctionConfig(config, resource, context) { if (config === undefined) return; if (!config || typeof config !== "object" || Array.isArray(config)) { throw new LocalError(`${resource} must be an object`, context); } const record = config; validatePositiveJsonInteger(record.timeout, `${resource}.timeout`, context); validatePositiveJsonInteger(record.memory, `${resource}.memory`, context); } function validateFunctionName(value, resource, context) { if (typeof value !== "string" || !FUNCTION_NAME_RE.test(value)) { throw new LocalError(`${resource} must be a lowercase URL-safe function name (1-128 chars, starts with a letter or digit)`, context); } } function validateFunctionCode(value, resource, context) { if (typeof value !== "string" || value.trim() === "") { throw new LocalError(`${resource} must be a non-empty source string`, context); } } function validateFunctionDeps(value, resource, context) { if (value === undefined) return; if (!Array.isArray(value)) { throw new LocalError(`${resource} must be an array of npm package specs`, context); } if (value.length > FUNCTION_DEP_MAX) { throw new LocalError(`${resource} must contain at most ${FUNCTION_DEP_MAX} entries`, context); } for (const [index, dep] of value.entries()) { const entry = `${resource}[${index}]`; if (typeof dep !== "string" || dep.trim() === "") { throw new LocalError(`${entry} must be a non-empty npm package spec`, context); } if (dep !== dep.trim()) { throw new LocalError(`${entry} must not contain leading or trailing whitespace`, context); } if (dep.length > FUNCTION_DEP_SPEC_MAX) { throw new LocalError(`${entry} must be ${FUNCTION_DEP_SPEC_MAX} characters or fewer`, context); } if (/\s/.test(dep)) { throw new LocalError(`${entry} must not contain whitespace`, context); } const packageName = depPackageName(dep); if (DISALLOWED_FUNCTION_DEPS.has(packageName)) { throw new LocalError(`${entry} references unsupported dependency ${packageName}`, context); } } } function depPackageName(spec) { if (spec.startsWith("@")) { const slash = spec.indexOf("/"); if (slash === -1) return spec; const versionAt = spec.indexOf("@", slash); return versionAt === -1 ? spec : spec.slice(0, versionAt); } const versionAt = spec.indexOf("@"); return versionAt === -1 ? spec : spec.slice(0, versionAt); } function validateFunctionSchedule(value, resource, context) { if (value === undefined || value === null) return; if (typeof value !== "string") { throw new LocalError(`${resource} must be a 5-field cron string, null, or undefined`, context); } const parts = value.trim().split(/\s+/); if (value.trim() === "" || parts.length !== 5 || parts.some((part) => part === "")) { throw new LocalError(`${resource} must be a 5-field cron string`, context); } } function validateFunctionLogRequestId(value, resource, context) { if (typeof value !== "string" || !FUNCTION_LOG_REQUEST_ID_RE.test(value)) { throw new LocalError(`${resource} must match req_, fnrun_, or fnatt_ followed by 4-128 url-safe chars`, context); } } function validatePositiveJsonInteger(value, resource, context, opts = {}) { if (value === undefined) return; if (!Number.isSafeInteger(value) || value < 1) { throw new LocalError(`${resource} must be a positive safe JSON integer`, context); } if (opts.max !== undefined && value > opts.max) { throw new LocalError(`${resource} must be <= ${opts.max}`, context); } } //# sourceMappingURL=functions.js.map