From c283d884cfe1deb6d9c00be68ce8bca424ae788c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 03:20:15 +0000 Subject: [PATCH 1/6] fix(agent-proxy): reconnect with events.list backfill so long sessions don't cut off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Anthropic Managed-Agents SSE stream (`/v1/sessions/{id}/events/stream`) is not guaranteed to stay open for the life of a multi-turn session. Inter-turn gaps (e.g. while MCP tools or the next model request run) of ~25s+ are common and can trigger upstream / proxy read timeouts; when that happens, the SDK's `for await` iterator just ends and the proxy silently closed the downstream response, leaving the browser with only the events from the first tool batch. Implement the official 'consolidation' pattern: treat any upstream end-of-stream that isn't a terminal `session.status_*` as a transient drop, fetch what we missed via `sessions.events.list()` (deduped by event.id), reopen the live stream, and keep going — up to a 20 minute wall-clock budget so a wedged session can't pin the function forever. Also switch the wire format from inline-markdown-with-status-comments to NDJSON, so the React side can render in-flight tool activity in a separate status banner from the streaming brief. The existing zero-width heartbeat is replaced by an explicit `{"type":"heartbeat"}` line. Drop the `agent.tool_use` write-tool special case — the agent now streams the brief directly via `agent.message` text blocks. Co-Authored-By: alex --- netlify/functions/agent-proxy.js | 381 +++++++++++++++++++++---------- src/App.jsx | 89 +++++++- 2 files changed, 339 insertions(+), 131 deletions(-) diff --git a/netlify/functions/agent-proxy.js b/netlify/functions/agent-proxy.js index f864b7e..c2b02e3 100644 --- a/netlify/functions/agent-proxy.js +++ b/netlify/functions/agent-proxy.js @@ -6,15 +6,50 @@ import Anthropic from '@anthropic-ai/sdk' * Proxies a request from the React frontend to a Claude Console-defined * Managed Agent via the /v1/sessions endpoints. The agent's model, system * prompt, tools, MCP servers, and skills are configured in the Console and - * referenced here by ID. We open the SSE event stream first, then send a - * `user.message` event, and pipe each `agent.message` text block to the - * client through a ReadableStream so we never hit Netlify's 26-second - * synchronous execution limit. + * referenced here by ID. + * + * Wire protocol (downstream to the browser): newline-delimited JSON + * (`application/x-ndjson`). One JSON object per line; the React side + * parses incrementally as bytes arrive. Object shapes: + * + * {"type":"text","text":"...markdown..."} // append to brief + * {"type":"status","kind":"thinking"} + * {"type":"status","kind":"tool_use","name":"...","label":"..."} + * {"type":"status","kind":"tool_result","ok":true} + * {"type":"status","kind":"tool_result","ok":false,"message":"..."} + * {"type":"status","kind":"session_error","message":"..."} + * {"type":"heartbeat"} // keep-alive only + * {"type":"done"} // session terminated cleanly + * {"type":"error","message":"..."} // unrecoverable + * + * Reconnect strategy: the upstream Anthropic SSE stream + * (`GET /v1/sessions/{id}/events/stream`) is not guaranteed to stay open + * for the full life of a multi-turn session — long inter-turn gaps (e.g. + * while MCP tools or the next model request are running) can trigger + * upstream / proxy read timeouts. When the upstream iterator ends without + * a terminal `session.status_*` event we treat it as a drop, fetch any + * events we missed via `sessions.events.list()` (deduped by event id), + * reopen the live stream, and keep going — up to a wall-clock budget. */ // All Managed Agents endpoints require this beta header. const MANAGED_AGENTS_BETA = 'managed-agents-2026-04-01' +// Downstream keep-alive cadence. The browser's fetch reader will time +// out / appear stuck if no bytes flow for too long; we emit a +// `{"type":"heartbeat"}` line when the upstream is quiet. +const HEARTBEAT_MS = 10_000 + +// Hard ceiling on total wall-clock time the function will keep +// reconnecting upstream on behalf of a single browser request. Past this +// we emit an `error` line and close the stream so the user can retry +// rather than having the request hang indefinitely. +const RECONNECT_BUDGET_MS = 20 * 60 * 1000 // 20 minutes + +// Short backoff between an upstream drop and the next reconnect attempt, +// so a tight error loop can't hammer the API. +const RECONNECT_BACKOFF_MS = 500 + // --------------------------------------------------------------------------- // Anthropic client (reused across warm Lambda invocations) // --------------------------------------------------------------------------- @@ -31,6 +66,42 @@ function getClient() { return _client } +// A `session.status_idle` with `stop_reason: requires_action` means the +// agent is waiting on a client-side response (custom tool result, tool +// confirmation). For this app we don't expose custom tools, so it should +// never fire — but if it ever does we explicitly do NOT treat it as +// terminal and let the loop keep tailing. +function isTerminal(event) { + if (event?.type === 'session.status_terminated') return true + if (event?.type === 'session.status_idle') { + const t = event.stop_reason?.type + return t === 'end_turn' || t === 'retries_exhausted' + } + return false +} + +function describeToolUse(event) { + const name = event.name || 'tool' + const input = event.input || {} + if (name === 'web_search' && input.query) { + return `Searching the web: ${input.query}` + } + if (name === 'web_fetch' && input.url) { + return `Fetching: ${input.url}` + } + if (event.type === 'agent.mcp_tool_use') { + const args = Object.entries(input) + .filter(([k]) => k !== 'limit') + .slice(0, 3) + .map(([k, v]) => + typeof v === 'object' ? `${k}=…` : `${k}=${String(v).slice(0, 40)}`, + ) + .join(', ') + return `${name}${args ? ` (${args})` : ''}` + } + return name +} + // --------------------------------------------------------------------------- // Handler // --------------------------------------------------------------------------- @@ -59,7 +130,6 @@ export default async (req /*, context */) => { return new Response('Invalid station website URL', { status: 400 }) } - // Read env vars at request time (Netlify makes them available per-invocation). const AGENT_ID = process.env.ANTHROPIC_AGENT_ID const ENVIRONMENT_ID = process.env.ANTHROPIC_ENVIRONMENT_ID const VAULT_IDS = (process.env.ANTHROPIC_VAULT_IDS || '') @@ -112,9 +182,9 @@ Please follow your instructions to produce the funding outlook brief.` // ----- Open the event stream BEFORE sending the user message ----- // (Stream-first ensures we don't miss any events the agent emits.) - let upstream + let initialUpstream try { - upstream = await client.beta.sessions.events.stream(session.id) + initialUpstream = await client.beta.sessions.events.stream(session.id) } catch (err) { return new Response( `Failed to open session event stream: ${err.message || err}`, @@ -133,160 +203,227 @@ Please follow your instructions to produce the funding outlook brief.` ], }) } catch (err) { + try { + initialUpstream.controller?.abort?.() + } catch { + /* ignore */ + } return new Response( `Failed to send user message: ${err.message || err}`, { status: 500 }, ) } - // ----- Pipe agent activity to the client ----- - // Long stretches between agent.message events (model "thinking", - // multi-tool batches, etc.) can silence the response stream long enough - // that Netlify's edge proxy drops the connection. To keep bytes flowing - // we (a) forward extra event types as concise status lines and (b) emit - // a periodic heartbeat space when the upstream goes quiet. const encoder = new TextEncoder() const seenEventIds = new Set() - const writtenFiles = new Set() // dedupe writes by path - const HEARTBEAT_MS = 10_000 + const deadline = Date.now() + RECONNECT_BUDGET_MS - const stream = new ReadableStream({ + const body = new ReadableStream({ async start(controller) { let lastSendAt = Date.now() - const send = (s) => { - controller.enqueue(encoder.encode(s)) - lastSendAt = Date.now() + const writeJson = (obj) => { + try { + controller.enqueue(encoder.encode(JSON.stringify(obj) + '\n')) + lastSendAt = Date.now() + } catch { + /* controller closed */ + } } - // Zero-width space — invisible in rendered Markdown but counts as a - // byte on the wire, which is enough to defeat proxy idle-timeouts. + + // Heartbeat to keep the downstream connection alive while we're + // waiting on the next upstream event (e.g. during a long MCP tool + // call or before the next model_request_start). const heartbeat = setInterval(() => { if (Date.now() - lastSendAt >= HEARTBEAT_MS) { - try { - controller.enqueue(encoder.encode('\u200B')) - lastSendAt = Date.now() - } catch { - /* controller may have closed */ - } + writeJson({ type: 'heartbeat' }) } }, HEARTBEAT_MS / 2) - try { - for await (const event of upstream) { - if (event.id && !seenEventIds.has(event.id)) { - seenEventIds.add(event.id) - - switch (event.type) { - case 'agent.message': { - if (Array.isArray(event.content)) { - for (const block of event.content) { - if (block?.type === 'text' && block.text) { - send(block.text + '\n\n') - } - } + const handle = (event) => { + switch (event.type) { + case 'agent.message': { + if (Array.isArray(event.content)) { + for (const block of event.content) { + if (block?.type === 'text' && block.text) { + writeJson({ type: 'text', text: block.text + '\n\n' }) } - break - } - - case 'agent.thinking': { - send(`\n\n_💭 thinking…_\n\n`) - break - } - - case 'agent.tool_use': { - // The write tool carries the actual brief in input.content. - if ( - event.name === 'write' && - typeof event.input?.content === 'string' - ) { - const path = event.input.file_path || event.input.path || '' - if (!writtenFiles.has(path)) { - writtenFiles.add(path) - send('\n\n---\n\n' + event.input.content + '\n\n') - } - } else { - const label = describeToolUse(event) - if (label) send(`\n\n_${label}_\n\n`) - } - break - } - - case 'agent.mcp_tool_use': { - const label = describeToolUse(event) - if (label) send(`\n\n_${label}_\n\n`) - break - } - - case 'agent.tool_result': - case 'agent.mcp_tool_result': { - if (event.is_error) { - const msg = - (Array.isArray(event.content) && - event.content[0]?.text) || - 'tool error' - send(`\n\n_⚠️ tool error: ${String(msg).slice(0, 200)}_\n\n`) - } else { - send(`\n\n_✓ result_\n\n`) - } - break - } - - case 'session.error': { - const msg = event.error?.message || 'unknown session error' - send(`\n\n[session error: ${msg}]`) - break } } + break } - // Terminal-state gate. - if (event.type === 'session.status_terminated') break - if ( - event.type === 'session.status_idle' && - event.stop_reason?.type !== 'requires_action' - ) { + case 'agent.thinking': { + writeJson({ type: 'status', kind: 'thinking' }) + break + } + + case 'agent.tool_use': + case 'agent.mcp_tool_use': { + writeJson({ + type: 'status', + kind: 'tool_use', + name: event.name || 'tool', + label: describeToolUse(event), + }) + break + } + + case 'agent.tool_result': + case 'agent.mcp_tool_result': { + if (event.is_error) { + const msg = + (Array.isArray(event.content) && + event.content[0]?.text) || + 'tool error' + writeJson({ + type: 'status', + kind: 'tool_result', + ok: false, + message: String(msg).slice(0, 300), + }) + } else { + writeJson({ type: 'status', kind: 'tool_result', ok: true }) + } + break + } + + case 'session.error': { + const msg = event.error?.message || 'unknown session error' + writeJson({ + type: 'status', + kind: 'session_error', + message: msg, + }) break } } - clearInterval(heartbeat) - controller.close() + } + + let done = false + let currentStream = initialUpstream + let iteration = 0 + + try { + while (!done) { + iteration++ + + // On reconnect (every iteration after the first), reopen the + // live stream and backfill anything we missed during the gap. + if (iteration > 1) { + if (Date.now() >= deadline) { + writeJson({ + type: 'error', + message: `Stream reconnect budget (${Math.round( + RECONNECT_BUDGET_MS / 60_000, + )} min) exhausted. The session may still be running — try again or check the Console.`, + }) + break + } + + await new Promise((r) => setTimeout(r, RECONNECT_BACKOFF_MS)) + + try { + currentStream = await client.beta.sessions.events.stream( + session.id, + ) + } catch (err) { + writeJson({ + type: 'error', + message: `Failed to reopen event stream: ${err?.message || err}`, + }) + break + } + + // Backfill: pull everything the session has emitted so far + // and dedupe by event.id. Auto-paginated by the SDK. + try { + for await (const event of client.beta.sessions.events.list( + session.id, + { order: 'asc' }, + )) { + if (event.id && !seenEventIds.has(event.id)) { + seenEventIds.add(event.id) + handle(event) + } + // Terminal checks must run even for already-seen events, + // or a terminal event that came in via the backfill gets + // skipped and the loop never exits. + if (isTerminal(event)) { + done = true + break + } + } + } catch (err) { + writeJson({ + type: 'status', + kind: 'session_error', + message: `Backfill failed: ${err?.message || err}`, + }) + // Don't break — fall through and try the live stream. + } + + if (done) break + } + + // Tail the currently-open live stream until it ends, errors, + // or hits a terminal session event. + try { + for await (const event of currentStream) { + if (event.id && !seenEventIds.has(event.id)) { + seenEventIds.add(event.id) + handle(event) + } + if (isTerminal(event)) { + done = true + break + } + } + } catch { + // Treat as transient. If it's actually persistent, the + // reopen + backfill on the next iteration will surface it + // (or burn down the reconnect budget cleanly). + } + } + + if (done) { + writeJson({ type: 'done' }) + } } catch (err) { + writeJson({ + type: 'error', + message: err?.message || String(err), + }) + } finally { clearInterval(heartbeat) - send(`\n\n[stream error: ${err.message || err}]`) - controller.close() + try { + currentStream?.controller?.abort?.() + } catch { + /* ignore */ + } + try { + controller.close() + } catch { + /* ignore */ + } } }, cancel() { - // If the client disconnects, abort the upstream stream. + // Browser disconnected. Best-effort: abort whichever upstream is + // currently open. (`currentStream` lives inside `start()` so we + // only have the initial one in scope here — that's enough to make + // sure we don't keep a socket open after the very first turn.) try { - upstream.controller?.abort?.() + initialUpstream.controller?.abort?.() } catch { /* ignore */ } }, }) - function describeToolUse(event) { - const name = event.name || 'tool' - const input = event.input || {} - if (name === 'web_search' && input.query) return `🔍 Searching: ${input.query}` - if (name === 'web_fetch' && input.url) return `🌐 Fetching: ${input.url}` - if (event.type === 'agent.mcp_tool_use') { - const args = Object.entries(input) - .filter(([k]) => k !== 'limit') - .slice(0, 3) - .map(([k, v]) => - typeof v === 'object' ? `${k}=…` : `${k}=${String(v).slice(0, 40)}`, - ) - .join(', ') - return `🛠️ ${name}${args ? ` (${args})` : ''}` - } - return `🛠️ ${name}` - } - - return new Response(stream, { + return new Response(body, { status: 200, headers: { - 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Type': 'application/x-ndjson; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', 'X-Accel-Buffering': 'no', }, diff --git a/src/App.jsx b/src/App.jsx index f94a020..cb3c7fc 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -20,6 +20,7 @@ export default function App() { const [isThinking, setIsThinking] = useState(false) const [isStreaming, setIsStreaming] = useState(false) const [result, setResult] = useState('') + const [status, setStatus] = useState(null) const [streamError, setStreamError] = useState(null) const resultRef = useRef(null) @@ -47,27 +48,72 @@ export default function App() { return Object.keys(next).length === 0 } + const handleEvent = (msg) => { + switch (msg?.type) { + case 'text': + if (typeof msg.text === 'string' && msg.text) { + setResult((prev) => prev + msg.text) + } + break + case 'status': + setStatus(msg) + break + case 'error': + case 'session_error': + setStreamError(msg.message || 'streaming error') + break + case 'done': + case 'heartbeat': + default: + break + } + } + + // Parse the NDJSON stream from /agent-proxy: one JSON object per line. const readStream = async (response) => { const reader = response.body.getReader() const decoder = new TextDecoder('utf-8') - let firstChunk = true + let buffer = '' + let firstByte = true + + const flushLines = () => { + let nl + while ((nl = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, nl).trim() + buffer = buffer.slice(nl + 1) + if (!line) continue + try { + handleEvent(JSON.parse(line)) + } catch { + // Ignore malformed lines — the heartbeat / done sentinel + // will still tell us when the stream is over. + } + } + } while (true) { const { done, value } = await reader.read() if (done) break - const chunk = decoder.decode(value, { stream: true }) - if (firstChunk) { - // Once data starts flowing, flip 'thinking' off and 'streaming' on. + buffer += decoder.decode(value, { stream: true }) + if (firstByte) { + // Once any bytes arrive, flip 'thinking' off and 'streaming' on. setIsThinking(false) setIsStreaming(true) - firstChunk = false + firstByte = false + } + flushLines() + } + // Flush any trailing buffered bytes (line without final \n). + buffer += decoder.decode() + if (buffer.trim()) { + try { + handleEvent(JSON.parse(buffer.trim())) + } catch { + /* ignore */ } - setResult((prev) => prev + chunk) } - // Flush any remaining buffered bytes - const tail = decoder.decode() - if (tail) setResult((prev) => prev + tail) setIsStreaming(false) + setStatus(null) } const handleSubmit = async (e) => { @@ -75,6 +121,7 @@ export default function App() { if (!validate()) return setResult('') + setStatus(null) setStreamError(null) setIsSubmitting(true) setIsThinking(true) @@ -101,6 +148,7 @@ export default function App() { setIsSubmitting(false) setIsThinking(false) setIsStreaming(false) + setStatus(null) } } @@ -184,6 +232,12 @@ export default function App() { )} + {isStreaming && status && ( + + {renderStatus(status)} + + )} + {streamError && (
{streamError} @@ -228,6 +282,23 @@ function Field({ label, name, type = 'text', value, onChange, error, placeholder ) } +function renderStatus(status) { + if (!status) return null + switch (status.kind) { + case 'thinking': + return 'Thinking…' + case 'tool_use': + return status.label || `Using tool: ${status.name || 'tool'}` + case 'tool_result': + if (status.ok) return 'Tool finished — continuing…' + return `Tool error: ${status.message || 'unknown error'}` + case 'session_error': + return `Session error: ${status.message || 'unknown error'}` + default: + return 'Working…' + } +} + function StatusBanner({ children }) { return (
From d1c5be112e52b4ac5a287e43f1169604c8951420 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 12:40:31 +0000 Subject: [PATCH 2/6] migrate agent-proxy to Netlify Edge Function so long sessions stream end-to-end The reconnect + events.list backfill in c283d88 is correct but never ran: the previous v2 Node Function was killed at ~27 s (well before the 20 min reconnect budget could matter), so streams always died after the first MCP tool batch. Move the proxy to a Netlify Edge Function (Deno runtime) which has no streaming-duration cap as long as we keep writing to the response body. Same reconnect / backfill / dedupe-by-event-id pattern; same NDJSON wire protocol to the browser. Implemented with plain fetch() against the Anthropic REST API (npm packages on Edge are beta) so we have no SDK runtime dependency. Frontend now POSTs to /api/agent-proxy. The Anthropic SDK is removed from the package; @netlify/edge-functions is added for ambient types. Co-Authored-By: alex --- README.md | 37 +- netlify.toml | 4 - .../agent-proxy.ts} | 361 +++- package-lock.json | 1774 ++++++++++++++++- package.json | 2 +- src/App.jsx | 2 +- 6 files changed, 1984 insertions(+), 196 deletions(-) rename netlify/{functions/agent-proxy.js => edge-functions/agent-proxy.ts} (52%) diff --git a/README.md b/README.md index 8a95de1..f62f816 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A React + Vite frontend (Tailwind, Noto Serif) that collects public media station details and streams an Anthropic Managed Agent response back to the -browser through a Netlify Functions v2 proxy. +browser through a Netlify Edge Function proxy. ## Setup @@ -31,17 +31,30 @@ Open http://localhost:8888. ## How streaming works -1. The browser POSTs the form to `/.netlify/functions/agent-proxy`. -2. The Netlify v2 function calls `anthropic.messages.stream(...)` and wraps - the upstream iterator in a `ReadableStream`. Each - `content_block_delta` text chunk is enqueued as plain UTF‑8 bytes. -3. The React app reads `response.body.getReader()` and decodes chunks with - `TextDecoder`, appending them to the result state. -4. A `Thinking` flag stays `true` until the first chunk arrives, then flips - to a streaming state with a pulsing cursor. +1. The browser POSTs the form to `/api/agent-proxy`. +2. A Netlify Edge Function (Deno runtime) creates an Anthropic Managed + Agent session, opens the upstream SSE event stream, and sends the + user message. It then tails the stream and re-opens it (with an + `events.list` backfill, deduped by event id) whenever it drops mid + session, until the session reports a terminal `session.status_*` + event or a 20-minute wall-clock budget is hit. +3. Downstream to the browser the function emits newline-delimited JSON + (`application/x-ndjson`) — `text`, `status`, `heartbeat`, `done`, + `error` — one object per line. +4. The React app reads `response.body.getReader()`, splits on `\n`, and + parses each line. `text` lines append to the brief; `status` lines + drive a separate “what the agent is doing now” banner. +5. A `Thinking` flag stays `true` until the first chunk arrives, then + flips to a streaming state with a pulsing cursor. + +Edge Functions are required here — the previous v2 Node Function ran on +AWS Lambda and got killed at ~27 s, well before any reconnect could +fire. Edge Functions run on Deno Deploy with no streaming-duration cap +as long as the function keeps writing to the response body. ## Files -- [src/App.jsx](src/App.jsx) — form, streaming reader, UI states. -- [netlify/functions/agent-proxy.js](netlify/functions/agent-proxy.js) — - Managed Agent proxy with `ReadableStream`. +- [src/App.jsx](src/App.jsx) — form, NDJSON streaming reader, UI states. +- [netlify/edge-functions/agent-proxy.ts](netlify/edge-functions/agent-proxy.ts) + — Managed Agent proxy with reconnect, backfill, and NDJSON wire + protocol. diff --git a/netlify.toml b/netlify.toml index 97e8591..f8cab62 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,10 +1,6 @@ [build] command = "npm run build" publish = "dist" - functions = "netlify/functions" - -[functions] - node_bundler = "esbuild" [dev] framework = "vite" diff --git a/netlify/functions/agent-proxy.js b/netlify/edge-functions/agent-proxy.ts similarity index 52% rename from netlify/functions/agent-proxy.js rename to netlify/edge-functions/agent-proxy.ts index c2b02e3..58aedf6 100644 --- a/netlify/functions/agent-proxy.js +++ b/netlify/edge-functions/agent-proxy.ts @@ -1,12 +1,20 @@ -import Anthropic from '@anthropic-ai/sdk' +import type { Config, Context } from '@netlify/edge-functions' /** - * Netlify Functions v2 handler. + * Netlify Edge Function (Deno runtime). * * Proxies a request from the React frontend to a Claude Console-defined - * Managed Agent via the /v1/sessions endpoints. The agent's model, system - * prompt, tools, MCP servers, and skills are configured in the Console and - * referenced here by ID. + * Managed Agent via the `/v1/sessions` endpoints. The agent's model, + * system prompt, tools, MCP servers, and skills are configured in the + * Console and referenced here by ID. + * + * This replaces the v2 Node Function at `netlify/functions/agent-proxy.js`. + * The Node Function process was killed at ~27 s by the platform — well + * before any SSE reconnect could fire — so the brief always stopped after + * the first MCP tool batch. Edge Functions run on Deno Deploy with a + * 40 s response-header timeout but no streaming-duration cap as long as + * we keep writing to the body, so the 20-minute reconnect budget below + * is actually reachable. * * Wire protocol (downstream to the browser): newline-delimited JSON * (`application/x-ndjson`). One JSON object per line; the React side @@ -26,14 +34,21 @@ import Anthropic from '@anthropic-ai/sdk' * (`GET /v1/sessions/{id}/events/stream`) is not guaranteed to stay open * for the full life of a multi-turn session — long inter-turn gaps (e.g. * while MCP tools or the next model request are running) can trigger - * upstream / proxy read timeouts. When the upstream iterator ends without + * upstream / proxy read timeouts. When the upstream stream ends without * a terminal `session.status_*` event we treat it as a drop, fetch any - * events we missed via `sessions.events.list()` (deduped by event id), + * events we missed via the events-list endpoint (deduped by event id), * reopen the live stream, and keep going — up to a wall-clock budget. + * + * We deliberately use plain `fetch()` against the Anthropic REST API + * rather than the `@anthropic-ai/sdk` npm package because npm support in + * Netlify Edge Functions is still in beta, and the SSE / list endpoints + * we need are easy to call directly. */ // All Managed Agents endpoints require this beta header. const MANAGED_AGENTS_BETA = 'managed-agents-2026-04-01' +const ANTHROPIC_VERSION = '2023-06-01' +const ANTHROPIC_API_BASE = 'https://api.anthropic.com' // Downstream keep-alive cadence. The browser's fetch reader will time // out / appear stuck if no bytes flow for too long; we emit a @@ -51,19 +66,38 @@ const RECONNECT_BUDGET_MS = 20 * 60 * 1000 // 20 minutes const RECONNECT_BACKOFF_MS = 500 // --------------------------------------------------------------------------- -// Anthropic client (reused across warm Lambda invocations) +// Types (kept loose — Anthropic Managed Agents events are large and we +// only inspect a small subset of fields). // --------------------------------------------------------------------------- -let _client -function getClient() { - if (_client) return _client - if (!process.env.ANTHROPIC_API_KEY) { - throw new Error('Server is missing ANTHROPIC_API_KEY') +type ContentBlock = { type?: string; text?: string } + +type AgentEvent = { + id?: string + type?: string + content?: ContentBlock[] + name?: string + input?: Record + is_error?: boolean + stop_reason?: { type?: string } + error?: { message?: string } +} + +type EventsListPage = { + data?: AgentEvent[] + has_more?: boolean + last_id?: string +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function authHeaders(apiKey: string): Record { + return { + 'x-api-key': apiKey, + 'anthropic-version': ANTHROPIC_VERSION, + 'anthropic-beta': MANAGED_AGENTS_BETA, } - _client = new Anthropic({ - apiKey: process.env.ANTHROPIC_API_KEY, - defaultHeaders: { 'anthropic-beta': MANAGED_AGENTS_BETA }, - }) - return _client } // A `session.status_idle` with `stop_reason: requires_action` means the @@ -71,22 +105,23 @@ function getClient() { // confirmation). For this app we don't expose custom tools, so it should // never fire — but if it ever does we explicitly do NOT treat it as // terminal and let the loop keep tailing. -function isTerminal(event) { - if (event?.type === 'session.status_terminated') return true - if (event?.type === 'session.status_idle') { +function isTerminal(event: AgentEvent | undefined): boolean { + if (!event) return false + if (event.type === 'session.status_terminated') return true + if (event.type === 'session.status_idle') { const t = event.stop_reason?.type return t === 'end_turn' || t === 'retries_exhausted' } return false } -function describeToolUse(event) { +function describeToolUse(event: AgentEvent): string { const name = event.name || 'tool' - const input = event.input || {} - if (name === 'web_search' && input.query) { + const input = (event.input || {}) as Record + if (name === 'web_search' && typeof input.query === 'string') { return `Searching the web: ${input.query}` } - if (name === 'web_fetch' && input.url) { + if (name === 'web_fetch' && typeof input.url === 'string') { return `Fetching: ${input.url}` } if (event.type === 'agent.mcp_tool_use') { @@ -94,7 +129,9 @@ function describeToolUse(event) { .filter(([k]) => k !== 'limit') .slice(0, 3) .map(([k, v]) => - typeof v === 'object' ? `${k}=…` : `${k}=${String(v).slice(0, 40)}`, + typeof v === 'object' && v !== null + ? `${k}=…` + : `${k}=${String(v).slice(0, 40)}`, ) .join(', ') return `${name}${args ? ` (${args})` : ''}` @@ -102,15 +139,112 @@ function describeToolUse(event) { return name } +async function anthropicFetch( + path: string, + apiKey: string, + init: RequestInit = {}, +): Promise { + const headers = { + ...authHeaders(apiKey), + ...(init.headers as Record | undefined), + } + return await fetch(`${ANTHROPIC_API_BASE}${path}`, { ...init, headers }) +} + +// Parse the SSE stream into an async iterable of decoded `AgentEvent` +// objects. Each SSE message is `event: \ndata: \n\n`. +async function* parseSse( + stream: ReadableStream, +): AsyncGenerator { + const reader = stream.getReader() + const decoder = new TextDecoder('utf-8') + let buffer = '' + + while (true) { + const { value, done } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + + // SSE events are separated by a blank line (`\n\n`). Process each + // complete event. + let sep + while ((sep = buffer.indexOf('\n\n')) !== -1) { + const raw = buffer.slice(0, sep) + buffer = buffer.slice(sep + 2) + + let dataPayload = '' + let evType: string | undefined + for (const line of raw.split('\n')) { + if (line.startsWith('event:')) { + evType = line.slice(6).trim() + } else if (line.startsWith('data:')) { + dataPayload += line.slice(5).trim() + } + // Anthropic doesn't multi-line `data:`, but tolerate it just in + // case — multiple `data:` lines are concatenated per the SSE + // spec. + } + if (!dataPayload) continue + try { + const parsed = JSON.parse(dataPayload) as AgentEvent + // `event:` header takes precedence; fall back to the `type` + // field inside `data` (Anthropic sets both). + if (evType && !parsed.type) parsed.type = evType + yield parsed + } catch { + // Malformed event — skip. + } + } + } +} + +// Page through `GET /v1/sessions/{id}/events` to backfill anything we +// missed while disconnected. The endpoint returns `{data, has_more, +// last_id}`; we keep paging while `has_more` is true. +async function* listAllEvents( + sessionId: string, + apiKey: string, +): AsyncGenerator { + let after: string | undefined + for (;;) { + const params = new URLSearchParams({ order: 'asc', limit: '100' }) + if (after) params.set('after_id', after) + const res = await anthropicFetch( + `/v1/sessions/${sessionId}/events?${params.toString()}`, + apiKey, + ) + if (!res.ok) { + throw new Error( + `events.list returned ${res.status} ${res.statusText}`, + ) + } + const page = (await res.json()) as EventsListPage + const items = page.data || [] + for (const ev of items) yield ev + if (!page.has_more) break + const lastId = page.last_id ?? items[items.length - 1]?.id + if (!lastId) break // can't paginate without an anchor + after = lastId + } +} + // --------------------------------------------------------------------------- // Handler // --------------------------------------------------------------------------- -export default async (req /*, context */) => { + +const URL_REGEX = + /^(https?:\/\/)?([\w-]+\.)+[\w-]{2,}(\/[\w\-._~:/?#[\]@!$&'()*+,;=%]*)?$/i + +export default async (req: Request, _context: Context) => { if (req.method !== 'POST') { return new Response('Method Not Allowed', { status: 405 }) } - let payload + let payload: { + stationName?: string + stationLocation?: string + stationWebsite?: string + } try { payload = await req.json() } catch { @@ -123,21 +257,22 @@ export default async (req /*, context */) => { stationWebsite = '', } = payload || {} - // Server-side URL validation (mirrors the client-side regex). - const URL_REGEX = - /^(https?:\/\/)?([\w-]+\.)+[\w-]{2,}(\/[\w\-._~:/?#[\]@!$&'()*+,;=%]*)?$/i if (!URL_REGEX.test(stationWebsite)) { return new Response('Invalid station website URL', { status: 400 }) } - const AGENT_ID = process.env.ANTHROPIC_AGENT_ID - const ENVIRONMENT_ID = process.env.ANTHROPIC_ENVIRONMENT_ID - const VAULT_IDS = (process.env.ANTHROPIC_VAULT_IDS || '') + const apiKey = Netlify.env.get('ANTHROPIC_API_KEY') + const AGENT_ID = Netlify.env.get('ANTHROPIC_AGENT_ID') + const ENVIRONMENT_ID = Netlify.env.get('ANTHROPIC_ENVIRONMENT_ID') + const VAULT_IDS = (Netlify.env.get('ANTHROPIC_VAULT_IDS') || '') .split(',') .map((s) => s.trim()) .filter(Boolean) - const missing = [] + if (!apiKey) { + return new Response('Server is missing ANTHROPIC_API_KEY', { status: 500 }) + } + const missing: string[] = [] if (!AGENT_ID) missing.push('ANTHROPIC_AGENT_ID') if (!ENVIRONMENT_ID) missing.push('ANTHROPIC_ENVIRONMENT_ID') if (missing.length) { @@ -146,16 +281,6 @@ export default async (req /*, context */) => { }) } - let client - try { - client = getClient() - } catch (err) { - return new Response( - `Failed to initialize agent: ${err.message || err}`, - { status: 500 }, - ) - } - const userMessage = `Here is a new public media station intake: - Station Name: ${stationName} @@ -165,63 +290,106 @@ export default async (req /*, context */) => { Please follow your instructions to produce the funding outlook brief.` // ----- Create the session ----- - let session + const createBody: Record = { + agent: AGENT_ID, + environment_id: ENVIRONMENT_ID, + } + if (VAULT_IDS.length) createBody.vault_ids = VAULT_IDS + + let sessionId: string try { - const sessionParams = { - agent: AGENT_ID, - environment_id: ENVIRONMENT_ID, + const createRes = await anthropicFetch('/v1/sessions', apiKey, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(createBody), + }) + if (!createRes.ok) { + const text = await createRes.text() + return new Response( + `Failed to create session: ${createRes.status} ${text}`, + { status: 500 }, + ) } - if (VAULT_IDS.length) sessionParams.vault_ids = VAULT_IDS - session = await client.beta.sessions.create(sessionParams) + const created = (await createRes.json()) as { id?: string } + if (!created.id) { + return new Response('Failed to create session: no id returned', { + status: 500, + }) + } + sessionId = created.id } catch (err) { return new Response( - `Failed to create session: ${err.message || err}`, + `Failed to create session: ${(err as Error)?.message || err}`, { status: 500 }, ) } // ----- Open the event stream BEFORE sending the user message ----- // (Stream-first ensures we don't miss any events the agent emits.) - let initialUpstream + const openStream = async (): Promise> => { + const res = await anthropicFetch( + `/v1/sessions/${sessionId}/events/stream`, + apiKey, + { method: 'GET', headers: { accept: 'text/event-stream' } }, + ) + if (!res.ok || !res.body) { + throw new Error( + `events.stream returned ${res.status} ${res.statusText}`, + ) + } + return res.body + } + + let upstream: ReadableStream try { - initialUpstream = await client.beta.sessions.events.stream(session.id) + upstream = await openStream() } catch (err) { return new Response( - `Failed to open session event stream: ${err.message || err}`, + `Failed to open session event stream: ${(err as Error)?.message || err}`, { status: 500 }, ) } // ----- Send the kickoff user.message event ----- try { - await client.beta.sessions.events.send(session.id, { - events: [ - { - type: 'user.message', - content: [{ type: 'text', text: userMessage }], - }, - ], - }) - } catch (err) { - try { - initialUpstream.controller?.abort?.() - } catch { - /* ignore */ + const sendRes = await anthropicFetch( + `/v1/sessions/${sessionId}/events`, + apiKey, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + events: [ + { + type: 'user.message', + content: [{ type: 'text', text: userMessage }], + }, + ], + }), + }, + ) + if (!sendRes.ok) { + const text = await sendRes.text() + return new Response( + `Failed to send user message: ${sendRes.status} ${text}`, + { status: 500 }, + ) } + } catch (err) { return new Response( - `Failed to send user message: ${err.message || err}`, + `Failed to send user message: ${(err as Error)?.message || err}`, { status: 500 }, ) } const encoder = new TextEncoder() - const seenEventIds = new Set() + const seenEventIds = new Set() const deadline = Date.now() + RECONNECT_BUDGET_MS - const body = new ReadableStream({ + const body = new ReadableStream({ async start(controller) { let lastSendAt = Date.now() - const writeJson = (obj) => { + const writeJson = (obj: unknown) => { try { controller.enqueue(encoder.encode(JSON.stringify(obj) + '\n')) lastSendAt = Date.now() @@ -230,16 +398,13 @@ Please follow your instructions to produce the funding outlook brief.` } } - // Heartbeat to keep the downstream connection alive while we're - // waiting on the next upstream event (e.g. during a long MCP tool - // call or before the next model_request_start). const heartbeat = setInterval(() => { if (Date.now() - lastSendAt >= HEARTBEAT_MS) { writeJson({ type: 'heartbeat' }) } }, HEARTBEAT_MS / 2) - const handle = (event) => { + const handle = (event: AgentEvent) => { switch (event.type) { case 'agent.message': { if (Array.isArray(event.content)) { @@ -272,8 +437,7 @@ Please follow your instructions to produce the funding outlook brief.` case 'agent.mcp_tool_result': { if (event.is_error) { const msg = - (Array.isArray(event.content) && - event.content[0]?.text) || + (Array.isArray(event.content) && event.content[0]?.text) || 'tool error' writeJson({ type: 'status', @@ -300,7 +464,7 @@ Please follow your instructions to produce the funding outlook brief.` } let done = false - let currentStream = initialUpstream + let currentStream: ReadableStream = upstream let iteration = 0 try { @@ -323,24 +487,19 @@ Please follow your instructions to produce the funding outlook brief.` await new Promise((r) => setTimeout(r, RECONNECT_BACKOFF_MS)) try { - currentStream = await client.beta.sessions.events.stream( - session.id, - ) + currentStream = await openStream() } catch (err) { writeJson({ type: 'error', - message: `Failed to reopen event stream: ${err?.message || err}`, + message: `Failed to reopen event stream: ${(err as Error)?.message || err}`, }) break } // Backfill: pull everything the session has emitted so far - // and dedupe by event.id. Auto-paginated by the SDK. + // and dedupe by event.id. try { - for await (const event of client.beta.sessions.events.list( - session.id, - { order: 'asc' }, - )) { + for await (const event of listAllEvents(sessionId, apiKey)) { if (event.id && !seenEventIds.has(event.id)) { seenEventIds.add(event.id) handle(event) @@ -357,7 +516,7 @@ Please follow your instructions to produce the funding outlook brief.` writeJson({ type: 'status', kind: 'session_error', - message: `Backfill failed: ${err?.message || err}`, + message: `Backfill failed: ${(err as Error)?.message || err}`, }) // Don't break — fall through and try the live stream. } @@ -368,7 +527,7 @@ Please follow your instructions to produce the funding outlook brief.` // Tail the currently-open live stream until it ends, errors, // or hits a terminal session event. try { - for await (const event of currentStream) { + for await (const event of parseSse(currentStream)) { if (event.id && !seenEventIds.has(event.id)) { seenEventIds.add(event.id) handle(event) @@ -391,15 +550,10 @@ Please follow your instructions to produce the funding outlook brief.` } catch (err) { writeJson({ type: 'error', - message: err?.message || String(err), + message: (err as Error)?.message || String(err), }) } finally { clearInterval(heartbeat) - try { - currentStream?.controller?.abort?.() - } catch { - /* ignore */ - } try { controller.close() } catch { @@ -407,17 +561,6 @@ Please follow your instructions to produce the funding outlook brief.` } } }, - cancel() { - // Browser disconnected. Best-effort: abort whichever upstream is - // currently open. (`currentStream` lives inside `start()` so we - // only have the initial one in scope here — that's enough to make - // sure we don't keep a socket open after the very first turn.) - try { - initialUpstream.controller?.abort?.() - } catch { - /* ignore */ - } - }, }) return new Response(body, { @@ -430,6 +573,6 @@ Please follow your instructions to produce the funding outlook brief.` }) } -export const config = { - path: '/.netlify/functions/agent-proxy', +export const config: Config = { + path: '/api/agent-proxy', } diff --git a/package-lock.json b/package-lock.json index 1cd33cc..a4c4ff4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,13 +8,13 @@ "name": "pmc-funder-tool", "version": "0.1.0", "dependencies": { - "@anthropic-ai/sdk": "^0.95.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", "remark-gfm": "^4.0.0" }, "devDependencies": { + "@netlify/edge-functions": "^2.11.1", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "postcss": "^8.4.49", @@ -35,27 +35,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.95.2", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.95.2.tgz", - "integrity": "sha512-Egddwo3sheo1PzUrMkZnH6VkQYwS0h/b/i8vSK8Ta9M45UQipAMeDFH57dYuDAfXMEUUGeKw6CMlremgMZgrSQ==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1", - "standardwebhooks": "^1.0.0" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -290,15 +269,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -347,6 +317,20 @@ "node": ">=6.9.0" } }, + "node_modules/@envelop/instrumentation": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.0.tgz", + "integrity": "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.2.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -636,6 +620,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -653,6 +654,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -670,6 +688,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -738,6 +773,43 @@ "node": ">=12" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/momoa": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@humanwhocodes/momoa/-/momoa-2.0.4.tgz", + "integrity": "sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@import-maps/resolve": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@import-maps/resolve/-/resolve-2.0.0.tgz", + "integrity": "sha512-RwzRTpmrrS6Q1ZhQExwuxJGK1Wqhv4stt+OF2JzS+uawewpwNyU7EJL1WpBex7aDiiGLs4FsXGkfUBdYuX7xiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -788,6 +860,600 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@netlify/dev-utils": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.3.0.tgz", + "integrity": "sha512-vZAL8pMuj3yPQlmHSgyaA/UQFxc6pZgU0LucFJ1+IPWGJtIzBXHRvuR4acpoP72HtyQPUHJ42s7U9GaaSGVNHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/server": "^0.10.0", + "ansis": "^4.1.0", + "chokidar": "^4.0.1", + "decache": "^4.6.2", + "dettle": "^1.0.5", + "dot-prop": "9.0.0", + "empathic": "^2.0.0", + "env-paths": "^3.0.0", + "image-size": "^2.0.2", + "js-image-generator": "^1.0.4", + "parse-gitignore": "^2.0.0", + "semver": "^7.7.2", + "tmp-promise": "^3.0.3", + "uuid": "^11.1.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || >=20" + } + }, + "node_modules/@netlify/dev-utils/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@netlify/dev-utils/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@netlify/dev-utils/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@netlify/edge-bundler": { + "version": "14.10.2", + "resolved": "https://registry.npmjs.org/@netlify/edge-bundler/-/edge-bundler-14.10.2.tgz", + "integrity": "sha512-ZSPFap1mBegChh8KMQ1QOcoGNSisccX6aMC3eEzACQaV9l1YnNwS88FDqphnA5dLEH6AgI87AE2bIJ/1QB98hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@import-maps/resolve": "^2.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "acorn": "^8.15.0", + "ajv": "^8.11.2", + "ajv-errors": "^3.0.0", + "better-ajv-errors": "^1.2.0", + "common-path-prefix": "^3.0.0", + "env-paths": "^3.0.0", + "esbuild": "0.28.0", + "execa": "^8.0.0", + "find-up": "^7.0.0", + "get-port": "^7.0.0", + "node-stream-zip": "^1.15.0", + "p-retry": "^6.0.0", + "p-wait-for": "^5.0.0", + "parse-imports": "^2.2.1", + "path-key": "^4.0.0", + "semver": "^7.3.8", + "tar": "^7.5.12", + "tmp-promise": "^3.0.3", + "urlpattern-polyfill": "8.0.2" + }, + "engines": { + "node": ">=18.14.0" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/@netlify/edge-bundler/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@netlify/edge-functions": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/@netlify/edge-functions/-/edge-functions-2.19.0.tgz", + "integrity": "sha512-OsTi1Ch59MRmr0/8QUqPADbtpcoGapBU7NLScfax1tKi43tTIleZRynIKlY4fx2X7orJc4tzU+zErf1JXOQZ8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@netlify/dev-utils": "4.3.0", + "@netlify/edge-bundler": "^14.5.2", + "@netlify/edge-functions-bootstrap": "2.16.0", + "@netlify/runtime-utils": "2.2.0", + "@netlify/types": "2.1.0", + "get-port": "^7.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@netlify/edge-functions-bootstrap": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@netlify/edge-functions-bootstrap/-/edge-functions-bootstrap-2.16.0.tgz", + "integrity": "sha512-v8QQihSbBHj3JxtJsHoepXALpNumD9M7egHoc8z62FYl5it34dWczkaJoFFopEyhiBVKi4K/n0ZYpdzwfujd6g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@netlify/runtime-utils": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.2.0.tgz", + "integrity": "sha512-K3kWIxIMucibzQsATU2xw2JI+OpS9PZfPW/a+81gmeLC8tLv5YAxTVT0NFY/3imk1kcOJb9g7658jPLqDJaiAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || >=20" + } + }, + "node_modules/@netlify/types": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@netlify/types/-/types-2.1.0.tgz", + "integrity": "sha512-ktUb5d58pt1lQGXO5E9S0F1ljM0g+CoQuGTVII0IxBc0apmPq5RI0o3OWLY7U3ZERRiYTg5UfjiMihBEzuZsuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || >=20" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1183,11 +1849,15 @@ "win32" ] }, - "node_modules/@stablelib/base64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", - "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", - "license": "MIT" + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -1292,6 +1962,13 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -1325,6 +2002,153 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@whatwg-node/disposablestack": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", + "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/fetch": { + "version": "0.10.13", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz", + "integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/node-fetch": "^0.8.3", + "urlpattern-polyfill": "^10.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/fetch/node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@whatwg-node/node-fetch": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.5.tgz", + "integrity": "sha512-4xzCl/zphPqlp9tASLVeUhB5+WJHbuWGYpfoC2q1qh5dw0AqZBW7L27V5roxYWijPxj4sspRAAoOH3d2ztaHUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@whatwg-node/server": { + "version": "0.10.18", + "resolved": "https://registry.npmjs.org/@whatwg-node/server/-/server-0.10.18.tgz", + "integrity": "sha512-kMwLlxUbduttIgaPdSkmEarFpP+mSY8FEm+QWMBRJwxOHWkri+cxd8KZHO9EMrB9vgUuz+5WEaCawaL5wGVoXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@envelop/instrumentation": "^1.0.0", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/fetch": "^0.10.13", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", + "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.0.tgz", + "integrity": "sha512-44mvgtPvohuU/70DdY5Oz2AIrLJ9k6/5x4KmoSvPwO+5Moijo0+N9D0fKbbYZQWP1hNm5CpOf+E01jhxG/r8xg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1413,6 +2237,26 @@ "node": ">=6.0.0" } }, + "node_modules/better-ajv-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/better-ajv-errors/-/better-ajv-errors-1.2.0.tgz", + "integrity": "sha512-UW+IsFycygIo7bclP9h5ugkNH8EjCSgqyFB/yQ4Hqqa1OEYDtb0uFIkYE0b6+CjkgJYVM5UKI/pJPxjYe9EZlA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/code-frame": "^7.16.0", + "@humanwhocodes/momoa": "^2.0.2", + "chalk": "^4.1.2", + "jsonpointer": "^5.0.0", + "leven": "^3.1.0 < 4" + }, + "engines": { + "node": ">= 12.13.0" + }, + "peerDependencies": { + "ajv": "4.11.8 - 8" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1473,6 +2317,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/callsite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", + "integrity": "sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1514,6 +2367,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -1592,6 +2462,36 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -1612,6 +2512,13 @@ "node": ">= 6" } }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true, + "license": "ISC" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1619,6 +2526,31 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1656,6 +2588,16 @@ } } }, + "node_modules/decache": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/decache/-/decache-4.6.2.tgz", + "integrity": "sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsite": "^1.0.0" + } + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -1678,6 +2620,13 @@ "node": ">=6" } }, + "node_modules/dettle": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/dettle/-/dettle-1.0.5.tgz", + "integrity": "sha512-ZVyjhAJ7sCe1PNXEGveObOH9AC8QvMga3HJIghHawtG7mE4K5pW9nz/vDGAr/U7a3LWgdOzEE7ac9MURnyfaTA==", + "dev": true, + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -1705,6 +2654,22 @@ "dev": true, "license": "MIT" }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.353", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", @@ -1712,6 +2677,29 @@ "dev": true, "license": "ISC" }, + "node_modules/empathic": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", + "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -1722,6 +2710,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1793,12 +2788,43 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1829,11 +2855,22 @@ "node": ">= 6" } }, - "node_modules/fast-sha256": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", - "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", - "license": "Unlicense" + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, "node_modules/fastq": { "version": "1.20.1", @@ -1858,6 +2895,24 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -1907,6 +2962,32 @@ "node": ">=6.9.0" } }, + "node_modules/get-port": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", + "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1920,6 +3001,16 @@ "node": ">=10.13.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -1983,6 +3074,39 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/image-size": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", + "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", + "dev": true, + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -2085,6 +3209,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-network-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2107,6 +3244,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -2117,6 +3274,23 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/js-image-generator": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/js-image-generator/-/js-image-generator-1.0.4.tgz", + "integrity": "sha512-ckb7kyVojGAnArouVR+5lBIuwU1fcrn7E/YYSd0FK7oIngAkMmRvHASLro9Zt5SQdWToaI66NybG+OGxPw/HlQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "jpeg-js": "^0.4.2" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2136,18 +3310,12 @@ "node": ">=6" } }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", @@ -2162,6 +3330,26 @@ "node": ">=6" } }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2182,6 +3370,22 @@ "dev": true, "license": "MIT" }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -2494,6 +3698,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3081,6 +4292,42 @@ "node": ">=8.6" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3125,6 +4372,20 @@ "dev": true, "license": "MIT" }, + "node_modules/node-stream-zip": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", + "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/antelle" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -3135,6 +4396,22 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3155,6 +4432,101 @@ "node": ">= 6" } }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-wait-for": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/p-wait-for/-/p-wait-for-5.0.2.tgz", + "integrity": "sha512-lwx6u1CotQYPVju77R+D0vFomni/AqRfqLmqQ8hekklqZ6gAY9rONh7lBQ0uxWMkC2AuX9b2DVAl8To0NyP1JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-timeout": "^6.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -3180,6 +4552,53 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse-gitignore": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-gitignore/-/parse-gitignore-2.0.0.tgz", + "integrity": "sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/parse-imports": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/parse-imports/-/parse-imports-2.2.1.tgz", + "integrity": "sha512-OL/zLggRp8mFhKL0rNORUTR4yBYujK/uU+xZL+/0Rgm2QE4nLO9v8PzEweSJEbMGKmDRjJE4R3IMJlL2di4JeQ==", + "dev": true, + "license": "Apache-2.0 AND MIT", + "dependencies": { + "es-module-lexer": "^1.5.3", + "slashes": "^3.0.12" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3572,6 +4991,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3594,6 +5023,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -3693,6 +5132,49 @@ "semver": "bin/semver.js" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slashes": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/slashes/-/slashes-3.0.12.tgz", + "integrity": "sha512-Q9VME8WyGkc7pJf6QEkj3wE+2CnvZMI+XJhwdTPR8Z/kWQRXi7boAWLDibRPyHRTUTPx5FaU7MsyrjI3yLB4HA==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3713,16 +5195,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/standardwebhooks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", - "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", - "license": "MIT", - "dependencies": { - "@stablelib/base64": "^1.0.0", - "fast-sha256": "^1.3.0" - } - }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -3737,6 +5209,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -3778,6 +5263,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -3829,6 +5327,33 @@ "node": ">=14.0.0" } }, + "node_modules/tar": { + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -3900,6 +5425,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3933,12 +5478,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -3946,6 +5485,39 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -4064,6 +5636,13 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/urlpattern-polyfill": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz", + "integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==", + "dev": true, + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4071,6 +5650,20 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -4159,6 +5752,36 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4166,6 +5789,19 @@ "dev": true, "license": "ISC" }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index aed25de..cbce231 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,13 @@ "preview": "vite preview" }, "dependencies": { - "@anthropic-ai/sdk": "^0.95.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", "remark-gfm": "^4.0.0" }, "devDependencies": { + "@netlify/edge-functions": "^2.11.1", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "postcss": "^8.4.49", diff --git a/src/App.jsx b/src/App.jsx index cb3c7fc..d05329a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -128,7 +128,7 @@ export default function App() { setIsStreaming(false) try { - const response = await fetch('/.netlify/functions/agent-proxy', { + const response = await fetch('/api/agent-proxy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form), From 8e44de52714da3c25760829310c71faac570006a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 13:00:54 +0000 Subject: [PATCH 3/6] feat(agent-proxy): segment streaming responses at ~54s to bypass Netlify Edge's ~60s response cap Netlify Edge Functions empirically cap a single streaming response at ~60s wall-clock, regardless of activity. Confirmed against Netlify's own canonical SSE example (edge-functions-examples.netlify.app/sse) which also cuts at +60.1s. The Anthropic Managed Agent session is fine for several minutes; the cap is per-HTTP-response. This commit splits a long brief across multiple HTTP responses, while keeping the UX of one continuous stream: netlify/edge-functions/agent-proxy.ts - Accept either a new-session payload {stationName, stationLocation, stationWebsite} or a resume payload {sessionId, lastEventId, startedAt}. - On resume, skip session creation + user.message send. Just reopen the live SSE stream and backfill via GET /v1/sessions/{id}/events?after_id=lastEventId (deduped by event.id), then keep tailing. - Single AbortController per segment. A 54s timer aborts the upstream, the for-await loops exit, and we write one final NDJSON line: {type:'segment_end', sessionId, lastEventId, startedAt}. - The 20-min OVERALL_BUDGET_MS is enforced via Date.now() - startedAt so it spans across all segments. - Refactor main loop so every iteration is openStream + backfill + tail. Cleaner than the previous initial-stream + reconnect-only-on- drop pattern. src/App.jsx - readStream() now returns a {sessionId, lastEventId, startedAt} payload if it saw a segment_end, or null if the stream ended cleanly. - handleSubmit() loops, reopening /api/agent-proxy with the resume payload until readStream returns null. Spinner/status state stays on across segments so the UI shows one continuous stream. README.md - Document the segmented-streaming protocol and why it exists. Co-Authored-By: alex --- README.md | 42 +- netlify/edge-functions/agent-proxy.ts | 639 ++++++++++++++++---------- src/App.jsx | 107 +++-- 3 files changed, 491 insertions(+), 297 deletions(-) diff --git a/README.md b/README.md index f62f816..8cfc7e4 100644 --- a/README.md +++ b/README.md @@ -34,27 +34,49 @@ Open http://localhost:8888. 1. The browser POSTs the form to `/api/agent-proxy`. 2. A Netlify Edge Function (Deno runtime) creates an Anthropic Managed Agent session, opens the upstream SSE event stream, and sends the - user message. It then tails the stream and re-opens it (with an + user message. It tails the stream and re-opens it (with an `events.list` backfill, deduped by event id) whenever it drops mid session, until the session reports a terminal `session.status_*` event or a 20-minute wall-clock budget is hit. 3. Downstream to the browser the function emits newline-delimited JSON - (`application/x-ndjson`) — `text`, `status`, `heartbeat`, `done`, - `error` — one object per line. + (`application/x-ndjson`) — `text`, `status`, `heartbeat`, + `segment_end`, `done`, `error` — one object per line. 4. The React app reads `response.body.getReader()`, splits on `\n`, and parses each line. `text` lines append to the brief; `status` lines drive a separate “what the agent is doing now” banner. 5. A `Thinking` flag stays `true` until the first chunk arrives, then flips to a streaming state with a pulsing cursor. -Edge Functions are required here — the previous v2 Node Function ran on -AWS Lambda and got killed at ~27 s, well before any reconnect could -fire. Edge Functions run on Deno Deploy with no streaming-duration cap -as long as the function keeps writing to the response body. +### Why segmented streaming + +Netlify Edge Functions empirically cap a single streaming response at +~60 s wall-clock (Netlify's own canonical SSE example cuts at the same +mark, even though the docs imply "indefinite" streaming). The Anthropic +Managed Agent session itself is fine for several minutes; the limit is +per-HTTP-response. So the proxy segments the stream: + +- Just before ~54 s the function writes one final NDJSON line — + `{"type":"segment_end","sessionId":"sess_…","lastEventId":"evt_…","startedAt":…}` — + and closes the response. +- The React side sees `segment_end`, immediately POSTs to + `/api/agent-proxy` again with `{sessionId, lastEventId, startedAt}` + in the body. +- The function recognises this as a resume payload: it does **not** + create a new session or re-send the user message. It just reopens + the live SSE stream, backfills via + `GET /v1/sessions/{id}/events?after_id=lastEventId` (deduped by + `event.id`), and keeps tailing. + +The 20-minute overall budget is enforced via `Date.now() - startedAt` +so it spans across all segments. The previous v2 Node Function (on AWS +Lambda) was killed at ~27 s, well before any reconnect could fire — +moving to Edge Functions extended that to ~60 s per response, and the +segment loop extends it the rest of the way. ## Files -- [src/App.jsx](src/App.jsx) — form, NDJSON streaming reader, UI states. +- [src/App.jsx](src/App.jsx) — form, NDJSON streaming reader, + segment-end resume loop, UI states. - [netlify/edge-functions/agent-proxy.ts](netlify/edge-functions/agent-proxy.ts) - — Managed Agent proxy with reconnect, backfill, and NDJSON wire - protocol. + — Managed Agent proxy with reconnect, backfill, segmentation, and + NDJSON wire protocol. diff --git a/netlify/edge-functions/agent-proxy.ts b/netlify/edge-functions/agent-proxy.ts index 58aedf6..f191b5b 100644 --- a/netlify/edge-functions/agent-proxy.ts +++ b/netlify/edge-functions/agent-proxy.ts @@ -4,71 +4,80 @@ import type { Config, Context } from '@netlify/edge-functions' * Netlify Edge Function (Deno runtime). * * Proxies a request from the React frontend to a Claude Console-defined - * Managed Agent via the `/v1/sessions` endpoints. The agent's model, - * system prompt, tools, MCP servers, and skills are configured in the - * Console and referenced here by ID. + * Managed Agent. The agent's model, system prompt, tools, MCP servers + * and skills are configured in the Console and referenced here by ID. * - * This replaces the v2 Node Function at `netlify/functions/agent-proxy.js`. - * The Node Function process was killed at ~27 s by the platform — well - * before any SSE reconnect could fire — so the brief always stopped after - * the first MCP tool batch. Edge Functions run on Deno Deploy with a - * 40 s response-header timeout but no streaming-duration cap as long as - * we keep writing to the body, so the 20-minute reconnect budget below - * is actually reachable. + * ## Why this exists * - * Wire protocol (downstream to the browser): newline-delimited JSON - * (`application/x-ndjson`). One JSON object per line; the React side - * parses incrementally as bytes arrive. Object shapes: + * Two layered problems are in scope: * - * {"type":"text","text":"...markdown..."} // append to brief + * 1. The upstream Anthropic SSE stream + * (`GET /v1/sessions/{id}/events/stream`) is not guaranteed to stay + * open for the full life of a multi-turn session; inter-turn gaps + * can trigger upstream / proxy read timeouts. We reconnect on drop + * and backfill missed events via `GET /v1/sessions/{id}/events`, + * deduped by `event.id`. + * + * 2. Netlify Edge Functions cap a single streaming response at ~60 s + * wall-clock (empirically confirmed against Netlify's own canonical + * SSE example). The Anthropic session itself is fine for ~minutes; + * the limit is per-HTTP-response. So we segment the stream: after + * ~54 s the function emits a `segment_end` line with `{sessionId, + * lastEventId, startedAt}` and closes the response. The React side + * immediately reopens against this same endpoint with that resume + * payload, and we pick up from `lastEventId` via backfill. To the + * user this looks like one continuous stream. + * + * ## Wire protocol (downstream to the browser): NDJSON + * + * {"type":"text","text":"...markdown..."} // append to brief * {"type":"status","kind":"thinking"} * {"type":"status","kind":"tool_use","name":"...","label":"..."} * {"type":"status","kind":"tool_result","ok":true} * {"type":"status","kind":"tool_result","ok":false,"message":"..."} * {"type":"status","kind":"session_error","message":"..."} - * {"type":"heartbeat"} // keep-alive only - * {"type":"done"} // session terminated cleanly - * {"type":"error","message":"..."} // unrecoverable + * {"type":"heartbeat"} // keep-alive + * {"type":"segment_end","sessionId":"sess_...", + * "lastEventId":"evt_...","startedAt":1234567890} // reopen me + * {"type":"done"} // terminal + * {"type":"error","message":"..."} // unrecoverable * - * Reconnect strategy: the upstream Anthropic SSE stream - * (`GET /v1/sessions/{id}/events/stream`) is not guaranteed to stay open - * for the full life of a multi-turn session — long inter-turn gaps (e.g. - * while MCP tools or the next model request are running) can trigger - * upstream / proxy read timeouts. When the upstream stream ends without - * a terminal `session.status_*` event we treat it as a drop, fetch any - * events we missed via the events-list endpoint (deduped by event id), - * reopen the live stream, and keep going — up to a wall-clock budget. + * ## Request payloads * - * We deliberately use plain `fetch()` against the Anthropic REST API - * rather than the `@anthropic-ai/sdk` npm package because npm support in - * Netlify Edge Functions is still in beta, and the SSE / list endpoints - * we need are easy to call directly. + * - New session (first call): + * {stationName, stationLocation, stationWebsite} + * - Resume (subsequent calls after a segment_end): + * {sessionId, lastEventId?, startedAt} + * + * The 20-min OVERALL budget spans across segments and is gated by + * `Date.now() - startedAt`. The SEGMENT budget is per-HTTP-response + * and exists only to keep us comfortably under Netlify's 60 s cap. */ -// All Managed Agents endpoints require this beta header. const MANAGED_AGENTS_BETA = 'managed-agents-2026-04-01' const ANTHROPIC_VERSION = '2023-06-01' const ANTHROPIC_API_BASE = 'https://api.anthropic.com' -// Downstream keep-alive cadence. The browser's fetch reader will time -// out / appear stuck if no bytes flow for too long; we emit a -// `{"type":"heartbeat"}` line when the upstream is quiet. +// Downstream keep-alive cadence. The browser's fetch reader appears +// stuck if no bytes flow for too long. const HEARTBEAT_MS = 10_000 -// Hard ceiling on total wall-clock time the function will keep -// reconnecting upstream on behalf of a single browser request. Past this -// we emit an `error` line and close the stream so the user can retry -// rather than having the request hang indefinitely. -const RECONNECT_BUDGET_MS = 20 * 60 * 1000 // 20 minutes +// Close the current response just before Netlify's empirical ~60 s +// streaming cap. Leaves a few seconds for a final `segment_end` write +// + `controller.close()` to flush cleanly. +const SEGMENT_BUDGET_MS = 54_000 -// Short backoff between an upstream drop and the next reconnect attempt, -// so a tight error loop can't hammer the API. +// Total wall-clock allowance across ALL segments for a single user +// brief. Past this we emit a final `error` line and stop reopening. +const OVERALL_BUDGET_MS = 20 * 60 * 1000 // 20 minutes + +// Short backoff between an upstream drop and the next reconnect. const RECONNECT_BACKOFF_MS = 500 // --------------------------------------------------------------------------- -// Types (kept loose — Anthropic Managed Agents events are large and we -// only inspect a small subset of fields). +// Types // --------------------------------------------------------------------------- + type ContentBlock = { type?: string; text?: string } type AgentEvent = { @@ -88,6 +97,18 @@ type EventsListPage = { last_id?: string } +type NewSessionPayload = { + stationName?: string + stationLocation?: string + stationWebsite?: string +} + +type ResumePayload = { + sessionId: string + lastEventId?: string + startedAt?: number +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -100,11 +121,11 @@ function authHeaders(apiKey: string): Record { } } -// A `session.status_idle` with `stop_reason: requires_action` means the -// agent is waiting on a client-side response (custom tool result, tool -// confirmation). For this app we don't expose custom tools, so it should -// never fire — but if it ever does we explicitly do NOT treat it as -// terminal and let the loop keep tailing. +// A `session.status_idle` with `stop_reason: requires_action` means +// the agent is waiting on a client-side response (custom tool result, +// tool confirmation). For this app we don't expose custom tools, so +// it shouldn't fire, but if it ever does we explicitly do NOT treat +// it as terminal — let the loop keep tailing. function isTerminal(event: AgentEvent | undefined): boolean { if (!event) return false if (event.type === 'session.status_terminated') return true @@ -151,8 +172,8 @@ async function anthropicFetch( return await fetch(`${ANTHROPIC_API_BASE}${path}`, { ...init, headers }) } -// Parse the SSE stream into an async iterable of decoded `AgentEvent` -// objects. Each SSE message is `event: \ndata: \n\n`. +// Parse the SSE stream into an async iterable of decoded events. +// Each SSE message is `event: \ndata: \n\n`. async function* parseSse( stream: ReadableStream, ): AsyncGenerator { @@ -160,58 +181,61 @@ async function* parseSse( const decoder = new TextDecoder('utf-8') let buffer = '' - while (true) { - const { value, done } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) + try { + while (true) { + const { value, done } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) - // SSE events are separated by a blank line (`\n\n`). Process each - // complete event. - let sep - while ((sep = buffer.indexOf('\n\n')) !== -1) { - const raw = buffer.slice(0, sep) - buffer = buffer.slice(sep + 2) + let sep + while ((sep = buffer.indexOf('\n\n')) !== -1) { + const raw = buffer.slice(0, sep) + buffer = buffer.slice(sep + 2) - let dataPayload = '' - let evType: string | undefined - for (const line of raw.split('\n')) { - if (line.startsWith('event:')) { - evType = line.slice(6).trim() - } else if (line.startsWith('data:')) { - dataPayload += line.slice(5).trim() + let dataPayload = '' + let evType: string | undefined + for (const line of raw.split('\n')) { + if (line.startsWith('event:')) { + evType = line.slice(6).trim() + } else if (line.startsWith('data:')) { + dataPayload += line.slice(5).trim() + } + } + if (!dataPayload) continue + try { + const parsed = JSON.parse(dataPayload) as AgentEvent + if (evType && !parsed.type) parsed.type = evType + yield parsed + } catch { + // Malformed event — skip. } - // Anthropic doesn't multi-line `data:`, but tolerate it just in - // case — multiple `data:` lines are concatenated per the SSE - // spec. - } - if (!dataPayload) continue - try { - const parsed = JSON.parse(dataPayload) as AgentEvent - // `event:` header takes precedence; fall back to the `type` - // field inside `data` (Anthropic sets both). - if (evType && !parsed.type) parsed.type = evType - yield parsed - } catch { - // Malformed event — skip. } } + } finally { + try { + reader.releaseLock() + } catch { + /* ignore */ + } } } -// Page through `GET /v1/sessions/{id}/events` to backfill anything we -// missed while disconnected. The endpoint returns `{data, has_more, -// last_id}`; we keep paging while `has_more` is true. +// Page through `GET /v1/sessions/{id}/events` to backfill anything +// we missed. Accepts an `after` id so we only fetch new events. async function* listAllEvents( sessionId: string, apiKey: string, + after: string | undefined, + signal?: AbortSignal, ): AsyncGenerator { - let after: string | undefined + let cursor = after for (;;) { const params = new URLSearchParams({ order: 'asc', limit: '100' }) - if (after) params.set('after_id', after) + if (cursor) params.set('after_id', cursor) const res = await anthropicFetch( `/v1/sessions/${sessionId}/events?${params.toString()}`, apiKey, + { signal }, ) if (!res.ok) { throw new Error( @@ -223,11 +247,49 @@ async function* listAllEvents( for (const ev of items) yield ev if (!page.has_more) break const lastId = page.last_id ?? items[items.length - 1]?.id - if (!lastId) break // can't paginate without an anchor - after = lastId + if (!lastId) break + cursor = lastId } } +async function openStream( + sessionId: string, + apiKey: string, + signal: AbortSignal, +): Promise> { + const res = await anthropicFetch( + `/v1/sessions/${sessionId}/events/stream`, + apiKey, + { + method: 'GET', + headers: { accept: 'text/event-stream' }, + signal, + }, + ) + if (!res.ok || !res.body) { + throw new Error( + `events.stream returned ${res.status} ${res.statusText}`, + ) + } + return res.body +} + +// `setTimeout` that resolves early on abort. +function abortableSleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const t = setTimeout(resolve, ms) + const onAbort = () => { + clearTimeout(t) + resolve() + } + if (signal.aborted) { + onAbort() + return + } + signal.addEventListener('abort', onAbort, { once: true }) + }) +} + // --------------------------------------------------------------------------- // Handler // --------------------------------------------------------------------------- @@ -240,48 +302,74 @@ export default async (req: Request, _context: Context) => { return new Response('Method Not Allowed', { status: 405 }) } - let payload: { - stationName?: string - stationLocation?: string - stationWebsite?: string - } + let payload: NewSessionPayload & Partial try { payload = await req.json() } catch { return new Response('Invalid JSON body', { status: 400 }) } - const { - stationName = '', - stationLocation = '', - stationWebsite = '', - } = payload || {} - - if (!URL_REGEX.test(stationWebsite)) { - return new Response('Invalid station website URL', { status: 400 }) - } - const apiKey = Netlify.env.get('ANTHROPIC_API_KEY') - const AGENT_ID = Netlify.env.get('ANTHROPIC_AGENT_ID') - const ENVIRONMENT_ID = Netlify.env.get('ANTHROPIC_ENVIRONMENT_ID') - const VAULT_IDS = (Netlify.env.get('ANTHROPIC_VAULT_IDS') || '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - if (!apiKey) { - return new Response('Server is missing ANTHROPIC_API_KEY', { status: 500 }) - } - const missing: string[] = [] - if (!AGENT_ID) missing.push('ANTHROPIC_AGENT_ID') - if (!ENVIRONMENT_ID) missing.push('ANTHROPIC_ENVIRONMENT_ID') - if (missing.length) { - return new Response(`Server is missing env var(s): ${missing.join(', ')}`, { + return new Response('Server is missing ANTHROPIC_API_KEY', { status: 500, }) } - const userMessage = `Here is a new public media station intake: + const isResume = + typeof payload.sessionId === 'string' && payload.sessionId.length > 0 + + let sessionId: string + let startedAt: number + let initialLastEventId: string | undefined + let userMessage: string | undefined + + if (isResume) { + sessionId = payload.sessionId as string + startedAt = + typeof payload.startedAt === 'number' ? payload.startedAt : Date.now() + initialLastEventId = + typeof payload.lastEventId === 'string' && payload.lastEventId + ? payload.lastEventId + : undefined + + if (Date.now() - startedAt > OVERALL_BUDGET_MS) { + return new Response( + `Session ${sessionId} exceeded the ${Math.round( + OVERALL_BUDGET_MS / 60_000, + )}-minute overall budget; please start a new brief.`, + { status: 408 }, + ) + } + } else { + const { + stationName = '', + stationLocation = '', + stationWebsite = '', + } = payload + + if (!URL_REGEX.test(stationWebsite)) { + return new Response('Invalid station website URL', { status: 400 }) + } + + const AGENT_ID = Netlify.env.get('ANTHROPIC_AGENT_ID') + const ENVIRONMENT_ID = Netlify.env.get('ANTHROPIC_ENVIRONMENT_ID') + const VAULT_IDS = (Netlify.env.get('ANTHROPIC_VAULT_IDS') || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + + const missing: string[] = [] + if (!AGENT_ID) missing.push('ANTHROPIC_AGENT_ID') + if (!ENVIRONMENT_ID) missing.push('ANTHROPIC_ENVIRONMENT_ID') + if (missing.length) { + return new Response( + `Server is missing env var(s): ${missing.join(', ')}`, + { status: 500 }, + ) + } + + userMessage = `Here is a new public media station intake: - Station Name: ${stationName} - Station Location: ${stationLocation} @@ -289,106 +377,49 @@ export default async (req: Request, _context: Context) => { Please follow your instructions to produce the funding outlook brief.` - // ----- Create the session ----- - const createBody: Record = { - agent: AGENT_ID, - environment_id: ENVIRONMENT_ID, - } - if (VAULT_IDS.length) createBody.vault_ids = VAULT_IDS - - let sessionId: string - try { - const createRes = await anthropicFetch('/v1/sessions', apiKey, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(createBody), - }) - if (!createRes.ok) { - const text = await createRes.text() - return new Response( - `Failed to create session: ${createRes.status} ${text}`, - { status: 500 }, - ) + const createBody: Record = { + agent: AGENT_ID, + environment_id: ENVIRONMENT_ID, } - const created = (await createRes.json()) as { id?: string } - if (!created.id) { - return new Response('Failed to create session: no id returned', { - status: 500, - }) - } - sessionId = created.id - } catch (err) { - return new Response( - `Failed to create session: ${(err as Error)?.message || err}`, - { status: 500 }, - ) - } + if (VAULT_IDS.length) createBody.vault_ids = VAULT_IDS - // ----- Open the event stream BEFORE sending the user message ----- - // (Stream-first ensures we don't miss any events the agent emits.) - const openStream = async (): Promise> => { - const res = await anthropicFetch( - `/v1/sessions/${sessionId}/events/stream`, - apiKey, - { method: 'GET', headers: { accept: 'text/event-stream' } }, - ) - if (!res.ok || !res.body) { - throw new Error( - `events.stream returned ${res.status} ${res.statusText}`, - ) - } - return res.body - } - - let upstream: ReadableStream - try { - upstream = await openStream() - } catch (err) { - return new Response( - `Failed to open session event stream: ${(err as Error)?.message || err}`, - { status: 500 }, - ) - } - - // ----- Send the kickoff user.message event ----- - try { - const sendRes = await anthropicFetch( - `/v1/sessions/${sessionId}/events`, - apiKey, - { + try { + const createRes = await anthropicFetch('/v1/sessions', apiKey, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - events: [ - { - type: 'user.message', - content: [{ type: 'text', text: userMessage }], - }, - ], - }), - }, - ) - if (!sendRes.ok) { - const text = await sendRes.text() + body: JSON.stringify(createBody), + }) + if (!createRes.ok) { + const text = await createRes.text() + return new Response( + `Failed to create session: ${createRes.status} ${text}`, + { status: 500 }, + ) + } + const created = (await createRes.json()) as { id?: string } + if (!created.id) { + return new Response('Failed to create session: no id returned', { + status: 500, + }) + } + sessionId = created.id + startedAt = Date.now() + } catch (err) { return new Response( - `Failed to send user message: ${sendRes.status} ${text}`, + `Failed to create session: ${(err as Error)?.message || err}`, { status: 500 }, ) } - } catch (err) { - return new Response( - `Failed to send user message: ${(err as Error)?.message || err}`, - { status: 500 }, - ) } const encoder = new TextEncoder() const seenEventIds = new Set() - const deadline = Date.now() + RECONNECT_BUDGET_MS const body = new ReadableStream({ async start(controller) { let lastSendAt = Date.now() + let lastEventIdSeen = initialLastEventId + const writeJson = (obj: unknown) => { try { controller.enqueue(encoder.encode(JSON.stringify(obj) + '\n')) @@ -404,6 +435,20 @@ Please follow your instructions to produce the funding outlook brief.` } }, HEARTBEAT_MS / 2) + // Single AbortController for this segment. The segment timer + // calls .abort() when we approach Netlify's wall-clock cap; the + // for-await loops exit promptly and we write `segment_end`. + const segmentAbort = new AbortController() + let segmenting = false + const segmentTimer = setTimeout(() => { + segmenting = true + try { + segmentAbort.abort() + } catch { + /* ignore */ + } + }, SEGMENT_BUDGET_MS) + const handle = (event: AgentEvent) => { switch (event.type) { case 'agent.message': { @@ -416,12 +461,10 @@ Please follow your instructions to produce the funding outlook brief.` } break } - case 'agent.thinking': { writeJson({ type: 'status', kind: 'thinking' }) break } - case 'agent.tool_use': case 'agent.mcp_tool_use': { writeJson({ @@ -432,7 +475,6 @@ Please follow your instructions to produce the funding outlook brief.` }) break } - case 'agent.tool_result': case 'agent.mcp_tool_result': { if (event.is_error) { @@ -450,7 +492,6 @@ Please follow your instructions to produce the funding outlook brief.` } break } - case 'session.error': { const msg = event.error?.message || 'unknown session error' writeJson({ @@ -463,74 +504,160 @@ Please follow your instructions to produce the funding outlook brief.` } } + // Brand new session: send the kickoff user.message BEFORE we + // start tailing. (For resume, the user.message was sent on the + // first segment, so we never re-send it.) + if (!isResume && userMessage) { + try { + const sendRes = await anthropicFetch( + `/v1/sessions/${sessionId}/events`, + apiKey, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + events: [ + { + type: 'user.message', + content: [{ type: 'text', text: userMessage }], + }, + ], + }), + }, + ) + if (!sendRes.ok) { + const text = await sendRes.text() + writeJson({ + type: 'error', + message: `Failed to send user message: ${sendRes.status} ${text}`, + }) + clearTimeout(segmentTimer) + clearInterval(heartbeat) + try { + controller.close() + } catch { + /* ignore */ + } + return + } + } catch (err) { + writeJson({ + type: 'error', + message: `Failed to send user message: ${(err as Error)?.message || err}`, + }) + clearTimeout(segmentTimer) + clearInterval(heartbeat) + try { + controller.close() + } catch { + /* ignore */ + } + return + } + } + let done = false - let currentStream: ReadableStream = upstream let iteration = 0 try { - while (!done) { + while (!done && !segmenting) { iteration++ - // On reconnect (every iteration after the first), reopen the - // live stream and backfill anything we missed during the gap. if (iteration > 1) { - if (Date.now() >= deadline) { - writeJson({ - type: 'error', - message: `Stream reconnect budget (${Math.round( - RECONNECT_BUDGET_MS / 60_000, - )} min) exhausted. The session may still be running — try again or check the Console.`, - }) - break - } + await abortableSleep(RECONNECT_BACKOFF_MS, segmentAbort.signal) + if (segmenting) break + } - await new Promise((r) => setTimeout(r, RECONNECT_BACKOFF_MS)) + if (Date.now() - startedAt >= OVERALL_BUDGET_MS) { + writeJson({ + type: 'error', + message: `Stream budget (${Math.round( + OVERALL_BUDGET_MS / 60_000, + )} min) exhausted. The session may still be running — try again or check the Console.`, + }) + break + } - try { - currentStream = await openStream() - } catch (err) { - writeJson({ - type: 'error', - message: `Failed to reopen event stream: ${(err as Error)?.message || err}`, - }) - break - } + // Open the live stream FIRST so any events emitted while + // we're backfilling are still captured on the wire. + let upstream: ReadableStream + try { + upstream = await openStream( + sessionId, + apiKey, + segmentAbort.signal, + ) + } catch (err) { + if (segmenting) break + writeJson({ + type: 'error', + message: `Failed to open event stream: ${(err as Error)?.message || err}`, + }) + break + } - // Backfill: pull everything the session has emitted so far - // and dedupe by event.id. - try { - for await (const event of listAllEvents(sessionId, apiKey)) { - if (event.id && !seenEventIds.has(event.id)) { + // Backfill anything we missed since `lastEventIdSeen`. + // Dedupes happen by event id; terminal events still flip + // `done` even if we've seen the id before. + try { + for await (const event of listAllEvents( + sessionId, + apiKey, + lastEventIdSeen, + segmentAbort.signal, + )) { + if (segmenting) break + if (event.id) { + if (!seenEventIds.has(event.id)) { seenEventIds.add(event.id) + lastEventIdSeen = event.id handle(event) - } - // Terminal checks must run even for already-seen events, - // or a terminal event that came in via the backfill gets - // skipped and the loop never exits. - if (isTerminal(event)) { - done = true - break + } else { + // Already handled in a previous iteration — but + // keep the cursor moving forward. + lastEventIdSeen = event.id } } - } catch (err) { + if (isTerminal(event)) { + done = true + break + } + } + } catch (err) { + if (!segmenting) { writeJson({ type: 'status', kind: 'session_error', message: `Backfill failed: ${(err as Error)?.message || err}`, }) - // Don't break — fall through and try the live stream. + // Fall through and try the live stream. } - - if (done) break } - // Tail the currently-open live stream until it ends, errors, - // or hits a terminal session event. + if (done || segmenting) { + // Drain the live-stream body we opened above so we don't + // leak the connection. + try { + await upstream.cancel() + } catch { + /* ignore */ + } + break + } + + // Tail the live stream until upstream EOFs (drop), the + // segment timer fires, or a terminal event arrives. try { - for await (const event of parseSse(currentStream)) { - if (event.id && !seenEventIds.has(event.id)) { - seenEventIds.add(event.id) - handle(event) + for await (const event of parseSse(upstream)) { + if (segmenting) break + if (event.id) { + if (!seenEventIds.has(event.id)) { + seenEventIds.add(event.id) + lastEventIdSeen = event.id + handle(event) + } else { + lastEventIdSeen = event.id + } } if (isTerminal(event)) { done = true @@ -538,14 +665,21 @@ Please follow your instructions to produce the funding outlook brief.` } } } catch { - // Treat as transient. If it's actually persistent, the - // reopen + backfill on the next iteration will surface it - // (or burn down the reconnect budget cleanly). + // Most commonly: AbortError from segmentAbort. Treated + // as a soft drop — next loop iteration will reopen and + // backfill (or exit cleanly because `segmenting` is set). } } if (done) { writeJson({ type: 'done' }) + } else if (segmenting) { + writeJson({ + type: 'segment_end', + sessionId, + lastEventId: lastEventIdSeen, + startedAt, + }) } } catch (err) { writeJson({ @@ -553,6 +687,7 @@ Please follow your instructions to produce the funding outlook brief.` message: (err as Error)?.message || String(err), }) } finally { + clearTimeout(segmentTimer) clearInterval(heartbeat) try { controller.close() diff --git a/src/App.jsx b/src/App.jsx index d05329a..86e38cc 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -48,33 +48,50 @@ export default function App() { return Object.keys(next).length === 0 } - const handleEvent = (msg) => { - switch (msg?.type) { - case 'text': - if (typeof msg.text === 'string' && msg.text) { - setResult((prev) => prev + msg.text) - } - break - case 'status': - setStatus(msg) - break - case 'error': - case 'session_error': - setStreamError(msg.message || 'streaming error') - break - case 'done': - case 'heartbeat': - default: - break - } - } - - // Parse the NDJSON stream from /agent-proxy: one JSON object per line. + // Parse the NDJSON stream from /api/agent-proxy: one JSON object per + // line. Returns a resume payload `{sessionId, lastEventId, startedAt}` + // if the server emitted a `segment_end` (Netlify Edge caps a single + // streaming response at ~60 s, so longer briefs span multiple + // segments). Returns null when the stream ends cleanly (`done`, + // `error`, or upstream EOF without a segment_end). const readStream = async (response) => { const reader = response.body.getReader() const decoder = new TextDecoder('utf-8') let buffer = '' let firstByte = true + let segmentEnd = null + let fatalError = null + + const handleEvent = (msg) => { + switch (msg?.type) { + case 'text': + if (typeof msg.text === 'string' && msg.text) { + setResult((prev) => prev + msg.text) + } + break + case 'status': + setStatus(msg) + break + case 'segment_end': + if (typeof msg.sessionId === 'string' && msg.sessionId) { + segmentEnd = { + sessionId: msg.sessionId, + lastEventId: msg.lastEventId, + startedAt: msg.startedAt, + } + } + break + case 'error': + case 'session_error': + fatalError = msg.message || 'streaming error' + setStreamError(fatalError) + break + case 'done': + case 'heartbeat': + default: + break + } + } const flushLines = () => { let nl @@ -112,8 +129,10 @@ export default function App() { /* ignore */ } } - setIsStreaming(false) - setStatus(null) + + // Only return a resume payload if we got a clean segment_end AND + // no fatal error fired in the same segment. + return fatalError ? null : segmentEnd } const handleSubmit = async (e) => { @@ -128,19 +147,37 @@ export default function App() { setIsStreaming(false) try { - const response = await fetch('/api/agent-proxy', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(form), - }) + let resume = null + // Loop across segments. Each iteration is one HTTP request; + // the server closes it at ~54 s and the next iteration picks + // up from `lastEventId` so the user sees one continuous brief. + // Hard cap on iterations as a belt-and-braces guard against a + // runaway segment loop. + for (let i = 0; i < 40; i++) { + const body = resume + ? { + sessionId: resume.sessionId, + lastEventId: resume.lastEventId, + startedAt: resume.startedAt, + } + : form - if (!response.ok || !response.body) { - throw new Error( - `Request failed: ${response.status} ${response.statusText}`, - ) + const response = await fetch('/api/agent-proxy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + if (!response.ok || !response.body) { + throw new Error( + `Request failed: ${response.status} ${response.statusText}`, + ) + } + + const next = await readStream(response) + if (!next) break + resume = next } - - await readStream(response) } catch (err) { console.error(err) setStreamError(err.message || 'Something went wrong while streaming.') From df3c8e7d1cad11b2eb0e4605734c7ca72d110597 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 13:08:54 +0000 Subject: [PATCH 4/6] fix(agent-proxy): events.list uses opaque `page` cursor, not `after_id` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Managed Agents events endpoint (`GET /v1/sessions/{id}/events`) does NOT support filtering by event id. It returns an opaque `next_page` cursor on each response and accepts it back via the `page` query parameter; an `after_id=` filter returns 400 Bad Request, which caused every segment resume to fail backfill (visible as `{"type":"status","kind":"session_error","message":"Backfill failed: events.list returned 400 Bad Request"}`). Caught during testing of commit 8e44de5: resuming from a segment boundary always returned 400 and the brief silently lost events from the previous segment. Changes: - `listAllEvents` now paginates via `page` / `next_page` and pulls the full session history (limit=1000). The Anthropic API has no per-id filter, so the caller is responsible for skipping events already delivered. - New `pastInitialId` flag at the top of the body loop: on resume, mute every event up to and including `initialLastEventId` (still adding them to `seenEventIds` so the live stream doesn't re-emit them), then start delivering. On a brand-new session the flag starts true and is a no-op. - Safety fallback: if backfill completes without ever seeing `initialLastEventId` (stale cursor / truncated history), flip the flag to true so we don't get stuck muting forever — the live stream will start delivering whatever shows up next. Co-Authored-By: alex --- netlify/edge-functions/agent-proxy.ts | 63 +++++++++++++++++++-------- 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/netlify/edge-functions/agent-proxy.ts b/netlify/edge-functions/agent-proxy.ts index f191b5b..2974ba6 100644 --- a/netlify/edge-functions/agent-proxy.ts +++ b/netlify/edge-functions/agent-proxy.ts @@ -93,8 +93,7 @@ type AgentEvent = { type EventsListPage = { data?: AgentEvent[] - has_more?: boolean - last_id?: string + next_page?: string } type NewSessionPayload = { @@ -221,17 +220,20 @@ async function* parseSse( } // Page through `GET /v1/sessions/{id}/events` to backfill anything -// we missed. Accepts an `after` id so we only fetch new events. +// we missed. The Managed Agents endpoint uses an opaque `page` cursor +// returned as `next_page` on each response; there is no `after_id` +// filter, so we always pull the full history and the caller is +// responsible for skipping events it has already handled (via +// `seenEventIds` and the resume-payload `lastEventId`). async function* listAllEvents( sessionId: string, apiKey: string, - after: string | undefined, signal?: AbortSignal, ): AsyncGenerator { - let cursor = after + let pageCursor: string | undefined for (;;) { - const params = new URLSearchParams({ order: 'asc', limit: '100' }) - if (cursor) params.set('after_id', cursor) + const params = new URLSearchParams({ order: 'asc', limit: '1000' }) + if (pageCursor) params.set('page', pageCursor) const res = await anthropicFetch( `/v1/sessions/${sessionId}/events?${params.toString()}`, apiKey, @@ -245,10 +247,8 @@ async function* listAllEvents( const page = (await res.json()) as EventsListPage const items = page.data || [] for (const ev of items) yield ev - if (!page.has_more) break - const lastId = page.last_id ?? items[items.length - 1]?.id - if (!lastId) break - cursor = lastId + if (!page.next_page) break + pageCursor = page.next_page } } @@ -419,6 +419,12 @@ Please follow your instructions to produce the funding outlook brief.` async start(controller) { let lastSendAt = Date.now() let lastEventIdSeen = initialLastEventId + // For resumes we replay the full session history via + // `listAllEvents` but mute every event up to and including + // `initialLastEventId` (the user has already seen those). Once + // we've cleared that cursor (or on a brand-new session where it + // is undefined), we start actually delivering events. + let pastInitialId = !initialLastEventId const writeJson = (obj: unknown) => { try { @@ -596,29 +602,40 @@ Please follow your instructions to produce the funding outlook brief.` break } - // Backfill anything we missed since `lastEventIdSeen`. - // Dedupes happen by event id; terminal events still flip - // `done` even if we've seen the id before. + // Backfill anything we missed. The events endpoint does + // NOT support `after_id`, so we always pull the full session + // history and skip everything up to and including + // `initialLastEventId` (the cursor handed to us by the + // previous segment via the resume payload). Within this + // segment, `seenEventIds` dedupes against the live stream. try { for await (const event of listAllEvents( sessionId, apiKey, - lastEventIdSeen, segmentAbort.signal, )) { if (segmenting) break if (event.id) { - if (!seenEventIds.has(event.id)) { + if (!pastInitialId) { + // Already delivered to the user in a previous + // segment — mark seen so the live stream doesn't + // re-emit it, but do NOT call handle(). + seenEventIds.add(event.id) + lastEventIdSeen = event.id + if (event.id === initialLastEventId) { + pastInitialId = true + } + } else if (!seenEventIds.has(event.id)) { seenEventIds.add(event.id) lastEventIdSeen = event.id handle(event) } else { - // Already handled in a previous iteration — but - // keep the cursor moving forward. + // Already handled earlier in this segment — keep + // the cursor moving forward. lastEventIdSeen = event.id } } - if (isTerminal(event)) { + if (isTerminal(event) && pastInitialId) { done = true break } @@ -634,6 +651,14 @@ Please follow your instructions to produce the funding outlook brief.` } } + // If the cursor handed to us by the previous segment didn't + // appear in the session's history (stale / corrupted / + // history truncated), don't get stuck muting events forever + // — start delivering whatever shows up next. + if (!pastInitialId) { + pastInitialId = true + } + if (done || segmenting) { // Drain the live-stream body we opened above so we don't // leak the connection. From 25c4e2442cc881ba0025b188dff8852e01b92fdf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 13:16:44 +0000 Subject: [PATCH 5/6] fix(agent-proxy): conservative segment budget + drop unnecessary upstream.cancel() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes after re-testing on the deploy preview: 1. SEGMENT_BUDGET_MS: 54_000 -> 40_000. Empirically, Netlify Edge Function responses get cut anywhere between ~49 s and ~60 s wall-clock — significantly more variance than the docs imply. With a 54 s budget, our segment_end write + controller.close() sometimes lands AFTER the platform has already killed the response (curl: 'HTTP/2 stream 1 was not closed cleanly before end of the underlying stream'). 40 s gives ~10 s of headroom on the low end of the observed range while still keeping segment overhead reasonable (~1 segment per 40 s of brief). 2. HEARTBEAT_MS: 10_000 -> 5_000. Tighter keep-alive so the connection stays warm even during long model-thinking gaps where the agent isn't emitting events. 3. Drop the 'await upstream.cancel()' between backfill and tail. The upstream fetch's body is already cleaned up by segmentAbort.abort() (or will be by the runtime once we drop our reference). On Deno Edge, awaiting cancel() on an already-aborted body can hang, which prevents segment_end from being written. Co-Authored-By: alex --- netlify/edge-functions/agent-proxy.ts | 31 ++++++++++++++++----------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/netlify/edge-functions/agent-proxy.ts b/netlify/edge-functions/agent-proxy.ts index 2974ba6..7572f32 100644 --- a/netlify/edge-functions/agent-proxy.ts +++ b/netlify/edge-functions/agent-proxy.ts @@ -59,13 +59,19 @@ const ANTHROPIC_VERSION = '2023-06-01' const ANTHROPIC_API_BASE = 'https://api.anthropic.com' // Downstream keep-alive cadence. The browser's fetch reader appears -// stuck if no bytes flow for too long. -const HEARTBEAT_MS = 10_000 +// stuck if no bytes flow for too long. We keep this tighter than the +// segment budget so the connection stays warm even during long +// model-thinking gaps with no agent events. +const HEARTBEAT_MS = 5_000 -// Close the current response just before Netlify's empirical ~60 s -// streaming cap. Leaves a few seconds for a final `segment_end` write -// + `controller.close()` to flush cleanly. -const SEGMENT_BUDGET_MS = 54_000 +// Close the current response well before Netlify's empirical streaming +// cap. We've observed responses cut anywhere between ~49 s and ~60 s, +// so a 54 s budget is too aggressive — sometimes the platform pulls +// the rug out before our final `segment_end` write + `controller.close()` +// can flush. 40 s gives ~10 s of headroom on the low end of the +// observed range while still amortising session setup (~1 segment per +// ~40 s of brief) across a typical 3-5 min brief. +const SEGMENT_BUDGET_MS = 40_000 // Total wall-clock allowance across ALL segments for a single user // brief. Past this we emit a final `error` line and stop reopening. @@ -660,13 +666,12 @@ Please follow your instructions to produce the funding outlook brief.` } if (done || segmenting) { - // Drain the live-stream body we opened above so we don't - // leak the connection. - try { - await upstream.cancel() - } catch { - /* ignore */ - } + // The upstream fetch is already cleaned up by + // `segmentAbort.abort()` (or will be by the runtime once + // we drop our reference). Don't `await upstream.cancel()` + // here — on an already-aborted body that call can hang + // on Deno Edge, which would prevent us from writing the + // final `segment_end` line. break } From ed0069ed895ac22012ec3f57bfea1520ccce1579 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 13:20:19 +0000 Subject: [PATCH 6/6] fix(agent-proxy): cancel upstream reader on abort so segment timer can actually interrupt parseSse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of "segment_end never written, response just runs until Netlify kills it": the Fetch spec only honors AbortSignal for the request/header phase. Once you have `res.body` and start reading from it, aborting that same signal does NOT close the body stream — any in-flight `reader.read()` hangs indefinitely. So when the segment timer fired and called segmentAbort.abort(), the running `for await (const event of parseSse(upstream))` stayed blocked on a `reader.read()` that never resolved. The while loop never exited, segment_end was never written, controller.close() was never called, and Netlify eventually pulled the rug somewhere between ~49 s and ~60 s in. Fix: parseSse now takes an optional AbortSignal and, when it fires, calls `reader.cancel()` directly. The WHATWG ReadableStream spec guarantees that any pending read() resolves with `{done: true}` after cancel, which lets the for-await exit promptly and the body loop re-evaluate `while (!done && !segmenting)` → break → writeJson(segment_end) → controller.close(). Confirmed in the curl trace: heartbeats now stop at the budget mark and segment_end fires before Netlify's cap. Co-Authored-By: alex --- netlify/edge-functions/agent-proxy.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/netlify/edge-functions/agent-proxy.ts b/netlify/edge-functions/agent-proxy.ts index 7572f32..df256ee 100644 --- a/netlify/edge-functions/agent-proxy.ts +++ b/netlify/edge-functions/agent-proxy.ts @@ -179,13 +179,30 @@ async function anthropicFetch( // Parse the SSE stream into an async iterable of decoded events. // Each SSE message is `event: \ndata: \n\n`. +// +// IMPORTANT: per the Fetch spec, an `AbortSignal` passed to `fetch()` +// only aborts the request/header phase — once `res.body` is returned, +// aborting that signal does NOT close the body stream and any +// in-flight `reader.read()` will hang forever. So we attach our own +// listener and `reader.cancel()` directly when the caller's signal +// aborts; the WHATWG spec guarantees pending reads resolve with +// `{done: true}` after cancel. async function* parseSse( stream: ReadableStream, + signal?: AbortSignal, ): AsyncGenerator { const reader = stream.getReader() const decoder = new TextDecoder('utf-8') let buffer = '' + const onAbort = () => { + reader.cancel().catch(() => {}) + } + if (signal) { + if (signal.aborted) onAbort() + else signal.addEventListener('abort', onAbort, { once: true }) + } + try { while (true) { const { value, done } = await reader.read() @@ -217,6 +234,9 @@ async function* parseSse( } } } finally { + if (signal) { + signal.removeEventListener('abort', onAbort) + } try { reader.releaseLock() } catch { @@ -678,7 +698,10 @@ Please follow your instructions to produce the funding outlook brief.` // Tail the live stream until upstream EOFs (drop), the // segment timer fires, or a terminal event arrives. try { - for await (const event of parseSse(upstream)) { + for await (const event of parseSse( + upstream, + segmentAbort.signal, + )) { if (segmenting) break if (event.id) { if (!seenEventIds.has(event.id)) {