@prereason/mcp 0.3.2 → 0.5.0

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/lib/claim.js ADDED
@@ -0,0 +1,233 @@
1
+ /**
2
+ * The claim flow, from the bridge's side.
3
+ *
4
+ * With no key configured the bridge asks PreReason for access on the person's
5
+ * behalf: it creates a claim, prints one link to stderr, and polls until the
6
+ * person has approved it in a browser. The first poll after approval returns
7
+ * the key once; the bridge saves it and attaches it to the running transport.
8
+ * Meanwhile the free tools keep working, and any AUTH_REQUIRED tool result is
9
+ * prefixed with the approve link so the model can relay it, because a person
10
+ * inside Claude Desktop never sees this process's stderr.
11
+ *
12
+ * Everything that touches the network or the clock is injectable
13
+ * (fetchImpl, sleep, now) so the flow is tested without a server.
14
+ */
15
+
16
+ import { writeCredentials } from './credentials.js';
17
+
18
+ export const DEFAULT_POLL_INTERVAL_MS = 5000;
19
+ /** Consecutive network failures before the flow gives up for this process. */
20
+ export const MAX_CONSECUTIVE_ERRORS = 10;
21
+
22
+ /** The claims endpoint on the same origin as the MCP endpoint (so PREREASON_URL overrides both). */
23
+ export function claimEndpoint(mcpUrl) {
24
+ return new URL('/api/agent/claims', mcpUrl).toString();
25
+ }
26
+
27
+ function parseRetryAfter(headers, fallbackMs) {
28
+ const raw = headers && typeof headers.get === 'function' ? headers.get('retry-after') : null;
29
+ const seconds = Number(raw);
30
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : fallbackMs;
31
+ }
32
+
33
+ async function readJson(res) {
34
+ try {
35
+ return await res.json();
36
+ } catch {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ /**
42
+ * POST a claim. Resolves { ok: true, claim } or { ok: false, status, retryAfterMs, message }.
43
+ */
44
+ export async function createClaim({ mcpUrl, clientName, purpose, requestHeaders = {}, fetchImpl = fetch }) {
45
+ const res = await fetchImpl(claimEndpoint(mcpUrl), {
46
+ method: 'POST',
47
+ headers: { 'content-type': 'application/json', accept: 'application/json', ...requestHeaders },
48
+ body: JSON.stringify({ client_name: clientName, purpose }),
49
+ });
50
+ const body = await readJson(res);
51
+ if (res.status === 201 && body && typeof body.claim_code === 'string' && typeof body.claim_token === 'string') {
52
+ return { ok: true, claim: body };
53
+ }
54
+ return {
55
+ ok: false,
56
+ status: res.status,
57
+ retryAfterMs: parseRetryAfter(res.headers, 60_000),
58
+ message: (body && body.message) || `claim request answered ${res.status}`,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Poll one claim until it settles. Outcomes:
64
+ * approved -> { outcome, apiKey, key, account, how_to_use }
65
+ * delivered -> the key was collected by another poll of the same token
66
+ * denied, expired, not_found, gave_up
67
+ */
68
+ export async function pollUntilSettled({ claim, requestHeaders = {}, fetchImpl = fetch, sleep = defaultSleep, now = () => Date.now(), onPending = () => {} }) {
69
+ let expiresAt = Date.parse(claim.expires_at);
70
+ const intervalMs = Math.max(1000, (claim.poll?.interval_seconds ?? 5) * 1000);
71
+ const pollUrl = claim.poll?.url ?? `${claimEndpoint(claim.approve_url)}/${claim.claim_code}`;
72
+ let consecutiveErrors = 0;
73
+
74
+ while (true) {
75
+ if (Number.isFinite(expiresAt) && now() >= expiresAt) return { outcome: 'expired' };
76
+
77
+ let res;
78
+ try {
79
+ res = await fetchImpl(pollUrl, {
80
+ headers: { accept: 'application/json', authorization: `Bearer ${claim.claim_token}`, ...requestHeaders },
81
+ });
82
+ } catch {
83
+ if (++consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) return { outcome: 'gave_up' };
84
+ await sleep(intervalMs);
85
+ continue;
86
+ }
87
+ consecutiveErrors = 0;
88
+
89
+ if (res.status === 404) return { outcome: 'not_found' };
90
+ if (res.status === 429) {
91
+ await sleep(parseRetryAfter(res.headers, intervalMs));
92
+ continue;
93
+ }
94
+ if (res.status !== 200) {
95
+ if (++consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) return { outcome: 'gave_up' };
96
+ await sleep(intervalMs);
97
+ continue;
98
+ }
99
+
100
+ const body = await readJson(res);
101
+ switch (body.status) {
102
+ case 'pending': {
103
+ // The approval page can extend the claim; honour the server's clock.
104
+ const fresh = Date.parse(body.expires_at);
105
+ if (Number.isFinite(fresh)) expiresAt = fresh;
106
+ onPending(body);
107
+ await sleep(Math.max(1000, (body.poll_interval ?? intervalMs / 1000) * 1000));
108
+ continue;
109
+ }
110
+ case 'approved': {
111
+ if (typeof body.api_key === 'string') {
112
+ return { outcome: 'approved', apiKey: body.api_key, key: body.key, account: body.account, how_to_use: body.how_to_use };
113
+ }
114
+ // KEY_ISSUE_FAILED: the approval stands, the mint is retried on the next poll.
115
+ await sleep(Math.max(1000, (body.retry_after ?? 5) * 1000));
116
+ continue;
117
+ }
118
+ case 'delivered':
119
+ return { outcome: 'delivered' };
120
+ case 'denied':
121
+ return { outcome: 'denied' };
122
+ case 'expired':
123
+ return { outcome: 'expired' };
124
+ default:
125
+ if (++consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) return { outcome: 'gave_up' };
126
+ await sleep(intervalMs);
127
+ }
128
+ }
129
+ }
130
+
131
+ /** The one line a person is meant to read. */
132
+ export function approvalLine(claim) {
133
+ return `PreReason: no API key found. Open ${claim.approve_url} to approve access (link expires in 15 min).`;
134
+ }
135
+
136
+ /**
137
+ * While a claim is pending, put the approve link in front of any AUTH_REQUIRED
138
+ * tool result so the model relays it. Leaves every other message untouched.
139
+ */
140
+ export function decorateAuthRequired(message, approveUrl) {
141
+ if (!approveUrl || !message || typeof message !== 'object') return message;
142
+ const result = message.result;
143
+ if (!result || result.isError !== true || !Array.isArray(result.content)) return message;
144
+ const first = result.content[0];
145
+ if (!first || first.type !== 'text' || typeof first.text !== 'string') return message;
146
+ let payload;
147
+ try {
148
+ payload = JSON.parse(first.text);
149
+ } catch {
150
+ return message;
151
+ }
152
+ if (!payload || payload.error !== 'AUTH_REQUIRED') return message;
153
+ const prefix = `Approve at ${approveUrl} (the human who owns this agent must open it and click Approve; the key arrives here on its own afterwards). `;
154
+ return {
155
+ ...message,
156
+ result: {
157
+ ...result,
158
+ content: [{ ...first, text: prefix + first.text }, ...result.content.slice(1)],
159
+ },
160
+ };
161
+ }
162
+
163
+ /**
164
+ * The whole flow: create, announce, poll, save, attach. `state.approveUrl` is
165
+ * set while the claim is pending so the message decorator can read it.
166
+ * Resolves the poll outcome (or a create failure) and never throws.
167
+ */
168
+ export async function runClaimFlow({
169
+ mcpUrl,
170
+ headers,
171
+ clientName,
172
+ purpose,
173
+ credentialsFile,
174
+ requestHeaders = {},
175
+ fetchImpl = fetch,
176
+ sleep = defaultSleep,
177
+ now = () => Date.now(),
178
+ log = (line) => process.stderr.write(`${line}\n`),
179
+ state = {},
180
+ }) {
181
+ let created;
182
+ try {
183
+ created = await createClaim({ mcpUrl, clientName, purpose, requestHeaders, fetchImpl });
184
+ } catch (error) {
185
+ log(`PreReason: could not reach ${claimEndpoint(mcpUrl)} to request access (${error?.message ?? 'network error'}). Free tools still work; set PREREASON_API_KEY to skip this step.`);
186
+ return { outcome: 'create_failed' };
187
+ }
188
+ if (!created.ok) {
189
+ const wait = Math.ceil(created.retryAfterMs / 60_000);
190
+ log(`PreReason: access request refused (${created.status}: ${created.message}). Try again in about ${wait} minute${wait === 1 ? '' : 's'}, or set PREREASON_API_KEY. Free tools still work.`);
191
+ return { outcome: 'create_refused', status: created.status };
192
+ }
193
+
194
+ const claim = created.claim;
195
+ state.approveUrl = claim.approve_url;
196
+ state.claimCode = claim.claim_code;
197
+ log(approvalLine(claim));
198
+
199
+ const settled = await pollUntilSettled({ claim, requestHeaders, fetchImpl, sleep, now });
200
+ state.approveUrl = null;
201
+
202
+ if (settled.outcome === 'approved') {
203
+ try {
204
+ writeCredentials(credentialsFile, {
205
+ apiKey: settled.apiKey,
206
+ claimCode: claim.claim_code,
207
+ clientName,
208
+ keyName: settled.key?.name ?? null,
209
+ now: new Date(now()),
210
+ });
211
+ headers.Authorization = `Bearer ${settled.apiKey}`;
212
+ log(`PreReason: access approved. Key "${settled.key?.name ?? 'Agent key'}" saved to ${credentialsFile}; get_context and get_metric work from the next call.`);
213
+ } catch (error) {
214
+ headers.Authorization = `Bearer ${settled.apiKey}`;
215
+ log(`PreReason: access approved and attached for this session, but the key could not be saved to ${credentialsFile} (${error?.message ?? 'write failed'}). Set PREREASON_API_KEY to keep it.`);
216
+ }
217
+ return settled;
218
+ }
219
+
220
+ const why = {
221
+ expired: 'the link expired before it was approved',
222
+ denied: 'the request was denied',
223
+ delivered: 'the key was collected elsewhere',
224
+ not_found: 'the claim is no longer known to the server',
225
+ gave_up: 'the server could not be reached',
226
+ }[settled.outcome] ?? settled.outcome;
227
+ log(`PreReason: access was not granted (${why}). Restart the bridge to ask again, or set PREREASON_API_KEY. Free tools still work.`);
228
+ return settled;
229
+ }
230
+
231
+ function defaultSleep(ms) {
232
+ return new Promise((resolve) => setTimeout(resolve, ms));
233
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Where the bridge's API key comes from, and where a claimed key is kept.
3
+ *
4
+ * Precedence, highest first:
5
+ * 1. PREREASON_API_KEY in the environment (the documented config path)
6
+ * 2. an Authorization or X-API-Key value passed with --header
7
+ * 3. the credentials file, written by the claim flow (~/.prereason/credentials.json,
8
+ * or PREREASON_CREDENTIALS_FILE, or --credentials-file)
9
+ *
10
+ * The file holds the key and a little provenance (which claim, which client
11
+ * name, when). It never holds a claim token: a token is a fifteen minute
12
+ * secret for one poll loop and dies with the process. On POSIX the directory
13
+ * is 0700 and the file 0600. On Windows chmod is a no-op (the mode bits do
14
+ * not exist), so the file relies on the profile directory's own permissions,
15
+ * which is what every other credential store in %USERPROFILE% does.
16
+ */
17
+
18
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { dirname, join } from 'node:path';
21
+
22
+ export const KEY_PATTERN = /^pr_(?:live|test)_[A-Za-z0-9_-]{16,}$/;
23
+ export const CREDENTIALS_VERSION = 1;
24
+
25
+ /** The credentials file path: the flag, then the env override, then the default under the home directory. */
26
+ export function credentialsPath({ env = process.env, home = homedir(), flag = null } = {}) {
27
+ if (flag) return flag;
28
+ if (env.PREREASON_CREDENTIALS_FILE) return env.PREREASON_CREDENTIALS_FILE;
29
+ return join(home, '.prereason', 'credentials.json');
30
+ }
31
+
32
+ /**
33
+ * Parse the CLI arguments the bridge understands.
34
+ * --header Key:Value (repeatable)
35
+ * --credentials-file <path>
36
+ * --login --logout --help/-h --version/-v
37
+ * <url> a bare argument overrides the endpoint
38
+ */
39
+ export function parseArgs(argv) {
40
+ const out = { headers: {}, url: null, credentialsFile: null, login: false, logout: false, help: false, version: false };
41
+ for (let i = 0; i < argv.length; i++) {
42
+ const arg = argv[i];
43
+ if (arg === '--header' && argv[i + 1] !== undefined) {
44
+ const value = argv[++i];
45
+ const colon = value.indexOf(':');
46
+ if (colon > 0) out.headers[value.slice(0, colon).trim()] = value.slice(colon + 1).trim();
47
+ } else if (arg === '--credentials-file' && argv[i + 1] !== undefined) {
48
+ out.credentialsFile = argv[++i];
49
+ } else if (arg === '--login') {
50
+ out.login = true;
51
+ } else if (arg === '--logout') {
52
+ out.logout = true;
53
+ } else if (arg === '--help' || arg === '-h') {
54
+ out.help = true;
55
+ } else if (arg === '--version' || arg === '-v') {
56
+ out.version = true;
57
+ } else if (!arg.startsWith('-')) {
58
+ out.url = arg;
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /** The API key carried by a --header value, if any. Accepts Authorization: Bearer and X-API-Key, any case. */
65
+ export function keyFromHeaders(headers) {
66
+ for (const [name, value] of Object.entries(headers || {})) {
67
+ const lower = name.toLowerCase();
68
+ if (lower === 'authorization') {
69
+ const m = /^Bearer\s+(\S+)$/i.exec(String(value).trim());
70
+ if (m && KEY_PATTERN.test(m[1])) return m[1];
71
+ } else if (lower === 'x-api-key') {
72
+ const v = String(value).trim();
73
+ if (KEY_PATTERN.test(v)) return v;
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /** Read the credentials file. Anything that is not a well formed key reads as null. */
80
+ export function readCredentials(path) {
81
+ if (!existsSync(path)) return null;
82
+ try {
83
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
84
+ if (!parsed || typeof parsed.api_key !== 'string' || !KEY_PATTERN.test(parsed.api_key)) return null;
85
+ return {
86
+ apiKey: parsed.api_key,
87
+ savedAt: typeof parsed.saved_at === 'string' ? parsed.saved_at : null,
88
+ claimCode: typeof parsed.claim_code === 'string' ? parsed.claim_code : null,
89
+ clientName: typeof parsed.client_name === 'string' ? parsed.client_name : null,
90
+ keyName: typeof parsed.key_name === 'string' ? parsed.key_name : null,
91
+ };
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Write the credentials file atomically (temp file, then rename) with the
99
+ * tightest modes the platform offers. Only these fields are ever written.
100
+ */
101
+ export function writeCredentials(path, { apiKey, claimCode = null, clientName = null, keyName = null, now = new Date() }) {
102
+ if (!KEY_PATTERN.test(apiKey)) throw new Error('refusing to save something that is not a PreReason API key');
103
+ const dir = dirname(path);
104
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
105
+ tighten(dir, 0o700);
106
+ const body = JSON.stringify(
107
+ {
108
+ version: CREDENTIALS_VERSION,
109
+ api_key: apiKey,
110
+ saved_at: now.toISOString(),
111
+ claim_code: claimCode,
112
+ client_name: clientName,
113
+ key_name: keyName,
114
+ },
115
+ null,
116
+ 2
117
+ );
118
+ const tmp = `${path}.${process.pid}.tmp`;
119
+ writeFileSync(tmp, body + '\n', { mode: 0o600 });
120
+ tighten(tmp, 0o600);
121
+ renameSync(tmp, path);
122
+ tighten(path, 0o600);
123
+ }
124
+
125
+ export function deleteCredentials(path) {
126
+ if (!existsSync(path)) return false;
127
+ unlinkSync(path);
128
+ return true;
129
+ }
130
+
131
+ function tighten(target, mode) {
132
+ if (process.platform === 'win32') return; // no mode bits to set
133
+ try {
134
+ chmodSync(target, mode);
135
+ } catch {
136
+ // a filesystem that refuses chmod (some mounts) still gets the file; nothing else to do
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Which key the bridge should use, and where it came from.
142
+ * Returns { key, source } with source one of 'env', 'header', 'file', or null.
143
+ */
144
+ export function resolveApiKey({ env = process.env, headers = {}, credentialsFile }) {
145
+ const fromEnv = env.PREREASON_API_KEY;
146
+ if (fromEnv && KEY_PATTERN.test(fromEnv.trim())) return { key: fromEnv.trim(), source: 'env' };
147
+ const fromHeader = keyFromHeaders(headers);
148
+ if (fromHeader) return { key: fromHeader, source: 'header' };
149
+ const stored = credentialsFile ? readCredentials(credentialsFile) : null;
150
+ if (stored) return { key: stored.apiKey, source: 'file' };
151
+ return { key: null, source: null };
152
+ }
package/lib/jsonrpc.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The smallest shape check that keeps garbage out of the relay.
3
+ *
4
+ * The bridge forwards JSON-RPC frames between a stdio host and PreReason's
5
+ * HTTP endpoint without reading them, so it has no reason to validate methods
6
+ * or params, and no reason to carry a schema library to do it. It only has to
7
+ * be sure that what it forwards is a JSON-RPC object: a host that receives a
8
+ * bare string or a number on stdout treats the stream as corrupt and drops the
9
+ * connection, which is the one failure the relay must not cause itself.
10
+ */
11
+
12
+ /** A single JSON-RPC 2.0 frame: an object, not an array, tagged "2.0". */
13
+ export function isJsonRpcMessage(value) {
14
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && value.jsonrpc === '2.0';
15
+ }
16
+
17
+ /**
18
+ * Throws on anything that is not one JSON-RPC message.
19
+ *
20
+ * Used on the stdio side, which is framed one message per line and has never
21
+ * carried a batch.
22
+ */
23
+ export function assertJsonRpcMessage(value) {
24
+ if (!isJsonRpcMessage(value)) {
25
+ throw new Error('not a JSON-RPC 2.0 message');
26
+ }
27
+ return value;
28
+ }
29
+
30
+ /**
31
+ * Throws on anything that is not a JSON-RPC message or an array of them.
32
+ *
33
+ * Used on the HTTP side. Batching left the MCP spec in 2025-06-18, but a
34
+ * server is still free to answer with an array and the relay should pass it
35
+ * on rather than decide the stream is broken.
36
+ */
37
+ export function assertJsonRpcPayload(value) {
38
+ if (Array.isArray(value)) {
39
+ if (value.length === 0) {
40
+ throw new Error('empty JSON-RPC batch');
41
+ }
42
+ for (const entry of value) {
43
+ if (!isJsonRpcMessage(entry)) {
44
+ throw new Error('batch entry is not a JSON-RPC 2.0 message');
45
+ }
46
+ }
47
+ return value;
48
+ }
49
+ return assertJsonRpcMessage(value);
50
+ }
51
+
52
+ /**
53
+ * True when the frame expects an answer, which is what decides whether a POST
54
+ * reads a response body or just releases the connection. A notification has a
55
+ * method and no id; a response has an id and no method.
56
+ */
57
+ export function expectsResponse(message) {
58
+ const frames = Array.isArray(message) ? message : [message];
59
+ return frames.some((frame) => typeof frame === 'object' && frame !== null && 'method' in frame && 'id' in frame && frame.id !== undefined);
60
+ }
package/lib/sse.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * A Server-Sent Events decoder, sized for this bridge.
3
+ *
4
+ * PreReason's own endpoint answers POST with application/json and 405s a GET,
5
+ * so nothing here runs against api.prereason.com today. It exists because
6
+ * PREREASON_URL can point the bridge at any Streamable HTTP server, and the
7
+ * transport half of that spec is allowed to answer a POST with an event
8
+ * stream. Keeping the decoder costs forty lines and keeps the bridge honest
9
+ * against a server that does.
10
+ *
11
+ * The field rules are the WHATWG ones, and the buffering rules follow
12
+ * eventsource-parser, which is what the MCP SDK used before this file
13
+ * replaced it:
14
+ *
15
+ * a blank line dispatches, and only if data was collected
16
+ * data lines join with a newline, and one trailing newline is dropped
17
+ * one space after the colon is part of the separator, not the value
18
+ * a line with no colon is a field with an empty value
19
+ * an id holding a NUL is discarded, and id does not survive a dispatch
20
+ * a line starting with a colon is a comment, which is how servers keep alive
21
+ */
22
+
23
+ /** Split text into complete lines plus the tail that has not ended yet. */
24
+ function splitLines(chunk) {
25
+ const lines = [];
26
+ let index = 0;
27
+
28
+ while (index < chunk.length) {
29
+ const cr = chunk.indexOf('\r', index);
30
+ const lf = chunk.indexOf('\n', index);
31
+ let end = -1;
32
+
33
+ if (cr !== -1 && lf !== -1) {
34
+ end = Math.min(cr, lf);
35
+ } else if (cr !== -1) {
36
+ // A lone carriage return at the very end may be the first half of a
37
+ // CRLF that the next chunk completes, so hold the line back.
38
+ end = cr === chunk.length - 1 ? -1 : cr;
39
+ } else if (lf !== -1) {
40
+ end = lf;
41
+ }
42
+
43
+ if (end === -1) {
44
+ return [lines, chunk.slice(index)];
45
+ }
46
+
47
+ lines.push(chunk.slice(index, end));
48
+ index = end + 1;
49
+ if (chunk[index - 1] === '\r' && chunk[index] === '\n') {
50
+ index += 1;
51
+ }
52
+ }
53
+
54
+ return [lines, ''];
55
+ }
56
+
57
+ export class SseDecoder {
58
+ #incomplete = '';
59
+ #data = '';
60
+ #eventType = '';
61
+ #id;
62
+ #onRetry;
63
+
64
+ constructor({ onRetry } = {}) {
65
+ this.#onRetry = onRetry;
66
+ }
67
+
68
+ /**
69
+ * Feed one decoded text chunk and get back the events that completed inside
70
+ * it. A chunk may end mid line, so the tail is carried to the next call.
71
+ */
72
+ push(text) {
73
+ const [lines, incomplete] = splitLines(this.#incomplete + text);
74
+ this.#incomplete = incomplete;
75
+
76
+ const events = [];
77
+ for (const line of lines) {
78
+ const event = this.#readLine(line);
79
+ if (event) {
80
+ events.push(event);
81
+ }
82
+ }
83
+ return events;
84
+ }
85
+
86
+ #readLine(line) {
87
+ if (line === '') {
88
+ return this.#dispatch();
89
+ }
90
+ if (line.startsWith(':')) {
91
+ return null;
92
+ }
93
+
94
+ const separator = line.indexOf(':');
95
+ if (separator === -1) {
96
+ this.#setField(line, '');
97
+ return null;
98
+ }
99
+
100
+ const offset = line[separator + 1] === ' ' ? 2 : 1;
101
+ this.#setField(line.slice(0, separator), line.slice(separator + offset));
102
+ return null;
103
+ }
104
+
105
+ #setField(field, value) {
106
+ if (field === 'data') {
107
+ this.#data += `${value}\n`;
108
+ } else if (field === 'event') {
109
+ this.#eventType = value;
110
+ } else if (field === 'id') {
111
+ this.#id = value.includes('\u0000') ? undefined : value;
112
+ } else if (field === 'retry' && /^\d+$/.test(value)) {
113
+ this.#onRetry?.(Number.parseInt(value, 10));
114
+ }
115
+ // Any other field is ignored, which is what the spec asks for.
116
+ }
117
+
118
+ #dispatch() {
119
+ const data = this.#data;
120
+ const event = this.#eventType;
121
+ const id = this.#id;
122
+
123
+ this.#data = '';
124
+ this.#eventType = '';
125
+ this.#id = undefined;
126
+
127
+ if (data.length === 0) {
128
+ return null;
129
+ }
130
+ return {
131
+ id,
132
+ event: event || undefined,
133
+ data: data.endsWith('\n') ? data.slice(0, -1) : data,
134
+ };
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Read an event stream body to the end, handing each event to onEvent.
140
+ *
141
+ * Takes the reader rather than the stream so the caller keeps the handle it
142
+ * needs to cancel, and decodes with TextDecoder rather than piping through a
143
+ * TextDecoderStream, because a multi byte character may straddle two chunks
144
+ * and { stream: true } is what carries the partial one across.
145
+ */
146
+ export async function readEventStream(body, { onEvent, onRetry } = {}) {
147
+ const reader = body.getReader();
148
+ const decoder = new TextDecoder();
149
+ const sse = new SseDecoder({ onRetry });
150
+
151
+ try {
152
+ while (true) {
153
+ const { value, done } = await reader.read();
154
+ if (done) {
155
+ break;
156
+ }
157
+ for (const event of sse.push(decoder.decode(value, { stream: true }))) {
158
+ onEvent?.(event);
159
+ }
160
+ }
161
+ // Flush any half decoded character. An event the server never terminated
162
+ // with a blank line is dropped on purpose: its data is a truncated frame,
163
+ // and forwarding half a JSON-RPC message is worse than losing it.
164
+ for (const event of sse.push(decoder.decode())) {
165
+ onEvent?.(event);
166
+ }
167
+ } finally {
168
+ reader.releaseLock();
169
+ }
170
+ }