release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
/**
|
|
|
|
|
* 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,
|
release(v4.7.0): peer-presence events for instant BroadcastChannel revoke
Adds the bridge-connection-lifecycle signal that closes Prism's
~45s revoke window down to one server→client round-trip (~50ms).
Server (`@shade/inbox-server`):
- `inbox.peer_connected` / `inbox.peer_disconnected` events on the
0↔1 boundary across WS + SSE bridges. Long-poll deliberately not
tracked (every poll boundary would flap; push transports are also
the only ones where instant revoke matters).
- `PresenceTracker` collapses two parallel bridges (e.g. WS + SSE
during fallback handover) into one connect/disconnect pair.
- `GET /v1/bridge/presence` SSE endpoint: signed query with
`kind: 'presence'`, `watched: string[]`; on open streams a
per-address snapshot, then change frames filtered server-side.
MAX_WATCHED_ADDRESSES = 64. Subscribing does not itself count as
a peer-bridge connection.
- `createBridgeRoutes` now returns `{ app, websocket, presence }`.
Client (`@shade/transport-bridge`):
- `PresenceBridge.subscribe({ watch, onPresenceChange })` →
`{ addPeer, removePeer, watching, unsubscribe }`. addPeer/removePeer
mutate via reconnect with a fresh signed query.
- `signPresenceQuery` helper for non-PresenceBridge consumers.
Tests cover all four acceptance criteria from the Prism request:
server-event smoke, online→offline subscription, address scoping
(carol invisible to a [alice]-only sub), reconnect, plus an
addPeer/removePeer regression.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:16:35 +02:00
|
|
|
PresenceBridge,
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
type IncomingMessage,
|
release(v4.7.0): peer-presence events for instant BroadcastChannel revoke
Adds the bridge-connection-lifecycle signal that closes Prism's
~45s revoke window down to one server→client round-trip (~50ms).
Server (`@shade/inbox-server`):
- `inbox.peer_connected` / `inbox.peer_disconnected` events on the
0↔1 boundary across WS + SSE bridges. Long-poll deliberately not
tracked (every poll boundary would flap; push transports are also
the only ones where instant revoke matters).
- `PresenceTracker` collapses two parallel bridges (e.g. WS + SSE
during fallback handover) into one connect/disconnect pair.
- `GET /v1/bridge/presence` SSE endpoint: signed query with
`kind: 'presence'`, `watched: string[]`; on open streams a
per-address snapshot, then change frames filtered server-side.
MAX_WATCHED_ADDRESSES = 64. Subscribing does not itself count as
a peer-bridge connection.
- `createBridgeRoutes` now returns `{ app, websocket, presence }`.
Client (`@shade/transport-bridge`):
- `PresenceBridge.subscribe({ watch, onPresenceChange })` →
`{ addPeer, removePeer, watching, unsubscribe }`. addPeer/removePeer
mutate via reconnect with a fresh signed query.
- `signPresenceQuery` helper for non-PresenceBridge consumers.
Tests cover all four acceptance criteria from the Prism request:
server-event smoke, online→offline subscription, address scoping
(carol invisible to a [alice]-only sub), reconnect, plus an
addPeer/removePeer regression.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:16:35 +02:00
|
|
|
type PresenceChange,
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
} from '../src/index.js';
|
|
|
|
|
|
|
|
|
|
const crypto = new SubtleCryptoProvider();
|
|
|
|
|
|
|
|
|
|
interface Harness {
|
|
|
|
|
server: ReturnType<typeof Bun.serve>;
|
|
|
|
|
baseUrl: string;
|
|
|
|
|
store: InboxStore;
|
|
|
|
|
events: InboxServerEvents;
|
|
|
|
|
bob: Awaited<ReturnType<typeof generateIdentityKeyPair>>;
|
|
|
|
|
alice: Awaited<ReturnType<typeof generateIdentityKeyPair>>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function bootstrap(opts: { mountWs?: boolean } = {}): Promise<Harness> {
|
|
|
|
|
const store = new MemoryInboxStore();
|
|
|
|
|
const events = new InboxServerEvents();
|
2026-05-08 16:31:42 +02:00
|
|
|
// V4.8.4 — share a BridgeDeliveryLog between bridge + inbox routes so
|
|
|
|
|
// the inbox-fetch path filters out blobs the bridge already pushed.
|
|
|
|
|
// Mirrors the wiring in `@shade/server/standalone.ts`.
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
const bridge = createBridgeRoutes({
|
|
|
|
|
store,
|
|
|
|
|
crypto,
|
|
|
|
|
events,
|
|
|
|
|
longPollTimeoutMs: 1_000,
|
|
|
|
|
longPollMaxTimeoutMs: 2_000,
|
|
|
|
|
heartbeatIntervalMs: 200,
|
|
|
|
|
fallbackPollIntervalMs: 50,
|
|
|
|
|
});
|
2026-05-08 16:31:42 +02:00
|
|
|
const inboxApp = createInboxRoutes(store, crypto, {
|
|
|
|
|
events,
|
|
|
|
|
disableRateLimit: true,
|
|
|
|
|
bridgeDeliveryLog: bridge.bridgeDeliveryLog,
|
|
|
|
|
});
|
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.
Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
non-realtime stack.
Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
version 4.0.0 on /health.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00
|
|
|
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<string> {
|
|
|
|
|
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<void> {
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-05-07 23:00:58 +02:00
|
|
|
|
|
|
|
|
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<Response> {
|
|
|
|
|
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<Response> {
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
release(v4.7.0): peer-presence events for instant BroadcastChannel revoke
Adds the bridge-connection-lifecycle signal that closes Prism's
~45s revoke window down to one server→client round-trip (~50ms).
Server (`@shade/inbox-server`):
- `inbox.peer_connected` / `inbox.peer_disconnected` events on the
0↔1 boundary across WS + SSE bridges. Long-poll deliberately not
tracked (every poll boundary would flap; push transports are also
the only ones where instant revoke matters).
- `PresenceTracker` collapses two parallel bridges (e.g. WS + SSE
during fallback handover) into one connect/disconnect pair.
- `GET /v1/bridge/presence` SSE endpoint: signed query with
`kind: 'presence'`, `watched: string[]`; on open streams a
per-address snapshot, then change frames filtered server-side.
MAX_WATCHED_ADDRESSES = 64. Subscribing does not itself count as
a peer-bridge connection.
- `createBridgeRoutes` now returns `{ app, websocket, presence }`.
Client (`@shade/transport-bridge`):
- `PresenceBridge.subscribe({ watch, onPresenceChange })` →
`{ addPeer, removePeer, watching, unsubscribe }`. addPeer/removePeer
mutate via reconnect with a fresh signed query.
- `signPresenceQuery` helper for non-PresenceBridge consumers.
Tests cover all four acceptance criteria from the Prism request:
server-event smoke, online→offline subscription, address scoping
(carol invisible to a [alice]-only sub), reconnect, plus an
addPeer/removePeer regression.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:16:35 +02:00
|
|
|
|
|
|
|
|
// ─── V4.7 — presence events ────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async function registerAddress(
|
|
|
|
|
baseUrl: string,
|
|
|
|
|
address: string,
|
|
|
|
|
identity: Awaited<ReturnType<typeof generateIdentityKeyPair>>,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const body = await signPayload(crypto, identity.signingPrivateKey, {
|
|
|
|
|
address,
|
|
|
|
|
signingKey: toBase64(identity.signingPublicKey),
|
|
|
|
|
});
|
|
|
|
|
const res = await fetch(`${baseUrl}/v1/inbox/register`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'content-type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
});
|
|
|
|
|
expect(res.status).toBe(200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('Presence — server emits peer_connected / peer_disconnected', () => {
|
|
|
|
|
let h: Harness;
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
h = await bootstrap();
|
|
|
|
|
// bootstrap registers bob; alice needs registration too so her bridge
|
|
|
|
|
// can authenticate against /v1/bridge/ws.
|
|
|
|
|
await registerAddress(h.baseUrl, 'alice', h.alice);
|
|
|
|
|
});
|
|
|
|
|
afterAll(() => {
|
|
|
|
|
h.server.stop(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('open + close a WsBridge → events fire on the inbox event bus (acceptance 1)', async () => {
|
|
|
|
|
const seen: Array<{ name: string; address: string; bridgeKind: string }> = [];
|
|
|
|
|
const off = h.events.on((e) => {
|
|
|
|
|
if (e.name === 'inbox.peer_connected' || e.name === 'inbox.peer_disconnected') {
|
|
|
|
|
seen.push({
|
|
|
|
|
name: e.name,
|
|
|
|
|
address: e.data.address,
|
|
|
|
|
bridgeKind: e.data.bridgeKind,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
const ws = new WsBridge({
|
|
|
|
|
baseUrl: h.baseUrl,
|
|
|
|
|
auth: { crypto, signingPrivateKey: h.alice.signingPrivateKey, address: 'alice' },
|
|
|
|
|
connectTimeoutMs: 2_000,
|
|
|
|
|
disableAutoReconnect: true,
|
|
|
|
|
});
|
|
|
|
|
await ws.connect({ onMessage: () => {} });
|
|
|
|
|
await waitFor(() => seen.some((s) => s.name === 'inbox.peer_connected'), 2_000);
|
|
|
|
|
await ws.disconnect();
|
|
|
|
|
await waitFor(() => seen.some((s) => s.name === 'inbox.peer_disconnected'), 2_000);
|
|
|
|
|
expect(seen[0]).toEqual({ name: 'inbox.peer_connected', address: 'alice', bridgeKind: 'ws' });
|
|
|
|
|
expect(seen[seen.length - 1]).toEqual({
|
|
|
|
|
name: 'inbox.peer_disconnected',
|
|
|
|
|
address: 'alice',
|
|
|
|
|
bridgeKind: 'ws',
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
off();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('PresenceBridge — subscribe to remote presence changes', () => {
|
|
|
|
|
let h: Harness;
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
h = await bootstrap();
|
|
|
|
|
await registerAddress(h.baseUrl, 'alice', h.alice);
|
|
|
|
|
});
|
|
|
|
|
afterAll(() => {
|
|
|
|
|
h.server.stop(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('online → offline → online over a single subscription (acceptance 2A + 4)', async () => {
|
|
|
|
|
const presence = new PresenceBridge({
|
|
|
|
|
baseUrl: h.baseUrl,
|
|
|
|
|
crypto,
|
|
|
|
|
signingPrivateKey: h.bob.signingPrivateKey,
|
|
|
|
|
address: 'bob',
|
|
|
|
|
initialBackoffMs: 50,
|
|
|
|
|
maxBackoffMs: 200,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const changes: PresenceChange[] = [];
|
|
|
|
|
const sub = await presence.subscribe({
|
|
|
|
|
watch: ['alice'],
|
|
|
|
|
onPresenceChange: (e) => {
|
|
|
|
|
changes.push(e);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Initial snapshot — alice not yet connected.
|
|
|
|
|
await waitFor(() => changes.length >= 1, 2_000);
|
|
|
|
|
expect(changes[0]!.address).toBe('alice');
|
|
|
|
|
expect(changes[0]!.status).toBe('offline');
|
|
|
|
|
expect(changes[0]!.via).toBeUndefined();
|
|
|
|
|
|
|
|
|
|
// Alice opens a WsBridge → bob must see online (acceptance 2A).
|
|
|
|
|
const aliceWs = new WsBridge({
|
|
|
|
|
baseUrl: h.baseUrl,
|
|
|
|
|
auth: { crypto, signingPrivateKey: h.alice.signingPrivateKey, address: 'alice' },
|
|
|
|
|
connectTimeoutMs: 2_000,
|
|
|
|
|
disableAutoReconnect: true,
|
|
|
|
|
});
|
|
|
|
|
await aliceWs.connect({ onMessage: () => {} });
|
|
|
|
|
await waitFor(
|
|
|
|
|
() => changes.some((c) => c.status === 'online' && c.address === 'alice'),
|
|
|
|
|
2_000,
|
|
|
|
|
);
|
|
|
|
|
const onlineFrame = changes.find((c) => c.status === 'online' && c.address === 'alice')!;
|
|
|
|
|
expect(onlineFrame.via).toBe('ws');
|
|
|
|
|
|
|
|
|
|
// Alice's bridge drops → bob must see offline.
|
|
|
|
|
await aliceWs.disconnect();
|
|
|
|
|
await waitFor(
|
|
|
|
|
() =>
|
|
|
|
|
changes.filter((c) => c.address === 'alice' && c.status === 'offline').length >= 2,
|
|
|
|
|
2_000,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Reconnect: alice reopens → bob sees online again (acceptance 4).
|
|
|
|
|
const aliceWs2 = new WsBridge({
|
|
|
|
|
baseUrl: h.baseUrl,
|
|
|
|
|
auth: { crypto, signingPrivateKey: h.alice.signingPrivateKey, address: 'alice' },
|
|
|
|
|
connectTimeoutMs: 2_000,
|
|
|
|
|
disableAutoReconnect: true,
|
|
|
|
|
});
|
|
|
|
|
await aliceWs2.connect({ onMessage: () => {} });
|
|
|
|
|
await waitFor(
|
|
|
|
|
() => changes.filter((c) => c.address === 'alice' && c.status === 'online').length >= 2,
|
|
|
|
|
2_000,
|
|
|
|
|
);
|
|
|
|
|
await aliceWs2.disconnect();
|
|
|
|
|
} finally {
|
|
|
|
|
await sub.unsubscribe();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('subscription on [alice] does not leak carol (acceptance 3)', async () => {
|
|
|
|
|
const carol = await generateIdentityKeyPair(crypto);
|
|
|
|
|
await registerAddress(h.baseUrl, 'carol', carol);
|
|
|
|
|
|
|
|
|
|
const presence = new PresenceBridge({
|
|
|
|
|
baseUrl: h.baseUrl,
|
|
|
|
|
crypto,
|
|
|
|
|
signingPrivateKey: h.bob.signingPrivateKey,
|
|
|
|
|
address: 'bob',
|
|
|
|
|
initialBackoffMs: 50,
|
|
|
|
|
maxBackoffMs: 200,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const changes: PresenceChange[] = [];
|
|
|
|
|
const sub = await presence.subscribe({
|
|
|
|
|
watch: ['alice'],
|
|
|
|
|
onPresenceChange: (e) => {
|
|
|
|
|
changes.push(e);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
// Drain the initial snapshot for alice.
|
|
|
|
|
await waitFor(() => changes.length >= 1, 2_000);
|
|
|
|
|
const baseline = changes.length;
|
|
|
|
|
|
|
|
|
|
// Carol opens a bridge — bob's alice-only subscription must NOT see her.
|
|
|
|
|
const carolWs = new WsBridge({
|
|
|
|
|
baseUrl: h.baseUrl,
|
|
|
|
|
auth: { crypto, signingPrivateKey: carol.signingPrivateKey, address: 'carol' },
|
|
|
|
|
connectTimeoutMs: 2_000,
|
|
|
|
|
disableAutoReconnect: true,
|
|
|
|
|
});
|
|
|
|
|
await carolWs.connect({ onMessage: () => {} });
|
|
|
|
|
// Give the server time to emit + filter.
|
|
|
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
|
|
|
await carolWs.disconnect();
|
|
|
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
|
|
|
|
|
|
|
|
const newFrames = changes.slice(baseline);
|
|
|
|
|
for (const f of newFrames) {
|
|
|
|
|
expect(f.address).toBe('alice');
|
|
|
|
|
expect(f.address).not.toBe('carol');
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
await sub.unsubscribe();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('addPeer / removePeer mutate the watched set via reconnect', async () => {
|
|
|
|
|
const carol = await generateIdentityKeyPair(crypto);
|
|
|
|
|
await registerAddress(h.baseUrl, 'carol-2', carol);
|
|
|
|
|
|
|
|
|
|
const presence = new PresenceBridge({
|
|
|
|
|
baseUrl: h.baseUrl,
|
|
|
|
|
crypto,
|
|
|
|
|
signingPrivateKey: h.bob.signingPrivateKey,
|
|
|
|
|
address: 'bob',
|
|
|
|
|
initialBackoffMs: 50,
|
|
|
|
|
maxBackoffMs: 200,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const changes: PresenceChange[] = [];
|
|
|
|
|
const sub = await presence.subscribe({
|
|
|
|
|
watch: ['alice'],
|
|
|
|
|
onPresenceChange: (e) => {
|
|
|
|
|
changes.push(e);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
await waitFor(() => changes.length >= 1, 2_000);
|
|
|
|
|
expect(sub.watching()).toEqual(['alice']);
|
|
|
|
|
|
|
|
|
|
// Add carol-2 — reconnect should deliver a fresh snapshot that
|
|
|
|
|
// includes the new address.
|
|
|
|
|
await sub.addPeer('carol-2');
|
|
|
|
|
await waitFor(() => changes.some((c) => c.address === 'carol-2'), 2_000);
|
|
|
|
|
expect(sub.watching().sort()).toEqual(['alice', 'carol-2']);
|
|
|
|
|
|
|
|
|
|
const carolWs = new WsBridge({
|
|
|
|
|
baseUrl: h.baseUrl,
|
|
|
|
|
auth: { crypto, signingPrivateKey: carol.signingPrivateKey, address: 'carol-2' },
|
|
|
|
|
connectTimeoutMs: 2_000,
|
|
|
|
|
disableAutoReconnect: true,
|
|
|
|
|
});
|
|
|
|
|
await carolWs.connect({ onMessage: () => {} });
|
|
|
|
|
await waitFor(
|
|
|
|
|
() => changes.some((c) => c.address === 'carol-2' && c.status === 'online'),
|
|
|
|
|
2_000,
|
|
|
|
|
);
|
|
|
|
|
await carolWs.disconnect();
|
|
|
|
|
|
|
|
|
|
// removePeer → carol-2 events must stop arriving.
|
|
|
|
|
await sub.removePeer('carol-2');
|
|
|
|
|
const baseline = changes.filter((c) => c.address === 'carol-2').length;
|
|
|
|
|
const carolWs2 = new WsBridge({
|
|
|
|
|
baseUrl: h.baseUrl,
|
|
|
|
|
auth: { crypto, signingPrivateKey: carol.signingPrivateKey, address: 'carol-2' },
|
|
|
|
|
connectTimeoutMs: 2_000,
|
|
|
|
|
disableAutoReconnect: true,
|
|
|
|
|
});
|
|
|
|
|
await carolWs2.connect({ onMessage: () => {} });
|
|
|
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
|
|
|
await carolWs2.disconnect();
|
|
|
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
|
|
|
const after = changes.filter((c) => c.address === 'carol-2').length;
|
|
|
|
|
expect(after).toBe(baseline);
|
|
|
|
|
expect(sub.watching()).toEqual(['alice']);
|
|
|
|
|
} finally {
|
|
|
|
|
await sub.unsubscribe();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-05-08 00:11:59 +02:00
|
|
|
|
|
|
|
|
// ─── V4.8 — sender-fingerprint propagation on bridge push ──────────
|
|
|
|
|
|
|
|
|
|
describe('Sender attribution — bridge push surfaces IncomingMessage.from', () => {
|
|
|
|
|
test('SSE push carries from = 8-byte hex of SHA-256(senderSigningKey)', async () => {
|
|
|
|
|
const h = await bootstrap();
|
|
|
|
|
try {
|
|
|
|
|
const received: IncomingMessage[] = [];
|
|
|
|
|
const bridge = new SseBridge({
|
|
|
|
|
baseUrl: h.baseUrl,
|
|
|
|
|
auth: bobAuth(h),
|
|
|
|
|
initialBackoffMs: 50,
|
|
|
|
|
maxBackoffMs: 200,
|
|
|
|
|
disableAutoReconnect: true,
|
|
|
|
|
});
|
|
|
|
|
await bridge.connect({ onMessage: (m) => received.push(m) });
|
|
|
|
|
try {
|
|
|
|
|
await putBlob(h, rand(48));
|
|
|
|
|
await waitFor(() => received.length === 1, 5_000);
|
|
|
|
|
const fp = received[0]!.from;
|
|
|
|
|
expect(fp).toMatch(/^[0-9a-f]{16}$/);
|
|
|
|
|
const digest = await globalThis.crypto.subtle.digest(
|
|
|
|
|
'SHA-256',
|
|
|
|
|
h.alice.signingPublicKey as unknown as ArrayBuffer,
|
|
|
|
|
);
|
|
|
|
|
const expected = Array.from(new Uint8Array(digest).slice(0, 8), (b) =>
|
|
|
|
|
b.toString(16).padStart(2, '0'),
|
|
|
|
|
).join('');
|
|
|
|
|
expect(fp).toBe(expected);
|
|
|
|
|
} finally {
|
|
|
|
|
await bridge.disconnect();
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
h.server.stop(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('WS push carries from likewise', async () => {
|
|
|
|
|
const h = await bootstrap();
|
|
|
|
|
try {
|
|
|
|
|
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) });
|
|
|
|
|
try {
|
|
|
|
|
await putBlob(h, rand(48));
|
|
|
|
|
await waitFor(() => received.length === 1, 5_000);
|
|
|
|
|
expect(received[0]!.from).toMatch(/^[0-9a-f]{16}$/);
|
|
|
|
|
} finally {
|
|
|
|
|
await bridge.disconnect();
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
h.server.stop(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('long-poll push carries from likewise', async () => {
|
|
|
|
|
const h = await bootstrap();
|
|
|
|
|
try {
|
|
|
|
|
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) });
|
|
|
|
|
try {
|
|
|
|
|
await putBlob(h, rand(48));
|
|
|
|
|
await waitFor(() => received.length === 1, 5_000);
|
|
|
|
|
expect(received[0]!.from).toMatch(/^[0-9a-f]{16}$/);
|
|
|
|
|
} finally {
|
|
|
|
|
await bridge.disconnect();
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
h.server.stop(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-05-08 12:13:46 +02:00
|
|
|
|
2026-05-08 16:31:42 +02:00
|
|
|
// ─── V4.8.4 — cross-channel dedup via shared BridgeDeliveryLog ────────
|
|
|
|
|
|
|
|
|
|
describe('BridgeDeliveryLog — bridge push suppresses subsequent inbox-poll for the same msgId', () => {
|
|
|
|
|
test('WS push then /v1/inbox/:addr/fetch: fetch returns 0 blobs but advances cursor', async () => {
|
|
|
|
|
// Reproduces the Prism FR `cross-channel-duplicate-fanout-v4.8.2`
|
|
|
|
|
// (re-verified on 4.8.3): a single inbox.send was being delivered
|
|
|
|
|
// both via the WS bridge AND via the next inbox-poll cycle, the
|
|
|
|
|
// duplicate dispatch tripping on already-consumed prekeys.
|
|
|
|
|
// V4.8.4's shared BridgeDeliveryLog records every successful
|
|
|
|
|
// bridge push and the inbox-fetch route filters those msgIds out
|
|
|
|
|
// for the grace window — so a recipient that runs both a bridge
|
|
|
|
|
// and a poll cycle observes exactly one delivery.
|
|
|
|
|
const h = await bootstrap();
|
|
|
|
|
try {
|
|
|
|
|
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) });
|
|
|
|
|
try {
|
|
|
|
|
const msgId = await putBlob(h, rand(48));
|
|
|
|
|
await waitFor(() => received.length === 1, 2_000);
|
|
|
|
|
// Give the bridge handler a tick to record the push in the log
|
|
|
|
|
// (it happens after the await on ws.send returns).
|
|
|
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
|
|
|
|
|
|
// Now do a regular inbox-fetch as if the recipient's
|
|
|
|
|
// `Inbox.pollOnce` cycle fired. With V4.8.4 wiring, the
|
|
|
|
|
// bridge-pushed msgId is filtered out.
|
|
|
|
|
const body = await signPayload(crypto, h.bob.signingPrivateKey, {
|
|
|
|
|
address: 'bob',
|
|
|
|
|
sinceCursor: 0,
|
|
|
|
|
});
|
|
|
|
|
const res = await fetch(`${h.baseUrl}/v1/inbox/bob/fetch`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'content-type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
});
|
|
|
|
|
const fetchJson = (await res.json()) as { blobs: unknown[]; cursor: number };
|
|
|
|
|
expect(fetchJson.blobs.length).toBe(0);
|
|
|
|
|
// Cursor advances past the suppressed blob so the next poll
|
|
|
|
|
// doesn't re-fetch the same range and stay stuck.
|
|
|
|
|
expect(fetchJson.cursor).toBeGreaterThan(0);
|
|
|
|
|
expect(received[0]!.msgId).toBe(msgId);
|
|
|
|
|
} finally {
|
|
|
|
|
await bridge.disconnect();
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
h.server.stop(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('SSE push also records into the log (parity with WS)', async () => {
|
|
|
|
|
const h = await bootstrap();
|
|
|
|
|
try {
|
|
|
|
|
const received: IncomingMessage[] = [];
|
|
|
|
|
const bridge = new SseBridge({
|
|
|
|
|
baseUrl: h.baseUrl,
|
|
|
|
|
auth: bobAuth(h),
|
|
|
|
|
initialBackoffMs: 50,
|
|
|
|
|
maxBackoffMs: 200,
|
|
|
|
|
disableAutoReconnect: true,
|
|
|
|
|
});
|
|
|
|
|
await bridge.connect({ onMessage: (m) => received.push(m) });
|
|
|
|
|
try {
|
|
|
|
|
await putBlob(h, rand(48));
|
|
|
|
|
await waitFor(() => received.length === 1, 2_000);
|
|
|
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
|
const body = await signPayload(crypto, h.bob.signingPrivateKey, {
|
|
|
|
|
address: 'bob',
|
|
|
|
|
sinceCursor: 0,
|
|
|
|
|
});
|
|
|
|
|
const res = await fetch(`${h.baseUrl}/v1/inbox/bob/fetch`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'content-type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
});
|
|
|
|
|
const fetchJson = (await res.json()) as { blobs: unknown[] };
|
|
|
|
|
expect(fetchJson.blobs.length).toBe(0);
|
|
|
|
|
} finally {
|
|
|
|
|
await bridge.disconnect();
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
h.server.stop(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a non-bridge-pushed msgId is still returned by inbox-fetch', async () => {
|
|
|
|
|
// Negative control: blobs that the bridge never pushed (e.g. the
|
|
|
|
|
// bridge wasn't connected when the put landed) must still come
|
|
|
|
|
// through the inbox-fetch path. The filter is bridge-delivered-
|
|
|
|
|
// specific, not a blanket suppression.
|
|
|
|
|
const h = await bootstrap();
|
|
|
|
|
try {
|
|
|
|
|
// No bridge connected.
|
|
|
|
|
const msgId = await putBlob(h, rand(48));
|
|
|
|
|
const body = await signPayload(crypto, h.bob.signingPrivateKey, {
|
|
|
|
|
address: 'bob',
|
|
|
|
|
sinceCursor: 0,
|
|
|
|
|
});
|
|
|
|
|
const res = await fetch(`${h.baseUrl}/v1/inbox/bob/fetch`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'content-type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
});
|
|
|
|
|
const fetchJson = (await res.json()) as { blobs: Array<{ msgId: string }> };
|
|
|
|
|
expect(fetchJson.blobs.length).toBe(1);
|
|
|
|
|
expect(fetchJson.blobs[0]!.msgId).toBe(msgId);
|
|
|
|
|
} finally {
|
|
|
|
|
h.server.stop(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-08 12:13:46 +02:00
|
|
|
// ─── V4.8.2 — per-connection msgId dedup (Prism FR: duplicate fan-out) ─
|
|
|
|
|
|
|
|
|
|
describe('Bridge dedup — single PUT yields exactly one push per connection', () => {
|
|
|
|
|
test('WS: storming inbox.blob_stored does not duplicate frames for one msgId', async () => {
|
|
|
|
|
const h = await bootstrap();
|
|
|
|
|
try {
|
|
|
|
|
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) });
|
|
|
|
|
try {
|
|
|
|
|
// One real PUT + replay the inbox.blob_stored event ten times to
|
|
|
|
|
// simulate any future code path (or external bug) that double-
|
|
|
|
|
// fires the trigger. The cursor in flushTo would already cover
|
|
|
|
|
// the happy case, but the per-connection LRU is the explicit
|
|
|
|
|
// dedup gate that survives even if cursor logic regresses.
|
|
|
|
|
const msgId = await putBlob(h, rand(48));
|
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
|
|
|
h.events.emit('inbox.blob_stored', {
|
|
|
|
|
address: 'bob',
|
|
|
|
|
msgId,
|
|
|
|
|
bytes: 48,
|
|
|
|
|
ttlSeconds: 60,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
await waitFor(() => received.length >= 1, 2_000);
|
|
|
|
|
// Give any stragglers a chance to arrive and inflate the count.
|
|
|
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
|
|
|
expect(received.length).toBe(1);
|
|
|
|
|
expect(received[0]!.msgId).toBe(msgId);
|
|
|
|
|
} finally {
|
|
|
|
|
await bridge.disconnect();
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
h.server.stop(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('SSE: same dedup contract', async () => {
|
|
|
|
|
const h = await bootstrap();
|
|
|
|
|
try {
|
|
|
|
|
const received: IncomingMessage[] = [];
|
|
|
|
|
const bridge = new SseBridge({
|
|
|
|
|
baseUrl: h.baseUrl,
|
|
|
|
|
auth: bobAuth(h),
|
|
|
|
|
initialBackoffMs: 50,
|
|
|
|
|
maxBackoffMs: 200,
|
|
|
|
|
disableAutoReconnect: true,
|
|
|
|
|
});
|
|
|
|
|
await bridge.connect({ onMessage: (m) => received.push(m) });
|
|
|
|
|
try {
|
|
|
|
|
const msgId = await putBlob(h, rand(48));
|
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
|
|
|
h.events.emit('inbox.blob_stored', {
|
|
|
|
|
address: 'bob',
|
|
|
|
|
msgId,
|
|
|
|
|
bytes: 48,
|
|
|
|
|
ttlSeconds: 60,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
await waitFor(() => received.length >= 1, 2_000);
|
|
|
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
|
|
|
expect(received.length).toBe(1);
|
|
|
|
|
expect(received[0]!.msgId).toBe(msgId);
|
|
|
|
|
} finally {
|
|
|
|
|
await bridge.disconnect();
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
h.server.stop(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|