@stage-labs/metro 0.1.0-beta.70 → 0.1.0-beta.72

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.
Files changed (25) hide show
  1. package/dist/bedrock.js +4 -73
  2. package/dist/claude-settings.js +40 -0
  3. package/dist/claude.js +101 -9
  4. package/dist/hold.js +20 -6
  5. package/dist/provider-flags.js +9 -0
  6. package/package.json +1 -1
  7. package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-api.ts +22 -4
  8. package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-settings.ts +117 -0
  9. package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +9 -3
  10. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +3 -0
  11. package/runtime/node_modules/@metro-labs/mcp/src/daemon/model-api.ts +192 -0
  12. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +5 -0
  13. package/runtime/node_modules/@metro-labs/mcp/src/daemon/terminal-ws.ts +18 -9
  14. package/runtime/node_modules/@metro-labs/mcp/src/gateway/bedrock.ts +252 -0
  15. package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-auth.ts +231 -0
  16. package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-device.ts +91 -0
  17. package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-stream.ts +288 -0
  18. package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex-translate.ts +203 -0
  19. package/runtime/node_modules/@metro-labs/mcp/src/gateway/codex.ts +211 -0
  20. package/runtime/node_modules/@metro-labs/mcp/src/gateway/eventstream.ts +123 -0
  21. package/runtime/node_modules/@metro-labs/mcp/src/gateway/forward.ts +169 -0
  22. package/runtime/node_modules/@metro-labs/mcp/src/gateway/gateway.ts +193 -0
  23. package/runtime/node_modules/@metro-labs/mcp/src/gateway/model-config.ts +180 -0
  24. package/runtime/node_modules/@metro-labs/mcp/src/gateway/openrouter.ts +31 -0
  25. package/runtime/runtime.json +1 -1
