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.

239 lines • 10.3 kB
/** * tenant-live-changes — `r.live.subscribe(projectId, { tables }, onEvent)` and * `r.live.changes(projectId, { tables, cursor, wait })`, also project-scoped * as `r.project(id).live.*`. * * The stream is `GET /live/v1` as Server-Sent Events, read through the * injected fetch so it works in Node and browsers alike and can carry the * `apikey` header (a bare `EventSource` cannot). It reconnects on its own with * `Last-Event-ID`, honors the server's `retry:` hint and `Retry-After`, and * surfaces `resync` to the caller — the one event every consumer must handle: * refetch what you care about. The held read is one request. * * Helpers are module-level on purpose: the public surface of the class is * exactly `changes` and `subscribe` (the surface sync test enumerates runtime * methods, and `private` is erased at runtime). */ import { gateSecret } from "../secret-gate.js"; import { LocalError } from "../errors.js"; const DEFAULT_RETRY_MS = 3000; const MAX_RETRY_MS = 30_000; export class Live { client; constructor(client) { this.client = client; } /** `GET /live/v1/changes` — hints since `cursor`, or hold up to `wait` seconds for the first one. */ async changes(projectId, opts) { gateSecret(this.client, "live.changes", projectId, opts); const context = "reading live changes"; const tables = assertTables(opts.tables, context); const headers = await authHeadersFor(this.client, projectId, opts, context); return this.client.request(`/live/v1/changes?${query(projectId, tables, { cursor: opts.cursor, wait: opts.wait === undefined ? undefined : String(opts.wait) })}`, { method: "GET", headers, withAuth: false, context }); } /** * `GET /live/v1` as a reconnecting SSE subscription. `onEvent` receives * `ready`, `change`, `resync`, `reconnect` (server-side lifetime close, the * loop reconnects) and `disconnected` (a retryable failure; the loop backs * off and retries). A non-retryable refusal (403 TABLE_NOT_LIVE, 401, 400) * rejects `done` with the gateway's error envelope, as `request` would. */ subscribe(projectId, opts, onEvent) { gateSecret(this.client, "live.subscribe", projectId, opts, onEvent); const context = "subscribing to live changes"; const tables = assertTables(opts.tables, context); const controller = new AbortController(); if (opts.signal) { if (opts.signal.aborted) controller.abort(); else opts.signal.addEventListener("abort", () => controller.abort(), { once: true }); } const done = runStream(this.client, projectId, tables, opts, onEvent, controller.signal, context); return { close: () => controller.abort(), done, }; } } function query(projectId, tables, extra) { const params = new URLSearchParams({ project_id: projectId, tables: tables.join(",") }); for (const [k, v] of Object.entries(extra)) if (v !== undefined && v !== "") params.set(k, v); return params.toString(); } function assertTables(tables, context) { if (!Array.isArray(tables) || tables.length === 0 || tables.some((t) => typeof t !== "string" || t === "")) { throw new LocalError("live: `tables` must be a non-empty array of live table names", context); } return tables; } async function authHeadersFor(client, projectId, opts, context) { const keys = await client.getProjectCredentials(projectId); if (!keys) { throw new LocalError(`live: no credentials stored for project ${projectId} (run \`run402 projects use ${projectId}\` or pass keys to the credentials provider)`, context); } if (opts.as === "service") return { apikey: keys.service_key }; if (opts.as === "user") return { apikey: keys.anon_key, Authorization: `Bearer ${opts.accessToken}` }; return { apikey: keys.anon_key }; } async function runStream(client, projectId, tables, opts, onEvent, signal, context) { let cursor = opts.cursor; let retryMs = DEFAULT_RETRY_MS; let failures = 0; const headers = await authHeadersFor(client, projectId, opts, context); const path = `/live/v1?${query(projectId, tables, {})}`; while (!signal.aborted) { let res; try { res = await client.fetch(`${client.apiBase}${path}`, { method: "GET", headers: { ...headers, Accept: "text/event-stream", ...(cursor ? { "Last-Event-ID": cursor } : {}), }, signal, }); } catch (err) { if (signal.aborted) return; failures += 1; const wait = Math.min(retryMs * 2 ** Math.min(failures - 1, 4), MAX_RETRY_MS); onEvent({ type: "disconnected", reason: err.message ?? "fetch failed", retry_in_ms: wait }); await sleep(wait, signal); continue; } if (res.status === 429 || res.status >= 500) { failures += 1; const retryAfter = Number(res.headers.get("retry-after")); const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : Math.min(retryMs * 2 ** Math.min(failures - 1, 4), MAX_RETRY_MS); await res.body?.cancel().catch(() => { }); onEvent({ type: "disconnected", reason: `HTTP ${res.status}`, retry_in_ms: wait }); await sleep(wait, signal); continue; } if (!res.ok) { // Let the kernel map the refusal into the canonical error (TABLE_NOT_LIVE, // AUTH_REQUIRED, VALIDATION_FAILED …). It throws; if the race resolved, // fall through and subscribe again. await res.body?.cancel().catch(() => { }); await client.request(path, { method: "GET", headers: { ...headers, Accept: "application/json" }, withAuth: false, context }); continue; } if (!res.body) { onEvent({ type: "disconnected", reason: "empty body", retry_in_ms: retryMs }); await sleep(retryMs, signal); continue; } failures = 0; const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let serverClosed = false; try { for (;;) { const { value, done: streamDone } = await reader.read(); if (streamDone) break; buffer += decoder.decode(value, { stream: true }); let idx; while ((idx = buffer.indexOf("\n\n")) !== -1) { const frame = buffer.slice(0, idx); buffer = buffer.slice(idx + 2); const parsed = parseFrame(frame); if (parsed.retry !== undefined) retryMs = parsed.retry; if (parsed.id !== undefined) cursor = parsed.id; if (parsed.event === "ready") { const data = JSON.parse(parsed.data); cursor = data.cursor; onEvent({ type: "ready", cursor: data.cursor, tables: data.tables }); } else if (parsed.event === "change") { const data = JSON.parse(parsed.data); onEvent({ type: "change", change: { ...data, cursor: parsed.id ?? cursor ?? "" } }); } else if (parsed.event === "resync") { const data = JSON.parse(parsed.data); onEvent({ type: "resync", tables: data.tables, reason: data.reason }); } else if (parsed.event === "reconnect") { const data = JSON.parse(parsed.data); if (data.cursor) cursor = data.cursor; serverClosed = true; onEvent({ type: "reconnect", cursor: cursor ?? null, reason: data.reason }); } } } } catch (err) { if (signal.aborted) return; onEvent({ type: "disconnected", reason: err.message ?? "stream error", retry_in_ms: retryMs }); await sleep(retryMs, signal); continue; } finally { reader.releaseLock?.(); } if (signal.aborted) return; if (!serverClosed) { // The connection dropped without a reconnect event: back off briefly. onEvent({ type: "disconnected", reason: "stream ended", retry_in_ms: retryMs }); await sleep(retryMs, signal); } } } function parseFrame(frame) { let event; let id; let retry; const data = []; for (const line of frame.split("\n")) { if (line === "" || line.startsWith(":")) continue; const sep = line.indexOf(":"); const field = sep === -1 ? line : line.slice(0, sep); let value = sep === -1 ? "" : line.slice(sep + 1); if (value.startsWith(" ")) value = value.slice(1); if (field === "event") event = value; else if (field === "data") data.push(value); else if (field === "id") id = value; else if (field === "retry") { const n = Number(value); if (Number.isFinite(n) && n >= 0) retry = n; } } return { event, data: data.join("\n"), id, retry }; } function sleep(ms, signal) { return new Promise((resolve) => { if (signal.aborted) return resolve(); const t = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, ms); const onAbort = () => { clearTimeout(t); resolve(); }; signal.addEventListener("abort", onAbort, { once: true }); }); } //# sourceMappingURL=live.js.map