Browsers' Window.fetch is a WebIDL bound operation; storing it as this.fetchImpl / this.fetchFn and calling via the instance receiver threw "Illegal invocation" on the first request. Bind once at construction in InboxClient, LongPollBridge, and SseBridge. Reported by Prism (multi-device E2EE terminal), blocking every browser consumer of the v4.6 transport stack on inbox.start() / bridge.connect(). WsBridge unaffected (uses WebSocket). Node/Bun fetch tolerates a free receiver, so the bug never surfaced server-side — added regression tests that install a strict-receiver globalThis.fetch to catch the issue without an actual browser harness. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
250 lines
8.5 KiB
TypeScript
250 lines
8.5 KiB
TypeScript
/**
|
|
* SSE (Server-Sent Events) bridge.
|
|
*
|
|
* Connects to `<base>/v1/bridge/stream?…` with a signed query string and
|
|
* parses an SSE feed manually using `fetch` + `ReadableStream`. We intentionally
|
|
* do NOT use the platform `EventSource` because:
|
|
*
|
|
* 1. `EventSource` does not let callers attach custom headers — query-param
|
|
* auth is the only portable route, but custom retry / reconnect knobs
|
|
* are also useful and the platform API hides them.
|
|
* 2. `EventSource` is not available in Node by default; rolling our own
|
|
* reader keeps the package zero-dep across runtimes.
|
|
*
|
|
* The wire format follows the standard SSE spec:
|
|
* `id: <receivedAt>\nevent: envelope\ndata: <json>\n\n`
|
|
*
|
|
* Only the `envelope` event carries payload; comment lines (`: ping`) are
|
|
* tolerated as keepalives. The `id` field is fed back to the server as
|
|
* `Last-Event-ID` (also encoded as the `since` query param) on reconnect to
|
|
* resume from the highest-seen cursor.
|
|
*/
|
|
|
|
import type { BridgeConnectOptions, BridgeTransport, IncomingMessage } from './types.js';
|
|
import { decodeWireMessage } from './types.js';
|
|
import type { BridgeAuthInput } from './auth.js';
|
|
import { signBridgeQuery } from './auth.js';
|
|
import { BridgeError } from './errors.js';
|
|
|
|
export interface SseBridgeOptions {
|
|
baseUrl: string;
|
|
auth: Omit<BridgeAuthInput, 'kind' | 'since'>;
|
|
/** Override `fetch` (tests). */
|
|
fetch?: typeof fetch;
|
|
/** Initial reconnect backoff (ms). Default 250. */
|
|
initialBackoffMs?: number;
|
|
/** Max backoff (ms). Default 10_000. */
|
|
maxBackoffMs?: number;
|
|
/** Initial cursor — start of stream by default. */
|
|
startCursor?: number;
|
|
/** Disable auto-reconnect (tests). Default false. */
|
|
disableAutoReconnect?: boolean;
|
|
}
|
|
|
|
const DEFAULT_INITIAL_BACKOFF = 250;
|
|
const DEFAULT_MAX_BACKOFF = 10_000;
|
|
|
|
export class SseBridge implements BridgeTransport {
|
|
readonly kind = 'sse';
|
|
private readonly fetchFn: typeof fetch;
|
|
private cursor: number;
|
|
private abortController: AbortController | null = null;
|
|
private connected = false;
|
|
private disposed = false;
|
|
private connectStarted = false;
|
|
private currentReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
private onMessage: BridgeConnectOptions['onMessage'] | null = null;
|
|
private onError: NonNullable<BridgeConnectOptions['onError']> = (err) =>
|
|
console.warn('[shade-bridge:sse]', err.message);
|
|
|
|
constructor(private readonly options: SseBridgeOptions) {
|
|
// Bind to globalThis: browser `fetch` is a WebIDL bound operation
|
|
// and throws "Illegal invocation" when called as `this.fetchFn(...)`.
|
|
const f = options.fetch ?? globalThis.fetch;
|
|
this.fetchFn = f.bind(globalThis);
|
|
this.cursor = options.startCursor ?? 0;
|
|
}
|
|
|
|
async connect(opts: BridgeConnectOptions): Promise<void> {
|
|
if (this.connectStarted) throw new BridgeError('SseBridge.connect already called');
|
|
this.connectStarted = true;
|
|
this.onMessage = opts.onMessage;
|
|
if (opts.onError) this.onError = opts.onError;
|
|
// Open the first connection; throw if it fails immediately so callers
|
|
// can fall back to a different transport.
|
|
await this.openOnce();
|
|
// Spawn the read loop; subsequent reconnects happen in the background.
|
|
void this.readLoop();
|
|
}
|
|
|
|
async disconnect(): Promise<void> {
|
|
this.disposed = true;
|
|
if (this.currentReader) {
|
|
try {
|
|
await this.currentReader.cancel();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
this.abortController?.abort();
|
|
this.connected = false;
|
|
}
|
|
|
|
/** Public so tests / observability can read the latest cursor. */
|
|
getCursor(): number {
|
|
return this.cursor;
|
|
}
|
|
|
|
/**
|
|
* Opens a single SSE connection and stores the reader on the instance.
|
|
* Throws on hard errors (network refused, non-200 status). Caller drives
|
|
* the read loop.
|
|
*/
|
|
private async openOnce(): Promise<void> {
|
|
const qs = await signBridgeQuery({
|
|
crypto: this.options.auth.crypto,
|
|
signingPrivateKey: this.options.auth.signingPrivateKey,
|
|
address: this.options.auth.address,
|
|
kind: 'stream',
|
|
since: this.cursor,
|
|
});
|
|
const url = `${stripTrailingSlash(this.options.baseUrl)}/v1/bridge/stream?${qs.toString()}`;
|
|
this.abortController = new AbortController();
|
|
let res: Response;
|
|
try {
|
|
res = await this.fetchFn(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
accept: 'text/event-stream',
|
|
'cache-control': 'no-cache',
|
|
'last-event-id': String(this.cursor),
|
|
},
|
|
signal: this.abortController.signal,
|
|
});
|
|
} catch (err) {
|
|
throw new BridgeError(`SSE connect failed: ${(err as Error).message}`);
|
|
}
|
|
if (!res.ok) {
|
|
throw new BridgeError(`SSE connect failed: HTTP ${res.status}`, res.status);
|
|
}
|
|
if (!res.body) {
|
|
throw new BridgeError('SSE response has no body');
|
|
}
|
|
this.currentReader = res.body.getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
|
this.connected = true;
|
|
}
|
|
|
|
private async readLoop(): Promise<void> {
|
|
let backoff = this.options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF;
|
|
const maxBackoff = this.options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF;
|
|
while (!this.disposed) {
|
|
try {
|
|
if (!this.currentReader) {
|
|
await this.openOnce();
|
|
}
|
|
await this.consume(this.currentReader!);
|
|
// Stream closed cleanly — server-side close. Reconnect.
|
|
} catch (err) {
|
|
if (this.disposed) return;
|
|
this.onError(err as Error);
|
|
}
|
|
this.currentReader = null;
|
|
this.connected = false;
|
|
if (this.disposed || this.options.disableAutoReconnect) return;
|
|
await sleep(backoff);
|
|
backoff = Math.min(backoff * 2, maxBackoff);
|
|
}
|
|
}
|
|
|
|
private async consume(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
|
|
const decoder = new TextDecoder();
|
|
let buf = '';
|
|
let dataLines: string[] = [];
|
|
let eventName: string | null = null;
|
|
let eventId: string | null = null;
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) return;
|
|
buf += decoder.decode(value, { stream: true });
|
|
let idx;
|
|
while ((idx = buf.indexOf('\n')) !== -1) {
|
|
const rawLine = buf.slice(0, idx);
|
|
buf = buf.slice(idx + 1);
|
|
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
|
|
if (line === '') {
|
|
// dispatch
|
|
if (dataLines.length > 0) {
|
|
const dataStr = dataLines.join('\n');
|
|
await this.handleEvent(eventName, eventId, dataStr);
|
|
}
|
|
dataLines = [];
|
|
eventName = null;
|
|
eventId = null;
|
|
continue;
|
|
}
|
|
if (line.startsWith(':')) continue; // comment / keepalive
|
|
const colon = line.indexOf(':');
|
|
const field = colon === -1 ? line : line.slice(0, colon);
|
|
let val = colon === -1 ? '' : line.slice(colon + 1);
|
|
if (val.startsWith(' ')) val = val.slice(1);
|
|
switch (field) {
|
|
case 'data':
|
|
dataLines.push(val);
|
|
break;
|
|
case 'event':
|
|
eventName = val;
|
|
break;
|
|
case 'id':
|
|
eventId = val;
|
|
break;
|
|
// 'retry' ignored; we drive backoff ourselves.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async handleEvent(name: string | null, id: string | null, data: string): Promise<void> {
|
|
if (id !== null) {
|
|
const n = Number(id);
|
|
if (Number.isFinite(n) && n > this.cursor) this.cursor = n;
|
|
}
|
|
if (name && name !== 'envelope' && name !== 'message' && name !== '') {
|
|
// Ignore non-payload events (e.g. ready, heartbeat).
|
|
return;
|
|
}
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(data);
|
|
} catch (err) {
|
|
this.onError(new BridgeError(`malformed SSE data: ${(err as Error).message}`));
|
|
return;
|
|
}
|
|
const wire = parsed as { msgId: string; ciphertext: string; receivedAt: number; from?: string };
|
|
if (typeof wire.ciphertext !== 'string' || typeof wire.receivedAt !== 'number') {
|
|
this.onError(new BridgeError('SSE event missing required fields'));
|
|
return;
|
|
}
|
|
const msg: IncomingMessage = decodeWireMessage(wire);
|
|
if (this.onMessage !== null) {
|
|
try {
|
|
await this.onMessage(msg);
|
|
} catch (err) {
|
|
this.onError(err as Error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** True between connect()'s first successful open and disconnect/error. */
|
|
get isConnected(): boolean {
|
|
return this.connected;
|
|
}
|
|
}
|
|
|
|
function stripTrailingSlash(s: string): string {
|
|
return s.endsWith('/') ? s.slice(0, -1) : s;
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|