@@ -0,0 +1,203 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isRecord } from '../daemon/is-record.js';
3
+
4
+ export const SIGNATURE_PREFIX = 'metro-codex:';
5
+ const DEFAULT_EFFORT = 'medium';
6
+ const EFFORTS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
7
+ const EFFORT_ALIAS: Record<string, string> = { max: 'xhigh' };
8
+ const IMAGE_NOTE = '[an image was attached here; this model cannot see it]';
9
+ const NAME_MAX = 64;
10
+ const NAME_RE = /^[a-zA-Z0-9_-]+$/;
11
+ const HASH_LEN = 8;
12
+
13
+ type Item = Record<string, unknown>;
14
+
15
+ export interface TranslateOptions {
16
+ promptCacheKey: string;
17
+ names?: ToolNames;
18
+ }
19
+
20
+ export interface ResponsesRequest {
21
+ model: string;
22
+ instructions?: string;
23
+ input: Item[];
24
+ tools: Item[];
25
+ tool_choice: unknown;
26
+ parallel_tool_calls: boolean;
27
+ reasoning: { effort: string; summary: string };
28
+ store: false;
29
+ stream: true;
30
+ include: string[];
31
+ prompt_cache_key: string;
32
+ }
33
+
34
+ const textOf = (value: unknown): string => (typeof value === 'string' ? value : '');
35
+
36
+ export function codexToolName(name: string): string {
37
+ if (name.length <= NAME_MAX && NAME_RE.test(name)) return name;
38
+ const clean = name.replace(/[^a-zA-Z0-9_-]/g, '_');
39
+ const hash = createHash('sha256').update(name).digest('hex').slice(0, HASH_LEN);
40
+ return `${clean.slice(0, NAME_MAX - HASH_LEN - 1)}_${hash}`;
41
+ }
42
+
43
+ export class ToolNames {
44
+ private readonly original = new Map<string, string>();
45
+
46
+ alias(name: string): string {
47
+ const alias = codexToolName(name);
48
+ if (alias !== name) this.original.set(alias, name);
49
+ return alias;
50
+ }
51
+
52
+ restore(alias: string): string {
53
+ return this.original.get(alias) ?? alias;
54
+ }
55
+ }
56
+
57
+ function blocksText(content: unknown): string {
58
+ if (typeof content === 'string') return content;
59
+ if (!Array.isArray(content)) return '';
60
+ return content
61
+ .filter(isRecord)
62
+ .filter((block) => block.type === 'text')
63
+ .map((block) => textOf(block.text))
64
+ .join('\n');
65
+ }
66
+
67
+ export function systemText(system: unknown): string {
68
+ return blocksText(system).trim();
69
+ }
70
+
71
+ export function encodeSignature(item: Item): string {
72
+ return `${SIGNATURE_PREFIX}${Buffer.from(JSON.stringify(item)).toString('base64url')}`;
73
+ }
74
+
75
+ export function decodeSignature(signature: unknown): Item | null {
76
+ if (typeof signature !== 'string' || !signature.startsWith(SIGNATURE_PREFIX)) return null;
77
+ try {
78
+ const parsed: unknown = JSON.parse(Buffer.from(signature.slice(SIGNATURE_PREFIX.length), 'base64url').toString('utf8'));
79
+ return isRecord(parsed) ? parsed : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function userPart(block: Item): Item | null {
86
+ if (block.type === 'text') return { type: 'input_text', text: textOf(block.text) };
87
+ if (block.type === 'image' && isRecord(block.source) && block.source.type === 'base64')
88
+ return { type: 'input_image', image_url: `data:${textOf(block.source.media_type)};base64,${textOf(block.source.data)}` };
89
+ return null;
90
+ }
91
+
92
+ const partText = (part: Item): string => (part.type === 'text' ? textOf(part.text) : part.type === 'image' ? IMAGE_NOTE : '');
93
+
94
+ function resultText(block: Item): string {
95
+ const content = block.content;
96
+ const text = typeof content === 'string' ? content : Array.isArray(content) ? content.filter(isRecord).map(partText).filter((t) => t !== '').join('\n') : '';
97
+ return block.is_error === true ? `[tool error] ${text}` : text;
98
+ }
99
+
100
+ function userItems(content: unknown): Item[] {
101
+ if (typeof content === 'string') return [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: content }] }];
102
+ if (!Array.isArray(content)) return [];
103
+ const out: Item[] = [];
104
+ let parts: Item[] = [];
105
+ const flush = (): void => {
106
+ if (parts.length > 0) out.push({ type: 'message', role: 'user', content: parts });
107
+ parts = [];
108
+ };
109
+ for (const block of content.filter(isRecord)) {
110
+ if (block.type === 'tool_result') {
111
+ flush();
112
+ out.push({ type: 'function_call_output', call_id: textOf(block.tool_use_id), output: resultText(block) });
113
+ continue;
114
+ }
115
+ const part = userPart(block);
116
+ if (part !== null) parts.push(part);
117
+ }
118
+ flush();
119
+ return out;
120
+ }
121
+
122
+ function assistantItems(content: unknown, names: ToolNames): Item[] {
123
+ if (typeof content === 'string') return [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: content }] }];
124
+ if (!Array.isArray(content)) return [];
125
+ const out: Item[] = [];
126
+ for (const block of content.filter(isRecord)) {
127
+ if (block.type === 'text') out.push({ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: textOf(block.text) }] });
128
+ else if (block.type === 'tool_use')
129
+ out.push({ type: 'function_call', call_id: textOf(block.id), name: names.alias(textOf(block.name)), arguments: JSON.stringify(block.input ?? {}) });
130
+ else if (block.type === 'thinking') {
131
+ const reasoning = decodeSignature(block.signature);
132
+ if (reasoning !== null) out.push({ type: 'reasoning', ...reasoning, summary: [] });
133
+ }
134
+ }
135
+ return out;
136
+ }
137
+
138
+ export function inputItems(messages: unknown, names = new ToolNames()): Item[] {
139
+ const out: Item[] = [];
140
+ if (!Array.isArray(messages)) return out;
141
+ for (const message of messages.filter(isRecord)) {
142
+ if (message.role === 'assistant') out.push(...assistantItems(message.content, names));
143
+ else out.push(...userItems(message.content));
144
+ }
145
+ return out;
146
+ }
147
+
148
+ export function toolItems(tools: unknown, names = new ToolNames()): Item[] {
149
+ if (!Array.isArray(tools)) return [];
150
+ return tools
151
+ .filter(isRecord)
152
+ .filter((tool) => typeof tool.name === 'string' && isRecord(tool.input_schema))
153
+ .map((tool) => ({
154
+ type: 'function',
155
+ name: names.alias(textOf(tool.name)),
156
+ description: textOf(tool.description),
157
+ parameters: tool.input_schema,
158
+ strict: false,
159
+ }));
160
+ }
161
+
162
+ export function toolChoiceOf(choice: unknown, names = new ToolNames()): unknown {
163
+ if (!isRecord(choice)) return 'auto';
164
+ if (choice.type === 'any') return 'required';
165
+ if (choice.type === 'none') return 'none';
166
+ if (choice.type === 'tool' && typeof choice.name === 'string') return { type: 'function', name: names.alias(choice.name) };
167
+ return 'auto';
168
+ }
169
+
170
+ function configuredEffort(body: Item): string | null {
171
+ const config = isRecord(body.output_config) ? body.output_config : {};
172
+ const effort = typeof config.effort === 'string' ? (EFFORT_ALIAS[config.effort] ?? config.effort) : '';
173
+ return EFFORTS.has(effort) ? effort : null;
174
+ }
175
+
176
+ export function effortOf(body: Item): string {
177
+ const configured = configuredEffort(body);
178
+ if (configured !== null) return configured;
179
+ const thinking = isRecord(body.thinking) ? body.thinking : {};
180
+ if (thinking.type === 'disabled') return 'low';
181
+ const budget = typeof thinking.budget_tokens === 'number' ? thinking.budget_tokens : 0;
182
+ if (budget > 0 && budget <= 4_000) return 'low';
183
+ if (budget > 16_000) return 'high';
184
+ return DEFAULT_EFFORT;
185
+ }
186
+
187
+ export function toResponsesRequest(body: Item, model: string, opts: TranslateOptions): ResponsesRequest {
188
+ const system = systemText(body.system);
189
+ const names = opts.names ?? new ToolNames();
190
+ return {
191
+ model,
192
+ ...(system === '' ? {} : { instructions: system }),
193
+ input: inputItems(body.messages, names),
194
+ tools: toolItems(body.tools, names),
195
+ tool_choice: toolChoiceOf(body.tool_choice, names),
196
+ parallel_tool_calls: true,
197
+ reasoning: { effort: effortOf(body), summary: 'auto' },
198
+ store: false,
199
+ stream: true,
200
+ include: ['reasoning.encrypted_content'],
201
+ prompt_cache_key: opts.promptCacheKey,
202
+ };
203
+ }
@@ -0,0 +1,211 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { arch, platform, release } from 'node:os';
4
+ import { isRecord } from '../daemon/is-record.js';
5
+ import { errMsg } from '../daemon/log.js';
6
+ import { refreshTokens, tokensStale, type CodexTokens } from './codex-auth.js';
7
+ import { assembleMessage, CodexEventTranslator, parseEvent, SseParser } from './codex-stream.js';
8
+ import { ToolNames, toResponsesRequest } from './codex-translate.js';
9
+ import { GatewayError, idleMessage, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
10
+ import type { ModelConfig } from './model-config.js';
11
+
12
+ export const CODEX_BASE = 'https://chatgpt.com/backend-api/codex';
13
+ const CODEX_VERSION = '0.104.0';
14
+ const PING_MS = 25_000;
15
+ const STATUS_OF: Record<string, number> = { rate_limit_error: 429, invalid_request_error: 400, permission_error: 403, overloaded_error: 529 };
16
+
17
+ export interface CodexDeps {
18
+ base?: string;
19
+ issuer?: string;
20
+ fetchImpl?: typeof fetch;
21
+ save: (tokens: CodexTokens) => void;
22
+ }
23
+
24
+ export interface CodexState {
25
+ refreshing: Promise<CodexTokens> | null;
26
+ latest: CodexTokens | null;
27
+ }
28
+
29
+ export const freshCodexState = (): CodexState => ({ refreshing: null, latest: null });
30
+
31
+ const OS_NAMES: Record<string, string> = { darwin: 'Mac OS', linux: 'Linux', win32: 'Windows' };
32
+
33
+ export const userAgent = (): string => `codex_cli_rs/${CODEX_VERSION} (${OS_NAMES[platform()] ?? platform()} ${release()}; ${arch()}) metro`;
34
+
35
+ function headersFor(tokens: CodexTokens, sessionId: string): Record<string, string> {
36
+ return {
37
+ authorization: `Bearer ${tokens.accessToken}`,
38
+ 'chatgpt-account-id': tokens.accountId,
39
+ 'content-type': 'application/json',
40
+ accept: 'text/event-stream',
41
+ originator: 'codex_cli_rs',
42
+ 'user-agent': userAgent(),
43
+ 'openai-beta': 'responses=experimental',
44
+ 'session-id': sessionId,
45
+ };
46
+ }
47
+
48
+ const newerThan = (a: CodexTokens, b: CodexTokens): boolean => Date.parse(a.savedAt) > Date.parse(b.savedAt);
49
+
50
+ function refreshed(tokens: CodexTokens, deps: CodexDeps, state: CodexState): Promise<CodexTokens> {
51
+ const latest = state.latest;
52
+ if (latest !== null && newerThan(latest, tokens) && !tokensStale(latest)) return Promise.resolve(latest);
53
+ if (state.refreshing !== null) return state.refreshing;
54
+ const run = refreshTokens(tokens, deps.issuer, deps.fetchImpl)
55
+ .then((fresh) => {
56
+ deps.save(fresh);
57
+ state.latest = fresh;
58
+ return fresh;
59
+ })
60
+ .catch((err: unknown) => {
61
+ throw new GatewayError(403, 'permission_error', `Codex sign-in expired (${errMsg(err)}): connect again on the Model page`);
62
+ })
63
+ .finally(() => {
64
+ state.refreshing = null;
65
+ });
66
+ state.refreshing = run;
67
+ return run;
68
+ }
69
+
70
+ export function currentTokens(cfg: ModelConfig, deps: CodexDeps, state: CodexState): Promise<CodexTokens> {
71
+ const tokens = cfg.codex.auth;
72
+ if (tokens === null) throw new GatewayError(400, 'invalid_request_error', 'Codex is not connected: sign in on the Model page.');
73
+ return tokensStale(tokens) ? refreshed(tokens, deps, state) : Promise.resolve(tokens);
74
+ }
75
+
76
+ function errorKind(status: number): string {
77
+ if (status === 401 || status === 403) return 'permission_error';
78
+ if (status === 429) return 'rate_limit_error';
79
+ if (status === 400 || status === 404 || status === 422) return 'invalid_request_error';
80
+ return 'api_error';
81
+ }
82
+
83
+ const sessionIdOf = (req: IncomingMessage): string => {
84
+ const raw = req.headers['x-claude-code-session-id'];
85
+ const given = (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? '';
86
+ return given === '' ? randomUUID() : given;
87
+ };
88
+
89
+ interface Call {
90
+ body: Record<string, unknown>;
91
+ model: string;
92
+ sessionId: string;
93
+ watch: Watch;
94
+ deps: CodexDeps;
95
+ names: ToolNames;
96
+ }
97
+
98
+ function send(call: Call, tokens: CodexTokens): Promise<Response> {
99
+ const request = toResponsesRequest(call.body, call.model, { promptCacheKey: call.sessionId, names: call.names });
100
+ return (call.deps.fetchImpl ?? fetch)(`${call.deps.base ?? CODEX_BASE}/responses`, {
101
+ method: 'POST',
102
+ headers: headersFor(tokens, call.sessionId),
103
+ body: JSON.stringify(request),
104
+ signal: call.watch.signal,
105
+ redirect: 'manual',
106
+ });
107
+ }
108
+
109
+ async function reach(call: Call, cfg: ModelConfig, state: CodexState): Promise<Response> {
110
+ let tokens = await currentTokens(cfg, call.deps, state);
111
+ let upstream = await send(call, tokens);
112
+ if (upstream.status === 401) {
113
+ await upstream.body?.cancel();
114
+ tokens = await refreshed(tokens, call.deps, state);
115
+ upstream = await send(call, tokens);
116
+ }
117
+ return upstream;
118
+ }
119
+
120
+ async function relayStream(upstream: Response, res: ServerResponse, model: string, names: ToolNames, watch: Watch): Promise<void> {
121
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
122
+ const translator = new CodexEventTranslator(model, (name) => names.restore(name));
123
+ const body = upstream.body;
124
+ if (body === null) {
125
+ res.end(translator.close());
126
+ return;
127
+ }
128
+ const ping = setInterval(() => res.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_MS);
129
+ try {
130
+ const parser = new SseParser();
131
+ const decoder = new TextDecoder();
132
+ const reader = body.getReader();
133
+ for (;;) {
134
+ const { done, value } = await reader.read();
135
+ if (done) break;
136
+ watch.touch();
137
+ for (const raw of parser.push(decoder.decode(value, { stream: true }))) {
138
+ const parsed = parseEvent(raw);
139
+ if (parsed !== null) res.write(translator.push(parsed.event, parsed.data));
140
+ }
141
+ }
142
+ res.write(translator.close());
143
+ } catch (err) {
144
+ if (!watch.idle()) throw err;
145
+ res.write(translator.finished ? '' : translator.close(idleMessage(watch.ms)));
146
+ } finally {
147
+ clearInterval(ping);
148
+ watch.stop();
149
+ res.end();
150
+ }
151
+ }
152
+
153
+ async function relayWhole(upstream: Response, res: ServerResponse, model: string, names: ToolNames): Promise<void> {
154
+ const translator = new CodexEventTranslator(model, (name) => names.restore(name));
155
+ let frames = '';
156
+ const parser = new SseParser();
157
+ for (const raw of parser.push(await upstream.text())) {
158
+ const parsed = parseEvent(raw);
159
+ if (parsed !== null) frames += translator.push(parsed.event, parsed.data);
160
+ }
161
+ frames += translator.close();
162
+ const message = assembleMessage(frames);
163
+ const kind = isRecord(message.error) ? String(message.error.type) : '';
164
+ res.writeHead(message.type === 'error' ? (STATUS_OF[kind] ?? 502) : 200, { 'content-type': 'application/json' });
165
+ res.end(JSON.stringify(message));
166
+ }
167
+
168
+ export async function codexMessages(
169
+ req: IncomingMessage,
170
+ res: ServerResponse,
171
+ body: Record<string, unknown>,
172
+ model: string,
173
+ cfg: ModelConfig,
174
+ deps: CodexDeps,
175
+ state: CodexState,
176
+ watch: Watch,
177
+ ): Promise<void> {
178
+ const call: Call = { body, model, sessionId: sessionIdOf(req), watch, deps, names: new ToolNames() };
179
+ const upstream = await reach(call, cfg, state);
180
+ if (!upstream.ok) {
181
+ const text = await upstream.text();
182
+ sendError(res, providerStatus(upstream.status), errorKind(upstream.status), upstreamMessage(text, `Codex answered ${String(upstream.status)}`));
183
+ return;
184
+ }
185
+ if (body.stream === true) await relayStream(upstream, res, model, call.names, watch);
186
+ else await relayWhole(upstream, res, model, call.names);
187
+ }
188
+
189
+ export function codexCount(res: ServerResponse, body: Record<string, unknown>): void {
190
+ res.writeHead(200, { 'content-type': 'application/json' });
191
+ res.end(JSON.stringify({ input_tokens: Math.ceil(JSON.stringify(body).length / 4) }));
192
+ }
193
+
194
+ export async function codexModels(tokens: CodexTokens, deps: Omit<CodexDeps, 'save'>): Promise<string[]> {
195
+ const res = await (deps.fetchImpl ?? fetch)(`${deps.base ?? CODEX_BASE}/models?client_version=${CODEX_VERSION}`, {
196
+ headers: { ...headersFor(tokens, randomUUID()), accept: 'application/json' },
197
+ redirect: 'manual',
198
+ });
199
+ if (!res.ok) throw new GatewayError(res.status, errorKind(res.status), `Codex would not list models (${String(res.status)})`);
200
+ const slugs = new Set<string>();
201
+ const walk = (value: unknown): void => {
202
+ if (Array.isArray(value)) value.forEach(walk);
203
+ else if (typeof value === 'object' && value !== null) {
204
+ const record = value as Record<string, unknown>;
205
+ if (typeof record.slug === 'string' && record.slug !== '') slugs.add(record.slug);
206
+ Object.values(record).forEach(walk);
207
+ }
208
+ };
209
+ walk(await res.json());
210
+ return [...slugs];
211
+ }
@@ -0,0 +1,123 @@
1
+ export interface EventStreamMessage {
2
+ headers: Record<string, string>;
3
+ payload: Buffer;
4
+ }
5
+
6
+ const PRELUDE_BYTES = 12;
7
+ const CRC_BYTES = 4;
8
+
9
+ const HEADER_TRUE = 0;
10
+ const HEADER_FALSE = 1;
11
+ const HEADER_BYTE = 2;
12
+ const HEADER_SHORT = 3;
13
+ const HEADER_INT = 4;
14
+ const HEADER_LONG = 5;
15
+ const HEADER_BYTES = 6;
16
+ const HEADER_STRING = 7;
17
+ const HEADER_TIMESTAMP = 8;
18
+ const HEADER_UUID = 9;
19
+
20
+ const FIXED_WIDTH: Record<number, number> = {
21
+ [HEADER_TRUE]: 0,
22
+ [HEADER_FALSE]: 0,
23
+ [HEADER_BYTE]: 1,
24
+ [HEADER_SHORT]: 2,
25
+ [HEADER_INT]: 4,
26
+ [HEADER_LONG]: 8,
27
+ [HEADER_TIMESTAMP]: 8,
28
+ [HEADER_UUID]: 16,
29
+ };
30
+
31
+ function readHeaderValue(buf: Buffer, at: number, type: number): { value: string; next: number } {
32
+ if (type === HEADER_BYTES || type === HEADER_STRING) {
33
+ const len = buf.readUInt16BE(at);
34
+ const raw = buf.subarray(at + 2, at + 2 + len);
35
+ const value = type === HEADER_STRING ? raw.toString('utf8') : raw.toString('base64');
36
+ return { value, next: at + 2 + len };
37
+ }
38
+ const width = FIXED_WIDTH[type];
39
+ if (width === undefined) throw new Error(`eventstream: unknown header type ${String(type)}`);
40
+ if (type === HEADER_TRUE) return { value: 'true', next: at };
41
+ if (type === HEADER_FALSE) return { value: 'false', next: at };
42
+ return { value: buf.subarray(at, at + width).toString('hex'), next: at + width };
43
+ }
44
+
45
+ function readHeaders(buf: Buffer): Record<string, string> {
46
+ const out: Record<string, string> = {};
47
+ let at = 0;
48
+ while (at < buf.length) {
49
+ const nameLen = buf.readUInt8(at);
50
+ const name = buf.subarray(at + 1, at + 1 + nameLen).toString('utf8');
51
+ const type = buf.readUInt8(at + 1 + nameLen);
52
+ const parsed = readHeaderValue(buf, at + 2 + nameLen, type);
53
+ out[name] = parsed.value;
54
+ at = parsed.next;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ export function decodeFrame(buf: Buffer, offset: number): { message: EventStreamMessage; end: number } | null {
60
+ if (buf.length - offset < PRELUDE_BYTES) return null;
61
+ const total = buf.readUInt32BE(offset);
62
+ const headersLen = buf.readUInt32BE(offset + 4);
63
+ if (total < PRELUDE_BYTES + CRC_BYTES || headersLen > total) throw new Error('eventstream: malformed frame prelude');
64
+ if (buf.length - offset < total) return null;
65
+ const headersStart = offset + PRELUDE_BYTES;
66
+ const payloadStart = headersStart + headersLen;
67
+ const payloadEnd = offset + total - CRC_BYTES;
68
+ return {
69
+ message: {
70
+ headers: readHeaders(buf.subarray(headersStart, payloadStart)),
71
+ payload: Buffer.from(buf.subarray(payloadStart, payloadEnd)),
72
+ },
73
+ end: offset + total,
74
+ };
75
+ }
76
+
77
+ export class EventStreamDecoder {
78
+ private pending: Buffer = Buffer.alloc(0);
79
+
80
+ push(chunk: Buffer): EventStreamMessage[] {
81
+ this.pending = this.pending.length === 0 ? chunk : Buffer.concat([this.pending, chunk]);
82
+ const out: EventStreamMessage[] = [];
83
+ let offset = 0;
84
+ for (;;) {
85
+ const frame = decodeFrame(this.pending, offset);
86
+ if (frame === null) break;
87
+ out.push(frame.message);
88
+ offset = frame.end;
89
+ }
90
+ this.pending = this.pending.subarray(offset);
91
+ return out;
92
+ }
93
+ }
94
+
95
+ function encodeHeader(name: string, value: string): Buffer {
96
+ const nameBuf = Buffer.from(name, 'utf8');
97
+ const valueBuf = Buffer.from(value, 'utf8');
98
+ const out = Buffer.alloc(1 + nameBuf.length + 1 + 2 + valueBuf.length);
99
+ let at = 0;
100
+ out.writeUInt8(nameBuf.length, at);
101
+ at += 1;
102
+ nameBuf.copy(out, at);
103
+ at += nameBuf.length;
104
+ out.writeUInt8(HEADER_STRING, at);
105
+ at += 1;
106
+ out.writeUInt16BE(valueBuf.length, at);
107
+ at += 2;
108
+ valueBuf.copy(out, at);
109
+ return out;
110
+ }
111
+
112
+ export function encodeFrame(headers: Record<string, string>, payload: Buffer): Buffer {
113
+ const headerBuf = Buffer.concat(Object.entries(headers).map(([name, value]) => encodeHeader(name, value)));
114
+ const total = PRELUDE_BYTES + headerBuf.length + payload.length + CRC_BYTES;
115
+ const out = Buffer.alloc(total);
116
+ out.writeUInt32BE(total, 0);
117
+ out.writeUInt32BE(headerBuf.length, 4);
118
+ out.writeUInt32BE(0, 8);
119
+ headerBuf.copy(out, PRELUDE_BYTES);
120
+ payload.copy(out, PRELUDE_BYTES + headerBuf.length);
121
+ out.writeUInt32BE(0, total - CRC_BYTES);
122
+ return out;
123
+ }