release(v4.8.4): server-side cross-channel dedup via BridgeDeliveryLog
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
V4.8.3 shipped client-side cross-channel dedup hook (`Inbox.acceptBridgeFrame`), but recipients that didn't migrate to the new wiring still observed the same envelope twice — once via WS bridge push, again ~30 s later via inbox-poll. Prism re-verified the FR after 4.8.3 and asked for a relay-side enforcement so app code doesn't have to ack-via-DELETE on every bridge frame. V4.8.4 adds an in-memory `BridgeDeliveryLog` (default 60 s grace, 8192-per-address cap) that records every successful WS / SSE / long-poll push of `(address, msgId)`. The `/v1/inbox/:addr/fetch` route filters out blobs in the log's grace window so a recipient running both a bridge and the 30 s poll cadence sees exactly one delivery. Cursor advances over the full fetched window so a poll that straddles a suppressed blob doesn't stall. The standalone server auto-wires the log between `createBridgeRoutes` and `createInboxRoutes`. Custom mounts thread the same instance through `bridgeDeliveryLog` on both factories. Tests cover WS-then-poll, SSE-then-poll, and a negative control (non-bridge-pushed blob still comes through inbox-fetch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/transport-bridge",
|
||||
"version": "4.8.3",
|
||||
"version": "4.8.4",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -47,7 +47,9 @@ interface Harness {
|
||||
async function bootstrap(opts: { mountWs?: boolean } = {}): Promise<Harness> {
|
||||
const store = new MemoryInboxStore();
|
||||
const events = new InboxServerEvents();
|
||||
const inboxApp = createInboxRoutes(store, crypto, { events, disableRateLimit: true });
|
||||
// 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`.
|
||||
const bridge = createBridgeRoutes({
|
||||
store,
|
||||
crypto,
|
||||
@@ -57,6 +59,11 @@ async function bootstrap(opts: { mountWs?: boolean } = {}): Promise<Harness> {
|
||||
heartbeatIntervalMs: 200,
|
||||
fallbackPollIntervalMs: 50,
|
||||
});
|
||||
const inboxApp = createInboxRoutes(store, crypto, {
|
||||
events,
|
||||
disableRateLimit: true,
|
||||
bridgeDeliveryLog: bridge.bridgeDeliveryLog,
|
||||
});
|
||||
const app = new Hono();
|
||||
app.route('/', inboxApp);
|
||||
app.route('/', bridge.app);
|
||||
@@ -952,6 +959,123 @@ describe('Sender attribution — bridge push surfaces IncomingMessage.from', ()
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── V4.8.2 — per-connection msgId dedup (Prism FR: duplicate fan-out) ─
|
||||
|
||||
describe('Bridge dedup — single PUT yields exactly one push per connection', () => {
|
||||
|
||||
Reference in New Issue
Block a user