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:
@@ -39,6 +39,7 @@ import { verifyPayload, validateAddress } from '@shade/server';
|
||||
import type { InboxStore } from './store.js';
|
||||
import type { InboxServerEvents } from './events.js';
|
||||
import { PresenceTracker, type TrackedBridgeKind } from './presence.js';
|
||||
import { BridgeDeliveryLog } from './bridge-delivery-log.js';
|
||||
|
||||
export type BridgeKind = 'stream' | 'poll' | 'ws';
|
||||
/**
|
||||
@@ -74,6 +75,16 @@ export interface BridgeRoutesOptions {
|
||||
* the bridge auto-creates an internal tracker bound to `events`.
|
||||
*/
|
||||
presenceTracker?: PresenceTracker;
|
||||
/**
|
||||
* V4.8.4 — shared bridge delivery log. After every successful WS /
|
||||
* SSE push we record `(address, msgId, now)` here so the inbox-fetch
|
||||
* route can suppress the same blob from a subsequent inbox-poll
|
||||
* within the log's grace window. Pass the same instance to
|
||||
* `createInboxRoutes` (or use the auto-created one returned in
|
||||
* `bridgeRoutes.bridgeDeliveryLog`). When omitted, the bridge
|
||||
* auto-creates its own log.
|
||||
*/
|
||||
bridgeDeliveryLog?: BridgeDeliveryLog;
|
||||
}
|
||||
|
||||
interface VerifiedBridgeRequest {
|
||||
@@ -103,6 +114,13 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
||||
websocket: unknown;
|
||||
/** Live presence tracker. Tests + observers can read it; routes update it. */
|
||||
presence: PresenceTracker;
|
||||
/**
|
||||
* V4.8.4 — the shared bridge-delivery log this router writes to on
|
||||
* every successful push. Wire the same instance into
|
||||
* `createInboxRoutes({ bridgeDeliveryLog })` so the inbox-fetch route
|
||||
* can suppress recently-pushed blobs.
|
||||
*/
|
||||
bridgeDeliveryLog: BridgeDeliveryLog;
|
||||
} {
|
||||
const app = new Hono();
|
||||
const pageLimit = opts.pageLimit ?? 50;
|
||||
@@ -111,6 +129,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
||||
const longPollMax = opts.longPollMaxTimeoutMs ?? 55_000;
|
||||
const fallbackPollIntervalMs = opts.fallbackPollIntervalMs ?? 1_000;
|
||||
const presence = opts.presenceTracker ?? new PresenceTracker(opts.events ?? null);
|
||||
const bridgeDeliveryLog = opts.bridgeDeliveryLog ?? new BridgeDeliveryLog();
|
||||
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof ShadeError) {
|
||||
@@ -138,6 +157,10 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
||||
const writer = makeBlobWriter(opts.store, pageLimit);
|
||||
const delivered = new DeliveredIdLru();
|
||||
|
||||
const recordPush = (msgId: string): void => {
|
||||
bridgeDeliveryLog.recordDelivered(address, msgId, Date.now());
|
||||
};
|
||||
|
||||
// Initial backlog drain.
|
||||
const flushed = await flushTo(
|
||||
writer,
|
||||
@@ -149,6 +172,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
||||
event: 'envelope',
|
||||
data: JSON.stringify(serializeBlob(blob)),
|
||||
});
|
||||
recordPush(blob.msgId);
|
||||
},
|
||||
delivered,
|
||||
);
|
||||
@@ -181,6 +205,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
||||
event: 'envelope',
|
||||
data: JSON.stringify(serializeBlob(blob)),
|
||||
});
|
||||
recordPush(blob.msgId);
|
||||
},
|
||||
delivered,
|
||||
);
|
||||
@@ -296,6 +321,8 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
||||
limit: pageLimit,
|
||||
});
|
||||
if (blobs.length > 0) {
|
||||
const now = Date.now();
|
||||
for (const b of blobs) bridgeDeliveryLog.recordDelivered(verified.address, b.msgId, now);
|
||||
return c.json(buildPollResponse(blobs, verified.since));
|
||||
}
|
||||
|
||||
@@ -310,6 +337,8 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
||||
fallbackPollIntervalMs,
|
||||
abortSignal: c.req.raw.signal,
|
||||
});
|
||||
const now = Date.now();
|
||||
for (const b of blobs) bridgeDeliveryLog.recordDelivered(verified.address, b.msgId, now);
|
||||
return c.json(buildPollResponse(blobs, verified.since));
|
||||
});
|
||||
|
||||
@@ -380,6 +409,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
||||
cursor,
|
||||
async (blob) => {
|
||||
ws.send(JSON.stringify(serializeBlob(blob)));
|
||||
bridgeDeliveryLog.recordDelivered(address, blob.msgId, Date.now());
|
||||
},
|
||||
delivered,
|
||||
);
|
||||
@@ -414,7 +444,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
||||
}),
|
||||
);
|
||||
|
||||
return { app, websocket, presence };
|
||||
return { app, websocket, presence, bridgeDeliveryLog };
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user