/** * Bridge integration tests — exercises real Bun.serve + InboxServer + * createBridgeRoutes against actual SSE / long-poll / WS clients. * * The acceptance criteria from V3.7 we cover here: * 1. "Send 100 small messages" passes on all three transports. * 2. WS-blocked client falls through to SSE without message loss. * 3. Long-poll uses no more than one outstanding request per client. */ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { Hono } from 'hono'; import { SubtleCryptoProvider } from '@shade/crypto-web'; import { createInboxRoutes, createBridgeRoutes, InboxServerEvents, MemoryInboxStore, computeMsgId, type InboxStore, } from '@shade/inbox-server'; import { signPayload } from '@shade/server'; import { generateIdentityKeyPair, toBase64 } from '@shade/core'; import { SseBridge, LongPollBridge, WsBridge, FallbackBridgeTransport, signBridgeQuery, type IncomingMessage, } from '../src/index.js'; const crypto = new SubtleCryptoProvider(); interface Harness { server: ReturnType; baseUrl: string; store: InboxStore; events: InboxServerEvents; bob: Awaited>; alice: Awaited>; } async function bootstrap(opts: { mountWs?: boolean } = {}): Promise { const store = new MemoryInboxStore(); const events = new InboxServerEvents(); const inboxApp = createInboxRoutes(store, crypto, { events, disableRateLimit: true }); const bridge = createBridgeRoutes({ store, crypto, events, longPollTimeoutMs: 1_000, longPollMaxTimeoutMs: 2_000, heartbeatIntervalMs: 200, fallbackPollIntervalMs: 50, }); const app = new Hono(); app.route('/', inboxApp); app.route('/', bridge.app); const port = 19000 + Math.floor(Math.random() * 1000); const server = Bun.serve({ port, fetch: (req, srv) => app.fetch(req, srv), websocket: opts.mountWs === false ? undefined : (bridge.websocket as any), }); // Register Bob. const bob = await generateIdentityKeyPair(crypto); const alice = await generateIdentityKeyPair(crypto); const regBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', signingKey: toBase64(bob.signingPublicKey), }); const regRes = await fetch(`http://localhost:${port}/v1/inbox/register`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(regBody), }); expect(regRes.status).toBe(200); return { server, baseUrl: `http://localhost:${port}`, store, events, bob, alice, }; } async function putBlob(harness: Harness, ciphertext: Uint8Array): Promise { const msgId = await computeMsgId(ciphertext); const body = await signPayload(crypto, harness.alice.signingPrivateKey, { senderSigningKey: toBase64(harness.alice.signingPublicKey), msgId, ciphertext: toBase64(ciphertext), }); const res = await fetch(`${harness.baseUrl}/v1/inbox/bob`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); expect(res.status).toBe(200); return msgId; } function rand(n: number): Uint8Array { const b = new Uint8Array(n); globalThis.crypto.getRandomValues(b); return b; } function bobAuth(harness: Harness) { return { crypto, signingPrivateKey: harness.bob.signingPrivateKey, address: 'bob', }; } async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { if (predicate()) return; await new Promise((r) => setTimeout(r, 25)); } throw new Error(`waitFor timeout: predicate still false after ${timeoutMs}ms`); } describe('signBridgeQuery', () => { test('produces deterministic shape', async () => { const id = await generateIdentityKeyPair(crypto); const qs = await signBridgeQuery({ crypto, signingPrivateKey: id.signingPrivateKey, address: 'foo', kind: 'stream', since: 42, }); expect(qs.get('address')).toBe('foo'); expect(qs.get('kind')).toBe('stream'); expect(qs.get('since')).toBe('42'); expect(qs.get('signedAt')).toMatch(/^\d+$/); expect(qs.get('signature')).toBeTruthy(); }); }); describe('SseBridge — send 100 small messages', () => { let h: Harness; beforeAll(async () => { h = await bootstrap(); }); afterAll(() => { h.server.stop(true); }); test('all messages reach the client over SSE', async () => { const received: IncomingMessage[] = []; const bridge = new SseBridge({ baseUrl: h.baseUrl, auth: bobAuth(h), initialBackoffMs: 100, maxBackoffMs: 500, }); await bridge.connect({ onMessage: (m) => { received.push(m); }, }); const sent: string[] = []; for (let i = 0; i < 100; i++) { const ct = rand(32 + (i % 8)); sent.push(await putBlob(h, ct)); } await waitFor(() => received.length === 100, 5_000); expect(received.length).toBe(100); const ids = new Set(received.map((m) => m.msgId)); expect(ids.size).toBe(100); for (const id of sent) expect(ids.has(id)).toBe(true); await bridge.disconnect(); }); }); describe('LongPollBridge — send 100 small messages', () => { let h: Harness; beforeAll(async () => { h = await bootstrap(); }); afterAll(() => { h.server.stop(true); }); test('all messages reach the client over long-poll', async () => { const received: IncomingMessage[] = []; const bridge = new LongPollBridge({ baseUrl: h.baseUrl, auth: bobAuth(h), pollTimeoutMs: 500, requestTimeoutMs: 1_500, errorBackoffMs: 50, }); await bridge.connect({ onMessage: (m) => { received.push(m); }, }); const sent: string[] = []; for (let i = 0; i < 100; i++) { const ct = rand(48); sent.push(await putBlob(h, ct)); } await waitFor(() => received.length === 100, 8_000); expect(received.length).toBe(100); const ids = new Set(received.map((m) => m.msgId)); for (const id of sent) expect(ids.has(id)).toBe(true); await bridge.disconnect(); }); test('one outstanding request at a time', async () => { let inflight = 0; let peak = 0; const wrapped: typeof fetch = async (input, init) => { inflight++; peak = Math.max(peak, inflight); try { return await fetch(input as any, init as any); } finally { inflight--; } }; const bridge = new LongPollBridge({ baseUrl: h.baseUrl, auth: bobAuth(h), pollTimeoutMs: 200, requestTimeoutMs: 500, errorBackoffMs: 50, fetch: wrapped, }); const received: IncomingMessage[] = []; await bridge.connect({ onMessage: (m) => void received.push(m) }); // Let the loop run a few times; nothing else should be pumping the // bridge concurrently. await new Promise((r) => setTimeout(r, 1_500)); expect(peak).toBeLessThanOrEqual(1); await bridge.disconnect(); }); }); describe('WsBridge — send 100 small messages', () => { let h: Harness; beforeAll(async () => { h = await bootstrap(); }); afterAll(() => { h.server.stop(true); }); test('all messages reach the client over WS', async () => { const received: IncomingMessage[] = []; const bridge = new WsBridge({ baseUrl: h.baseUrl, auth: bobAuth(h), connectTimeoutMs: 2_000, disableAutoReconnect: true, }); await bridge.connect({ onMessage: (m) => { received.push(m); }, }); const sent: string[] = []; for (let i = 0; i < 100; i++) { const ct = rand(40); sent.push(await putBlob(h, ct)); } await waitFor(() => received.length === 100, 5_000); expect(received.length).toBe(100); const ids = new Set(received.map((m) => m.msgId)); for (const id of sent) expect(ids.has(id)).toBe(true); await bridge.disconnect(); }); }); describe('FallbackBridgeTransport', () => { let h: Harness; beforeAll(async () => { h = await bootstrap(); }); afterAll(() => { h.server.stop(true); }); test('falls through WS → SSE when WS endpoint is blocked', async () => { const received: IncomingMessage[] = []; const ws = new WsBridge({ baseUrl: 'http://127.0.0.1:1', // unreachable on purpose auth: bobAuth(h), connectTimeoutMs: 500, disableAutoReconnect: true, }); const sse = new SseBridge({ baseUrl: h.baseUrl, auth: bobAuth(h), initialBackoffMs: 100, maxBackoffMs: 500, }); const lp = new LongPollBridge({ baseUrl: h.baseUrl, auth: bobAuth(h), pollTimeoutMs: 500, requestTimeoutMs: 1_500, errorBackoffMs: 50, }); const fallback = new FallbackBridgeTransport([ws, sse, lp]); await fallback.connect({ onMessage: (m) => { received.push(m); }, }); expect(fallback.activeKind).toBe('sse'); expect(fallback.attempts).toEqual(['ws', 'sse']); const sent: string[] = []; for (let i = 0; i < 10; i++) { const ct = rand(32); sent.push(await putBlob(h, ct)); } await waitFor(() => received.length === 10, 5_000); expect(received.length).toBe(10); const ids = new Set(received.map((m) => m.msgId)); for (const id of sent) expect(ids.has(id)).toBe(true); await fallback.disconnect(); }); test('falls through SSE → long-poll when SSE endpoint returns 502', async () => { // Build a server where SSE explicitly returns 502; long-poll passes // through. We build a small Hono shim that wraps the bridge app. const port = h.server.port; const altPort = port + 1; const store = h.store; // share data with the main harness const events = h.events; const bridge = createBridgeRoutes({ store, crypto, events, longPollTimeoutMs: 500, longPollMaxTimeoutMs: 1_000, heartbeatIntervalMs: 200, fallbackPollIntervalMs: 50, }); const inboxApp = createInboxRoutes(store, crypto, { events, disableRateLimit: true }); const wrapped = new Hono(); wrapped.get('/v1/bridge/stream', (c) => c.text('bad gateway', 502)); wrapped.route('/', bridge.app); wrapped.route('/', inboxApp); const altServer = Bun.serve({ port: altPort, fetch: (req, srv) => wrapped.fetch(req, srv), websocket: bridge.websocket as any, }); try { const altUrl = `http://localhost:${altPort}`; const received: IncomingMessage[] = []; const sse = new SseBridge({ baseUrl: altUrl, auth: bobAuth(h), initialBackoffMs: 100, maxBackoffMs: 200, disableAutoReconnect: true, }); const lp = new LongPollBridge({ baseUrl: altUrl, auth: bobAuth(h), pollTimeoutMs: 200, requestTimeoutMs: 600, errorBackoffMs: 50, }); const fallback = new FallbackBridgeTransport([sse, lp]); await fallback.connect({ onMessage: (m) => { received.push(m); }, }); expect(fallback.activeKind).toBe('long-poll'); // Push a message via the original PUT path. const ct = rand(32); await putBlob(h, ct); await waitFor(() => received.length >= 1, 4_000); expect(received.length).toBeGreaterThanOrEqual(1); await fallback.disconnect(); } finally { altServer.stop(true); } }); test('throws when every bridge fails', async () => { const ws = new WsBridge({ baseUrl: 'http://127.0.0.1:1', auth: bobAuth(h), connectTimeoutMs: 200, disableAutoReconnect: true, }); const sse = new SseBridge({ baseUrl: 'http://127.0.0.1:1', auth: bobAuth(h), initialBackoffMs: 50, maxBackoffMs: 100, disableAutoReconnect: true, }); const lp = new LongPollBridge({ baseUrl: 'http://127.0.0.1:1', auth: bobAuth(h), pollTimeoutMs: 100, requestTimeoutMs: 300, errorBackoffMs: 50, }); const fallback = new FallbackBridgeTransport([ws, sse, lp]); await expect( fallback.connect({ onMessage: () => undefined }), ).rejects.toThrow(); await fallback.disconnect(); }); }); describe('Bridge auth — rejects bad signatures', () => { let h: Harness; beforeAll(async () => { h = await bootstrap(); }); afterAll(() => { h.server.stop(true); }); test('SSE rejects when signature is wrong', async () => { const eve = await generateIdentityKeyPair(crypto); const sse = new SseBridge({ baseUrl: h.baseUrl, auth: { crypto, signingPrivateKey: eve.signingPrivateKey, address: 'bob' }, initialBackoffMs: 50, maxBackoffMs: 100, disableAutoReconnect: true, }); await expect(sse.connect({ onMessage: () => undefined })).rejects.toThrow(); await sse.disconnect(); }); test('long-poll rejects when address is unregistered', async () => { const newId = await generateIdentityKeyPair(crypto); const lp = new LongPollBridge({ baseUrl: h.baseUrl, auth: { crypto, signingPrivateKey: newId.signingPrivateKey, address: 'unregistered' }, pollTimeoutMs: 200, requestTimeoutMs: 500, errorBackoffMs: 50, }); await expect(lp.connect({ onMessage: () => undefined })).rejects.toThrow(); await lp.disconnect(); }); }); describe('Bridge cursor resume', () => { let h: Harness; beforeAll(async () => { h = await bootstrap(); }); afterAll(() => { h.server.stop(true); }); test('SSE picks up after disconnect using Last-Event-ID cursor', async () => { const first: IncomingMessage[] = []; const sseA = new SseBridge({ baseUrl: h.baseUrl, auth: bobAuth(h), initialBackoffMs: 100, maxBackoffMs: 200, disableAutoReconnect: true, }); await sseA.connect({ onMessage: (m) => first.push(m) }); await putBlob(h, rand(20)); await putBlob(h, rand(24)); await waitFor(() => first.length === 2, 3_000); const cursor = sseA.getCursor(); await sseA.disconnect(); // Push more while disconnected. await putBlob(h, rand(28)); await putBlob(h, rand(32)); const second: IncomingMessage[] = []; const sseB = new SseBridge({ baseUrl: h.baseUrl, auth: bobAuth(h), startCursor: cursor, initialBackoffMs: 100, maxBackoffMs: 200, disableAutoReconnect: true, }); await sseB.connect({ onMessage: (m) => second.push(m) }); await waitFor(() => second.length === 2, 3_000); expect(second.length).toBe(2); // No overlap with the first batch. const firstIds = new Set(first.map((m) => m.msgId)); for (const m of second) expect(firstIds.has(m.msgId!)).toBe(false); await sseB.disconnect(); }); }); describe('Bridges — default fetch is bound to globalThis', () => { // Regression: browsers' `fetch` is a WebIDL bound operation that throws // "Illegal invocation" when called via `this.fetchFn(...)`. Constructors // for LongPollBridge / SseBridge must `bind(globalThis)`. function installStrictFetch(): { restore: () => void; getReceiver: () => unknown } { const realFetch = globalThis.fetch; let observedReceiver: unknown = 'unset'; function strictFetch(this: unknown, _input: unknown, _init?: unknown): Promise { observedReceiver = this; if (this !== globalThis) { throw new TypeError("Failed to execute 'fetch' on 'Window': Illegal invocation"); } return Promise.resolve( new Response('{"blobs":[]}', { status: 200, headers: { 'content-type': 'application/json' }, }), ); } Object.defineProperty(globalThis, 'fetch', { configurable: true, writable: true, value: strictFetch, }); return { restore: () => Object.defineProperty(globalThis, 'fetch', { configurable: true, writable: true, value: realFetch, }), getReceiver: () => observedReceiver, }; } test('LongPollBridge default path passes globalThis as `this`', async () => { const { restore, getReceiver } = installStrictFetch(); try { const id = await generateIdentityKeyPair(crypto); const bridge = new LongPollBridge({ baseUrl: 'http://example.invalid', auth: { crypto, signingPrivateKey: id.signingPrivateKey, address: 'foo' }, pollTimeoutMs: 100, requestTimeoutMs: 200, errorBackoffMs: 50, disableLoop: true, }); await bridge.connect({ onMessage: () => {} }); expect(getReceiver()).toBe(globalThis); await bridge.disconnect(); } finally { restore(); } }); test('SseBridge default path passes globalThis as `this`', async () => { // For SSE we only need to know the very first fetch was bound; the // 200-with-empty-stream response will let openOnce return cleanly, // and disableAutoReconnect prevents an infinite reconnect loop. const realFetch = globalThis.fetch; let observedReceiver: unknown = 'unset'; function strictFetch(this: unknown, _input: unknown, _init?: unknown): Promise { observedReceiver = this; if (this !== globalThis) { throw new TypeError("Failed to execute 'fetch' on 'Window': Illegal invocation"); } // Empty SSE-shaped body: stream closes immediately. return Promise.resolve( new Response('', { status: 200, headers: { 'content-type': 'text/event-stream' }, }), ); } Object.defineProperty(globalThis, 'fetch', { configurable: true, writable: true, value: strictFetch, }); try { const id = await generateIdentityKeyPair(crypto); const bridge = new SseBridge({ baseUrl: 'http://example.invalid', auth: { crypto, signingPrivateKey: id.signingPrivateKey, address: 'foo' }, initialBackoffMs: 50, maxBackoffMs: 100, disableAutoReconnect: true, }); await bridge.connect({ onMessage: () => {} }); expect(observedReceiver).toBe(globalThis); await bridge.disconnect(); } finally { Object.defineProperty(globalThis, 'fetch', { configurable: true, writable: true, value: realFetch, }); } }); });