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>
This commit is contained in:
2026-05-07 23:16:35 +02:00
parent 8746571d2a
commit 594992a183
34 changed files with 1042 additions and 28 deletions

View File

@@ -28,7 +28,9 @@ import {
WsBridge,
FallbackBridgeTransport,
signBridgeQuery,
PresenceBridge,
type IncomingMessage,
type PresenceChange,
} from '../src/index.js';
const crypto = new SubtleCryptoProvider();
@@ -611,3 +613,257 @@ describe('Bridges — default fetch is bound to globalThis', () => {
}
});
});
// ─── 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();
}
});
});