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:
87
CHANGELOG.md
87
CHANGELOG.md
@@ -5,6 +5,93 @@ 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.8.4] — 2026-05-08 — Server-side cross-channel dedup via `BridgeDeliveryLog`
|
||||||
|
|
||||||
|
V4.8.3 shipped the *client-side* cross-channel dedup hook
|
||||||
|
(`Inbox.acceptBridgeFrame`), but a recipient that didn't migrate to
|
||||||
|
the new wiring kept observing the same envelope twice — once via the
|
||||||
|
WS bridge push, again ~30 s later when the next inbox-poll cycle
|
||||||
|
fetched it. Prism re-verified the FR after 4.8.3 and called this out:
|
||||||
|
they wanted the relay itself to enforce the
|
||||||
|
"one `Inbox.send` ⇒ one observable delivery" contract so app code
|
||||||
|
doesn't have to ack-via-DELETE on every bridge frame.
|
||||||
|
|
||||||
|
V4.8.4 adds the **server-side** dedup gate. A new in-memory
|
||||||
|
`BridgeDeliveryLog` (default 60 s grace, 8192-entry-per-address cap)
|
||||||
|
records every successful WS / SSE / long-poll push of
|
||||||
|
`(address, msgId)`. The inbox-fetch route reads the log and filters
|
||||||
|
out blobs the bridge has already pushed within the grace window. The
|
||||||
|
cursor advances over the *full* fetched window so a poll cycle that
|
||||||
|
straddles a suppressed blob doesn't get stuck — the bridge frame is
|
||||||
|
the canonical delivery; a recipient that crashed before processing it
|
||||||
|
falls back to ack-via-DELETE or waits for TTL the same way it would
|
||||||
|
in a no-bridge deployment.
|
||||||
|
|
||||||
|
The standalone server (`@shade/server`) auto-wires the log between
|
||||||
|
`createBridgeRoutes` and `createInboxRoutes`, so self-hosted relays
|
||||||
|
get the fix without configuration. Custom mounts thread the same
|
||||||
|
instance through `bridgeDeliveryLog` on both factories.
|
||||||
|
|
||||||
|
Reported by Prism (multi-device E2EE terminal). Acceptance: a single
|
||||||
|
`inbox.send` from sender A produces exactly one observable receive
|
||||||
|
on recipient B even when B runs both a bridge subscription and the
|
||||||
|
30 s `Inbox.pollOnce` cadence — confirmed by `bun test
|
||||||
|
packages/shade-transport-bridge/tests/bridge.test.ts`'s new
|
||||||
|
"BridgeDeliveryLog" describe block (WS + SSE coverage).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### `@shade/inbox-server`
|
||||||
|
- `BridgeDeliveryLog` class — in-memory `Map<address, Map<msgId,
|
||||||
|
deliveredAt>>` with grace-window filtering. Bounded per address
|
||||||
|
(default 8192, oldest-first eviction); lazy cleanup on insert.
|
||||||
|
Exported from the package root.
|
||||||
|
- `BridgeDeliveryLogOptions` — `{ graceMs?, maxPerAddress? }`.
|
||||||
|
- `createBridgeRoutes(...).bridgeDeliveryLog` — the auto-created log
|
||||||
|
the bridge handlers write to. Inject one explicitly via
|
||||||
|
`BridgeRoutesOptions.bridgeDeliveryLog` when you need to share an
|
||||||
|
instance across multiple bridge mounts.
|
||||||
|
- `InboxRoutesOptions.bridgeDeliveryLog` — when provided, the
|
||||||
|
`/v1/inbox/:addr/fetch` route filters out blobs in the log's
|
||||||
|
grace window. Cursor advances over the *full* unsuppressed-plus-
|
||||||
|
suppressed page so successive polls don't stall.
|
||||||
|
- `createInboxServer({ bridgeDeliveryLog })` — opt-in for the
|
||||||
|
high-level factory.
|
||||||
|
|
||||||
|
#### `@shade/server` — `standalone.ts`
|
||||||
|
- The shared `BridgeDeliveryLog` is auto-wired between
|
||||||
|
`createBridgeRoutes` and `createInboxRoutes` so self-hosted
|
||||||
|
relays inherit the cross-channel dedup with zero configuration.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `packages/shade-transport-bridge/tests/bridge.test.ts` — new
|
||||||
|
"BridgeDeliveryLog" describe block:
|
||||||
|
1. WS push then `/v1/inbox/:addr/fetch` returns 0 blobs but the
|
||||||
|
cursor has advanced.
|
||||||
|
2. SSE push records into the log identically (transport parity).
|
||||||
|
3. A blob the bridge never pushed (e.g. bridge wasn't connected)
|
||||||
|
still comes through inbox-fetch — the filter is bridge-
|
||||||
|
delivered-specific, not a blanket suppression.
|
||||||
|
- `bootstrap()` in the test file now wires the bridge's
|
||||||
|
auto-created log into `createInboxRoutes`, mirroring the
|
||||||
|
standalone-server wiring.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
For self-hosted operators of the standalone server: drop-in.
|
||||||
|
|
||||||
|
For consumers that mount `createInboxRoutes` + `createBridgeRoutes`
|
||||||
|
themselves: pass the same `bridgeDeliveryLog` to both factories. The
|
||||||
|
bridge auto-creates one and exposes it as
|
||||||
|
`bridgeRoutes.bridgeDeliveryLog`; the inbox routes accept it as
|
||||||
|
`InboxRoutesOptions.bridgeDeliveryLog`. Without the wiring, the bridge
|
||||||
|
push still works as before but the cross-channel dedup is off.
|
||||||
|
|
||||||
|
For client-side consumers, V4.8.3's `Inbox.acceptBridgeFrame` is
|
||||||
|
still the recommended path (instant ack, no grace-window wait), but
|
||||||
|
clients that only consume bridge pushes via their own dispatcher now
|
||||||
|
get the dedup for free as long as the relay is on V4.8.4.
|
||||||
|
|
||||||
## [4.8.3] — 2026-05-08 — Cross-channel msgId dedup + `Shade.aliasSession`
|
## [4.8.3] — 2026-05-08 — Cross-channel msgId dedup + `Shade.aliasSession`
|
||||||
|
|
||||||
Two follow-ups to the V4.8.2 duplicate-fan-out fixes Prism filed
|
Two follow-ups to the V4.8.2 duplicate-fan-out fixes Prism filed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/cli",
|
"name": "@shade/cli",
|
||||||
"version": "4.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/files",
|
"name": "@shade/files",
|
||||||
"version": "4.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
140
packages/shade-inbox-server/src/bridge-delivery-log.ts
Normal file
140
packages/shade-inbox-server/src/bridge-delivery-log.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* BridgeDeliveryLog — V4.8.4 cross-channel dedup.
|
||||||
|
*
|
||||||
|
* Records per-`(address, msgId)` "delivered via bridge push" timestamps so
|
||||||
|
* the inbox-fetch route can filter out blobs the bridge has already pushed
|
||||||
|
* to the recipient. The relay's contract becomes:
|
||||||
|
*
|
||||||
|
* one `Inbox.send` ⇒ one observable delivery on the recipient
|
||||||
|
*
|
||||||
|
* even when the recipient runs both a bridge subscription (WS / SSE) AND
|
||||||
|
* the regular inbox-poll. Without it, bridge-push and inbox-poll are
|
||||||
|
* independent paths against the same store and the recipient gets the
|
||||||
|
* same envelope twice — bridge-first, then ~30 s later via the next poll
|
||||||
|
* — tripping on already-consumed prekeys (`one-time prekey not found`)
|
||||||
|
* or surfacing as duplicate `shade.receive` work.
|
||||||
|
*
|
||||||
|
* The log is in-memory per process and intentionally bounded: each entry
|
||||||
|
* lives for `graceMs` (default 60 s, well past a typical `pollIntervalMs`
|
||||||
|
* of 30 s). After grace, the entry is forgotten and inbox-poll falls back
|
||||||
|
* to delivering the blob — that's the legitimate "bridge dropped the
|
||||||
|
* frame, poll picked up" recovery path. If the recipient explicitly
|
||||||
|
* acks the blob (HTTP `DELETE /v1/inbox/:addr/:msgId`), the blob is gone
|
||||||
|
* from storage and the log entry is moot.
|
||||||
|
*
|
||||||
|
* Multi-bridge per address (e.g. WS + SSE redundancy on the same client,
|
||||||
|
* or two devices sharing one signing key) is preserved: every bridge
|
||||||
|
* connection still fetches + pushes the blob — each push records its own
|
||||||
|
* timestamp — so each connected bridge gets the frame. Only the *poll*
|
||||||
|
* fetch is filtered, not the bridge fetches themselves.
|
||||||
|
*
|
||||||
|
* @see Prism FR `cross-channel-duplicate-fanout-v4.8.2.md`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_GRACE_MS = 60_000;
|
||||||
|
|
||||||
|
export interface BridgeDeliveryLogOptions {
|
||||||
|
/**
|
||||||
|
* How long a `(address, msgId)` mark suppresses inbox-poll delivery.
|
||||||
|
* Defaults to 60_000ms — twice the default `pollIntervalMs` of the
|
||||||
|
* `@shade/inbox` orchestrator, so a poll cycle that races a bridge
|
||||||
|
* push always sees the mark, but a stuck recipient still gets the
|
||||||
|
* blob via poll within ~minutes.
|
||||||
|
*/
|
||||||
|
graceMs?: number;
|
||||||
|
/**
|
||||||
|
* Maximum entries per address. Bounds memory under a busy address.
|
||||||
|
* Oldest entries (by recorded timestamp) are evicted first. Default
|
||||||
|
* 8192 — comfortably above any realistic backlog.
|
||||||
|
*/
|
||||||
|
maxPerAddress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BridgeDeliveryLog {
|
||||||
|
private readonly log = new Map<string, Map<string, number>>();
|
||||||
|
private readonly graceMs: number;
|
||||||
|
private readonly maxPerAddress: number;
|
||||||
|
|
||||||
|
constructor(options: BridgeDeliveryLogOptions = {}) {
|
||||||
|
this.graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
|
||||||
|
this.maxPerAddress = options.maxPerAddress ?? 8192;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark `(address, msgId)` as bridge-delivered at `now`. */
|
||||||
|
recordDelivered(address: string, msgId: string, now: number): void {
|
||||||
|
let inner = this.log.get(address);
|
||||||
|
if (!inner) {
|
||||||
|
inner = new Map();
|
||||||
|
this.log.set(address, inner);
|
||||||
|
}
|
||||||
|
inner.set(msgId, now);
|
||||||
|
// Lazy cleanup: drop entries past 2× grace so the map stays bounded
|
||||||
|
// without a separate timer. Bound by `maxPerAddress` as a fallback
|
||||||
|
// for pathological burst scenarios.
|
||||||
|
if (inner.size > this.maxPerAddress) {
|
||||||
|
const cutoff = now - this.graceMs * 2;
|
||||||
|
for (const [id, ts] of inner) {
|
||||||
|
if (ts < cutoff) inner.delete(id);
|
||||||
|
}
|
||||||
|
// Still over cap? Drop the oldest.
|
||||||
|
if (inner.size > this.maxPerAddress) {
|
||||||
|
const sorted = Array.from(inner.entries()).sort((a, b) => a[1] - b[1]);
|
||||||
|
const toDrop = sorted.slice(0, inner.size - this.maxPerAddress);
|
||||||
|
for (const [id] of toDrop) inner.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if `(address, msgId)` was bridge-delivered within the
|
||||||
|
* grace window.
|
||||||
|
*/
|
||||||
|
isRecentlyDelivered(address: string, msgId: string, now: number): boolean {
|
||||||
|
const inner = this.log.get(address);
|
||||||
|
if (!inner) return false;
|
||||||
|
const ts = inner.get(msgId);
|
||||||
|
if (ts === undefined) return false;
|
||||||
|
if (now - ts > this.graceMs) {
|
||||||
|
inner.delete(msgId); // tombstone the stale entry
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter `blobs` down to those not currently in the bridge-delivered
|
||||||
|
* grace window. Used by the inbox-fetch route to suppress duplicates.
|
||||||
|
*/
|
||||||
|
filterRecent<T extends { msgId: string }>(
|
||||||
|
address: string,
|
||||||
|
blobs: T[],
|
||||||
|
now: number,
|
||||||
|
): T[] {
|
||||||
|
const inner = this.log.get(address);
|
||||||
|
if (!inner || inner.size === 0) return blobs;
|
||||||
|
return blobs.filter((b) => {
|
||||||
|
const ts = inner.get(b.msgId);
|
||||||
|
if (ts === undefined) return true;
|
||||||
|
if (now - ts > this.graceMs) {
|
||||||
|
inner.delete(b.msgId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the entry for `(address, msgId)`. Called from blob-delete paths. */
|
||||||
|
forget(address: string, msgId: string): void {
|
||||||
|
this.log.get(address)?.delete(msgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop every entry for `address`. Called from address-delete paths. */
|
||||||
|
forgetAddress(address: string): void {
|
||||||
|
this.log.delete(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only inspection. */
|
||||||
|
size(address: string): number {
|
||||||
|
return this.log.get(address)?.size ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ 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';
|
import { PresenceTracker, type TrackedBridgeKind } from './presence.js';
|
||||||
|
import { BridgeDeliveryLog } from './bridge-delivery-log.js';
|
||||||
|
|
||||||
export type BridgeKind = 'stream' | 'poll' | 'ws';
|
export type BridgeKind = 'stream' | 'poll' | 'ws';
|
||||||
/**
|
/**
|
||||||
@@ -74,6 +75,16 @@ export interface BridgeRoutesOptions {
|
|||||||
* the bridge auto-creates an internal tracker bound to `events`.
|
* the bridge auto-creates an internal tracker bound to `events`.
|
||||||
*/
|
*/
|
||||||
presenceTracker?: PresenceTracker;
|
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 {
|
interface VerifiedBridgeRequest {
|
||||||
@@ -103,6 +114,13 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
|||||||
websocket: unknown;
|
websocket: unknown;
|
||||||
/** Live presence tracker. Tests + observers can read it; routes update it. */
|
/** Live presence tracker. Tests + observers can read it; routes update it. */
|
||||||
presence: PresenceTracker;
|
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 app = new Hono();
|
||||||
const pageLimit = opts.pageLimit ?? 50;
|
const pageLimit = opts.pageLimit ?? 50;
|
||||||
@@ -111,6 +129,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
|||||||
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);
|
const presence = opts.presenceTracker ?? new PresenceTracker(opts.events ?? null);
|
||||||
|
const bridgeDeliveryLog = opts.bridgeDeliveryLog ?? new BridgeDeliveryLog();
|
||||||
|
|
||||||
app.onError((err, c) => {
|
app.onError((err, c) => {
|
||||||
if (err instanceof ShadeError) {
|
if (err instanceof ShadeError) {
|
||||||
@@ -138,6 +157,10 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
|||||||
const writer = makeBlobWriter(opts.store, pageLimit);
|
const writer = makeBlobWriter(opts.store, pageLimit);
|
||||||
const delivered = new DeliveredIdLru();
|
const delivered = new DeliveredIdLru();
|
||||||
|
|
||||||
|
const recordPush = (msgId: string): void => {
|
||||||
|
bridgeDeliveryLog.recordDelivered(address, msgId, Date.now());
|
||||||
|
};
|
||||||
|
|
||||||
// Initial backlog drain.
|
// Initial backlog drain.
|
||||||
const flushed = await flushTo(
|
const flushed = await flushTo(
|
||||||
writer,
|
writer,
|
||||||
@@ -149,6 +172,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
|||||||
event: 'envelope',
|
event: 'envelope',
|
||||||
data: JSON.stringify(serializeBlob(blob)),
|
data: JSON.stringify(serializeBlob(blob)),
|
||||||
});
|
});
|
||||||
|
recordPush(blob.msgId);
|
||||||
},
|
},
|
||||||
delivered,
|
delivered,
|
||||||
);
|
);
|
||||||
@@ -181,6 +205,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
|||||||
event: 'envelope',
|
event: 'envelope',
|
||||||
data: JSON.stringify(serializeBlob(blob)),
|
data: JSON.stringify(serializeBlob(blob)),
|
||||||
});
|
});
|
||||||
|
recordPush(blob.msgId);
|
||||||
},
|
},
|
||||||
delivered,
|
delivered,
|
||||||
);
|
);
|
||||||
@@ -296,6 +321,8 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
|||||||
limit: pageLimit,
|
limit: pageLimit,
|
||||||
});
|
});
|
||||||
if (blobs.length > 0) {
|
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));
|
return c.json(buildPollResponse(blobs, verified.since));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +337,8 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
|||||||
fallbackPollIntervalMs,
|
fallbackPollIntervalMs,
|
||||||
abortSignal: c.req.raw.signal,
|
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));
|
return c.json(buildPollResponse(blobs, verified.since));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -380,6 +409,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
|||||||
cursor,
|
cursor,
|
||||||
async (blob) => {
|
async (blob) => {
|
||||||
ws.send(JSON.stringify(serializeBlob(blob)));
|
ws.send(JSON.stringify(serializeBlob(blob)));
|
||||||
|
bridgeDeliveryLog.recordDelivered(address, blob.msgId, Date.now());
|
||||||
},
|
},
|
||||||
delivered,
|
delivered,
|
||||||
);
|
);
|
||||||
@@ -414,7 +444,7 @@ export function createBridgeRoutes(opts: BridgeRoutesOptions): {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { app, websocket, presence };
|
return { app, websocket, presence, bridgeDeliveryLog };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ 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 { PresenceTracker } from './presence.js';
|
||||||
export type { TrackedBridgeKind } from './presence.js';
|
export type { TrackedBridgeKind } from './presence.js';
|
||||||
|
export { BridgeDeliveryLog } from './bridge-delivery-log.js';
|
||||||
|
export type { BridgeDeliveryLogOptions } from './bridge-delivery-log.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a standalone Shade Inbox Server.
|
* Create a standalone Shade Inbox Server.
|
||||||
@@ -51,12 +53,13 @@ export function createInboxServer(options: {
|
|||||||
store?: InboxStore;
|
store?: InboxStore;
|
||||||
disableRateLimit?: boolean;
|
disableRateLimit?: boolean;
|
||||||
events?: InboxServerEvents;
|
events?: InboxServerEvents;
|
||||||
} & Pick<InboxRoutesOptions, 'observability' | 'quota'>): Hono {
|
} & Pick<InboxRoutesOptions, 'observability' | 'quota' | 'bridgeDeliveryLog'>): Hono {
|
||||||
const store = options.store ?? new MemoryInboxStore();
|
const store = options.store ?? new MemoryInboxStore();
|
||||||
const routesOptions: InboxRoutesOptions = {};
|
const routesOptions: InboxRoutesOptions = {};
|
||||||
if (options.disableRateLimit !== undefined) routesOptions.disableRateLimit = options.disableRateLimit;
|
if (options.disableRateLimit !== undefined) routesOptions.disableRateLimit = options.disableRateLimit;
|
||||||
if (options.events !== undefined) routesOptions.events = options.events;
|
if (options.events !== undefined) routesOptions.events = options.events;
|
||||||
if (options.observability !== undefined) routesOptions.observability = options.observability;
|
if (options.observability !== undefined) routesOptions.observability = options.observability;
|
||||||
if (options.quota !== undefined) routesOptions.quota = options.quota;
|
if (options.quota !== undefined) routesOptions.quota = options.quota;
|
||||||
|
if (options.bridgeDeliveryLog !== undefined) routesOptions.bridgeDeliveryLog = options.bridgeDeliveryLog;
|
||||||
return createInboxRoutes(store, options.crypto, routesOptions);
|
return createInboxRoutes(store, options.crypto, routesOptions);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type { InboxStore } from './store.js';
|
|||||||
import { InboxServerEvents, shortHash } from './events.js';
|
import { InboxServerEvents, shortHash } from './events.js';
|
||||||
import { computeMsgId, isValidMsgId, constantTimeStringEqual } from './msg-id.js';
|
import { computeMsgId, isValidMsgId, constantTimeStringEqual } from './msg-id.js';
|
||||||
import { clampTtl, DEFAULT_INBOX_QUOTA, type InboxQuotaConfig } from './quota.js';
|
import { clampTtl, DEFAULT_INBOX_QUOTA, type InboxQuotaConfig } from './quota.js';
|
||||||
|
import type { BridgeDeliveryLog } from './bridge-delivery-log.js';
|
||||||
|
|
||||||
/** Max metadata-only body size (no ciphertext). 64 KB is enough headroom. */
|
/** Max metadata-only body size (no ciphertext). 64 KB is enough headroom. */
|
||||||
const MAX_META_BODY_SIZE = 64 * 1024;
|
const MAX_META_BODY_SIZE = 64 * 1024;
|
||||||
@@ -54,6 +55,16 @@ export interface InboxRoutesOptions {
|
|||||||
observability?: ObservabilityHook;
|
observability?: ObservabilityHook;
|
||||||
/** Override quota policy. */
|
/** Override quota policy. */
|
||||||
quota?: Partial<InboxQuotaConfig>;
|
quota?: Partial<InboxQuotaConfig>;
|
||||||
|
/**
|
||||||
|
* V4.8.4 — shared bridge delivery log. When provided (and the same
|
||||||
|
* instance is wired into `createBridgeRoutes`), the inbox-fetch route
|
||||||
|
* filters out blobs already pushed via bridge within the log's grace
|
||||||
|
* window. Without this, a recipient that runs both a bridge
|
||||||
|
* subscription and inbox-poll receives the same envelope twice.
|
||||||
|
* Optional — leaving it unset preserves the pre-V4.8.4 behavior of
|
||||||
|
* always returning every blob the cursor matches.
|
||||||
|
*/
|
||||||
|
bridgeDeliveryLog?: BridgeDeliveryLog;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createInboxRoutes(
|
export function createInboxRoutes(
|
||||||
@@ -171,6 +182,7 @@ export function createInboxRoutes(
|
|||||||
await verifyPayload(crypto, owner, { ...body, address });
|
await verifyPayload(crypto, owner, { ...body, address });
|
||||||
|
|
||||||
await store.deleteAddress(address);
|
await store.deleteAddress(address);
|
||||||
|
options.bridgeDeliveryLog?.forgetAddress(address);
|
||||||
events?.emit('inbox.address_deleted', { address });
|
events?.emit('inbox.address_deleted', { address });
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
@@ -317,13 +329,25 @@ export function createInboxRoutes(
|
|||||||
await verifyPayload(crypto, owner, { ...body, address });
|
await verifyPayload(crypto, owner, { ...body, address });
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const rows = await store.fetchBlobs({
|
const rawRows = await store.fetchBlobs({
|
||||||
address,
|
address,
|
||||||
sinceCursor,
|
sinceCursor,
|
||||||
now,
|
now,
|
||||||
limit: quota.fetchPageLimit,
|
limit: quota.fetchPageLimit,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// V4.8.4 — drop blobs the bridge has already pushed to this address
|
||||||
|
// within the grace window. This is the cross-channel dedup gate that
|
||||||
|
// makes "one inbox.send ⇒ one observable delivery" hold even when
|
||||||
|
// the recipient runs both a bridge subscription and inbox-poll. The
|
||||||
|
// cursor still advances over the whole `rawRows` window so the
|
||||||
|
// client doesn't get stuck behind suppressed blobs — pollOnce uses
|
||||||
|
// `nextCursor` (max receivedAt seen by the server, suppressed or
|
||||||
|
// not) for the next fetch.
|
||||||
|
const rows = options.bridgeDeliveryLog
|
||||||
|
? options.bridgeDeliveryLog.filterRecent(address, rawRows, now)
|
||||||
|
: rawRows;
|
||||||
|
|
||||||
let bytes = 0;
|
let bytes = 0;
|
||||||
const blobs = rows.map((r) => {
|
const blobs = rows.map((r) => {
|
||||||
bytes += r.ciphertext.length;
|
bytes += r.ciphertext.length;
|
||||||
@@ -344,14 +368,24 @@ export function createInboxRoutes(
|
|||||||
if (r.senderFp) out.from = r.senderFp;
|
if (r.senderFp) out.from = r.senderFp;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
const nextCursor = rows.length > 0 ? rows[rows.length - 1]!.receivedAt : sinceCursor;
|
// Advance the cursor past the FULL rawRows window — including blobs
|
||||||
|
// we suppressed because the bridge already pushed them. If we
|
||||||
|
// anchored the cursor on `rows` only, suppressed blobs in the
|
||||||
|
// middle of the window would block all subsequent fetches forever
|
||||||
|
// (re-fetched on every poll, re-suppressed, no progress). The
|
||||||
|
// bridge-delivery contract is "the bridge frame is the canonical
|
||||||
|
// delivery"; if the recipient missed processing it, they fall back
|
||||||
|
// to ack-via-DELETE or the blob ages out at TTL — same as a
|
||||||
|
// recipient that crashes mid-handler in the no-bridge case.
|
||||||
|
const cursorAnchor = rawRows.length > 0 ? rawRows[rawRows.length - 1]!.receivedAt : sinceCursor;
|
||||||
|
const nextCursor = cursorAnchor;
|
||||||
|
|
||||||
events?.emit('inbox.blob_fetched', { address, count: blobs.length, bytes });
|
events?.emit('inbox.blob_fetched', { address, count: blobs.length, bytes });
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
blobs,
|
blobs,
|
||||||
cursor: nextCursor,
|
cursor: nextCursor,
|
||||||
hasMore: rows.length === quota.fetchPageLimit,
|
hasMore: rawRows.length === quota.fetchPageLimit,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -377,6 +411,10 @@ export function createInboxRoutes(
|
|||||||
if (removed) {
|
if (removed) {
|
||||||
events?.emit('inbox.blob_acked', { address, msgId });
|
events?.emit('inbox.blob_acked', { address, msgId });
|
||||||
}
|
}
|
||||||
|
// Drop any bridge-delivery mark — keeps the log bounded under
|
||||||
|
// sustained traffic (otherwise long-lived addresses accumulate
|
||||||
|
// entries even after the underlying blob is gone).
|
||||||
|
options.bridgeDeliveryLog?.forget(address, msgId);
|
||||||
return c.json({ ok: removed });
|
return c.json({ ok: removed });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox",
|
"name": "@shade/inbox",
|
||||||
"version": "4.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -182,22 +182,26 @@ app.route(
|
|||||||
...(kt ? { keyTransparency: kt } : {}),
|
...(kt ? { keyTransparency: kt } : {}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.route(
|
|
||||||
'/',
|
|
||||||
createInboxRoutes(inboxStore, crypto, {
|
|
||||||
events: inboxEvents,
|
|
||||||
disableRateLimit,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// V3.7 transport-bridge — SSE / long-poll / WS fallbacks for the inbox.
|
// V3.7 transport-bridge — SSE / long-poll / WS fallbacks for the inbox.
|
||||||
// Held as a top-level reference so the WebSocket handler can be passed to
|
// Created BEFORE the inbox routes so the shared bridge-delivery log can
|
||||||
// Bun.serve below.
|
// be wired into both. The log is the cross-channel dedup gate that lets
|
||||||
|
// the inbox-fetch route skip blobs already pushed via bridge — see
|
||||||
|
// V4.8.4 changelog and the Prism FR
|
||||||
|
// `cross-channel-duplicate-fanout-v4.8.2.md`.
|
||||||
const bridgeRoutes = createBridgeRoutes({
|
const bridgeRoutes = createBridgeRoutes({
|
||||||
store: inboxStore,
|
store: inboxStore,
|
||||||
crypto,
|
crypto,
|
||||||
events: inboxEvents,
|
events: inboxEvents,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.route(
|
||||||
|
'/',
|
||||||
|
createInboxRoutes(inboxStore, crypto, {
|
||||||
|
events: inboxEvents,
|
||||||
|
disableRateLimit,
|
||||||
|
bridgeDeliveryLog: bridgeRoutes.bridgeDeliveryLog,
|
||||||
|
}),
|
||||||
|
);
|
||||||
app.route('/', bridgeRoutes.app);
|
app.route('/', bridgeRoutes.app);
|
||||||
|
|
||||||
// ─── Optional: Observer + Dashboard ──────────────────────────
|
// ─── Optional: Observer + Dashboard ──────────────────────────
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-encrypted",
|
"name": "@shade/storage-encrypted",
|
||||||
"version": "4.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ interface Harness {
|
|||||||
async function bootstrap(opts: { mountWs?: boolean } = {}): Promise<Harness> {
|
async function bootstrap(opts: { mountWs?: boolean } = {}): Promise<Harness> {
|
||||||
const store = new MemoryInboxStore();
|
const store = new MemoryInboxStore();
|
||||||
const events = new InboxServerEvents();
|
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({
|
const bridge = createBridgeRoutes({
|
||||||
store,
|
store,
|
||||||
crypto,
|
crypto,
|
||||||
@@ -57,6 +59,11 @@ async function bootstrap(opts: { mountWs?: boolean } = {}): Promise<Harness> {
|
|||||||
heartbeatIntervalMs: 200,
|
heartbeatIntervalMs: 200,
|
||||||
fallbackPollIntervalMs: 50,
|
fallbackPollIntervalMs: 50,
|
||||||
});
|
});
|
||||||
|
const inboxApp = createInboxRoutes(store, crypto, {
|
||||||
|
events,
|
||||||
|
disableRateLimit: true,
|
||||||
|
bridgeDeliveryLog: bridge.bridgeDeliveryLog,
|
||||||
|
});
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
app.route('/', inboxApp);
|
app.route('/', inboxApp);
|
||||||
app.route('/', bridge.app);
|
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) ─
|
// ─── V4.8.2 — per-connection msgId dedup (Prism FR: duplicate fan-out) ─
|
||||||
|
|
||||||
describe('Bridge dedup — single PUT yields exactly one push per connection', () => {
|
describe('Bridge dedup — single PUT yields exactly one push per connection', () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-webrtc",
|
"name": "@shade/transport-webrtc",
|
||||||
"version": "4.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"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.8.3",
|
"version": "4.8.4",
|
||||||
"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