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:
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));
|
||||
}
|
||||
Reference in New Issue
Block a user