@cubos/agent-sdk 0.0.1140428 → 0.0.1140658
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/dist/client.d.ts +15 -2
- package/dist/generated/schema.d.ts +1 -1
- package/dist/http.d.ts +4 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +72 -16
- package/dist/index.js.map +6 -6
- package/dist/sse.d.ts +35 -3
- package/dist/sse.js +55 -8
- package/dist/sse.js.map +3 -3
- package/package.json +1 -1
package/dist/sse.d.ts
CHANGED
|
@@ -17,8 +17,13 @@ export interface SseOptions<T> {
|
|
|
17
17
|
event: string | string[];
|
|
18
18
|
onEvent: (data: T, event: string) => void;
|
|
19
19
|
signal: AbortSignal;
|
|
20
|
-
/** Called before each (re)connect, so the caller can mint a fresh token.
|
|
21
|
-
|
|
20
|
+
/** Called before each (re)connect, so the caller can mint a fresh token.
|
|
21
|
+
* `forceRefresh` is set on the one immediate retry that follows a 401 — a
|
|
22
|
+
* caller that caches a token must bypass the cache when it sees it, or the
|
|
23
|
+
* retry replays the rejected credential and the stream dies for good. */
|
|
24
|
+
headers?: (opts: {
|
|
25
|
+
forceRefresh: boolean;
|
|
26
|
+
}) => Promise<Record<string, string>> | Record<string, string>;
|
|
22
27
|
/** Called once per successful connect, before any frame is delivered. The
|
|
23
28
|
* hook a caller needs to catch up on state the stream won't replay: it fires
|
|
24
29
|
* on every reconnect too, so a gap the backoff swallowed is covered as
|
|
@@ -28,8 +33,29 @@ export interface SseOptions<T> {
|
|
|
28
33
|
* the server actually sent. */
|
|
29
34
|
lastEventId?: string;
|
|
30
35
|
fetchImpl?: FetchLike;
|
|
36
|
+
/**
|
|
37
|
+
* Reconnect when this many milliseconds pass with no bytes at all — not even
|
|
38
|
+
* a keep-alive comment. Defaults to `DEFAULT_IDLE_TIMEOUT_MS`; `0` disables.
|
|
39
|
+
*
|
|
40
|
+
* A half-open socket (a phone leaving the background, a proxy that dropped
|
|
41
|
+
* the connection without telling either end) leaves `read()` waiting forever,
|
|
42
|
+
* with nothing for the reconnect loop below to react to. The server sends a
|
|
43
|
+
* comment every 15s, so silence past twice that is the connection being gone
|
|
44
|
+
* rather than the agent being quiet.
|
|
45
|
+
*/
|
|
46
|
+
idleTimeoutMs?: number;
|
|
47
|
+
/** Every failed attempt, transient or fatal. The loop is already reconnecting
|
|
48
|
+
* when this fires unless the promise also rejects. */
|
|
31
49
|
onError?: (err: unknown) => void;
|
|
32
50
|
}
|
|
51
|
+
/** Twice the server's 15s keep-alive interval: one comment may be lost to a
|
|
52
|
+
* hiccup without the connection being gone, two in a row means it is. */
|
|
53
|
+
export declare const DEFAULT_IDLE_TIMEOUT_MS = 30000;
|
|
54
|
+
/** Raised when the watchdog trips, so a caller logging `onError` can tell a
|
|
55
|
+
* connection that went silent from one that failed outright. */
|
|
56
|
+
export declare class SseIdleTimeout extends Error {
|
|
57
|
+
constructor(url: string, ms: number);
|
|
58
|
+
}
|
|
33
59
|
/**
|
|
34
60
|
* Reads `url` until `signal` aborts, reconnecting through any drop — clean
|
|
35
61
|
* close from the server included, since intermediaries (Cloudflare et al.)
|
|
@@ -38,7 +64,13 @@ export interface SseOptions<T> {
|
|
|
38
64
|
*
|
|
39
65
|
* `Last-Event-ID` is replayed from the last frame the server sent, so the
|
|
40
66
|
* backend's catch-up query fills whatever the gap swallowed. 4xx ends the loop
|
|
41
|
-
* — a
|
|
67
|
+
* — a deleted conversation won't fix itself by retrying — with one exception: a
|
|
68
|
+
* 401 buys one immediate reconnect with `forceRefresh`, because a short-lived
|
|
69
|
+
* token expiring under a stream that outlives it is the expected case, not a
|
|
70
|
+
* misconfiguration. Only after that second rejection does the promise reject.
|
|
71
|
+
*
|
|
72
|
+
* A connection that goes silent past `idleTimeoutMs` is dropped and reopened:
|
|
73
|
+
* see that option for why waiting on `read()` forever is not an option.
|
|
42
74
|
*/
|
|
43
75
|
export declare function readSse<T>(opts: SseOptions<T>): Promise<void>;
|
|
44
76
|
export declare function parseFrame<T>(frame: string, expectedEvent: string | string[]): {
|
package/dist/sse.js
CHANGED
|
@@ -79,6 +79,7 @@ async function raiseForStatus(res, fallback) {
|
|
|
79
79
|
|
|
80
80
|
// src/sse.ts
|
|
81
81
|
var MAX_BACKOFF_MS = 30000;
|
|
82
|
+
var DEFAULT_IDLE_TIMEOUT_MS = 30000;
|
|
82
83
|
|
|
83
84
|
class Fatal extends Error {
|
|
84
85
|
cause;
|
|
@@ -87,25 +88,54 @@ class Fatal extends Error {
|
|
|
87
88
|
this.cause = cause;
|
|
88
89
|
}
|
|
89
90
|
}
|
|
91
|
+
|
|
92
|
+
class SseIdleTimeout extends Error {
|
|
93
|
+
constructor(url, ms) {
|
|
94
|
+
super(`Stream ${url} sent nothing for ${ms}ms — reconnecting.`);
|
|
95
|
+
this.name = "SseIdleTimeout";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
90
98
|
async function readSse(opts) {
|
|
91
99
|
const doFetch = opts.fetchImpl ?? globalThis.fetch;
|
|
100
|
+
const idleMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
92
101
|
let lastEventId = opts.lastEventId;
|
|
93
102
|
let attempt = 0;
|
|
103
|
+
let refreshing = false;
|
|
94
104
|
while (!opts.signal.aborted) {
|
|
95
105
|
let madeProgress = false;
|
|
106
|
+
const connection = new AbortController;
|
|
107
|
+
const unlink = forward(opts.signal, connection);
|
|
108
|
+
let idleTimer;
|
|
109
|
+
let wentIdle = false;
|
|
110
|
+
const armIdle = () => {
|
|
111
|
+
if (idleMs <= 0)
|
|
112
|
+
return;
|
|
113
|
+
clearTimeout(idleTimer);
|
|
114
|
+
idleTimer = setTimeout(() => {
|
|
115
|
+
wentIdle = true;
|
|
116
|
+
connection.abort();
|
|
117
|
+
}, idleMs);
|
|
118
|
+
};
|
|
96
119
|
try {
|
|
97
120
|
const headers = {
|
|
98
|
-
...await opts.headers?.(),
|
|
121
|
+
...await opts.headers?.({ forceRefresh: refreshing }),
|
|
99
122
|
Accept: "text/event-stream"
|
|
100
123
|
};
|
|
101
124
|
if (lastEventId !== undefined)
|
|
102
125
|
headers["Last-Event-ID"] = lastEventId;
|
|
103
|
-
|
|
126
|
+
armIdle();
|
|
127
|
+
const res = await doFetch(opts.url, { headers, signal: connection.signal });
|
|
104
128
|
if (opts.signal.aborted)
|
|
105
129
|
return;
|
|
106
|
-
if (res.ok && res.body)
|
|
130
|
+
if (res.ok && res.body) {
|
|
131
|
+
refreshing = false;
|
|
107
132
|
opts.onOpen?.();
|
|
133
|
+
}
|
|
108
134
|
if (!res.ok || !res.body) {
|
|
135
|
+
if (res.status === 401 && opts.headers && !refreshing) {
|
|
136
|
+
refreshing = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
109
139
|
if (res.status >= 400 && res.status < 500) {
|
|
110
140
|
await raiseForStatus(res, `Could not open ${opts.url}.`).catch((err) => {
|
|
111
141
|
throw new Fatal(err);
|
|
@@ -114,7 +144,7 @@ async function readSse(opts) {
|
|
|
114
144
|
}
|
|
115
145
|
throw new Error(`stream open failed with HTTP ${res.status}`);
|
|
116
146
|
}
|
|
117
|
-
for await (const frame of frames(res.body,
|
|
147
|
+
for await (const frame of frames(res.body, connection.signal, armIdle)) {
|
|
118
148
|
const parsed = parseFrame(frame, opts.event);
|
|
119
149
|
if (parsed === null)
|
|
120
150
|
continue;
|
|
@@ -123,12 +153,18 @@ async function readSse(opts) {
|
|
|
123
153
|
opts.onEvent(parsed.data, parsed.event);
|
|
124
154
|
madeProgress = true;
|
|
125
155
|
}
|
|
156
|
+
if (wentIdle)
|
|
157
|
+
throw new SseIdleTimeout(opts.url, idleMs);
|
|
126
158
|
} catch (err) {
|
|
127
159
|
if (opts.signal.aborted)
|
|
128
160
|
return;
|
|
129
161
|
if (err instanceof Fatal)
|
|
130
162
|
throw err.cause;
|
|
131
|
-
opts.onError?.(err);
|
|
163
|
+
opts.onError?.(wentIdle ? new SseIdleTimeout(opts.url, idleMs) : err);
|
|
164
|
+
} finally {
|
|
165
|
+
clearTimeout(idleTimer);
|
|
166
|
+
unlink();
|
|
167
|
+
connection.abort();
|
|
132
168
|
}
|
|
133
169
|
if (opts.signal.aborted)
|
|
134
170
|
return;
|
|
@@ -139,7 +175,7 @@ async function readSse(opts) {
|
|
|
139
175
|
await sleep(delayMs, opts.signal);
|
|
140
176
|
}
|
|
141
177
|
}
|
|
142
|
-
async function* frames(body, signal) {
|
|
178
|
+
async function* frames(body, signal, onRead) {
|
|
143
179
|
const reader = body.getReader();
|
|
144
180
|
const decoder = new TextDecoder;
|
|
145
181
|
let buffer = "";
|
|
@@ -148,6 +184,7 @@ async function* frames(body, signal) {
|
|
|
148
184
|
const { value, done } = await reader.read();
|
|
149
185
|
if (done)
|
|
150
186
|
return;
|
|
187
|
+
onRead();
|
|
151
188
|
buffer += decoder.decode(value, { stream: true });
|
|
152
189
|
for (;; ) {
|
|
153
190
|
const sep = buffer.indexOf(`
|
|
@@ -200,10 +237,20 @@ function sleep(ms, signal) {
|
|
|
200
237
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
201
238
|
});
|
|
202
239
|
}
|
|
240
|
+
function forward(outer, child) {
|
|
241
|
+
const onAbort = () => child.abort();
|
|
242
|
+
if (outer.aborted)
|
|
243
|
+
child.abort();
|
|
244
|
+
else
|
|
245
|
+
outer.addEventListener("abort", onAbort, { once: true });
|
|
246
|
+
return () => outer.removeEventListener("abort", onAbort);
|
|
247
|
+
}
|
|
203
248
|
export {
|
|
204
249
|
readSse,
|
|
205
|
-
parseFrame
|
|
250
|
+
parseFrame,
|
|
251
|
+
SseIdleTimeout,
|
|
252
|
+
DEFAULT_IDLE_TIMEOUT_MS
|
|
206
253
|
};
|
|
207
254
|
|
|
208
|
-
//# debugId=
|
|
255
|
+
//# debugId=24E2FA6F4632B4F664756E2164756E21
|
|
209
256
|
//# sourceMappingURL=sse.js.map
|
package/dist/sse.js.map
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
"sources": ["../src/errors.ts", "../src/sse.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
5
|
"/** Base for everything this SDK throws, so `catch (e) { if (e instanceof\n * AgentError) }` covers both an HTTP failure and a connection that never got\n * there. */\nexport class AgentError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"AgentError\";\n }\n}\n\n/** The server answered, and said no. */\nexport class AgentApiError extends AgentError {\n readonly status: number;\n /** Server's `x-request-id`, when present. Worth quoting in a bug report. */\n readonly requestId: string | null;\n /** The response body, verbatim and untruncated. Some routes explain the\n * failure in there (an MCP probe's reason, a rejected cron) in a form worth\n * showing the user; `message` only carries a truncated preview. */\n readonly body: string;\n\n constructor(message: string, status: number, requestId: string | null = null, body = \"\") {\n super(message);\n this.name = \"AgentApiError\";\n this.status = status;\n this.requestId = requestId;\n this.body = body;\n }\n\n /** The body parsed as JSON, or `undefined` when it isn't. */\n json<T = unknown>(): T | undefined {\n try {\n return JSON.parse(this.body) as T;\n } catch {\n return undefined;\n }\n }\n\n /** The token was rejected. The client already retried once with a fresh one,\n * so seeing this means `getToken` is handing back something unusable. */\n get isAuthError(): boolean {\n return this.status === 401 || this.status === 403;\n }\n\n get isNotFound(): boolean {\n return this.status === 404;\n }\n\n /** The request clashed with current state — a slug already taken, a\n * conversation that belongs to a channel, a user already blocked. */\n get isConflict(): boolean {\n return this.status === 409;\n }\n\n /** Worth retrying after a pause: the server is overloaded or briefly down. */\n get isRetryable(): boolean {\n return this.status === 429 || this.status >= 500;\n }\n}\n\n/**\n * The client was constructed wrong — a `baseUrl` with no scheme, most often.\n * Thrown at construction, not on the first call, so the stack points at the\n * mistake.\n */\nexport class AgentConfigError extends AgentError {\n constructor(message: string) {\n super(message);\n this.name = \"AgentConfigError\";\n }\n}\n\n/**\n * The request never produced a response: DNS, TLS, a refused connection, CORS,\n * or the timeout below.\n *\n * Without this, `fetch` rejects with a bare `TypeError: fetch failed` and the\n * caller cannot tell a wrong `baseUrl` from a server that said 500 — the two\n * need completely different fixes.\n */\nexport class AgentNetworkError extends AgentError {\n /** Whatever `fetch` (or the abort) threw. */\n readonly cause: unknown;\n /** True when the SDK's own timeout fired rather than the network failing. */\n readonly timedOut: boolean;\n\n constructor(message: string, cause: unknown, timedOut = false) {\n super(message);\n this.name = \"AgentNetworkError\";\n this.cause = cause;\n this.timedOut = timedOut;\n }\n}\n\nconst DEFAULT_MESSAGES: Record<number, string> = {\n 400: \"Invalid request.\",\n 401: \"Not authenticated.\",\n 403: \"Not allowed.\",\n 404: \"Not found.\",\n 409: \"Conflicts with the current state.\",\n 413: \"Payload too large.\",\n 429: \"Rate limited.\",\n};\n\nexport async function raiseForStatus(res: Response, fallback: string): Promise<void> {\n if (res.ok) return;\n let detail = \"\";\n try {\n detail = await res.text();\n } catch {\n detail = \"\";\n }\n const base = DEFAULT_MESSAGES[res.status] ?? fallback;\n throw new AgentApiError(\n detail ? `${base} (${detail.slice(0, 500)})` : base,\n res.status,\n res.headers.get(\"x-request-id\"),\n detail,\n );\n}\n",
|
|
6
|
-
"// A `fetch`-based Server-Sent Events reader. Not `EventSource`: that can't\n// attach an Authorization header, which every stream here requires.\n//\n// Runtime-agnostic on purpose — `fetch`, `ReadableStream` and `AbortController`\n// only, no DOM. Exported as its own entry point (`@cubos/agent-sdk/sse`) so the\n// operator dashboard can reuse it without adopting the rest of the client.\n\nimport { raiseForStatus } from \"./errors.js\";\n\n/** Just the call signature, not `typeof fetch` — that also demands runtime\n * extras (Bun's `preconnect`, undici's statics) a caller's wrapper won't have. */\nexport type FetchLike = (\n input: string,\n init?: {\n method?: string;\n headers?: Record<string, string>;\n body?: string | FormData;\n signal?: AbortSignal;\n },\n) => Promise<Response>;\n\nexport interface SseOptions<T> {\n url: string;\n /**\n * Only frames with one of these `event:` names are delivered; the rest are\n * dropped. Several names on one connection is how a stream keeps two kinds of\n * frame in a single order — split across two connections there is none, and a\n * client cannot tell which came first.\n */\n event: string | string[];\n onEvent: (data: T, event: string) => void;\n signal: AbortSignal;\n /** Called before each (re)connect, so the caller can mint a fresh token. */\n headers?: () => Promise<Record<string, string>> | Record<string, string>;\n /** Called once per successful connect, before any frame is delivered. The\n * hook a caller needs to catch up on state the stream won't replay: it fires\n * on every reconnect too, so a gap the backoff swallowed is covered as\n * well. Not awaited — a slow catch-up must not stall frame delivery. */\n onOpen?: () => void;\n /** Resume cursor for the first connect. Later reconnects use the last `id:`\n * the server actually sent. */\n lastEventId?: string;\n fetchImpl?: FetchLike;\n onError?: (err: unknown) => void;\n}\n\nconst MAX_BACKOFF_MS = 30_000;\n\n/** Marks an error the reconnect loop must not swallow. */\nclass Fatal extends Error {\n override readonly cause: unknown;\n constructor(cause: unknown) {\n super(\"fatal stream error\");\n this.cause = cause;\n }\n}\n\n/**\n * Reads `url` until `signal` aborts, reconnecting through any drop — clean\n * close from the server included, since intermediaries (Cloudflare et al.)\n * close idle SSE connections without warning and the caller would otherwise\n * silently stop receiving.\n *\n * `Last-Event-ID` is replayed from the last frame the server sent, so the\n * backend's catch-up query fills whatever the gap swallowed. 4xx ends the loop\n * — a bad token or a deleted conversation won't fix itself by retrying.\n */\nexport async function readSse<T>(opts: SseOptions<T>): Promise<void> {\n const doFetch = opts.fetchImpl ?? globalThis.fetch;\n let lastEventId = opts.lastEventId;\n let attempt = 0;\n\n while (!opts.signal.aborted) {\n let madeProgress = false;\n try {\n const headers: Record<string, string> = {\n ...(await opts.headers?.()),\n Accept: \"text/event-stream\",\n };\n if (lastEventId !== undefined) headers[\"Last-Event-ID\"] = lastEventId;\n\n const res = await doFetch(opts.url, { headers, signal: opts.signal });\n if (opts.signal.aborted) return;\n if (res.ok && res.body) opts.onOpen?.();\n\n if (!res.ok || !res.body) {\n if (res.status >= 400 && res.status < 500) {\n // Wrapped so the catch below can tell it apart from a transient\n // failure and rethrow instead of reconnecting forever.\n await raiseForStatus(res, `Could not open ${opts.url}.`).catch((err) => {\n throw new Fatal(err);\n });\n return;\n }\n throw new Error(`stream open failed with HTTP ${res.status}`);\n }\n\n for await (const frame of frames(res.body, opts.signal)) {\n const parsed = parseFrame<T>(frame, opts.event);\n if (parsed === null) continue;\n if (parsed.id !== null) lastEventId = parsed.id;\n opts.onEvent(parsed.data, parsed.event);\n madeProgress = true;\n }\n } catch (err) {\n if (opts.signal.aborted) return;\n // A bad token or a deleted conversation won't fix itself by retrying.\n if (err instanceof Fatal) throw err.cause;\n // Everything else is transient (network, 5xx, proxy hangup) — report and\n // back off rather than end the subscription.\n opts.onError?.(err);\n }\n\n if (opts.signal.aborted) return;\n\n // Any delivered frame resets the backoff: a long-lived stream that did real\n // work and then dropped should come back fast, not wait out the ceiling.\n if (madeProgress) attempt = 0;\n const delayMs = Math.min(MAX_BACKOFF_MS, 1_000 * 2 ** attempt);\n attempt += 1;\n await sleep(delayMs, opts.signal);\n }\n}\n\nasync function* frames(\n body: ReadableStream<Uint8Array>,\n signal: AbortSignal,\n): AsyncGenerator<string> {\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n try {\n while (!signal.aborted) {\n const { value, done } = await reader.read();\n if (done) return;\n buffer += decoder.decode(value, { stream: true });\n for (;;) {\n const sep = buffer.indexOf(\"\\n\\n\");\n if (sep === -1) break;\n yield buffer.slice(0, sep);\n buffer = buffer.slice(sep + 2);\n }\n }\n } finally {\n reader.cancel().catch(() => {});\n }\n}\n\nexport function parseFrame<T>(\n frame: string,\n expectedEvent: string | string[],\n): { data: T; id: string | null; event: string } | null {\n let dataLine: string | null = null;\n let id: string | null = null;\n let eventName = \"message\";\n for (const line of frame.split(\"\\n\")) {\n if (line.startsWith(\":\")) continue;\n if (line.startsWith(\"data:\")) dataLine = line.slice(5).trimStart();\n else if (line.startsWith(\"event:\")) eventName = line.slice(6).trim();\n else if (line.startsWith(\"id:\")) id = line.slice(3).trim();\n }\n const wanted =\n typeof expectedEvent === \"string\"\n ? eventName === expectedEvent\n : expectedEvent.includes(eventName);\n if (!wanted || dataLine === null) return null;\n try {\n return { data: JSON.parse(dataLine) as T, id, event: eventName };\n } catch {\n return null;\n }\n}\n\nfunction sleep(ms: number, signal: AbortSignal): Promise<void> {\n return new Promise((resolve) => {\n const onAbort = () => {\n clearTimeout(timer);\n resolve();\n };\n const timer = setTimeout(() => {\n signal.removeEventListener(\"abort\", onAbort);\n resolve();\n }, ms);\n signal.addEventListener(\"abort\", onAbort, { once: true });\n });\n}\n"
|
|
6
|
+
"// A `fetch`-based Server-Sent Events reader. Not `EventSource`: that can't\n// attach an Authorization header, which every stream here requires.\n//\n// Runtime-agnostic on purpose — `fetch`, `ReadableStream` and `AbortController`\n// only, no DOM. Exported as its own entry point (`@cubos/agent-sdk/sse`) so the\n// operator dashboard can reuse it without adopting the rest of the client.\n\nimport { raiseForStatus } from \"./errors.js\";\n\n/** Just the call signature, not `typeof fetch` — that also demands runtime\n * extras (Bun's `preconnect`, undici's statics) a caller's wrapper won't have. */\nexport type FetchLike = (\n input: string,\n init?: {\n method?: string;\n headers?: Record<string, string>;\n body?: string | FormData;\n signal?: AbortSignal;\n },\n) => Promise<Response>;\n\nexport interface SseOptions<T> {\n url: string;\n /**\n * Only frames with one of these `event:` names are delivered; the rest are\n * dropped. Several names on one connection is how a stream keeps two kinds of\n * frame in a single order — split across two connections there is none, and a\n * client cannot tell which came first.\n */\n event: string | string[];\n onEvent: (data: T, event: string) => void;\n signal: AbortSignal;\n /** Called before each (re)connect, so the caller can mint a fresh token.\n * `forceRefresh` is set on the one immediate retry that follows a 401 — a\n * caller that caches a token must bypass the cache when it sees it, or the\n * retry replays the rejected credential and the stream dies for good. */\n headers?: (opts: {\n forceRefresh: boolean;\n }) => Promise<Record<string, string>> | Record<string, string>;\n /** Called once per successful connect, before any frame is delivered. The\n * hook a caller needs to catch up on state the stream won't replay: it fires\n * on every reconnect too, so a gap the backoff swallowed is covered as\n * well. Not awaited — a slow catch-up must not stall frame delivery. */\n onOpen?: () => void;\n /** Resume cursor for the first connect. Later reconnects use the last `id:`\n * the server actually sent. */\n lastEventId?: string;\n fetchImpl?: FetchLike;\n /**\n * Reconnect when this many milliseconds pass with no bytes at all — not even\n * a keep-alive comment. Defaults to `DEFAULT_IDLE_TIMEOUT_MS`; `0` disables.\n *\n * A half-open socket (a phone leaving the background, a proxy that dropped\n * the connection without telling either end) leaves `read()` waiting forever,\n * with nothing for the reconnect loop below to react to. The server sends a\n * comment every 15s, so silence past twice that is the connection being gone\n * rather than the agent being quiet.\n */\n idleTimeoutMs?: number;\n /** Every failed attempt, transient or fatal. The loop is already reconnecting\n * when this fires unless the promise also rejects. */\n onError?: (err: unknown) => void;\n}\n\nconst MAX_BACKOFF_MS = 30_000;\n\n/** Twice the server's 15s keep-alive interval: one comment may be lost to a\n * hiccup without the connection being gone, two in a row means it is. */\nexport const DEFAULT_IDLE_TIMEOUT_MS = 30_000;\n\n/** Marks an error the reconnect loop must not swallow. */\nclass Fatal extends Error {\n override readonly cause: unknown;\n constructor(cause: unknown) {\n super(\"fatal stream error\");\n this.cause = cause;\n }\n}\n\n/** Raised when the watchdog trips, so a caller logging `onError` can tell a\n * connection that went silent from one that failed outright. */\nexport class SseIdleTimeout extends Error {\n constructor(url: string, ms: number) {\n super(`Stream ${url} sent nothing for ${ms}ms — reconnecting.`);\n this.name = \"SseIdleTimeout\";\n }\n}\n\n/**\n * Reads `url` until `signal` aborts, reconnecting through any drop — clean\n * close from the server included, since intermediaries (Cloudflare et al.)\n * close idle SSE connections without warning and the caller would otherwise\n * silently stop receiving.\n *\n * `Last-Event-ID` is replayed from the last frame the server sent, so the\n * backend's catch-up query fills whatever the gap swallowed. 4xx ends the loop\n * — a deleted conversation won't fix itself by retrying — with one exception: a\n * 401 buys one immediate reconnect with `forceRefresh`, because a short-lived\n * token expiring under a stream that outlives it is the expected case, not a\n * misconfiguration. Only after that second rejection does the promise reject.\n *\n * A connection that goes silent past `idleTimeoutMs` is dropped and reopened:\n * see that option for why waiting on `read()` forever is not an option.\n */\nexport async function readSse<T>(opts: SseOptions<T>): Promise<void> {\n const doFetch = opts.fetchImpl ?? globalThis.fetch;\n const idleMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;\n let lastEventId = opts.lastEventId;\n let attempt = 0;\n // Set by a 401 and consumed by the connect that follows it, so the retry is\n // the only attempt that pays for a freshly minted token.\n let refreshing = false;\n\n while (!opts.signal.aborted) {\n let madeProgress = false;\n // Aborts this connection alone — the watchdog must be able to drop a dead\n // socket without ending the subscription the caller's signal owns.\n const connection = new AbortController();\n const unlink = forward(opts.signal, connection);\n let idleTimer: ReturnType<typeof setTimeout> | undefined;\n let wentIdle = false;\n const armIdle = () => {\n if (idleMs <= 0) return;\n clearTimeout(idleTimer);\n idleTimer = setTimeout(() => {\n wentIdle = true;\n connection.abort();\n }, idleMs);\n };\n\n try {\n const headers: Record<string, string> = {\n ...(await opts.headers?.({ forceRefresh: refreshing })),\n Accept: \"text/event-stream\",\n };\n if (lastEventId !== undefined) headers[\"Last-Event-ID\"] = lastEventId;\n\n // Armed before the fetch: a connect that never answers is as dead as one\n // that stops mid-stream, and nothing else bounds it.\n armIdle();\n const res = await doFetch(opts.url, { headers, signal: connection.signal });\n if (opts.signal.aborted) return;\n if (res.ok && res.body) {\n refreshing = false;\n opts.onOpen?.();\n }\n\n if (!res.ok || !res.body) {\n // One immediate retry, no backoff: a token that expired between minting\n // and this connect is the expected case for a stream that outlives its\n // TTL, and `headers` is the only thing that can produce a new one. A\n // credential that is simply wrong takes the fatal path on the retry.\n if (res.status === 401 && opts.headers && !refreshing) {\n refreshing = true;\n continue;\n }\n if (res.status >= 400 && res.status < 500) {\n // Wrapped so the catch below can tell it apart from a transient\n // failure and rethrow instead of reconnecting forever.\n await raiseForStatus(res, `Could not open ${opts.url}.`).catch((err) => {\n throw new Fatal(err);\n });\n return;\n }\n throw new Error(`stream open failed with HTTP ${res.status}`);\n }\n\n for await (const frame of frames(res.body, connection.signal, armIdle)) {\n const parsed = parseFrame<T>(frame, opts.event);\n if (parsed === null) continue;\n if (parsed.id !== null) lastEventId = parsed.id;\n opts.onEvent(parsed.data, parsed.event);\n madeProgress = true;\n }\n if (wentIdle) throw new SseIdleTimeout(opts.url, idleMs);\n } catch (err) {\n if (opts.signal.aborted) return;\n // A deleted conversation, or a credential a refresh didn't fix.\n if (err instanceof Fatal) throw err.cause;\n // Everything else is transient (network, 5xx, proxy hangup, a socket that\n // went silent) — report and back off rather than end the subscription.\n opts.onError?.(wentIdle ? new SseIdleTimeout(opts.url, idleMs) : err);\n } finally {\n clearTimeout(idleTimer);\n unlink();\n // Nothing below this point reads from the body; letting it hang open\n // would leak a connection per reconnect.\n connection.abort();\n }\n\n if (opts.signal.aborted) return;\n\n // Any delivered frame resets the backoff: a long-lived stream that did real\n // work and then dropped should come back fast, not wait out the ceiling.\n if (madeProgress) attempt = 0;\n const delayMs = Math.min(MAX_BACKOFF_MS, 1_000 * 2 ** attempt);\n attempt += 1;\n await sleep(delayMs, opts.signal);\n }\n}\n\n/** `onRead` fires for every chunk the socket delivers, keep-alive comments\n * included — the watchdog is asking whether the connection is alive, which is\n * not the same question as whether a frame the caller wanted came through. */\nasync function* frames(\n body: ReadableStream<Uint8Array>,\n signal: AbortSignal,\n onRead: () => void,\n): AsyncGenerator<string> {\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n try {\n while (!signal.aborted) {\n const { value, done } = await reader.read();\n if (done) return;\n onRead();\n buffer += decoder.decode(value, { stream: true });\n for (;;) {\n const sep = buffer.indexOf(\"\\n\\n\");\n if (sep === -1) break;\n yield buffer.slice(0, sep);\n buffer = buffer.slice(sep + 2);\n }\n }\n } finally {\n reader.cancel().catch(() => {});\n }\n}\n\nexport function parseFrame<T>(\n frame: string,\n expectedEvent: string | string[],\n): { data: T; id: string | null; event: string } | null {\n let dataLine: string | null = null;\n let id: string | null = null;\n let eventName = \"message\";\n for (const line of frame.split(\"\\n\")) {\n if (line.startsWith(\":\")) continue;\n if (line.startsWith(\"data:\")) dataLine = line.slice(5).trimStart();\n else if (line.startsWith(\"event:\")) eventName = line.slice(6).trim();\n else if (line.startsWith(\"id:\")) id = line.slice(3).trim();\n }\n const wanted =\n typeof expectedEvent === \"string\"\n ? eventName === expectedEvent\n : expectedEvent.includes(eventName);\n if (!wanted || dataLine === null) return null;\n try {\n return { data: JSON.parse(dataLine) as T, id, event: eventName };\n } catch {\n return null;\n }\n}\n\nfunction sleep(ms: number, signal: AbortSignal): Promise<void> {\n return new Promise((resolve) => {\n const onAbort = () => {\n clearTimeout(timer);\n resolve();\n };\n const timer = setTimeout(() => {\n signal.removeEventListener(\"abort\", onAbort);\n resolve();\n }, ms);\n signal.addEventListener(\"abort\", onAbort, { once: true });\n });\n}\n\n/** Aborts `child` when `outer` does, and hands back the unsubscribe — without\n * it every reconnect would leave another listener on a signal that lives as\n * long as the subscription. */\nfunction forward(outer: AbortSignal, child: AbortController): () => void {\n const onAbort = () => child.abort();\n if (outer.aborted) child.abort();\n else outer.addEventListener(\"abort\", onAbort, { once: true });\n return () => outer.removeEventListener(\"abort\", onAbort);\n}\n"
|
|
7
7
|
],
|
|
8
|
-
"mappings": ";AAGO,MAAM,mBAAmB,MAAM;AAAA,EACpC,WAAW,CAAC,SAAiB;AAAA,IAC3B,MAAM,OAAO;AAAA,IACb,KAAK,OAAO;AAAA;AAEhB;AAAA;AAGO,MAAM,sBAAsB,WAAW;AAAA,EACnC;AAAA,EAEA;AAAA,EAIA;AAAA,EAET,WAAW,CAAC,SAAiB,QAAgB,YAA2B,MAAM,OAAO,IAAI;AAAA,IACvF,MAAM,OAAO;AAAA,IACb,KAAK,OAAO;AAAA,IACZ,KAAK,SAAS;AAAA,IACd,KAAK,YAAY;AAAA,IACjB,KAAK,OAAO;AAAA;AAAA,EAId,IAAiB,GAAkB;AAAA,IACjC,IAAI;AAAA,MACF,OAAO,KAAK,MAAM,KAAK,IAAI;AAAA,MAC3B,MAAM;AAAA,MACN;AAAA;AAAA;AAAA,MAMA,WAAW,GAAY;AAAA,IACzB,OAAO,KAAK,WAAW,OAAO,KAAK,WAAW;AAAA;AAAA,MAG5C,UAAU,GAAY;AAAA,IACxB,OAAO,KAAK,WAAW;AAAA;AAAA,MAKrB,UAAU,GAAY;AAAA,IACxB,OAAO,KAAK,WAAW;AAAA;AAAA,MAIrB,WAAW,GAAY;AAAA,IACzB,OAAO,KAAK,WAAW,OAAO,KAAK,UAAU;AAAA;AAEjD;AAAA;AAOO,MAAM,yBAAyB,WAAW;AAAA,EAC/C,WAAW,CAAC,SAAiB;AAAA,IAC3B,MAAM,OAAO;AAAA,IACb,KAAK,OAAO;AAAA;AAEhB;AAAA;AAUO,MAAM,0BAA0B,WAAW;AAAA,EAEvC;AAAA,EAEA;AAAA,EAET,WAAW,CAAC,SAAiB,OAAgB,WAAW,OAAO;AAAA,IAC7D,MAAM,OAAO;AAAA,IACb,KAAK,OAAO;AAAA,IACZ,KAAK,QAAQ;AAAA,IACb,KAAK,WAAW;AAAA;AAEpB;AAEA,IAAM,mBAA2C;AAAA,EAC/C,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AACP;AAEA,eAAsB,cAAc,CAAC,KAAe,UAAiC;AAAA,EACnF,IAAI,IAAI;AAAA,IAAI;AAAA,EACZ,IAAI,SAAS;AAAA,EACb,IAAI;AAAA,IACF,SAAS,MAAM,IAAI,KAAK;AAAA,IACxB,MAAM;AAAA,IACN,SAAS;AAAA;AAAA,EAEX,MAAM,OAAO,iBAAiB,IAAI,WAAW;AAAA,EAC7C,MAAM,IAAI,cACR,SAAS,GAAG,SAAS,OAAO,MAAM,GAAG,GAAG,OAAO,MAC/C,IAAI,QACJ,IAAI,QAAQ,IAAI,cAAc,GAC9B,MACF;AAAA;;;
|
|
9
|
-
"debugId": "
|
|
8
|
+
"mappings": "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",
|
|
9
|
+
"debugId": "24E2FA6F4632B4F664756E2164756E21",
|
|
10
10
|
"names": []
|
|
11
11
|
}
|