release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers byte-for-byte. The version bump is semantic: audit-cycle complete, opt-in surface fully exposed, threat model refreshed for every new surface. Highlights: - All 24 @shade/* packages bumped to 4.0.0 in lockstep. - CHANGELOG 4.0.0 section is the canonical manifest of what landed. - THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12 Web-Worker boundary) + residual-risks table refreshed. - OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox, bridge, observer, /metrics, /healthz, /ready. - MIGRATION 0.3.x → 4.0 documented + smoke-tested against shade migrate-storage on a real SQLite DB. - docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer. - scripts/soak.ts harness for the GA-stable 2-week soak window. - All V*.md plans archived under docs/archive/ with Status: Done. - Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen non-realtime stack. Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green. Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports version 4.0.0 on /health. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
45
packages/shade-transport-bridge/README.md
Normal file
45
packages/shade-transport-bridge/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# @shade/transport-bridge
|
||||
|
||||
Transport-agnostic delivery for Shade: **WS → SSE → long-poll**, in priority
|
||||
order, behind a single `IncomingMessage` interface.
|
||||
|
||||
```ts
|
||||
import {
|
||||
FallbackBridgeTransport,
|
||||
WsBridge,
|
||||
SseBridge,
|
||||
LongPollBridge,
|
||||
} from '@shade/transport-bridge';
|
||||
|
||||
const auth = { crypto, signingPrivateKey, address: 'bob' };
|
||||
|
||||
const bridge = new FallbackBridgeTransport([
|
||||
new WsBridge({ baseUrl, auth }),
|
||||
new SseBridge({ baseUrl, auth }),
|
||||
new LongPollBridge({ baseUrl, auth }),
|
||||
]);
|
||||
|
||||
await bridge.connect({
|
||||
onMessage: (msg) => {
|
||||
// msg: { from: string; bytes: Uint8Array; receivedAt: number; msgId?: string }
|
||||
},
|
||||
});
|
||||
|
||||
console.log(bridge.activeKind); // "ws" | "sse" | "long-poll"
|
||||
```
|
||||
|
||||
Pair with `createBridgeRoutes` in `@shade/inbox-server` to expose the
|
||||
matching `/v1/bridge/{stream,poll,ws}` endpoints. Full design + threat
|
||||
model in [`docs/transport.md`](../../docs/transport.md).
|
||||
|
||||
## What it solves
|
||||
|
||||
Browser extensions, strict corporate proxies, and edge runtimes routinely
|
||||
block long-lived WebSockets. Apps that already use the Shade inbox shouldn't
|
||||
have to write three custom delivery paths to handle the realistic mix of
|
||||
hostile networks they ship into. This package is the canonical answer.
|
||||
|
||||
## Status
|
||||
|
||||
V3.7. Stable wire format, additive change to `@shade/inbox-server`. See
|
||||
[CHANGELOG](../../CHANGELOG.md).
|
||||
27
packages/shade-transport-bridge/package.json
Normal file
27
packages/shade-transport-bridge/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@shade/transport-bridge",
|
||||
"version": "4.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/server": "workspace:*"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@shade/inbox-server": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"hono": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shade/crypto-web": "workspace:*",
|
||||
"@shade/inbox-server": "workspace:*",
|
||||
"hono": "^4.12.12"
|
||||
}
|
||||
}
|
||||
64
packages/shade-transport-bridge/src/auth.ts
Normal file
64
packages/shade-transport-bridge/src/auth.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Bridge auth primitive.
|
||||
*
|
||||
* SSE/EventSource cannot carry custom headers in browsers, so the bridge
|
||||
* protocol uses signed query parameters for every endpoint kind. The
|
||||
* signature is over the canonical `{address, kind, since, signedAt}` payload
|
||||
* using the recipient's Ed25519 signing key. The server looks up the owner
|
||||
* key for `address` (registered via `/v1/inbox/register`) and verifies the
|
||||
* signature with the same `verifyPayload` path used by the inbox.
|
||||
*/
|
||||
|
||||
import type { CryptoProvider } from '@shade/core';
|
||||
import { signPayload } from '@shade/server';
|
||||
|
||||
export type BridgeKind = 'stream' | 'poll' | 'ws';
|
||||
|
||||
export interface BridgeAuthInput {
|
||||
crypto: CryptoProvider;
|
||||
signingPrivateKey: Uint8Array;
|
||||
address: string;
|
||||
kind: BridgeKind;
|
||||
since: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URLSearchParams that must be appended to the bridge URL
|
||||
* (`/v1/bridge/stream`, `/v1/bridge/poll`, `/v1/bridge/ws`). The same shape
|
||||
* is consumed by `verifyBridgeAuth` on the server side.
|
||||
*/
|
||||
export async function signBridgeQuery(input: BridgeAuthInput): Promise<URLSearchParams> {
|
||||
const signed = await signPayload(input.crypto, input.signingPrivateKey, {
|
||||
address: input.address,
|
||||
kind: input.kind,
|
||||
since: input.since,
|
||||
});
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('address', input.address);
|
||||
qs.set('kind', input.kind);
|
||||
qs.set('since', String(input.since));
|
||||
qs.set('signedAt', String(signed.signedAt));
|
||||
qs.set('signature', signed.signature);
|
||||
return qs;
|
||||
}
|
||||
|
||||
export function bridgeQueryToCanonical(qs: URLSearchParams): {
|
||||
address: string;
|
||||
kind: BridgeKind;
|
||||
since: number;
|
||||
signedAt: number;
|
||||
signature: string;
|
||||
} | null {
|
||||
const address = qs.get('address');
|
||||
const kind = qs.get('kind') as BridgeKind | null;
|
||||
const sinceStr = qs.get('since');
|
||||
const signedAtStr = qs.get('signedAt');
|
||||
const signature = qs.get('signature');
|
||||
if (!address || !kind || sinceStr === null || !signedAtStr || !signature) return null;
|
||||
if (kind !== 'stream' && kind !== 'poll' && kind !== 'ws') return null;
|
||||
const since = Number(sinceStr);
|
||||
const signedAt = Number(signedAtStr);
|
||||
if (!Number.isFinite(since) || since < 0) return null;
|
||||
if (!Number.isFinite(signedAt)) return null;
|
||||
return { address, kind, since, signedAt, signature };
|
||||
}
|
||||
23
packages/shade-transport-bridge/src/errors.ts
Normal file
23
packages/shade-transport-bridge/src/errors.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ShadeError } from '@shade/core';
|
||||
|
||||
/**
|
||||
* Thrown when a bridge transport fails — connect rejected, malformed wire
|
||||
* data, abrupt disconnect with no reconnect possible, etc.
|
||||
*
|
||||
* Carries `httpStatus` when the failure has a recognizable HTTP root cause
|
||||
* so a fallback chain can decide whether to skip to the next bridge.
|
||||
*/
|
||||
export class BridgeError extends ShadeError {
|
||||
constructor(message: string, public readonly httpStatus?: number) {
|
||||
super('SHADE_BRIDGE_ERROR', message);
|
||||
this.name = 'BridgeError';
|
||||
}
|
||||
|
||||
override toJSON(): { name: string; code: string; message: string; httpStatus?: number } {
|
||||
const base = super.toJSON();
|
||||
if (this.httpStatus !== undefined) {
|
||||
return { ...base, httpStatus: this.httpStatus };
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
78
packages/shade-transport-bridge/src/fallback.ts
Normal file
78
packages/shade-transport-bridge/src/fallback.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* FallbackBridgeTransport — composes a chain of bridges in priority order.
|
||||
*
|
||||
* On `connect`, tries each bridge in turn. The first one that completes
|
||||
* `connect` without throwing wins; later bridges are dropped on the floor.
|
||||
* If every bridge throws, the composed call re-throws the last error so the
|
||||
* caller sees a real failure rather than silent degradation.
|
||||
*
|
||||
* Fallback is sticky: once a transport is selected, it stays selected for
|
||||
* the lifetime of the instance. To re-evaluate the chain (e.g. after a
|
||||
* proxy goes down), instantiate a new `FallbackBridgeTransport`.
|
||||
*/
|
||||
|
||||
import type { BridgeConnectOptions, BridgeTransport } from './types.js';
|
||||
import { BridgeError } from './errors.js';
|
||||
|
||||
export class FallbackBridgeTransport implements BridgeTransport {
|
||||
readonly kind = 'fallback';
|
||||
private active: BridgeTransport | null = null;
|
||||
private connectStarted = false;
|
||||
private disposed = false;
|
||||
private attemptedKinds: string[] = [];
|
||||
|
||||
constructor(private readonly chain: BridgeTransport[]) {
|
||||
if (chain.length === 0) {
|
||||
throw new BridgeError('FallbackBridgeTransport requires a non-empty chain');
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable identifier of the picked transport, e.g. "ws" / "sse" / "long-poll". */
|
||||
get activeKind(): string | null {
|
||||
return this.active?.kind ?? null;
|
||||
}
|
||||
|
||||
/** Read-only list of bridges that were attempted (in order) before success. */
|
||||
get attempts(): readonly string[] {
|
||||
return this.attemptedKinds;
|
||||
}
|
||||
|
||||
async connect(opts: BridgeConnectOptions): Promise<void> {
|
||||
if (this.connectStarted) throw new BridgeError('FallbackBridgeTransport.connect already called');
|
||||
this.connectStarted = true;
|
||||
let lastErr: Error | null = null;
|
||||
for (const bridge of this.chain) {
|
||||
this.attemptedKinds.push(bridge.kind);
|
||||
try {
|
||||
await bridge.connect(opts);
|
||||
if (this.disposed) {
|
||||
// disconnect was called concurrently — clean up the just-connected
|
||||
// bridge before returning.
|
||||
await bridge.disconnect().catch(() => {});
|
||||
return;
|
||||
}
|
||||
this.active = bridge;
|
||||
return;
|
||||
} catch (err) {
|
||||
lastErr = err as Error;
|
||||
// Best-effort cleanup: the bridge may have started reconnect timers
|
||||
// even though `connect` rejected.
|
||||
try {
|
||||
await bridge.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastErr ?? new BridgeError('all bridges failed to connect');
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.disposed = true;
|
||||
if (this.active) {
|
||||
const cur = this.active;
|
||||
this.active = null;
|
||||
await cur.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
38
packages/shade-transport-bridge/src/index.ts
Normal file
38
packages/shade-transport-bridge/src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @shade/transport-bridge — V3.7
|
||||
*
|
||||
* Transport-agnostic delivery for environments that cannot or will not run
|
||||
* a WebSocket: SSE primary fallback, long-poll secondary, plus a thin WS
|
||||
* adapter for the happy path. All three implementations surface the same
|
||||
* {@link IncomingMessage} shape so application code stays portable.
|
||||
*
|
||||
* Server-side routes live in `@shade/inbox-server`'s `createBridgeRoutes`.
|
||||
* Both ends share the same auth scheme: signed query parameters using the
|
||||
* recipient's Ed25519 signing key, verified against the address-owner key
|
||||
* registered via `/v1/inbox/register`.
|
||||
*/
|
||||
|
||||
export type {
|
||||
IncomingMessage,
|
||||
IncomingMessageHandler,
|
||||
BridgeConnectOptions,
|
||||
BridgeTransport,
|
||||
BridgeWireMessage,
|
||||
} from './types.js';
|
||||
export { decodeWireMessage } from './types.js';
|
||||
|
||||
export { BridgeError } from './errors.js';
|
||||
|
||||
export { signBridgeQuery, bridgeQueryToCanonical } from './auth.js';
|
||||
export type { BridgeKind, BridgeAuthInput } from './auth.js';
|
||||
|
||||
export { SseBridge } from './sse-bridge.js';
|
||||
export type { SseBridgeOptions } from './sse-bridge.js';
|
||||
|
||||
export { LongPollBridge } from './long-poll-bridge.js';
|
||||
export type { LongPollBridgeOptions } from './long-poll-bridge.js';
|
||||
|
||||
export { WsBridge } from './ws-bridge.js';
|
||||
export type { WsBridgeOptions } from './ws-bridge.js';
|
||||
|
||||
export { FallbackBridgeTransport } from './fallback.js';
|
||||
161
packages/shade-transport-bridge/src/long-poll-bridge.ts
Normal file
161
packages/shade-transport-bridge/src/long-poll-bridge.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Long-poll bridge.
|
||||
*
|
||||
* Repeatedly issues `GET /v1/bridge/poll?since=<cursor>&…` with a 25-second
|
||||
* default timeout (under typical proxy idle cutoffs). Each request blocks on
|
||||
* the server until either a new envelope is available for the address or the
|
||||
* timeout fires. The contract is simple by design — at any time exactly one
|
||||
* outstanding request per client.
|
||||
*
|
||||
* The signed query string is regenerated for every request because the
|
||||
* inbox auth layer rejects signatures older than 5 minutes — long-poll
|
||||
* connections may live indefinitely, but each individual request lives at
|
||||
* most `pollTimeoutMs` (server) + a small buffer.
|
||||
*/
|
||||
|
||||
import type { BridgeConnectOptions, BridgeTransport, BridgeWireMessage } from './types.js';
|
||||
import { decodeWireMessage } from './types.js';
|
||||
import type { BridgeAuthInput } from './auth.js';
|
||||
import { signBridgeQuery } from './auth.js';
|
||||
import { BridgeError } from './errors.js';
|
||||
|
||||
export interface LongPollBridgeOptions {
|
||||
baseUrl: string;
|
||||
auth: Omit<BridgeAuthInput, 'kind' | 'since'>;
|
||||
fetch?: typeof fetch;
|
||||
/** Server-side hold timeout, in ms. Default 25_000. */
|
||||
pollTimeoutMs?: number;
|
||||
/** Client-side request budget, must exceed pollTimeoutMs. Default +5_000. */
|
||||
requestTimeoutMs?: number;
|
||||
/** Initial cursor — start of stream by default. */
|
||||
startCursor?: number;
|
||||
/** Backoff after an unrecoverable network error. Default 2_000. */
|
||||
errorBackoffMs?: number;
|
||||
/** Disable auto-loop (single request only — for tests). */
|
||||
disableLoop?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_POLL_TIMEOUT_MS = 25_000;
|
||||
const DEFAULT_ERROR_BACKOFF_MS = 2_000;
|
||||
|
||||
export class LongPollBridge implements BridgeTransport {
|
||||
readonly kind = 'long-poll';
|
||||
private readonly fetchFn: typeof fetch;
|
||||
private cursor: number;
|
||||
private disposed = false;
|
||||
private connectStarted = false;
|
||||
private inflight: AbortController | null = null;
|
||||
private onMessage: BridgeConnectOptions['onMessage'] | null = null;
|
||||
private onError: NonNullable<BridgeConnectOptions['onError']> = (err) =>
|
||||
console.warn('[shade-bridge:long-poll]', err.message);
|
||||
private loopPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(private readonly options: LongPollBridgeOptions) {
|
||||
this.fetchFn = options.fetch ?? globalThis.fetch;
|
||||
this.cursor = options.startCursor ?? 0;
|
||||
}
|
||||
|
||||
async connect(opts: BridgeConnectOptions): Promise<void> {
|
||||
if (this.connectStarted) throw new BridgeError('LongPollBridge.connect already called');
|
||||
this.connectStarted = true;
|
||||
this.onMessage = opts.onMessage;
|
||||
if (opts.onError) this.onError = opts.onError;
|
||||
// Single probe request — establishes that the endpoint is reachable and
|
||||
// authenticates correctly. We surface auth/4xx errors as connect-time
|
||||
// failures so a fallback chain can switch to the next transport.
|
||||
await this.pollOnce({ probe: true });
|
||||
if (this.options.disableLoop) return;
|
||||
this.loopPromise = this.runLoop();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.disposed = true;
|
||||
this.inflight?.abort();
|
||||
if (this.loopPromise) {
|
||||
try {
|
||||
await this.loopPromise;
|
||||
} catch {
|
||||
/* swallow loop teardown errors */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCursor(): number {
|
||||
return this.cursor;
|
||||
}
|
||||
|
||||
private async runLoop(): Promise<void> {
|
||||
while (!this.disposed) {
|
||||
try {
|
||||
await this.pollOnce({ probe: false });
|
||||
} catch (err) {
|
||||
if (this.disposed) return;
|
||||
this.onError(err as Error);
|
||||
await sleep(this.options.errorBackoffMs ?? DEFAULT_ERROR_BACKOFF_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async pollOnce(args: { probe: boolean }): Promise<void> {
|
||||
const qs = await signBridgeQuery({
|
||||
crypto: this.options.auth.crypto,
|
||||
signingPrivateKey: this.options.auth.signingPrivateKey,
|
||||
address: this.options.auth.address,
|
||||
kind: 'poll',
|
||||
since: this.cursor,
|
||||
});
|
||||
const pollTimeoutMs = this.options.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
|
||||
qs.set('timeoutMs', String(pollTimeoutMs));
|
||||
const url = `${stripTrailingSlash(this.options.baseUrl)}/v1/bridge/poll?${qs.toString()}`;
|
||||
this.inflight = new AbortController();
|
||||
const requestBudget = this.options.requestTimeoutMs ?? pollTimeoutMs + 5_000;
|
||||
const watchdog = setTimeout(() => this.inflight?.abort(), requestBudget);
|
||||
try {
|
||||
const res = await this.fetchFn(url, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'application/json' },
|
||||
signal: this.inflight.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
// For a probe call, surface the error so the caller can fall back.
|
||||
// For a steady-state call, the loop's catch will rate-limit retries.
|
||||
throw new BridgeError(`long-poll failed: HTTP ${res.status}`, res.status);
|
||||
}
|
||||
const body = (await res.json()) as { blobs: BridgeWireMessage[]; cursor?: number };
|
||||
if (!Array.isArray(body.blobs)) {
|
||||
throw new BridgeError('long-poll body missing `blobs` array');
|
||||
}
|
||||
for (const wire of body.blobs) {
|
||||
if (typeof wire.receivedAt === 'number' && wire.receivedAt > this.cursor) {
|
||||
this.cursor = wire.receivedAt;
|
||||
}
|
||||
const msg = decodeWireMessage(wire);
|
||||
if (this.onMessage) {
|
||||
try {
|
||||
await this.onMessage(msg);
|
||||
} catch (err) {
|
||||
this.onError(err as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof body.cursor === 'number' && body.cursor > this.cursor) {
|
||||
this.cursor = body.cursor;
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.disposed) return;
|
||||
// Probe call surfaces the error; loop call passes it back to the loop.
|
||||
if (args.probe) throw err;
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(watchdog);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stripTrailingSlash(s: string): string {
|
||||
return s.endsWith('/') ? s.slice(0, -1) : s;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
246
packages/shade-transport-bridge/src/sse-bridge.ts
Normal file
246
packages/shade-transport-bridge/src/sse-bridge.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* SSE (Server-Sent Events) bridge.
|
||||
*
|
||||
* Connects to `<base>/v1/bridge/stream?…` with a signed query string and
|
||||
* parses an SSE feed manually using `fetch` + `ReadableStream`. We intentionally
|
||||
* do NOT use the platform `EventSource` because:
|
||||
*
|
||||
* 1. `EventSource` does not let callers attach custom headers — query-param
|
||||
* auth is the only portable route, but custom retry / reconnect knobs
|
||||
* are also useful and the platform API hides them.
|
||||
* 2. `EventSource` is not available in Node by default; rolling our own
|
||||
* reader keeps the package zero-dep across runtimes.
|
||||
*
|
||||
* The wire format follows the standard SSE spec:
|
||||
* `id: <receivedAt>\nevent: envelope\ndata: <json>\n\n`
|
||||
*
|
||||
* Only the `envelope` event carries payload; comment lines (`: ping`) are
|
||||
* tolerated as keepalives. The `id` field is fed back to the server as
|
||||
* `Last-Event-ID` (also encoded as the `since` query param) on reconnect to
|
||||
* resume from the highest-seen cursor.
|
||||
*/
|
||||
|
||||
import type { BridgeConnectOptions, BridgeTransport, IncomingMessage } from './types.js';
|
||||
import { decodeWireMessage } from './types.js';
|
||||
import type { BridgeAuthInput } from './auth.js';
|
||||
import { signBridgeQuery } from './auth.js';
|
||||
import { BridgeError } from './errors.js';
|
||||
|
||||
export interface SseBridgeOptions {
|
||||
baseUrl: string;
|
||||
auth: Omit<BridgeAuthInput, 'kind' | 'since'>;
|
||||
/** Override `fetch` (tests). */
|
||||
fetch?: typeof fetch;
|
||||
/** Initial reconnect backoff (ms). Default 250. */
|
||||
initialBackoffMs?: number;
|
||||
/** Max backoff (ms). Default 10_000. */
|
||||
maxBackoffMs?: number;
|
||||
/** Initial cursor — start of stream by default. */
|
||||
startCursor?: number;
|
||||
/** Disable auto-reconnect (tests). Default false. */
|
||||
disableAutoReconnect?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_INITIAL_BACKOFF = 250;
|
||||
const DEFAULT_MAX_BACKOFF = 10_000;
|
||||
|
||||
export class SseBridge implements BridgeTransport {
|
||||
readonly kind = 'sse';
|
||||
private readonly fetchFn: typeof fetch;
|
||||
private cursor: number;
|
||||
private abortController: AbortController | null = null;
|
||||
private connected = false;
|
||||
private disposed = false;
|
||||
private connectStarted = false;
|
||||
private currentReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||
private onMessage: BridgeConnectOptions['onMessage'] | null = null;
|
||||
private onError: NonNullable<BridgeConnectOptions['onError']> = (err) =>
|
||||
console.warn('[shade-bridge:sse]', err.message);
|
||||
|
||||
constructor(private readonly options: SseBridgeOptions) {
|
||||
this.fetchFn = options.fetch ?? globalThis.fetch;
|
||||
this.cursor = options.startCursor ?? 0;
|
||||
}
|
||||
|
||||
async connect(opts: BridgeConnectOptions): Promise<void> {
|
||||
if (this.connectStarted) throw new BridgeError('SseBridge.connect already called');
|
||||
this.connectStarted = true;
|
||||
this.onMessage = opts.onMessage;
|
||||
if (opts.onError) this.onError = opts.onError;
|
||||
// Open the first connection; throw if it fails immediately so callers
|
||||
// can fall back to a different transport.
|
||||
await this.openOnce();
|
||||
// Spawn the read loop; subsequent reconnects happen in the background.
|
||||
void this.readLoop();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.disposed = true;
|
||||
if (this.currentReader) {
|
||||
try {
|
||||
await this.currentReader.cancel();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
this.abortController?.abort();
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
/** Public so tests / observability can read the latest cursor. */
|
||||
getCursor(): number {
|
||||
return this.cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a single SSE connection and stores the reader on the instance.
|
||||
* Throws on hard errors (network refused, non-200 status). Caller drives
|
||||
* the read loop.
|
||||
*/
|
||||
private async openOnce(): Promise<void> {
|
||||
const qs = await signBridgeQuery({
|
||||
crypto: this.options.auth.crypto,
|
||||
signingPrivateKey: this.options.auth.signingPrivateKey,
|
||||
address: this.options.auth.address,
|
||||
kind: 'stream',
|
||||
since: this.cursor,
|
||||
});
|
||||
const url = `${stripTrailingSlash(this.options.baseUrl)}/v1/bridge/stream?${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',
|
||||
'last-event-id': String(this.cursor),
|
||||
},
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new BridgeError(`SSE connect failed: ${(err as Error).message}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new BridgeError(`SSE connect failed: HTTP ${res.status}`, res.status);
|
||||
}
|
||||
if (!res.body) {
|
||||
throw new BridgeError('SSE response has no body');
|
||||
}
|
||||
this.currentReader = res.body.getReader();
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
private async readLoop(): Promise<void> {
|
||||
let backoff = this.options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF;
|
||||
const maxBackoff = this.options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF;
|
||||
while (!this.disposed) {
|
||||
try {
|
||||
if (!this.currentReader) {
|
||||
await this.openOnce();
|
||||
}
|
||||
await this.consume(this.currentReader!);
|
||||
// Stream closed cleanly — server-side close. Reconnect.
|
||||
} catch (err) {
|
||||
if (this.disposed) return;
|
||||
this.onError(err as Error);
|
||||
}
|
||||
this.currentReader = null;
|
||||
this.connected = false;
|
||||
if (this.disposed || this.options.disableAutoReconnect) return;
|
||||
await sleep(backoff);
|
||||
backoff = Math.min(backoff * 2, maxBackoff);
|
||||
}
|
||||
}
|
||||
|
||||
private async consume(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
let dataLines: string[] = [];
|
||||
let eventName: string | null = null;
|
||||
let eventId: string | null = null;
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) return;
|
||||
buf += decoder.decode(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 === '') {
|
||||
// dispatch
|
||||
if (dataLines.length > 0) {
|
||||
const dataStr = dataLines.join('\n');
|
||||
await this.handleEvent(eventName, eventId, dataStr);
|
||||
}
|
||||
dataLines = [];
|
||||
eventName = null;
|
||||
eventId = null;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(':')) continue; // comment / keepalive
|
||||
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);
|
||||
switch (field) {
|
||||
case 'data':
|
||||
dataLines.push(val);
|
||||
break;
|
||||
case 'event':
|
||||
eventName = val;
|
||||
break;
|
||||
case 'id':
|
||||
eventId = val;
|
||||
break;
|
||||
// 'retry' ignored; we drive backoff ourselves.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleEvent(name: string | null, id: string | null, data: string): Promise<void> {
|
||||
if (id !== null) {
|
||||
const n = Number(id);
|
||||
if (Number.isFinite(n) && n > this.cursor) this.cursor = n;
|
||||
}
|
||||
if (name && name !== 'envelope' && name !== 'message' && name !== '') {
|
||||
// Ignore non-payload events (e.g. ready, heartbeat).
|
||||
return;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
} catch (err) {
|
||||
this.onError(new BridgeError(`malformed SSE data: ${(err as Error).message}`));
|
||||
return;
|
||||
}
|
||||
const wire = parsed as { msgId: string; ciphertext: string; receivedAt: number; from?: string };
|
||||
if (typeof wire.ciphertext !== 'string' || typeof wire.receivedAt !== 'number') {
|
||||
this.onError(new BridgeError('SSE event missing required fields'));
|
||||
return;
|
||||
}
|
||||
const msg: IncomingMessage = decodeWireMessage(wire);
|
||||
if (this.onMessage !== null) {
|
||||
try {
|
||||
await this.onMessage(msg);
|
||||
} catch (err) {
|
||||
this.onError(err as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True between connect()'s first successful open and disconnect/error. */
|
||||
get isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
}
|
||||
|
||||
function stripTrailingSlash(s: string): string {
|
||||
return s.endsWith('/') ? s.slice(0, -1) : s;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
94
packages/shade-transport-bridge/src/types.ts
Normal file
94
packages/shade-transport-bridge/src/types.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Common types for the @shade/transport-bridge package.
|
||||
*
|
||||
* The bridge layer abstracts "how do I receive ciphertext envelopes" so that
|
||||
* an application can stay transport-agnostic. Three implementations ship in
|
||||
* v0.1.0: WS, SSE, long-poll. A `FallbackBridgeTransport` wraps them in
|
||||
* priority order so a client blocked from WebSockets by a strict proxy
|
||||
* automatically slides down to SSE, then long-poll.
|
||||
*
|
||||
* Every bridge surfaces the same {@link IncomingMessage} shape — application
|
||||
* code never branches on transport.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A single transport-level incoming message.
|
||||
*
|
||||
* `from` is the relay-known sender fingerprint (8-byte hex of the sender's
|
||||
* Ed25519 signing key, computed by the inbox server at PUT time). It is
|
||||
* empty for legacy blobs that were stored before the inbox started tracking
|
||||
* sender provenance, and for non-inbox bridges that have no upstream sender
|
||||
* notion. The authoritative sender identity always lives inside the Double
|
||||
* Ratchet envelope and is recovered post-decrypt — `from` is a hint, not a
|
||||
* trust anchor.
|
||||
*/
|
||||
export interface IncomingMessage {
|
||||
from: string;
|
||||
bytes: Uint8Array;
|
||||
receivedAt: number;
|
||||
/** Relay-assigned message id (sha256 of ciphertext). Useful for ack/dedup. */
|
||||
msgId?: string;
|
||||
}
|
||||
|
||||
/** Subscriber callback. Bridges MAY invoke it concurrently. */
|
||||
export type IncomingMessageHandler = (msg: IncomingMessage) => void | Promise<void>;
|
||||
|
||||
export interface BridgeConnectOptions {
|
||||
onMessage: IncomingMessageHandler;
|
||||
/** Caller-supplied error handler; defaults to console.warn. */
|
||||
onError?: (err: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common contract for every bridge transport.
|
||||
*
|
||||
* Implementations are expected to:
|
||||
* - block in `connect` until they have either established a session OR
|
||||
* surfaced a hard failure as a thrown error;
|
||||
* - keep delivering messages until `disconnect` is called;
|
||||
* - reconnect transparently on transient drops while bumping the cursor
|
||||
* to avoid duplicate delivery (Last-Event-ID for SSE, `since` cursor for
|
||||
* long-poll, etc.).
|
||||
*
|
||||
* Bridges are *single-use*: call `connect` once, then `disconnect`. Calling
|
||||
* `connect` twice on the same instance must throw.
|
||||
*/
|
||||
export interface BridgeTransport {
|
||||
connect(options: BridgeConnectOptions): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
/** Resolves to a stable identifier ("ws" | "sse" | "long-poll" | …). */
|
||||
readonly kind: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-format for a single message inside an SSE event, long-poll batch
|
||||
* entry, or WS frame. The bridge protocol is JSON-over-the-wire with
|
||||
* base64-encoded ciphertext — the same shape regardless of transport.
|
||||
*/
|
||||
export interface BridgeWireMessage {
|
||||
msgId: string;
|
||||
/** base64 ciphertext */
|
||||
ciphertext: string;
|
||||
receivedAt: number;
|
||||
expiresAt?: number;
|
||||
/** Sender fingerprint hint (may be empty). */
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export function decodeWireMessage(wire: BridgeWireMessage): IncomingMessage {
|
||||
const bytes = base64ToBytes(wire.ciphertext);
|
||||
const msg: IncomingMessage = {
|
||||
from: wire.from ?? '',
|
||||
bytes,
|
||||
receivedAt: wire.receivedAt,
|
||||
};
|
||||
if (wire.msgId !== undefined) msg.msgId = wire.msgId;
|
||||
return msg;
|
||||
}
|
||||
|
||||
function base64ToBytes(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
205
packages/shade-transport-bridge/src/ws-bridge.ts
Normal file
205
packages/shade-transport-bridge/src/ws-bridge.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* WebSocket bridge.
|
||||
*
|
||||
* Connects to `<base>/v1/bridge/ws?…` (where `base` uses the http(s):// URL
|
||||
* — the bridge swaps to ws(s):// internally) and consumes JSON-encoded
|
||||
* `BridgeWireMessage` frames pushed by the server.
|
||||
*
|
||||
* Auth uses the same signed query-string scheme as SSE/long-poll. The server
|
||||
* runs the verification at upgrade time and rejects upgrades with a 4xx;
|
||||
* once the WebSocket is open, frames are unauthenticated at the WS layer
|
||||
* (the address-owner relationship is bound at upgrade and the connection is
|
||||
* dedicated to that address).
|
||||
*/
|
||||
|
||||
import type { BridgeConnectOptions, BridgeTransport, BridgeWireMessage } from './types.js';
|
||||
import { decodeWireMessage } from './types.js';
|
||||
import type { BridgeAuthInput } from './auth.js';
|
||||
import { signBridgeQuery } from './auth.js';
|
||||
import { BridgeError } from './errors.js';
|
||||
|
||||
export interface WsBridgeOptions {
|
||||
baseUrl: string;
|
||||
auth: Omit<BridgeAuthInput, 'kind' | 'since'>;
|
||||
/** WebSocket constructor override (browsers vs Node vs Bun). */
|
||||
WebSocketCtor?: typeof WebSocket;
|
||||
/** Connect timeout (ms). Default 5_000. */
|
||||
connectTimeoutMs?: number;
|
||||
/** Initial cursor. */
|
||||
startCursor?: number;
|
||||
/** Disable auto-reconnect on drop. Default false. */
|
||||
disableAutoReconnect?: boolean;
|
||||
/** Initial reconnect backoff (ms). Default 250. */
|
||||
initialBackoffMs?: number;
|
||||
/** Max reconnect backoff (ms). Default 10_000. */
|
||||
maxBackoffMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT = 5_000;
|
||||
const DEFAULT_INITIAL_BACKOFF = 250;
|
||||
const DEFAULT_MAX_BACKOFF = 10_000;
|
||||
|
||||
export class WsBridge implements BridgeTransport {
|
||||
readonly kind = 'ws';
|
||||
private readonly Ctor: typeof WebSocket;
|
||||
private cursor: number;
|
||||
private connectStarted = false;
|
||||
private disposed = false;
|
||||
private current: WebSocket | null = null;
|
||||
private onMessage: BridgeConnectOptions['onMessage'] | null = null;
|
||||
private onError: NonNullable<BridgeConnectOptions['onError']> = (err) =>
|
||||
console.warn('[shade-bridge:ws]', err.message);
|
||||
private reconnectPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(private readonly options: WsBridgeOptions) {
|
||||
const ctor =
|
||||
options.WebSocketCtor ??
|
||||
((globalThis as unknown as { WebSocket?: typeof WebSocket }).WebSocket);
|
||||
if (ctor === undefined) {
|
||||
throw new BridgeError('WebSocket constructor not available; pass options.WebSocketCtor');
|
||||
}
|
||||
this.Ctor = ctor;
|
||||
this.cursor = options.startCursor ?? 0;
|
||||
}
|
||||
|
||||
async connect(opts: BridgeConnectOptions): Promise<void> {
|
||||
if (this.connectStarted) throw new BridgeError('WsBridge.connect already called');
|
||||
this.connectStarted = true;
|
||||
this.onMessage = opts.onMessage;
|
||||
if (opts.onError) this.onError = opts.onError;
|
||||
await this.openOnce();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.disposed = true;
|
||||
if (this.current) {
|
||||
try {
|
||||
this.current.close(1000, 'client disconnect');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.current = null;
|
||||
}
|
||||
if (this.reconnectPromise) {
|
||||
try {
|
||||
await this.reconnectPromise;
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCursor(): number {
|
||||
return this.cursor;
|
||||
}
|
||||
|
||||
private async openOnce(): Promise<void> {
|
||||
const qs = await signBridgeQuery({
|
||||
crypto: this.options.auth.crypto,
|
||||
signingPrivateKey: this.options.auth.signingPrivateKey,
|
||||
address: this.options.auth.address,
|
||||
kind: 'ws',
|
||||
since: this.cursor,
|
||||
});
|
||||
const url = `${toWsUrl(this.options.baseUrl)}/v1/bridge/ws?${qs.toString()}`;
|
||||
const ws = new this.Ctor(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
const connectTimeoutMs = this.options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
reject(new BridgeError(`WS connect timeout to ${url}`));
|
||||
}, connectTimeoutMs);
|
||||
ws.addEventListener('open', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
ws.addEventListener('error', () => {
|
||||
clearTimeout(timer);
|
||||
reject(new BridgeError(`WS connect error to ${url}`));
|
||||
});
|
||||
});
|
||||
this.current = ws;
|
||||
this.attachListeners(ws);
|
||||
}
|
||||
|
||||
private attachListeners(ws: WebSocket): void {
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const data = ev.data;
|
||||
let text: string;
|
||||
if (typeof data === 'string') {
|
||||
text = data;
|
||||
} else if (data instanceof ArrayBuffer) {
|
||||
text = new TextDecoder().decode(data);
|
||||
} else {
|
||||
this.onError(new BridgeError('WS frame neither text nor ArrayBuffer'));
|
||||
return;
|
||||
}
|
||||
let wire: BridgeWireMessage;
|
||||
try {
|
||||
wire = JSON.parse(text);
|
||||
} catch (err) {
|
||||
this.onError(new BridgeError(`malformed WS frame: ${(err as Error).message}`));
|
||||
return;
|
||||
}
|
||||
if (typeof wire.ciphertext !== 'string' || typeof wire.receivedAt !== 'number') {
|
||||
this.onError(new BridgeError('WS frame missing required fields'));
|
||||
return;
|
||||
}
|
||||
if (wire.receivedAt > this.cursor) this.cursor = wire.receivedAt;
|
||||
const msg = decodeWireMessage(wire);
|
||||
if (this.onMessage) {
|
||||
Promise.resolve(this.onMessage(msg)).catch((err) => this.onError(err as Error));
|
||||
}
|
||||
});
|
||||
const onCloseOrError = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.current = null;
|
||||
if (this.options.disableAutoReconnect) return;
|
||||
this.reconnectPromise = this.reconnectLoop();
|
||||
};
|
||||
ws.addEventListener('close', onCloseOrError);
|
||||
ws.addEventListener('error', () => {
|
||||
// Treat error as a close — the platform fires 'close' too in most
|
||||
// implementations, but Node's `ws` library does not always.
|
||||
if (this.current === ws) {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async reconnectLoop(): Promise<void> {
|
||||
let backoff = this.options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF;
|
||||
const maxBackoff = this.options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF;
|
||||
while (!this.disposed) {
|
||||
await sleep(backoff);
|
||||
if (this.disposed) return;
|
||||
try {
|
||||
await this.openOnce();
|
||||
return;
|
||||
} catch (err) {
|
||||
this.onError(err as Error);
|
||||
backoff = Math.min(backoff * 2, maxBackoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toWsUrl(httpUrl: string): string {
|
||||
const trimmed = httpUrl.endsWith('/') ? httpUrl.slice(0, -1) : httpUrl;
|
||||
if (trimmed.startsWith('https://')) return 'wss://' + trimmed.slice('https://'.length);
|
||||
if (trimmed.startsWith('http://')) return 'ws://' + trimmed.slice('http://'.length);
|
||||
return trimmed; // already ws(s)://
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
512
packages/shade-transport-bridge/tests/bridge.test.ts
Normal file
512
packages/shade-transport-bridge/tests/bridge.test.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* Bridge integration tests — exercises real Bun.serve + InboxServer +
|
||||
* createBridgeRoutes against actual SSE / long-poll / WS clients.
|
||||
*
|
||||
* The acceptance criteria from V3.7 we cover here:
|
||||
* 1. "Send 100 small messages" passes on all three transports.
|
||||
* 2. WS-blocked client falls through to SSE without message loss.
|
||||
* 3. Long-poll uses no more than one outstanding request per client.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import {
|
||||
createInboxRoutes,
|
||||
createBridgeRoutes,
|
||||
InboxServerEvents,
|
||||
MemoryInboxStore,
|
||||
computeMsgId,
|
||||
type InboxStore,
|
||||
} from '@shade/inbox-server';
|
||||
import { signPayload } from '@shade/server';
|
||||
import { generateIdentityKeyPair, toBase64 } from '@shade/core';
|
||||
|
||||
import {
|
||||
SseBridge,
|
||||
LongPollBridge,
|
||||
WsBridge,
|
||||
FallbackBridgeTransport,
|
||||
signBridgeQuery,
|
||||
type IncomingMessage,
|
||||
} from '../src/index.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
interface Harness {
|
||||
server: ReturnType<typeof Bun.serve>;
|
||||
baseUrl: string;
|
||||
store: InboxStore;
|
||||
events: InboxServerEvents;
|
||||
bob: Awaited<ReturnType<typeof generateIdentityKeyPair>>;
|
||||
alice: Awaited<ReturnType<typeof generateIdentityKeyPair>>;
|
||||
}
|
||||
|
||||
async function bootstrap(opts: { mountWs?: boolean } = {}): Promise<Harness> {
|
||||
const store = new MemoryInboxStore();
|
||||
const events = new InboxServerEvents();
|
||||
const inboxApp = createInboxRoutes(store, crypto, { events, disableRateLimit: true });
|
||||
const bridge = createBridgeRoutes({
|
||||
store,
|
||||
crypto,
|
||||
events,
|
||||
longPollTimeoutMs: 1_000,
|
||||
longPollMaxTimeoutMs: 2_000,
|
||||
heartbeatIntervalMs: 200,
|
||||
fallbackPollIntervalMs: 50,
|
||||
});
|
||||
const app = new Hono();
|
||||
app.route('/', inboxApp);
|
||||
app.route('/', bridge.app);
|
||||
|
||||
const port = 19000 + Math.floor(Math.random() * 1000);
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
fetch: (req, srv) => app.fetch(req, srv),
|
||||
websocket: opts.mountWs === false ? undefined : (bridge.websocket as any),
|
||||
});
|
||||
|
||||
// Register Bob.
|
||||
const bob = await generateIdentityKeyPair(crypto);
|
||||
const alice = await generateIdentityKeyPair(crypto);
|
||||
const regBody = await signPayload(crypto, bob.signingPrivateKey, {
|
||||
address: 'bob',
|
||||
signingKey: toBase64(bob.signingPublicKey),
|
||||
});
|
||||
const regRes = await fetch(`http://localhost:${port}/v1/inbox/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(regBody),
|
||||
});
|
||||
expect(regRes.status).toBe(200);
|
||||
|
||||
return {
|
||||
server,
|
||||
baseUrl: `http://localhost:${port}`,
|
||||
store,
|
||||
events,
|
||||
bob,
|
||||
alice,
|
||||
};
|
||||
}
|
||||
|
||||
async function putBlob(harness: Harness, ciphertext: Uint8Array): Promise<string> {
|
||||
const msgId = await computeMsgId(ciphertext);
|
||||
const body = await signPayload(crypto, harness.alice.signingPrivateKey, {
|
||||
senderSigningKey: toBase64(harness.alice.signingPublicKey),
|
||||
msgId,
|
||||
ciphertext: toBase64(ciphertext),
|
||||
});
|
||||
const res = await fetch(`${harness.baseUrl}/v1/inbox/bob`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return msgId;
|
||||
}
|
||||
|
||||
function rand(n: number): Uint8Array {
|
||||
const b = new Uint8Array(n);
|
||||
globalThis.crypto.getRandomValues(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
function bobAuth(harness: Harness) {
|
||||
return {
|
||||
crypto,
|
||||
signingPrivateKey: harness.bob.signingPrivateKey,
|
||||
address: 'bob',
|
||||
};
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (predicate()) return;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
throw new Error(`waitFor timeout: predicate still false after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
describe('signBridgeQuery', () => {
|
||||
test('produces deterministic shape', async () => {
|
||||
const id = await generateIdentityKeyPair(crypto);
|
||||
const qs = await signBridgeQuery({
|
||||
crypto,
|
||||
signingPrivateKey: id.signingPrivateKey,
|
||||
address: 'foo',
|
||||
kind: 'stream',
|
||||
since: 42,
|
||||
});
|
||||
expect(qs.get('address')).toBe('foo');
|
||||
expect(qs.get('kind')).toBe('stream');
|
||||
expect(qs.get('since')).toBe('42');
|
||||
expect(qs.get('signedAt')).toMatch(/^\d+$/);
|
||||
expect(qs.get('signature')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SseBridge — send 100 small messages', () => {
|
||||
let h: Harness;
|
||||
beforeAll(async () => {
|
||||
h = await bootstrap();
|
||||
});
|
||||
afterAll(() => {
|
||||
h.server.stop(true);
|
||||
});
|
||||
|
||||
test('all messages reach the client over SSE', async () => {
|
||||
const received: IncomingMessage[] = [];
|
||||
const bridge = new SseBridge({
|
||||
baseUrl: h.baseUrl,
|
||||
auth: bobAuth(h),
|
||||
initialBackoffMs: 100,
|
||||
maxBackoffMs: 500,
|
||||
});
|
||||
await bridge.connect({
|
||||
onMessage: (m) => {
|
||||
received.push(m);
|
||||
},
|
||||
});
|
||||
|
||||
const sent: string[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const ct = rand(32 + (i % 8));
|
||||
sent.push(await putBlob(h, ct));
|
||||
}
|
||||
await waitFor(() => received.length === 100, 5_000);
|
||||
expect(received.length).toBe(100);
|
||||
const ids = new Set(received.map((m) => m.msgId));
|
||||
expect(ids.size).toBe(100);
|
||||
for (const id of sent) expect(ids.has(id)).toBe(true);
|
||||
await bridge.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LongPollBridge — send 100 small messages', () => {
|
||||
let h: Harness;
|
||||
beforeAll(async () => {
|
||||
h = await bootstrap();
|
||||
});
|
||||
afterAll(() => {
|
||||
h.server.stop(true);
|
||||
});
|
||||
|
||||
test('all messages reach the client over long-poll', async () => {
|
||||
const received: IncomingMessage[] = [];
|
||||
const bridge = new LongPollBridge({
|
||||
baseUrl: h.baseUrl,
|
||||
auth: bobAuth(h),
|
||||
pollTimeoutMs: 500,
|
||||
requestTimeoutMs: 1_500,
|
||||
errorBackoffMs: 50,
|
||||
});
|
||||
await bridge.connect({
|
||||
onMessage: (m) => {
|
||||
received.push(m);
|
||||
},
|
||||
});
|
||||
const sent: string[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const ct = rand(48);
|
||||
sent.push(await putBlob(h, ct));
|
||||
}
|
||||
await waitFor(() => received.length === 100, 8_000);
|
||||
expect(received.length).toBe(100);
|
||||
const ids = new Set(received.map((m) => m.msgId));
|
||||
for (const id of sent) expect(ids.has(id)).toBe(true);
|
||||
await bridge.disconnect();
|
||||
});
|
||||
|
||||
test('one outstanding request at a time', async () => {
|
||||
let inflight = 0;
|
||||
let peak = 0;
|
||||
const wrapped: typeof fetch = async (input, init) => {
|
||||
inflight++;
|
||||
peak = Math.max(peak, inflight);
|
||||
try {
|
||||
return await fetch(input as any, init as any);
|
||||
} finally {
|
||||
inflight--;
|
||||
}
|
||||
};
|
||||
const bridge = new LongPollBridge({
|
||||
baseUrl: h.baseUrl,
|
||||
auth: bobAuth(h),
|
||||
pollTimeoutMs: 200,
|
||||
requestTimeoutMs: 500,
|
||||
errorBackoffMs: 50,
|
||||
fetch: wrapped,
|
||||
});
|
||||
const received: IncomingMessage[] = [];
|
||||
await bridge.connect({ onMessage: (m) => void received.push(m) });
|
||||
// Let the loop run a few times; nothing else should be pumping the
|
||||
// bridge concurrently.
|
||||
await new Promise((r) => setTimeout(r, 1_500));
|
||||
expect(peak).toBeLessThanOrEqual(1);
|
||||
await bridge.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WsBridge — send 100 small messages', () => {
|
||||
let h: Harness;
|
||||
beforeAll(async () => {
|
||||
h = await bootstrap();
|
||||
});
|
||||
afterAll(() => {
|
||||
h.server.stop(true);
|
||||
});
|
||||
|
||||
test('all messages reach the client over WS', async () => {
|
||||
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);
|
||||
},
|
||||
});
|
||||
const sent: string[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const ct = rand(40);
|
||||
sent.push(await putBlob(h, ct));
|
||||
}
|
||||
await waitFor(() => received.length === 100, 5_000);
|
||||
expect(received.length).toBe(100);
|
||||
const ids = new Set(received.map((m) => m.msgId));
|
||||
for (const id of sent) expect(ids.has(id)).toBe(true);
|
||||
await bridge.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FallbackBridgeTransport', () => {
|
||||
let h: Harness;
|
||||
beforeAll(async () => {
|
||||
h = await bootstrap();
|
||||
});
|
||||
afterAll(() => {
|
||||
h.server.stop(true);
|
||||
});
|
||||
|
||||
test('falls through WS → SSE when WS endpoint is blocked', async () => {
|
||||
const received: IncomingMessage[] = [];
|
||||
const ws = new WsBridge({
|
||||
baseUrl: 'http://127.0.0.1:1', // unreachable on purpose
|
||||
auth: bobAuth(h),
|
||||
connectTimeoutMs: 500,
|
||||
disableAutoReconnect: true,
|
||||
});
|
||||
const sse = new SseBridge({
|
||||
baseUrl: h.baseUrl,
|
||||
auth: bobAuth(h),
|
||||
initialBackoffMs: 100,
|
||||
maxBackoffMs: 500,
|
||||
});
|
||||
const lp = new LongPollBridge({
|
||||
baseUrl: h.baseUrl,
|
||||
auth: bobAuth(h),
|
||||
pollTimeoutMs: 500,
|
||||
requestTimeoutMs: 1_500,
|
||||
errorBackoffMs: 50,
|
||||
});
|
||||
const fallback = new FallbackBridgeTransport([ws, sse, lp]);
|
||||
await fallback.connect({
|
||||
onMessage: (m) => {
|
||||
received.push(m);
|
||||
},
|
||||
});
|
||||
expect(fallback.activeKind).toBe('sse');
|
||||
expect(fallback.attempts).toEqual(['ws', 'sse']);
|
||||
|
||||
const sent: string[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const ct = rand(32);
|
||||
sent.push(await putBlob(h, ct));
|
||||
}
|
||||
await waitFor(() => received.length === 10, 5_000);
|
||||
expect(received.length).toBe(10);
|
||||
const ids = new Set(received.map((m) => m.msgId));
|
||||
for (const id of sent) expect(ids.has(id)).toBe(true);
|
||||
await fallback.disconnect();
|
||||
});
|
||||
|
||||
test('falls through SSE → long-poll when SSE endpoint returns 502', async () => {
|
||||
// Build a server where SSE explicitly returns 502; long-poll passes
|
||||
// through. We build a small Hono shim that wraps the bridge app.
|
||||
const port = h.server.port;
|
||||
const altPort = port + 1;
|
||||
|
||||
const store = h.store; // share data with the main harness
|
||||
const events = h.events;
|
||||
const bridge = createBridgeRoutes({
|
||||
store,
|
||||
crypto,
|
||||
events,
|
||||
longPollTimeoutMs: 500,
|
||||
longPollMaxTimeoutMs: 1_000,
|
||||
heartbeatIntervalMs: 200,
|
||||
fallbackPollIntervalMs: 50,
|
||||
});
|
||||
const inboxApp = createInboxRoutes(store, crypto, { events, disableRateLimit: true });
|
||||
const wrapped = new Hono();
|
||||
wrapped.get('/v1/bridge/stream', (c) => c.text('bad gateway', 502));
|
||||
wrapped.route('/', bridge.app);
|
||||
wrapped.route('/', inboxApp);
|
||||
|
||||
const altServer = Bun.serve({
|
||||
port: altPort,
|
||||
fetch: (req, srv) => wrapped.fetch(req, srv),
|
||||
websocket: bridge.websocket as any,
|
||||
});
|
||||
try {
|
||||
const altUrl = `http://localhost:${altPort}`;
|
||||
const received: IncomingMessage[] = [];
|
||||
const sse = new SseBridge({
|
||||
baseUrl: altUrl,
|
||||
auth: bobAuth(h),
|
||||
initialBackoffMs: 100,
|
||||
maxBackoffMs: 200,
|
||||
disableAutoReconnect: true,
|
||||
});
|
||||
const lp = new LongPollBridge({
|
||||
baseUrl: altUrl,
|
||||
auth: bobAuth(h),
|
||||
pollTimeoutMs: 200,
|
||||
requestTimeoutMs: 600,
|
||||
errorBackoffMs: 50,
|
||||
});
|
||||
const fallback = new FallbackBridgeTransport([sse, lp]);
|
||||
await fallback.connect({
|
||||
onMessage: (m) => {
|
||||
received.push(m);
|
||||
},
|
||||
});
|
||||
expect(fallback.activeKind).toBe('long-poll');
|
||||
// Push a message via the original PUT path.
|
||||
const ct = rand(32);
|
||||
await putBlob(h, ct);
|
||||
await waitFor(() => received.length >= 1, 4_000);
|
||||
expect(received.length).toBeGreaterThanOrEqual(1);
|
||||
await fallback.disconnect();
|
||||
} finally {
|
||||
altServer.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('throws when every bridge fails', async () => {
|
||||
const ws = new WsBridge({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
auth: bobAuth(h),
|
||||
connectTimeoutMs: 200,
|
||||
disableAutoReconnect: true,
|
||||
});
|
||||
const sse = new SseBridge({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
auth: bobAuth(h),
|
||||
initialBackoffMs: 50,
|
||||
maxBackoffMs: 100,
|
||||
disableAutoReconnect: true,
|
||||
});
|
||||
const lp = new LongPollBridge({
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
auth: bobAuth(h),
|
||||
pollTimeoutMs: 100,
|
||||
requestTimeoutMs: 300,
|
||||
errorBackoffMs: 50,
|
||||
});
|
||||
const fallback = new FallbackBridgeTransport([ws, sse, lp]);
|
||||
await expect(
|
||||
fallback.connect({ onMessage: () => undefined }),
|
||||
).rejects.toThrow();
|
||||
await fallback.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bridge auth — rejects bad signatures', () => {
|
||||
let h: Harness;
|
||||
beforeAll(async () => {
|
||||
h = await bootstrap();
|
||||
});
|
||||
afterAll(() => {
|
||||
h.server.stop(true);
|
||||
});
|
||||
|
||||
test('SSE rejects when signature is wrong', async () => {
|
||||
const eve = await generateIdentityKeyPair(crypto);
|
||||
const sse = new SseBridge({
|
||||
baseUrl: h.baseUrl,
|
||||
auth: { crypto, signingPrivateKey: eve.signingPrivateKey, address: 'bob' },
|
||||
initialBackoffMs: 50,
|
||||
maxBackoffMs: 100,
|
||||
disableAutoReconnect: true,
|
||||
});
|
||||
await expect(sse.connect({ onMessage: () => undefined })).rejects.toThrow();
|
||||
await sse.disconnect();
|
||||
});
|
||||
|
||||
test('long-poll rejects when address is unregistered', async () => {
|
||||
const newId = await generateIdentityKeyPair(crypto);
|
||||
const lp = new LongPollBridge({
|
||||
baseUrl: h.baseUrl,
|
||||
auth: { crypto, signingPrivateKey: newId.signingPrivateKey, address: 'unregistered' },
|
||||
pollTimeoutMs: 200,
|
||||
requestTimeoutMs: 500,
|
||||
errorBackoffMs: 50,
|
||||
});
|
||||
await expect(lp.connect({ onMessage: () => undefined })).rejects.toThrow();
|
||||
await lp.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bridge cursor resume', () => {
|
||||
let h: Harness;
|
||||
beforeAll(async () => {
|
||||
h = await bootstrap();
|
||||
});
|
||||
afterAll(() => {
|
||||
h.server.stop(true);
|
||||
});
|
||||
|
||||
test('SSE picks up after disconnect using Last-Event-ID cursor', async () => {
|
||||
const first: IncomingMessage[] = [];
|
||||
const sseA = new SseBridge({
|
||||
baseUrl: h.baseUrl,
|
||||
auth: bobAuth(h),
|
||||
initialBackoffMs: 100,
|
||||
maxBackoffMs: 200,
|
||||
disableAutoReconnect: true,
|
||||
});
|
||||
await sseA.connect({ onMessage: (m) => first.push(m) });
|
||||
await putBlob(h, rand(20));
|
||||
await putBlob(h, rand(24));
|
||||
await waitFor(() => first.length === 2, 3_000);
|
||||
const cursor = sseA.getCursor();
|
||||
await sseA.disconnect();
|
||||
|
||||
// Push more while disconnected.
|
||||
await putBlob(h, rand(28));
|
||||
await putBlob(h, rand(32));
|
||||
|
||||
const second: IncomingMessage[] = [];
|
||||
const sseB = new SseBridge({
|
||||
baseUrl: h.baseUrl,
|
||||
auth: bobAuth(h),
|
||||
startCursor: cursor,
|
||||
initialBackoffMs: 100,
|
||||
maxBackoffMs: 200,
|
||||
disableAutoReconnect: true,
|
||||
});
|
||||
await sseB.connect({ onMessage: (m) => second.push(m) });
|
||||
await waitFor(() => second.length === 2, 3_000);
|
||||
expect(second.length).toBe(2);
|
||||
// No overlap with the first batch.
|
||||
const firstIds = new Set(first.map((m) => m.msgId));
|
||||
for (const m of second) expect(firstIds.has(m.msgId!)).toBe(false);
|
||||
await sseB.disconnect();
|
||||
});
|
||||
});
|
||||
8
packages/shade-transport-bridge/tsconfig.json
Normal file
8
packages/shade-transport-bridge/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user