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:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user