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:
115
CHANGELOG.md
115
CHANGELOG.md
@@ -5,6 +5,121 @@ All notable changes to Shade are documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [4.7.0] — 2026-05-07 — Peer-presence events for instant `BroadcastChannel` revoke
|
||||||
|
|
||||||
|
`BroadcastChannel.removeMember` (v4.6) is the right primitive for revoking a
|
||||||
|
paired peer's sender-key membership when, say, a tab closes or a laptop
|
||||||
|
locks — but until now there was no signal saying "this peer's bridge just
|
||||||
|
went away". Apps had to fall back to client-side heartbeats:
|
||||||
|
`apps/web/src/shade/heartbeat.ts`-style 20s pings + a 10s GC sweep, with a
|
||||||
|
~45s worst-case revoke window. For a terminal-mirroring product whose
|
||||||
|
threat model includes *"someone takes the unattended laptop"*, 45s of
|
||||||
|
legitimate broadcast access for the attacker is too long.
|
||||||
|
|
||||||
|
This release surfaces the bridge-connection-lifecycle signal that
|
||||||
|
`createBridgeRoutes` already had internally. The inbox event bus now emits
|
||||||
|
`inbox.peer_connected` / `inbox.peer_disconnected` on the 0↔1 boundary
|
||||||
|
across WS + SSE bridges, and a new `/v1/bridge/presence` SSE endpoint plus
|
||||||
|
the `PresenceBridge` client class let any authenticated SDK subscribe to
|
||||||
|
presence transitions for a watcher-declared address list. The SDK glue
|
||||||
|
collapses to ~5 lines:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const sub = await new PresenceBridge({ baseUrl, crypto, signingPrivateKey, address }).subscribe({
|
||||||
|
watch: paired_peers,
|
||||||
|
onPresenceChange: (e) => {
|
||||||
|
if (e.status === 'offline') void channel.removeMember(e.address);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Reported by Prism — collapses Prism's wave-3 heartbeat-based revoke from
|
||||||
|
~45s to ~50ms (one network round-trip) for the overwhelmingly common case
|
||||||
|
of a clean WS close.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### `@shade/inbox-server`
|
||||||
|
- `InboxServerEventMap` gains two new event names:
|
||||||
|
- `inbox.peer_connected` — `{ address, bridgeKind: 'ws' | 'sse' }` —
|
||||||
|
fires when an address transitions from zero to ≥1 active push-bridge
|
||||||
|
connections.
|
||||||
|
- `inbox.peer_disconnected` — `{ address, bridgeKind, reason: 'closed' | 'error' }`
|
||||||
|
— fires when the last push-bridge connection for the address closes.
|
||||||
|
- New `PresenceTracker` class (`packages/shade-inbox-server/src/presence.ts`)
|
||||||
|
— per-address connection-count map; emits transitions into a wired
|
||||||
|
`InboxServerEvents`. Two parallel bridges (WS + SSE during a fallback
|
||||||
|
handover) collapse into one `peer_connected` / `peer_disconnected`
|
||||||
|
pair so consumers don't see flicker.
|
||||||
|
- `createBridgeRoutes` now returns `{ app, websocket, presence }` so
|
||||||
|
operators / tests can read the live presence map. A `presenceTracker`
|
||||||
|
option lets multiple route mounts share state.
|
||||||
|
- New `GET /v1/bridge/presence` SSE endpoint:
|
||||||
|
- Auth: signed query `{ address, kind: 'presence', watched: string[],
|
||||||
|
signedAt, signature }` against the watcher's registered owner key.
|
||||||
|
`kind: 'presence'` is bound into the canonical signed payload to
|
||||||
|
prevent cross-endpoint replay against `/v1/bridge/{stream,poll,ws}`.
|
||||||
|
- On open: emits one `event: presence` SSE frame per watched address
|
||||||
|
with the current online/offline snapshot.
|
||||||
|
- On change: streams `{ address, status, at, via: 'ws'|'sse' }` frames
|
||||||
|
filtered server-side to the watcher's address list.
|
||||||
|
- Subscribing does NOT itself count as a peer-bridge connection — a
|
||||||
|
PresenceBridge open will not make the watcher appear online to
|
||||||
|
other watchers.
|
||||||
|
- `MAX_WATCHED_ADDRESSES = 64` per subscription.
|
||||||
|
|
||||||
|
#### `@shade/transport-bridge`
|
||||||
|
- New `PresenceBridge` class with `subscribe({ watch, onPresenceChange,
|
||||||
|
onError? })` returning `{ addPeer, removePeer, watching, unsubscribe }`.
|
||||||
|
- `addPeer` / `removePeer` mutate the watched set by aborting the
|
||||||
|
current SSE connection so the run loop reopens with a fresh signed
|
||||||
|
query. Mutations are expected to be rare (only on pair / unpair) so
|
||||||
|
the brief reconnect gap is acceptable.
|
||||||
|
- Auto-reconnect with exponential backoff (250ms → 10s, same defaults
|
||||||
|
as `SseBridge`); `disableAutoReconnect: true` for tests.
|
||||||
|
- `signPresenceQuery` helper exported from `@shade/transport-bridge/auth`
|
||||||
|
for non-PresenceBridge consumers (manual EventSource, observability
|
||||||
|
scrapers, etc.).
|
||||||
|
|
||||||
|
### Why long-poll is NOT tracked
|
||||||
|
|
||||||
|
A long-poll client toggles in/out of `/v1/bridge/poll` every few seconds,
|
||||||
|
and treating each request boundary as a presence transition would
|
||||||
|
dominate the event stream with flapping. Push transports are also the
|
||||||
|
only ones where a ~50ms revoke window matters — long-poll users are
|
||||||
|
already on a slow path. Apps that need presence over long-poll continue
|
||||||
|
to use client-side heartbeats.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `packages/shade-transport-bridge/tests/bridge.test.ts` — four blocks
|
||||||
|
covering all acceptance criteria from the request:
|
||||||
|
- **(1)** `WsBridge.connect()` then `disconnect()` → operator's
|
||||||
|
`events.on(...)` sees `inbox.peer_connected` then
|
||||||
|
`inbox.peer_disconnected` with `address: 'alice'`, `bridgeKind: 'ws'`.
|
||||||
|
- **(2A)** Bob subscribes presence on `[alice]`; alice opens a
|
||||||
|
WsBridge → bob's `onPresenceChange` fires `online` within 2s.
|
||||||
|
- **(3)** Bob's `[alice]` subscription must NOT receive frames for
|
||||||
|
an unrelated `carol` address opening her own bridge.
|
||||||
|
- **(4)** Alice's bridge reopens after a drop → bob sees `online`
|
||||||
|
again on the same subscription.
|
||||||
|
- Plus an `addPeer` / `removePeer` regression that verifies the
|
||||||
|
reconnect-on-mutation path delivers a fresh snapshot for the new
|
||||||
|
address and stops delivering for the removed one.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
None. Strict additive — existing `InboxServerEvents` consumers keep
|
||||||
|
working unchanged. `createBridgeRoutes`'s return type added a
|
||||||
|
`presence` field; destructuring code that names only `app, websocket`
|
||||||
|
keeps compiling.
|
||||||
|
|
||||||
|
For Prism specifically: drop the wave-3 heartbeat module
|
||||||
|
(`apps/web/src/shade/heartbeat.ts`) on the PC sidecar and replace with
|
||||||
|
a `PresenceBridge` subscription on the paired-peer set. Keep the
|
||||||
|
heartbeat as a network-partition fallback if you want a belt-and-
|
||||||
|
braces revoke story; with presence-events the worst-case revoke window
|
||||||
|
drops from ~45s to one server→PC round-trip.
|
||||||
|
|
||||||
## [4.6.1] — 2026-05-07 — Browser `fetch` receiver lost in `Inbox` and HTTP bridges
|
## [4.6.1] — 2026-05-07 — Browser `fetch` receiver lost in `Inbox` and HTTP bridges
|
||||||
|
|
||||||
Every browser consumer of the v4.6.0 transport stack crashed on the
|
Every browser consumer of the v4.6.0 transport stack crashed on the
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/cli",
|
"name": "@shade/cli",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/cli.ts",
|
"main": "src/cli.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/core",
|
"name": "@shade/core",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/crypto-web",
|
"name": "@shade/crypto-web",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/dashboard",
|
"name": "@shade/dashboard",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/files",
|
"name": "@shade/files",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox-server",
|
"name": "@shade/inbox-server",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -38,8 +38,15 @@ import {
|
|||||||
import { verifyPayload, validateAddress } from '@shade/server';
|
import { verifyPayload, validateAddress } from '@shade/server';
|
||||||
import type { InboxStore } from './store.js';
|
import type { InboxStore } from './store.js';
|
||||||
import type { InboxServerEvents } from './events.js';
|
import type { InboxServerEvents } from './events.js';
|
||||||
|
import { PresenceTracker, type TrackedBridgeKind } from './presence.js';
|
||||||
|
|
||||||
export type BridgeKind = 'stream' | 'poll' | 'ws';
|
export type BridgeKind = 'stream' | 'poll' | 'ws';
|
||||||
|
/**
|
||||||
|
* Wire-protocol kind tag for `/v1/bridge/presence`. Distinct from
|
||||||
|
* `BridgeKind` because the canonical signed payload is shaped
|
||||||
|
* differently (`watched: string[]` instead of `since: number`).
|
||||||
|
*/
|
||||||
|
export type PresenceKind = 'presence';
|
||||||
|
|
||||||
export interface BridgeRoutesOptions {
|
export interface BridgeRoutesOptions {
|
||||||
store: InboxStore;
|
store: InboxStore;
|
||||||
@@ -60,6 +67,13 @@ export interface BridgeRoutesOptions {
|
|||||||
* Default 1_000.
|
* Default 1_000.
|
||||||
*/
|
*/
|
||||||
fallbackPollIntervalMs?: number;
|
fallbackPollIntervalMs?: number;
|
||||||
|
/**
|
||||||
|
* Inject an existing presence tracker. Useful when multiple
|
||||||
|
* `createBridgeRoutes` calls need to share state (e.g. mounting the
|
||||||
|
* routes under several hostnames in a single process). When omitted,
|
||||||
|
* the bridge auto-creates an internal tracker bound to `events`.
|
||||||
|
*/
|
||||||
|
presenceTracker?: PresenceTracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VerifiedBridgeRequest {
|
interface VerifiedBridgeRequest {
|
||||||
@@ -68,6 +82,13 @@ interface VerifiedBridgeRequest {
|
|||||||
since: number;
|
since: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VerifiedPresenceRequest {
|
||||||
|
/** The watcher's address (signer of the request). */
|
||||||
|
address: string;
|
||||||
|
/** Addresses whose presence the watcher is asking to track. */
|
||||||
|
watched: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the bridge Hono router and a paired Bun-WebSocket handler.
|
* Build the bridge Hono router and a paired Bun-WebSocket handler.
|
||||||
*
|
*
|
||||||
@@ -80,6 +101,8 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
|||||||
app: Hono;
|
app: Hono;
|
||||||
/** Pass to `Bun.serve({ websocket })`. Undefined if Bun adapter is missing. */
|
/** Pass to `Bun.serve({ websocket })`. Undefined if Bun adapter is missing. */
|
||||||
websocket: unknown;
|
websocket: unknown;
|
||||||
|
/** Live presence tracker. Tests + observers can read it; routes update it. */
|
||||||
|
presence: PresenceTracker;
|
||||||
} {
|
} {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
const pageLimit = opts.pageLimit ?? 50;
|
const pageLimit = opts.pageLimit ?? 50;
|
||||||
@@ -87,6 +110,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
|||||||
const longPollDefault = opts.longPollTimeoutMs ?? 25_000;
|
const longPollDefault = opts.longPollTimeoutMs ?? 25_000;
|
||||||
const longPollMax = opts.longPollMaxTimeoutMs ?? 55_000;
|
const longPollMax = opts.longPollMaxTimeoutMs ?? 55_000;
|
||||||
const fallbackPollIntervalMs = opts.fallbackPollIntervalMs ?? 1_000;
|
const fallbackPollIntervalMs = opts.fallbackPollIntervalMs ?? 1_000;
|
||||||
|
const presence = opts.presenceTracker ?? new PresenceTracker(opts.events ?? null);
|
||||||
|
|
||||||
app.onError((err, c) => {
|
app.onError((err, c) => {
|
||||||
if (err instanceof ShadeError) {
|
if (err instanceof ShadeError) {
|
||||||
@@ -102,6 +126,14 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
|||||||
const verified = await verifyBridgeAuth(c, opts, 'stream');
|
const verified = await verifyBridgeAuth(c, opts, 'stream');
|
||||||
return streamSSE(c, async (stream) => {
|
return streamSSE(c, async (stream) => {
|
||||||
const address = verified.address;
|
const address = verified.address;
|
||||||
|
const connId = presence.newConnectionId();
|
||||||
|
presence.markConnected(address, 'sse', connId);
|
||||||
|
let presenceClosed = false;
|
||||||
|
const closePresence = (reason: 'closed' | 'error'): void => {
|
||||||
|
if (presenceClosed) return;
|
||||||
|
presenceClosed = true;
|
||||||
|
presence.markDisconnected(address, 'sse', connId, reason);
|
||||||
|
};
|
||||||
let cursor = verified.since;
|
let cursor = verified.since;
|
||||||
const writer = makeBlobWriter(opts.store, pageLimit);
|
const writer = makeBlobWriter(opts.store, pageLimit);
|
||||||
|
|
||||||
@@ -163,6 +195,68 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
|||||||
clearInterval(fallbackTimer);
|
clearInterval(fallbackTimer);
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
await pendingFlushPromise.catch(() => {});
|
await pendingFlushPromise.catch(() => {});
|
||||||
|
closePresence('closed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Presence (V4.7) ──────────────────────────────────────────
|
||||||
|
// SSE feed of `peer_connected` / `peer_disconnected` events filtered
|
||||||
|
// by a watcher-supplied address list. Subscribing does NOT count as
|
||||||
|
// a peer-bridge connection (it doesn't call `markConnected`) so
|
||||||
|
// monitoring presence doesn't make you appear online to others.
|
||||||
|
app.get('/v1/bridge/presence', async (c) => {
|
||||||
|
const verified = await verifyPresenceAuth(c, opts);
|
||||||
|
return streamSSE(c, async (stream) => {
|
||||||
|
const watched = new Set(verified.watched);
|
||||||
|
|
||||||
|
// Initial snapshot — one frame per watched address with current
|
||||||
|
// status. Lets subscribers render UI immediately rather than
|
||||||
|
// waiting for the next state change.
|
||||||
|
const now = Date.now();
|
||||||
|
for (const addr of verified.watched) {
|
||||||
|
await stream.writeSSE({
|
||||||
|
event: 'presence',
|
||||||
|
data: JSON.stringify({
|
||||||
|
address: addr,
|
||||||
|
status: presence.isOnline(addr) ? 'online' : 'offline',
|
||||||
|
at: now,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
if (opts.events) {
|
||||||
|
unsubscribe = opts.events.on((e) => {
|
||||||
|
if (e.name !== 'inbox.peer_connected' && e.name !== 'inbox.peer_disconnected') return;
|
||||||
|
const data = e.data as { address: string; bridgeKind: TrackedBridgeKind };
|
||||||
|
if (!watched.has(data.address)) return;
|
||||||
|
const status = e.name === 'inbox.peer_connected' ? 'online' : 'offline';
|
||||||
|
// Fire-and-forget: drop the frame if the stream has gone away.
|
||||||
|
void stream
|
||||||
|
.writeSSE({
|
||||||
|
event: 'presence',
|
||||||
|
data: JSON.stringify({
|
||||||
|
address: data.address,
|
||||||
|
status,
|
||||||
|
at: e.timestamp,
|
||||||
|
via: data.bridgeKind,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
stream.write(`: ping ${Date.now()}\n\n`).catch(() => {});
|
||||||
|
}, heartbeatIntervalMs);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const sig = c.req.raw.signal;
|
||||||
|
if (sig.aborted) return resolve();
|
||||||
|
sig.addEventListener('abort', () => resolve(), { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
unsubscribe?.();
|
||||||
|
clearInterval(heartbeat);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -230,6 +324,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const address = verified.address;
|
const address = verified.address;
|
||||||
|
const connId = presence.newConnectionId();
|
||||||
let cursor = verified.since;
|
let cursor = verified.since;
|
||||||
const writer = makeBlobWriter(opts.store, pageLimit);
|
const writer = makeBlobWriter(opts.store, pageLimit);
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
@@ -237,12 +332,19 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
|||||||
let pendingFlushPromise: Promise<void> = Promise.resolve();
|
let pendingFlushPromise: Promise<void> = Promise.resolve();
|
||||||
let signalled = false;
|
let signalled = false;
|
||||||
let connected = true;
|
let connected = true;
|
||||||
|
let presenceClosed = false;
|
||||||
|
const closePresence = (reason: 'closed' | 'error'): void => {
|
||||||
|
if (presenceClosed) return;
|
||||||
|
presenceClosed = true;
|
||||||
|
presence.markDisconnected(address, 'ws', connId, reason);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onOpen(_evt: unknown, ws: {
|
onOpen(_evt: unknown, ws: {
|
||||||
send: (data: string) => void;
|
send: (data: string) => void;
|
||||||
close: (code?: number, reason?: string) => void;
|
close: (code?: number, reason?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
presence.markConnected(address, 'ws', connId);
|
||||||
const triggerFlush = (): void => {
|
const triggerFlush = (): void => {
|
||||||
signalled = true;
|
signalled = true;
|
||||||
pendingFlushPromise = pendingFlushPromise.then(async () => {
|
pendingFlushPromise = pendingFlushPromise.then(async () => {
|
||||||
@@ -269,12 +371,19 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
|||||||
connected = false;
|
connected = false;
|
||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
if (fallbackTimer) clearInterval(fallbackTimer);
|
if (fallbackTimer) clearInterval(fallbackTimer);
|
||||||
|
closePresence('closed');
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
connected = false;
|
||||||
|
unsubscribe?.();
|
||||||
|
if (fallbackTimer) clearInterval(fallbackTimer);
|
||||||
|
closePresence('error');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { app, websocket };
|
return { app, websocket, presence };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────
|
||||||
@@ -321,6 +430,68 @@ async function verifyBridgeAuth(
|
|||||||
return { address, kind: kind as BridgeKind, since };
|
return { address, kind: kind as BridgeKind, since };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_WATCHED_ADDRESSES = 64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a `/v1/bridge/presence` request.
|
||||||
|
*
|
||||||
|
* Signed canonical payload: `{address, kind: 'presence', watched: string[],
|
||||||
|
* signedAt}`. The watcher's address must be a registered inbox; the
|
||||||
|
* signature is verified against the registered owner key for that
|
||||||
|
* address. The `watched` list bounds what the subscription will
|
||||||
|
* receive — server-side filtering is enforced inside the handler.
|
||||||
|
*/
|
||||||
|
async function verifyPresenceAuth(
|
||||||
|
c: Context,
|
||||||
|
opts: BridgeRoutesOptions,
|
||||||
|
): Promise<VerifiedPresenceRequest> {
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const qs = url.searchParams;
|
||||||
|
const address = validateAddress(qs.get('address'));
|
||||||
|
const kind = qs.get('kind');
|
||||||
|
if (kind !== 'presence') {
|
||||||
|
throw new ValidationError(`bridge kind mismatch: expected presence`, 'kind');
|
||||||
|
}
|
||||||
|
const watchedRaw = qs.get('watched');
|
||||||
|
if (watchedRaw === null) throw new ValidationError('missing watched', 'watched');
|
||||||
|
// Empty subscription is allowed (subscribe to nothing — useful for a
|
||||||
|
// client that intends to call addPeer right after open). A null/
|
||||||
|
// missing param is still rejected so the canonicalization is
|
||||||
|
// unambiguous.
|
||||||
|
const watched =
|
||||||
|
watchedRaw === ''
|
||||||
|
? []
|
||||||
|
: watchedRaw.split(',').map((a) => validateAddress(a));
|
||||||
|
if (watched.length > MAX_WATCHED_ADDRESSES) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`watched list too large: ${watched.length} > ${MAX_WATCHED_ADDRESSES}`,
|
||||||
|
'watched',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const signedAtStr = qs.get('signedAt');
|
||||||
|
const signature = qs.get('signature');
|
||||||
|
if (signedAtStr === null) throw new ValidationError('missing signedAt', 'signedAt');
|
||||||
|
if (!signature) throw new ValidationError('missing signature', 'signature');
|
||||||
|
const signedAt = Number(signedAtStr);
|
||||||
|
if (!Number.isFinite(signedAt)) {
|
||||||
|
throw new ValidationError('signedAt must be a number', 'signedAt');
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = await opts.store.getAddressOwner(address);
|
||||||
|
if (!owner) {
|
||||||
|
throw new UnauthorizedError(`address ${address} is not registered`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await verifyPayload(opts.crypto, owner, {
|
||||||
|
address,
|
||||||
|
kind,
|
||||||
|
watched,
|
||||||
|
signedAt,
|
||||||
|
signature,
|
||||||
|
});
|
||||||
|
return { address, watched };
|
||||||
|
}
|
||||||
|
|
||||||
interface BlobRow {
|
interface BlobRow {
|
||||||
msgId: string;
|
msgId: string;
|
||||||
ciphertext: Uint8Array;
|
ciphertext: Uint8Array;
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ export interface InboxServerEventMap {
|
|||||||
'inbox.expired_purged': { count: number };
|
'inbox.expired_purged': { count: number };
|
||||||
'inbox.rate_limited': { route: string; key: string };
|
'inbox.rate_limited': { route: string; key: string };
|
||||||
'inbox.quota_rejected': { address: string; reason: 'address-quota' | 'sender-quota' | 'body-too-large' };
|
'inbox.quota_rejected': { address: string; reason: 'address-quota' | 'sender-quota' | 'body-too-large' };
|
||||||
|
// V4.7 — bridge presence transitions. Emitted on the 0↔1 boundary
|
||||||
|
// across tracked transports for a given address. Long-poll is
|
||||||
|
// intentionally NOT tracked: an LP client toggles in/out of a request
|
||||||
|
// every few seconds, and the resulting flapping would dominate the
|
||||||
|
// event stream. Push transports (WS, SSE) are also the only ones
|
||||||
|
// where the ~50ms revoke window for `BroadcastChannel.removeMember`
|
||||||
|
// matters — long-poll users are already on a slow path.
|
||||||
|
'inbox.peer_connected': { address: string; bridgeKind: 'ws' | 'sse' };
|
||||||
|
'inbox.peer_disconnected': {
|
||||||
|
address: string;
|
||||||
|
bridgeKind: 'ws' | 'sse';
|
||||||
|
reason: 'closed' | 'error';
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InboxServerEventName = keyof InboxServerEventMap;
|
export type InboxServerEventName = keyof InboxServerEventMap;
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export {
|
|||||||
export type { InboxQuotaConfig } from './quota.js';
|
export type { InboxQuotaConfig } from './quota.js';
|
||||||
export { createBridgeRoutes } from './bridge.js';
|
export { createBridgeRoutes } from './bridge.js';
|
||||||
export type { BridgeRoutesOptions, BridgeKind } from './bridge.js';
|
export type { BridgeRoutesOptions, BridgeKind } from './bridge.js';
|
||||||
|
export { PresenceTracker } from './presence.js';
|
||||||
|
export type { TrackedBridgeKind } from './presence.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a standalone Shade Inbox Server.
|
* Create a standalone Shade Inbox Server.
|
||||||
|
|||||||
75
packages/shade-inbox-server/src/presence.ts
Normal file
75
packages/shade-inbox-server/src/presence.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* V4.7 — bridge-connection presence tracking.
|
||||||
|
*
|
||||||
|
* The bridge handlers (`/v1/bridge/stream` and `/v1/bridge/ws`) call
|
||||||
|
* `markConnected` on open and `markDisconnected` on close. The tracker
|
||||||
|
* keeps a per-address set of connection ids; the `inbox.peer_connected`
|
||||||
|
* / `inbox.peer_disconnected` events fire only on the 0↔1 boundary so
|
||||||
|
* that two simultaneous bridges (e.g. SSE + WS during a transport-
|
||||||
|
* fallback handover) collapse into a single connected/disconnected
|
||||||
|
* pair from the consumer's point of view.
|
||||||
|
*
|
||||||
|
* Long-poll (`/v1/bridge/poll`) is intentionally NOT tracked — see the
|
||||||
|
* note on `InboxServerEventMap` in `events.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { InboxServerEvents } from './events.js';
|
||||||
|
|
||||||
|
export type TrackedBridgeKind = 'ws' | 'sse';
|
||||||
|
|
||||||
|
export class PresenceTracker {
|
||||||
|
private readonly connections = new Map<string, Set<string>>();
|
||||||
|
private nextConnId = 1;
|
||||||
|
|
||||||
|
constructor(private readonly events: InboxServerEvents | null) {}
|
||||||
|
|
||||||
|
/** Allocate a fresh connection id for `markConnected` / `markDisconnected`. */
|
||||||
|
newConnectionId(): string {
|
||||||
|
return `c${this.nextConnId++}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot: is `address` currently connected over any tracked transport?
|
||||||
|
* Used by `/v1/bridge/presence` to push the initial state to a new
|
||||||
|
* subscriber.
|
||||||
|
*/
|
||||||
|
isOnline(address: string): boolean {
|
||||||
|
const set = this.connections.get(address);
|
||||||
|
return set !== undefined && set.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
markConnected(address: string, bridgeKind: TrackedBridgeKind, connectionId: string): void {
|
||||||
|
let set = this.connections.get(address);
|
||||||
|
const wasOnline = set !== undefined && set.size > 0;
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
this.connections.set(address, set);
|
||||||
|
}
|
||||||
|
set.add(connectionId);
|
||||||
|
if (!wasOnline) {
|
||||||
|
this.events?.emit('inbox.peer_connected', { address, bridgeKind });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markDisconnected(
|
||||||
|
address: string,
|
||||||
|
bridgeKind: TrackedBridgeKind,
|
||||||
|
connectionId: string,
|
||||||
|
reason: 'closed' | 'error',
|
||||||
|
): void {
|
||||||
|
const set = this.connections.get(address);
|
||||||
|
if (!set) return;
|
||||||
|
if (!set.delete(connectionId)) return;
|
||||||
|
if (set.size === 0) {
|
||||||
|
this.connections.delete(address);
|
||||||
|
this.events?.emit('inbox.peer_disconnected', { address, bridgeKind, reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inspect the underlying map. Test/observability use only. */
|
||||||
|
snapshot(): Map<string, ReadonlySet<string>> {
|
||||||
|
return new Map(
|
||||||
|
Array.from(this.connections.entries(), ([k, v]) => [k, v as ReadonlySet<string>]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox",
|
"name": "@shade/inbox",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/key-transparency",
|
"name": "@shade/key-transparency",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/keychain",
|
"name": "@shade/keychain",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/observability",
|
"name": "@shade/observability",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/observer",
|
"name": "@shade/observer",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/proto",
|
"name": "@shade/proto",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/recovery",
|
"name": "@shade/recovery",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/sdk",
|
"name": "@shade/sdk",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/server",
|
"name": "@shade/server",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-encrypted",
|
"name": "@shade/storage-encrypted",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-indexeddb",
|
"name": "@shade/storage-indexeddb",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-postgres",
|
"name": "@shade/storage-postgres",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-sqlite",
|
"name": "@shade/storage-sqlite",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/streams",
|
"name": "@shade/streams",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transfer",
|
"name": "@shade/transfer",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-bridge",
|
"name": "@shade/transport-bridge",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -62,3 +62,39 @@ export function bridgeQueryToCanonical(qs: URLSearchParams): {
|
|||||||
if (!Number.isFinite(signedAt)) return null;
|
if (!Number.isFinite(signedAt)) return null;
|
||||||
return { address, kind, since, signedAt, signature };
|
return { address, kind, since, signedAt, signature };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── V4.7 — presence subscription auth ────────────────────────────
|
||||||
|
|
||||||
|
export interface PresenceAuthInput {
|
||||||
|
crypto: CryptoProvider;
|
||||||
|
signingPrivateKey: Uint8Array;
|
||||||
|
/** The watcher's own address (signer of the request). */
|
||||||
|
address: string;
|
||||||
|
/** Addresses to subscribe presence updates for. May be empty. */
|
||||||
|
watched: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the signed query string for `GET /v1/bridge/presence`. The
|
||||||
|
* `kind: 'presence'` field is bound into the canonical payload to
|
||||||
|
* prevent cross-endpoint replay against `/v1/bridge/{stream,poll,ws}`.
|
||||||
|
*
|
||||||
|
* The `watched` array is sorted to give the signed bytes a canonical
|
||||||
|
* order; the wire form encodes it as a single comma-separated
|
||||||
|
* `watched=` parameter (address syntax disallows `,`).
|
||||||
|
*/
|
||||||
|
export async function signPresenceQuery(input: PresenceAuthInput): Promise<URLSearchParams> {
|
||||||
|
const watched = [...input.watched].sort();
|
||||||
|
const signed = await signPayload(input.crypto, input.signingPrivateKey, {
|
||||||
|
address: input.address,
|
||||||
|
kind: 'presence',
|
||||||
|
watched,
|
||||||
|
});
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set('address', input.address);
|
||||||
|
qs.set('kind', 'presence');
|
||||||
|
qs.set('watched', watched.join(','));
|
||||||
|
qs.set('signedAt', String(signed.signedAt));
|
||||||
|
qs.set('signature', signed.signature);
|
||||||
|
return qs;
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,8 +23,17 @@ export { decodeWireMessage } from './types.js';
|
|||||||
|
|
||||||
export { BridgeError } from './errors.js';
|
export { BridgeError } from './errors.js';
|
||||||
|
|
||||||
export { signBridgeQuery, bridgeQueryToCanonical } from './auth.js';
|
export { signBridgeQuery, bridgeQueryToCanonical, signPresenceQuery } from './auth.js';
|
||||||
export type { BridgeKind, BridgeAuthInput } from './auth.js';
|
export type { BridgeKind, BridgeAuthInput, PresenceAuthInput } from './auth.js';
|
||||||
|
|
||||||
|
export { PresenceBridge } from './presence-bridge.js';
|
||||||
|
export type {
|
||||||
|
PresenceBridgeOptions,
|
||||||
|
PresenceSubscribeOptions,
|
||||||
|
PresenceSubscription,
|
||||||
|
PresenceChange,
|
||||||
|
PresenceVia,
|
||||||
|
} from './presence-bridge.js';
|
||||||
|
|
||||||
export { SseBridge } from './sse-bridge.js';
|
export { SseBridge } from './sse-bridge.js';
|
||||||
export type { SseBridgeOptions } from './sse-bridge.js';
|
export type { SseBridgeOptions } from './sse-bridge.js';
|
||||||
|
|||||||
337
packages/shade-transport-bridge/src/presence-bridge.ts
Normal file
337
packages/shade-transport-bridge/src/presence-bridge.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* V4.7 — presence subscription client.
|
||||||
|
*
|
||||||
|
* Consumes the SSE feed at `<base>/v1/bridge/presence?…` and fires
|
||||||
|
* `onPresenceChange` whenever a watched address transitions
|
||||||
|
* online/offline. Tracking is server-side: the inbox-server emits
|
||||||
|
* presence events on the 0↔1 boundary across WS + SSE bridge
|
||||||
|
* connections, and this client filters by the watcher's declared
|
||||||
|
* address list.
|
||||||
|
*
|
||||||
|
* Threat model context: the typical consumer (Prism, password
|
||||||
|
* managers, anything sender-key-broadcasting) wires this to
|
||||||
|
* `BroadcastChannel.removeMember` so a clean WS/SSE close on a
|
||||||
|
* paired-peer device revokes its sender-key membership within
|
||||||
|
* ~50ms. Long-poll bridges are deliberately NOT tracked on the
|
||||||
|
* server (see `inbox-server` `events.ts`); presence here is
|
||||||
|
* push-transport only.
|
||||||
|
*
|
||||||
|
* Watched-list mutations (`addPeer` / `removePeer`) trigger a
|
||||||
|
* reconnect with a fresh signed query so the server-side filter
|
||||||
|
* reflects the new set. Mutations are expected to be rare (only on
|
||||||
|
* pair / unpair, not on every message), so the brief reconnect gap
|
||||||
|
* is acceptable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CryptoProvider } from '@shade/core';
|
||||||
|
import { signPresenceQuery } from './auth.js';
|
||||||
|
import { BridgeError } from './errors.js';
|
||||||
|
|
||||||
|
export type PresenceVia = 'ws' | 'sse';
|
||||||
|
|
||||||
|
export interface PresenceChange {
|
||||||
|
address: string;
|
||||||
|
status: 'online' | 'offline';
|
||||||
|
/** Server's wall-clock time (ms since epoch) when the change happened. */
|
||||||
|
at: number;
|
||||||
|
/** Which transport carried the connection. Absent on the initial snapshot. */
|
||||||
|
via?: PresenceVia;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceBridgeOptions {
|
||||||
|
/** Bridge base URL — same as `LongPollBridge` / `SseBridge`. */
|
||||||
|
baseUrl: string;
|
||||||
|
crypto: CryptoProvider;
|
||||||
|
/** Watcher's Ed25519 signing key (the address must be a registered inbox). */
|
||||||
|
signingPrivateKey: Uint8Array;
|
||||||
|
/** Watcher's address (the registered inbox owner). */
|
||||||
|
address: string;
|
||||||
|
/** Override `fetch` (tests). */
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
/** Initial reconnect backoff (ms). Default 250. */
|
||||||
|
initialBackoffMs?: number;
|
||||||
|
/** Max reconnect backoff (ms). Default 10_000. */
|
||||||
|
maxBackoffMs?: number;
|
||||||
|
/** Disable automatic reconnect. Default false. */
|
||||||
|
disableAutoReconnect?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceSubscribeOptions {
|
||||||
|
/** Initial set of addresses to watch. May be empty. */
|
||||||
|
watch: readonly string[];
|
||||||
|
/** Fired whenever a watched address transitions, plus once per address on initial open. */
|
||||||
|
onPresenceChange: (change: PresenceChange) => void | Promise<void>;
|
||||||
|
/** Optional reconnect / parse error reporter. */
|
||||||
|
onError?: (err: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceSubscription {
|
||||||
|
/** Add an address to the watched set. Triggers a reconnect. */
|
||||||
|
addPeer(address: string): Promise<void>;
|
||||||
|
/** Remove an address from the watched set. Triggers a reconnect. */
|
||||||
|
removePeer(address: string): Promise<void>;
|
||||||
|
/** Snapshot of the currently-watched addresses. */
|
||||||
|
watching(): readonly string[];
|
||||||
|
/** Tear down. Idempotent. */
|
||||||
|
unsubscribe(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_INITIAL_BACKOFF = 250;
|
||||||
|
const DEFAULT_MAX_BACKOFF = 10_000;
|
||||||
|
|
||||||
|
export class PresenceBridge {
|
||||||
|
private readonly fetchFn: typeof fetch;
|
||||||
|
|
||||||
|
constructor(private readonly options: PresenceBridgeOptions) {
|
||||||
|
const f = options.fetch ?? globalThis.fetch;
|
||||||
|
this.fetchFn = f.bind(globalThis);
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribe(opts: PresenceSubscribeOptions): Promise<PresenceSubscription> {
|
||||||
|
const session = new PresenceSession(this.options, this.fetchFn, opts);
|
||||||
|
await session.start();
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PresenceSession implements PresenceSubscription {
|
||||||
|
private watched: string[];
|
||||||
|
private abortController: AbortController | null = null;
|
||||||
|
private currentReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||||
|
private disposed = false;
|
||||||
|
private readLoopPromise: Promise<void> | null = null;
|
||||||
|
private readonly onPresenceChange: PresenceSubscribeOptions['onPresenceChange'];
|
||||||
|
private readonly onError: NonNullable<PresenceSubscribeOptions['onError']>;
|
||||||
|
private firstOpenResolve: (() => void) | null = null;
|
||||||
|
private firstOpenReject: ((err: Error) => void) | null = null;
|
||||||
|
private firstOpenSettled = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly options: PresenceBridgeOptions,
|
||||||
|
private readonly fetchFn: typeof fetch,
|
||||||
|
opts: PresenceSubscribeOptions,
|
||||||
|
) {
|
||||||
|
this.watched = [...opts.watch];
|
||||||
|
this.onPresenceChange = opts.onPresenceChange;
|
||||||
|
this.onError =
|
||||||
|
opts.onError ?? ((err) => console.warn('[shade-bridge:presence]', err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
watching(): readonly string[] {
|
||||||
|
return [...this.watched];
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
return this.openAndPump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open one SSE connection and resolve once the first response has
|
||||||
|
* been received (so that callers can `await subscribe()` and know
|
||||||
|
* the connection is established before the first state change).
|
||||||
|
* The read loop continues in the background.
|
||||||
|
*/
|
||||||
|
private openAndPump(): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
this.firstOpenSettled = false;
|
||||||
|
this.firstOpenResolve = () => {
|
||||||
|
if (this.firstOpenSettled) return;
|
||||||
|
this.firstOpenSettled = true;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
this.firstOpenReject = (err: Error) => {
|
||||||
|
if (this.firstOpenSettled) return;
|
||||||
|
this.firstOpenSettled = true;
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
this.readLoopPromise = this.runLoop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runLoop(): Promise<void> {
|
||||||
|
let backoff = this.options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF;
|
||||||
|
const maxBackoff = this.options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF;
|
||||||
|
let firstAttempt = true;
|
||||||
|
while (!this.disposed) {
|
||||||
|
try {
|
||||||
|
await this.openOnce();
|
||||||
|
if (firstAttempt) {
|
||||||
|
firstAttempt = false;
|
||||||
|
this.firstOpenResolve?.();
|
||||||
|
}
|
||||||
|
// Reset backoff on a successful open.
|
||||||
|
backoff = this.options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF;
|
||||||
|
await this.consume();
|
||||||
|
} catch (err) {
|
||||||
|
if (this.disposed) return;
|
||||||
|
if (firstAttempt) {
|
||||||
|
// Failed before we ever got a 200 — surface to the caller of subscribe().
|
||||||
|
this.firstOpenReject?.(err as Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.onError(err as Error);
|
||||||
|
}
|
||||||
|
this.currentReader = null;
|
||||||
|
if (this.disposed || this.options.disableAutoReconnect) return;
|
||||||
|
await sleep(backoff);
|
||||||
|
backoff = Math.min(backoff * 2, maxBackoff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openOnce(): Promise<void> {
|
||||||
|
const qs = await signPresenceQuery({
|
||||||
|
crypto: this.options.crypto,
|
||||||
|
signingPrivateKey: this.options.signingPrivateKey,
|
||||||
|
address: this.options.address,
|
||||||
|
watched: this.watched,
|
||||||
|
});
|
||||||
|
const url = `${stripTrailingSlash(this.options.baseUrl)}/v1/bridge/presence?${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' },
|
||||||
|
signal: this.abortController.signal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new BridgeError(`presence connect failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new BridgeError(`presence connect failed: HTTP ${res.status}`, res.status);
|
||||||
|
}
|
||||||
|
if (!res.body) {
|
||||||
|
throw new BridgeError('presence response has no body');
|
||||||
|
}
|
||||||
|
this.currentReader = res.body.getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async consume(): Promise<void> {
|
||||||
|
const reader = this.currentReader;
|
||||||
|
if (!reader) return;
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
|
let dataLines: string[] = [];
|
||||||
|
let eventName: string | null = null;
|
||||||
|
while (true) {
|
||||||
|
let chunk: Awaited<ReturnType<typeof reader.read>>;
|
||||||
|
try {
|
||||||
|
chunk = await reader.read();
|
||||||
|
} catch (err) {
|
||||||
|
// Reader cancelled (mutation / unsubscribe) — exit cleanly.
|
||||||
|
if (this.disposed || (err as Error).name === 'AbortError') return;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (chunk.done) return;
|
||||||
|
buf += decoder.decode(chunk.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 === '') {
|
||||||
|
if (dataLines.length > 0) {
|
||||||
|
await this.dispatch(eventName, dataLines.join('\n'));
|
||||||
|
}
|
||||||
|
dataLines = [];
|
||||||
|
eventName = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith(':')) continue;
|
||||||
|
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);
|
||||||
|
if (field === 'data') dataLines.push(val);
|
||||||
|
else if (field === 'event') eventName = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dispatch(name: string | null, data: string): Promise<void> {
|
||||||
|
if (name !== null && name !== '' && name !== 'presence') return;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(data);
|
||||||
|
} catch (err) {
|
||||||
|
this.onError(new BridgeError(`malformed presence data: ${(err as Error).message}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const change = parsed as PresenceChange;
|
||||||
|
if (
|
||||||
|
typeof change.address !== 'string' ||
|
||||||
|
(change.status !== 'online' && change.status !== 'offline') ||
|
||||||
|
typeof change.at !== 'number'
|
||||||
|
) {
|
||||||
|
this.onError(new BridgeError('presence frame missing required fields'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.onPresenceChange(change);
|
||||||
|
} catch (err) {
|
||||||
|
this.onError(err as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPeer(address: string): Promise<void> {
|
||||||
|
if (this.disposed) throw new BridgeError('PresenceBridge subscription disposed');
|
||||||
|
if (this.watched.includes(address)) return;
|
||||||
|
this.watched = [...this.watched, address];
|
||||||
|
await this.reconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePeer(address: string): Promise<void> {
|
||||||
|
if (this.disposed) throw new BridgeError('PresenceBridge subscription disposed');
|
||||||
|
if (!this.watched.includes(address)) return;
|
||||||
|
this.watched = this.watched.filter((a) => a !== address);
|
||||||
|
await this.reconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tear down the current SSE connection so the run loop reopens with
|
||||||
|
* the new watched list. Cancels via abort + reader.cancel — both are
|
||||||
|
* tolerated by the consume() catch path.
|
||||||
|
*/
|
||||||
|
private async reconnect(): Promise<void> {
|
||||||
|
const reader = this.currentReader;
|
||||||
|
this.currentReader = null;
|
||||||
|
this.abortController?.abort();
|
||||||
|
if (reader) {
|
||||||
|
try {
|
||||||
|
await reader.cancel();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async unsubscribe(): Promise<void> {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.disposed = true;
|
||||||
|
const reader = this.currentReader;
|
||||||
|
this.currentReader = null;
|
||||||
|
this.abortController?.abort();
|
||||||
|
if (reader) {
|
||||||
|
try {
|
||||||
|
await reader.cancel();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.readLoopPromise) {
|
||||||
|
try {
|
||||||
|
await this.readLoopPromise;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTrailingSlash(s: string): string {
|
||||||
|
return s.endsWith('/') ? s.slice(0, -1) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
@@ -28,7 +28,9 @@ import {
|
|||||||
WsBridge,
|
WsBridge,
|
||||||
FallbackBridgeTransport,
|
FallbackBridgeTransport,
|
||||||
signBridgeQuery,
|
signBridgeQuery,
|
||||||
|
PresenceBridge,
|
||||||
type IncomingMessage,
|
type IncomingMessage,
|
||||||
|
type PresenceChange,
|
||||||
} from '../src/index.js';
|
} from '../src/index.js';
|
||||||
|
|
||||||
const crypto = new SubtleCryptoProvider();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-webrtc",
|
"name": "@shade/transport-webrtc",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport",
|
"name": "@shade/transport",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/widgets",
|
"name": "@shade/widgets",
|
||||||
"version": "4.6.1",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user