@pylonsync/sync 0.3.202 → 0.3.203

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ // Fetch + WebSocket mocks that route engine traffic to a TestServer.
2
+ //
3
+ // We intentionally don't reimplement the wire format here — every
4
+ // path the engine actually calls lives in `handle()` and produces
5
+ // exactly the JSON shape the engine parses. New engine paths require
6
+ // a new branch here (which is the point: forces the harness to stay
7
+ // honest about what the engine sends).
8
+
9
+ import type { Row } from "../types";
10
+ import type { TestServer } from "./server";
11
+
12
+ export interface TransportHandle {
13
+ /** Token the engine is currently sending as Authorization: Bearer */
14
+ currentToken: () => string | undefined;
15
+ /** Set / clear the active token (mirrors localStorage flips). */
16
+ setToken: (token: string | undefined) => void;
17
+ /** Number of `fetch` calls observed — assert against this in
18
+ * tests that need to confirm "nothing more was requested." */
19
+ fetchCount: () => number;
20
+ /** Number of WS connections opened so far. */
21
+ wsConnectCount: () => number;
22
+ /** Tear down the global stubs. */
23
+ restore: () => void;
24
+ }
25
+
26
+ interface MockResponse {
27
+ status: number;
28
+ body: unknown;
29
+ headers?: Record<string, string>;
30
+ }
31
+
32
+ export function installTransport(server: TestServer): TransportHandle {
33
+ let token: string | undefined;
34
+ let fetchCount = 0;
35
+ let wsConnectCount = 0;
36
+
37
+ const originalFetch = globalThis.fetch;
38
+ const originalWS = (globalThis as { WebSocket?: unknown }).WebSocket;
39
+
40
+ globalThis.fetch = (async (
41
+ input: RequestInfo | URL,
42
+ init?: RequestInit,
43
+ ): Promise<Response> => {
44
+ fetchCount += 1;
45
+ const url = typeof input === "string" ? input : input.toString();
46
+ const method = (init?.method ?? "GET").toUpperCase();
47
+ const authHeader = readAuthHeader(init);
48
+ // If the request didn't include Authorization but our test rig
49
+ // has a "current token", forward it the way the engine actually
50
+ // does. The engine reads from localStorage; we shortcut here
51
+ // because the engine's currentToken() comes from the storage
52
+ // adapter we'd otherwise need to mock.
53
+ const effectiveToken = authHeader ?? token;
54
+
55
+ const result = await handle(server, method, url, effectiveToken, init);
56
+ const json = result.body == null ? "" : JSON.stringify(result.body);
57
+ return new Response(json, {
58
+ status: result.status,
59
+ headers: {
60
+ "content-type": "application/json",
61
+ ...(result.headers ?? {}),
62
+ },
63
+ });
64
+ }) as typeof fetch;
65
+
66
+ // Minimal WebSocket stub. The engine calls `new WebSocket(url)`,
67
+ // attaches onopen/onmessage/onclose, and posts session-changed +
68
+ // change events. We hand it a controllable EventTarget the
69
+ // TestServer subscribes to.
70
+ class MockWebSocket extends EventTarget {
71
+ static readonly CONNECTING = 0;
72
+ static readonly OPEN = 1;
73
+ static readonly CLOSING = 2;
74
+ static readonly CLOSED = 3;
75
+ readyState = 0;
76
+ binaryType: "blob" | "arraybuffer" = "blob";
77
+ url: string;
78
+ protocol = "";
79
+ onopen: ((ev: Event) => void) | null = null;
80
+ onclose: ((ev: Event) => void) | null = null;
81
+ onmessage: ((ev: MessageEvent) => void) | null = null;
82
+ onerror: ((ev: Event) => void) | null = null;
83
+ private unsubscribe: (() => void) | null = null;
84
+ private boundToken: string | undefined;
85
+
86
+ constructor(url: string, protocols?: string | string[]) {
87
+ super();
88
+ this.url = url;
89
+ wsConnectCount += 1;
90
+ // The engine encodes the token as `bearer.<percent-encoded>` in
91
+ // the WS subprotocol when one is set. Decode it so the harness
92
+ // can route this connection to the right subscriber bucket
93
+ // independently of any localStorage state.
94
+ const protoToken = extractBearerFromProtocols(protocols);
95
+ this.boundToken = protoToken ?? token;
96
+ const auth = server.authContextFor(this.boundToken);
97
+ if (auth.userId) {
98
+ this.unsubscribe = server.subscribe(auth.userId, (msg) => {
99
+ const event = new MessageEvent("message", {
100
+ data: JSON.stringify(msg),
101
+ });
102
+ this.dispatchEvent(event);
103
+ this.onmessage?.(event);
104
+ });
105
+ }
106
+ // Fire onopen on the next microtask so listeners attached
107
+ // synchronously after construction still see it.
108
+ queueMicrotask(() => {
109
+ this.readyState = 1;
110
+ const ev = new Event("open");
111
+ this.dispatchEvent(ev);
112
+ this.onopen?.(ev);
113
+ });
114
+ }
115
+
116
+ send(data: string): void {
117
+ // Capture outbound client messages so tests can assert that
118
+ // `reactive-subscribe`, `crdt-subscribe`, etc., were actually
119
+ // emitted by the engine. The real server would dispatch on
120
+ // these; the harness just records them.
121
+ try {
122
+ const parsed = JSON.parse(data);
123
+ server.recordClientWsMessage(this.boundToken, parsed);
124
+ } catch {
125
+ server.recordClientWsMessage(this.boundToken, data);
126
+ }
127
+ }
128
+
129
+ close(): void {
130
+ this.readyState = 3;
131
+ this.unsubscribe?.();
132
+ this.unsubscribe = null;
133
+ const ev = new Event("close");
134
+ this.dispatchEvent(ev);
135
+ this.onclose?.(ev);
136
+ }
137
+ }
138
+ (globalThis as { WebSocket?: unknown }).WebSocket = MockWebSocket;
139
+
140
+ return {
141
+ currentToken: () => token,
142
+ setToken: (t) => {
143
+ token = t;
144
+ },
145
+ fetchCount: () => fetchCount,
146
+ wsConnectCount: () => wsConnectCount,
147
+ restore: () => {
148
+ globalThis.fetch = originalFetch;
149
+ if (originalWS) {
150
+ (globalThis as { WebSocket?: unknown }).WebSocket = originalWS;
151
+ } else {
152
+ delete (globalThis as { WebSocket?: unknown }).WebSocket;
153
+ }
154
+ },
155
+ };
156
+ }
157
+
158
+ function readAuthHeader(init: RequestInit | undefined): string | undefined {
159
+ if (!init?.headers) return undefined;
160
+ const headersInit = init.headers;
161
+ let raw: string | null = null;
162
+ if (headersInit instanceof Headers) {
163
+ raw = headersInit.get("Authorization") ?? headersInit.get("authorization");
164
+ } else if (Array.isArray(headersInit)) {
165
+ for (const [k, v] of headersInit) {
166
+ if (k.toLowerCase() === "authorization") {
167
+ raw = v;
168
+ break;
169
+ }
170
+ }
171
+ } else {
172
+ const h = headersInit as Record<string, string>;
173
+ raw = h.Authorization ?? h.authorization ?? null;
174
+ }
175
+ if (!raw) return undefined;
176
+ return raw.startsWith("Bearer ") ? raw.slice("Bearer ".length) : raw;
177
+ }
178
+
179
+ function extractBearerFromProtocols(
180
+ protocols: string | string[] | undefined,
181
+ ): string | undefined {
182
+ if (!protocols) return undefined;
183
+ const list = Array.isArray(protocols) ? protocols : [protocols];
184
+ for (const p of list) {
185
+ if (typeof p !== "string") continue;
186
+ if (p.startsWith("bearer.")) {
187
+ try {
188
+ return decodeURIComponent(p.slice("bearer.".length));
189
+ } catch {
190
+ return p.slice("bearer.".length);
191
+ }
192
+ }
193
+ }
194
+ return undefined;
195
+ }
196
+
197
+ async function handle(
198
+ server: TestServer,
199
+ method: string,
200
+ url: string,
201
+ token: string | undefined,
202
+ _init: RequestInit | undefined,
203
+ ): Promise<MockResponse> {
204
+ // /api/auth/me — cheap auth context probe. Engine expects snake_case
205
+ // keys here (user_id / tenant_id / is_admin); the TestServer holds
206
+ // them as camelCase internally so we re-key on the way out.
207
+ if (url.endsWith("/api/auth/me") && method === "GET") {
208
+ const ctx = server.authContextFor(token);
209
+ return {
210
+ status: 200,
211
+ body: {
212
+ user_id: ctx.userId,
213
+ tenant_id: ctx.tenantId,
214
+ is_admin: ctx.isAdmin,
215
+ roles: ctx.roles,
216
+ },
217
+ };
218
+ }
219
+
220
+ // /api/sync/pull?since=N — incremental change pull.
221
+ if (url.includes("/api/sync/pull") && method === "GET") {
222
+ const primed = server.consumeNextPullStatus();
223
+ if (primed) {
224
+ return {
225
+ status: primed,
226
+ body: { error: { code: "RESYNC_REQUIRED" } },
227
+ };
228
+ }
229
+ const since = Number(new URL(url, "http://test").searchParams.get("since") ?? "0");
230
+ const resp = await server.pull(token, since);
231
+ return { status: 200, body: resp };
232
+ }
233
+
234
+ // /api/entities/<E>/cursor — policy-filtered list for reconcile.
235
+ const cursorMatch = url.match(/\/api\/entities\/([^/?]+)\/cursor/);
236
+ if (cursorMatch && method === "GET") {
237
+ const entity = cursorMatch[1]!;
238
+ const rows: Row[] = await server.listEntityRows(entity, token);
239
+ return {
240
+ status: 200,
241
+ body: { data: rows, next_cursor: null, has_more: false },
242
+ };
243
+ }
244
+
245
+ // /api/sync/push — accept ops from optimistic mutations.
246
+ if (url.endsWith("/api/sync/push") && method === "POST") {
247
+ return { status: 200, body: { ops: [] } };
248
+ }
249
+
250
+ // Anything else: 404 with a clear error so test failures point
251
+ // at the exact missing route rather than a generic JSON parse.
252
+ return {
253
+ status: 404,
254
+ body: { error: { code: "TEST_HARNESS_UNHANDLED", path: url, method } },
255
+ };
256
+ }
@@ -0,0 +1,87 @@
1
+ // Tests for the createTransport factory + its sse→polling fallback.
2
+ //
3
+ // Regression for codex round-7 P2: pre-refactor, `transport: "sse"` in
4
+ // an environment without a native `EventSource` (Node / jsdom /
5
+ // unsupported browser) silently fell back to polling via the catch
6
+ // block inside `connectSse()`. The extraction dropped that path; the
7
+ // factory restores it by feature-checking up front so the engine
8
+ // never holds an SseTransport that can never connect.
9
+
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+
12
+ import {
13
+ createTransport,
14
+ PollingTransport,
15
+ SseTransport,
16
+ WebSocketTransport,
17
+ } from "./index";
18
+ import type { TransportHost } from "./types";
19
+
20
+ function noopHost(): TransportHost {
21
+ return {
22
+ baseUrl: "http://stub.invalid",
23
+ getToken: () => undefined,
24
+ isLeader: () => true,
25
+ isRunning: () => true,
26
+ onChangeEvent: () => {},
27
+ onJsonMessage: () => {},
28
+ onBinaryFrame: () => {},
29
+ onConnected: () => {},
30
+ onDisconnected: () => {},
31
+ setStatus: () => {},
32
+ performPollTick: async () => {},
33
+ performReconnectPull: async () => {},
34
+ };
35
+ }
36
+
37
+ describe("createTransport factory", () => {
38
+ test("websocket kind builds a WebSocketTransport", () => {
39
+ const t = createTransport("websocket", noopHost());
40
+ expect(t).toBeInstanceOf(WebSocketTransport);
41
+ });
42
+
43
+ test("poll kind builds a PollingTransport", () => {
44
+ const t = createTransport("poll", noopHost());
45
+ expect(t).toBeInstanceOf(PollingTransport);
46
+ });
47
+ });
48
+
49
+ describe("createTransport sse → polling fallback", () => {
50
+ let originalEventSource: unknown;
51
+
52
+ beforeEach(() => {
53
+ originalEventSource = (globalThis as { EventSource?: unknown }).EventSource;
54
+ });
55
+
56
+ afterEach(() => {
57
+ if (originalEventSource === undefined) {
58
+ delete (globalThis as { EventSource?: unknown }).EventSource;
59
+ } else {
60
+ (globalThis as { EventSource?: unknown }).EventSource =
61
+ originalEventSource;
62
+ }
63
+ });
64
+
65
+ test("sse kind WITH EventSource defined → SseTransport", () => {
66
+ // Provide a minimal stub so `typeof EventSource !== "undefined"`.
67
+ (globalThis as { EventSource: unknown }).EventSource = class {
68
+ // constructor is enough; we don't actually start the transport here
69
+ // (start would try to open a real connection). The factory check
70
+ // is purely a `typeof` lookup.
71
+ };
72
+ const t = createTransport("sse", noopHost());
73
+ expect(t).toBeInstanceOf(SseTransport);
74
+ });
75
+
76
+ test("sse kind WITHOUT EventSource → PollingTransport (fallback)", () => {
77
+ // Bun's test runtime has no global EventSource by default. Make sure
78
+ // it's actually undefined for this test, then assert the fallback
79
+ // kicks in.
80
+ delete (globalThis as { EventSource?: unknown }).EventSource;
81
+ const t = createTransport("sse", noopHost());
82
+ expect(t).toBeInstanceOf(PollingTransport);
83
+ // The pre-refactor behavior this pins: a Node consumer that calls
84
+ // `init({ transport: "sse" })` keeps syncing via polling instead of
85
+ // sitting in `connecting` forever with no socket and no timer.
86
+ });
87
+ });
@@ -0,0 +1,42 @@
1
+ // Public surface for the transport layer. The engine imports types
2
+ // from here and constructs a transport via `createTransport(kind, host)`.
3
+
4
+ import { PollingTransport } from "./polling";
5
+ import { SseTransport } from "./sse";
6
+ import type { Transport, TransportHost } from "./types";
7
+ import { WebSocketTransport } from "./websocket";
8
+
9
+ export type TransportKind = "websocket" | "sse" | "poll";
10
+ export type { Transport, TransportHost } from "./types";
11
+ export { WebSocketTransport } from "./websocket";
12
+ export { SseTransport } from "./sse";
13
+ export { PollingTransport } from "./polling";
14
+
15
+ /** Build the right transport for a given kind. The host supplies all
16
+ * config + the inbound dispatch callbacks; the transport hides the
17
+ * underlying mechanism.
18
+ *
19
+ * Fallback rule: `transport: "sse"` in an environment without a
20
+ * native EventSource (Node, jsdom without a polyfill, old browsers)
21
+ * silently downgrades to polling. The pre-refactor `connectSse()`
22
+ * did this inside its constructor catch — we lift the decision to
23
+ * the factory so the engine never sees an SseTransport that can
24
+ * never connect. Clients that NEED SSE specifically can detect this
25
+ * by feature-checking `typeof EventSource` themselves before calling
26
+ * `init()`. */
27
+ export function createTransport(
28
+ kind: TransportKind,
29
+ host: TransportHost,
30
+ ): Transport {
31
+ switch (kind) {
32
+ case "websocket":
33
+ return new WebSocketTransport(host);
34
+ case "sse":
35
+ if (typeof EventSource === "undefined") {
36
+ return new PollingTransport(host);
37
+ }
38
+ return new SseTransport(host);
39
+ case "poll":
40
+ return new PollingTransport(host);
41
+ }
42
+ }
@@ -0,0 +1,102 @@
1
+ // Unit tests for the PollingTransport in isolation — the engine
2
+ // integration is covered by `scenarios.test.ts` (which exercises
3
+ // poll-mode reconcile + race fixes). These tests just pin the
4
+ // transport's own contract:
5
+ // - start() begins ticking on `pollIntervalMs`
6
+ // - each tick calls host.performPollTick()
7
+ // - stop() halts the loop
8
+ // - send() / bumpReconnect() / onConnected are deterministic no-ops
9
+
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+
12
+ import { PollingTransport } from "./polling";
13
+ import type { TransportHost } from "./types";
14
+
15
+ function makeHost(overrides: Partial<TransportHost> = {}): TransportHost & {
16
+ ticks: number;
17
+ } {
18
+ const host = {
19
+ baseUrl: "http://stub.invalid",
20
+ pollIntervalMs: 20,
21
+ getToken: () => undefined,
22
+ isLeader: () => true,
23
+ isRunning: () => true,
24
+ onChangeEvent: () => {},
25
+ onJsonMessage: () => {},
26
+ onBinaryFrame: () => {},
27
+ onConnected: () => {},
28
+ onDisconnected: () => {},
29
+ setStatus: () => {},
30
+ performPollTick: async () => {
31
+ host.ticks += 1;
32
+ },
33
+ performReconnectPull: async () => {},
34
+ ticks: 0,
35
+ ...overrides,
36
+ };
37
+ return host as TransportHost & { ticks: number };
38
+ }
39
+
40
+ describe("PollingTransport", () => {
41
+ let t: PollingTransport | null = null;
42
+ let host: ReturnType<typeof makeHost> | null = null;
43
+
44
+ beforeEach(() => {
45
+ host = makeHost();
46
+ t = new PollingTransport(host);
47
+ });
48
+
49
+ afterEach(() => {
50
+ t?.stop();
51
+ t = null;
52
+ host = null;
53
+ });
54
+
55
+ test("start kicks off a tick loop on pollIntervalMs cadence", async () => {
56
+ t!.start();
57
+ expect(host!.ticks).toBe(0); // no eager tick — first tick fires AFTER the interval
58
+ await new Promise((r) => setTimeout(r, 70));
59
+ // 70ms / 20ms = ~3 ticks. Allow some slop for timer jitter.
60
+ expect(host!.ticks).toBeGreaterThanOrEqual(2);
61
+ expect(host!.ticks).toBeLessThanOrEqual(5);
62
+ });
63
+
64
+ test("stop halts the loop", async () => {
65
+ t!.start();
66
+ await new Promise((r) => setTimeout(r, 40));
67
+ const seen = host!.ticks;
68
+ t!.stop();
69
+ await new Promise((r) => setTimeout(r, 60));
70
+ expect(host!.ticks).toBe(seen);
71
+ });
72
+
73
+ test("start is idempotent", () => {
74
+ t!.start();
75
+ t!.start();
76
+ t!.start();
77
+ // No assertion beyond not throwing — the second/third call should
78
+ // see the existing timer and bail.
79
+ expect(t!.isOpen()).toBe(true);
80
+ });
81
+
82
+ test("send is a no-op (polling has no uplink)", () => {
83
+ t!.start();
84
+ expect(() => t!.send({ type: "ping" })).not.toThrow();
85
+ });
86
+
87
+ test("bumpReconnect is a no-op (polling has no backoff)", () => {
88
+ expect(() => t!.bumpReconnect(5)).not.toThrow();
89
+ });
90
+
91
+ test("does not start when host is not running", () => {
92
+ host!.isRunning = () => false;
93
+ t!.start();
94
+ expect(t!.isOpen()).toBe(false);
95
+ });
96
+
97
+ test("does not start when host is not the multi-tab leader", () => {
98
+ host!.isLeader = () => false;
99
+ t!.start();
100
+ expect(t!.isOpen()).toBe(false);
101
+ });
102
+ });
@@ -0,0 +1,63 @@
1
+ // Polling transport. No persistent connection — every `pollIntervalMs`
2
+ // the transport asks the host to do one push→pull cycle. Used by
3
+ // environments where WS / SSE are unavailable (corporate proxies that
4
+ // strip the Upgrade header, ancient browsers, certain serverless edge
5
+ // runtimes).
6
+ //
7
+ // `send()` is a no-op — no socket exists. Subscribe / presence / topic
8
+ // frames silently disappear. Apps that need bidirectional should not
9
+ // use polling.
10
+
11
+ import type { Transport, TransportHost } from "./types";
12
+
13
+ const DEFAULT_POLL_INTERVAL_MS = 1_000;
14
+
15
+ export class PollingTransport implements Transport {
16
+ private readonly host: TransportHost;
17
+ private timer: ReturnType<typeof setInterval> | null = null;
18
+
19
+ constructor(host: TransportHost) {
20
+ this.host = host;
21
+ }
22
+
23
+ start(): void {
24
+ if (!this.host.isRunning()) return;
25
+ if (!this.host.isLeader()) return;
26
+ if (this.timer) return;
27
+ const interval = this.host.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
28
+ this.timer = setInterval(() => {
29
+ void this.host.performPollTick();
30
+ }, interval);
31
+ // Polling intentionally does NOT fire onConnected on start — there
32
+ // is no real "open" event with this transport, just a heartbeat
33
+ // tick every `pollIntervalMs`. Engine status stays at whatever
34
+ // `start()` set it to (typically "connecting"), and each
35
+ // successful tick implicitly proves liveness without us claiming
36
+ // a connection that doesn't exist. Apps that need an explicit
37
+ // "online" indicator under polling can derive it from the
38
+ // store's `notify` cadence.
39
+ }
40
+
41
+ stop(): void {
42
+ if (this.timer) {
43
+ clearInterval(this.timer);
44
+ this.timer = null;
45
+ }
46
+ }
47
+
48
+ send(_msg: unknown): void {
49
+ /* no uplink on polling */
50
+ }
51
+
52
+ isOpen(): boolean {
53
+ return this.timer !== null;
54
+ }
55
+
56
+ /** No-op — polling doesn't have a backoff counter; it just retries
57
+ * every interval. A 429 on the in-flight push/pull will surface as
58
+ * a thrown error on the next tick but the loop continues at the
59
+ * same cadence. */
60
+ bumpReconnect(_by = 1): void {
61
+ /* polling has no backoff */
62
+ }
63
+ }
@@ -0,0 +1,57 @@
1
+ // Unit tests for the ReconnectBackoff helper. Pins the contract the
2
+ // WS + SSE transports rely on:
3
+ // - Starts at 0 attempts (delay = 0 on first nextDelayMs).
4
+ // - bump() increments; nextDelayMs() grows exponentially with jitter.
5
+ // - reset() returns to 0.
6
+ // - Cap at MAX_DELAY_MS.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+
10
+ import { ReconnectBackoff } from "./reconnect";
11
+
12
+ describe("ReconnectBackoff", () => {
13
+ test("first delay is 0 (no jitter on no attempts)", () => {
14
+ const b = new ReconnectBackoff(1_000);
15
+ // attempts=0, but nextDelayMs uses max(1, attempts)=1 → exp=1000 →
16
+ // random in [0, 1000]. Just check the BOUND.
17
+ const d = b.nextDelayMs();
18
+ expect(d).toBeGreaterThanOrEqual(0);
19
+ expect(d).toBeLessThanOrEqual(1_000);
20
+ });
21
+
22
+ test("each bump doubles the exponent (until cap)", () => {
23
+ const b = new ReconnectBackoff(1_000);
24
+ b.bump(); // 1 → exp=1000
25
+ const d1Max = b.nextDelayMs();
26
+ b.bump(); // 2 → exp=2000
27
+ const d2Max = b.nextDelayMs();
28
+ b.bump(); // 3 → exp=4000
29
+ const d3Max = b.nextDelayMs();
30
+ // Bounds (not exact values — jitter is random).
31
+ expect(d1Max).toBeLessThanOrEqual(1_000);
32
+ expect(d2Max).toBeLessThanOrEqual(2_000);
33
+ expect(d3Max).toBeLessThanOrEqual(4_000);
34
+ });
35
+
36
+ test("delay caps at 30_000 ms even with many attempts", () => {
37
+ const b = new ReconnectBackoff(1_000);
38
+ // 10 doublings = 2^10 * 1_000 = ~1M ms unclamped. Cap is 30_000.
39
+ for (let i = 0; i < 20; i++) b.bump();
40
+ const d = b.nextDelayMs();
41
+ expect(d).toBeLessThanOrEqual(30_000);
42
+ });
43
+
44
+ test("reset clears the counter", () => {
45
+ const b = new ReconnectBackoff(1_000);
46
+ b.bump(5);
47
+ b.reset();
48
+ // Same bound as the fresh state.
49
+ expect(b.nextDelayMs()).toBeLessThanOrEqual(1_000);
50
+ });
51
+
52
+ test("bump(N) jumps by N — used by the 429 case in the engine", () => {
53
+ const b = new ReconnectBackoff(1_000);
54
+ b.bump(3); // attempts=3 → exp=4000
55
+ expect(b.nextDelayMs()).toBeLessThanOrEqual(4_000);
56
+ });
57
+ });
@@ -0,0 +1,50 @@
1
+ // Shared exponential-backoff-with-full-jitter helper used by WS and
2
+ // SSE. Polling has its own steady-cadence loop and doesn't backoff.
3
+ //
4
+ // Thundering-herd fix: when the server restarts, every connected
5
+ // client fires onclose at nearly the same instant. Without jitter
6
+ // they all reconnect at `baseDelay` and hammer the newly-booted
7
+ // server; after a few cycles the reconnect waves align and the
8
+ // server never recovers. Full-jitter (`delay = random(0, exp)`)
9
+ // spreads clients evenly across the backoff window so the second-
10
+ // wave load is flat, not spiky. Algorithm from AWS Architecture
11
+ // Blog "Exponential Backoff and Jitter" — the "Full Jitter" variant.
12
+
13
+ const DEFAULT_BASE_DELAY_MS = 1_000;
14
+ const MAX_DELAY_MS = 30_000;
15
+
16
+ /** Stateful backoff counter. Reset to 0 attempts after a successful
17
+ * stable connection window. Increment on every failed connect /
18
+ * unexpected close. */
19
+ export class ReconnectBackoff {
20
+ private attempts = 0;
21
+ private baseDelayMs: number;
22
+
23
+ constructor(baseDelayMs?: number) {
24
+ this.baseDelayMs = baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
25
+ }
26
+
27
+ /** Record a failed connect / unexpected close. Returns the new attempt
28
+ * count so callers can use it for bookkeeping (e.g., the 429 case
29
+ * bumps by 3 to push the next delay further out). */
30
+ bump(by = 1): number {
31
+ this.attempts += by;
32
+ return this.attempts;
33
+ }
34
+
35
+ /** Reset the counter — call this after the connection has been
36
+ * stable long enough that subsequent reconnects shouldn't carry
37
+ * the prior backoff over. */
38
+ reset(): void {
39
+ this.attempts = 0;
40
+ }
41
+
42
+ /** Compute the next delay in ms. Always returns at least 0; never
43
+ * negative. Full-jitter over [0, exp] where exp grows
44
+ * exponentially with the attempt count and caps at MAX_DELAY_MS. */
45
+ nextDelayMs(): number {
46
+ const attempt = Math.max(1, this.attempts);
47
+ const exp = Math.min(MAX_DELAY_MS, this.baseDelayMs * Math.pow(2, attempt - 1));
48
+ return Math.floor(Math.random() * exp);
49
+ }
50
+ }