@pylonsync/sync 0.3.201 → 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.
- package/package.json +1 -1
- package/src/index.ts +733 -642
- package/src/local-store.ts +74 -0
- package/src/multi-tab-orchestrator.test.ts +173 -0
- package/src/multi-tab-orchestrator.ts +366 -0
- package/src/multi-tab.test.ts +196 -0
- package/src/multi-tab.ts +366 -0
- package/src/mutation-queue.ts +12 -2
- package/src/op-queue.test.ts +91 -0
- package/src/op-queue.ts +73 -0
- package/src/reconcile.test.ts +31 -33
- package/src/round6-codex.test.ts +328 -0
- package/src/scenarios.test.ts +606 -0
- package/src/server-subscriptions.test.ts +99 -0
- package/src/server-subscriptions.ts +78 -0
- package/src/session-chain.test.ts +133 -0
- package/src/session-resolver.test.ts +94 -0
- package/src/session-resolver.ts +133 -0
- package/src/subscription-coordinator.test.ts +209 -0
- package/src/subscription-coordinator.ts +471 -0
- package/src/test-harness/env.ts +191 -0
- package/src/test-harness/index.ts +16 -0
- package/src/test-harness/server.ts +433 -0
- package/src/test-harness/transport.ts +256 -0
- package/src/transports/factory.test.ts +87 -0
- package/src/transports/index.ts +42 -0
- package/src/transports/polling.test.ts +102 -0
- package/src/transports/polling.ts +63 -0
- package/src/transports/reconnect.test.ts +57 -0
- package/src/transports/reconnect.ts +50 -0
- package/src/transports/sse.ts +140 -0
- package/src/transports/types.ts +116 -0
- package/src/transports/websocket.test.ts +310 -0
- package/src/transports/websocket.ts +222 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Server-Sent Events transport. One-way GET stream of change events
|
|
2
|
+
// from a dedicated `/events` endpoint on baseUrl-port+2.
|
|
3
|
+
//
|
|
4
|
+
// SSE has no uplink, so `send()` is a no-op — the engine continues to
|
|
5
|
+
// dispatch subscribe / presence / topic frames through this method but
|
|
6
|
+
// they don't reach the wire on this transport. Apps that need
|
|
7
|
+
// bidirectional should pick WebSocket; SSE is for read-only mirror
|
|
8
|
+
// clients (dashboards, monitors) that don't subscribe to anything
|
|
9
|
+
// individually.
|
|
10
|
+
//
|
|
11
|
+
// Reconnect: SSE has its own browser-level reconnect, but it can't see
|
|
12
|
+
// our auth token (no header on the GET retry), so we own the reconnect
|
|
13
|
+
// loop here too — close on error, jittered backoff, pull-on-reconnect.
|
|
14
|
+
|
|
15
|
+
import type { ChangeEvent } from "../types";
|
|
16
|
+
import { ReconnectBackoff } from "./reconnect";
|
|
17
|
+
import type { Transport, TransportHost } from "./types";
|
|
18
|
+
|
|
19
|
+
export class SseTransport implements Transport {
|
|
20
|
+
private readonly host: TransportHost;
|
|
21
|
+
private es: EventSource | null = null;
|
|
22
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
23
|
+
private readonly backoff: ReconnectBackoff;
|
|
24
|
+
|
|
25
|
+
constructor(host: TransportHost) {
|
|
26
|
+
this.host = host;
|
|
27
|
+
this.backoff = new ReconnectBackoff(host.reconnectDelayMs);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
start(): void {
|
|
31
|
+
if (!this.host.isRunning()) return;
|
|
32
|
+
if (!this.host.isLeader()) return;
|
|
33
|
+
if (this.es) return;
|
|
34
|
+
|
|
35
|
+
const sseUrl = this.deriveSseUrl();
|
|
36
|
+
try {
|
|
37
|
+
const es = new EventSource(sseUrl);
|
|
38
|
+
this.es = es;
|
|
39
|
+
es.onopen = () => {
|
|
40
|
+
this.host.setStatus("connected");
|
|
41
|
+
this.backoff.reset();
|
|
42
|
+
this.host.onConnected();
|
|
43
|
+
};
|
|
44
|
+
es.onmessage = (event) => {
|
|
45
|
+
try {
|
|
46
|
+
const msg = JSON.parse(event.data) as Record<string, unknown>;
|
|
47
|
+
if (
|
|
48
|
+
typeof msg.seq === "number" &&
|
|
49
|
+
msg.seq > 0 &&
|
|
50
|
+
typeof msg.entity === "string" &&
|
|
51
|
+
typeof msg.kind === "string"
|
|
52
|
+
) {
|
|
53
|
+
this.host.onChangeEvent(msg as unknown as ChangeEvent);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// Non-change envelopes are rare on SSE (no reactive queries,
|
|
57
|
+
// no row-revoked typically) but route through anyway so
|
|
58
|
+
// future protocol additions don't get silently dropped.
|
|
59
|
+
this.host.onJsonMessage(msg);
|
|
60
|
+
} catch {
|
|
61
|
+
// Ignore malformed events.
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
es.onerror = () => {
|
|
65
|
+
try {
|
|
66
|
+
es.close();
|
|
67
|
+
} catch {
|
|
68
|
+
/* may already be closed */
|
|
69
|
+
}
|
|
70
|
+
if (this.es === es) this.es = null;
|
|
71
|
+
if (!this.host.isRunning()) return;
|
|
72
|
+
this.host.setStatus("reconnecting");
|
|
73
|
+
this.host.onDisconnected();
|
|
74
|
+
this.scheduleReconnect();
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
// EventSource constructor threw at runtime — rare, but a buggy
|
|
78
|
+
// polyfill or a malformed URL can land here. The `transport: "sse"`
|
|
79
|
+
// → polling fallback for the "EventSource undefined" case lives in
|
|
80
|
+
// `createTransport`; this catch covers the narrower "exists but
|
|
81
|
+
// failed to construct" case. Schedule a reconnect so we retry
|
|
82
|
+
// rather than silently giving up.
|
|
83
|
+
this.scheduleReconnect();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
stop(): void {
|
|
88
|
+
if (this.es) {
|
|
89
|
+
try {
|
|
90
|
+
this.es.close();
|
|
91
|
+
} catch {
|
|
92
|
+
/* may already be closed */
|
|
93
|
+
}
|
|
94
|
+
this.es = null;
|
|
95
|
+
}
|
|
96
|
+
if (this.reconnectTimer) {
|
|
97
|
+
clearTimeout(this.reconnectTimer);
|
|
98
|
+
this.reconnectTimer = null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** SSE is one-way. The engine still calls send() for subscribe /
|
|
103
|
+
* presence / topic / ping, but those frames have nowhere to go.
|
|
104
|
+
* Silent no-op rather than throw: a no-op keeps app code identical
|
|
105
|
+
* across transports. */
|
|
106
|
+
send(_msg: unknown): void {
|
|
107
|
+
/* no uplink on SSE */
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** SSE is always "open enough" to receive once the EventSource is
|
|
111
|
+
* attached and not in CLOSED. We expose this for the engine's
|
|
112
|
+
* `connected` getter so UI status matches reality. */
|
|
113
|
+
isOpen(): boolean {
|
|
114
|
+
return (
|
|
115
|
+
this.es !== null && this.es.readyState !== EventSource.CLOSED
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
bumpReconnect(by = 1): void {
|
|
120
|
+
this.backoff.bump(by);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- internals --------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
private scheduleReconnect(): void {
|
|
126
|
+
if (!this.host.isRunning()) return;
|
|
127
|
+
this.backoff.bump();
|
|
128
|
+
const delay = this.backoff.nextDelayMs();
|
|
129
|
+
this.reconnectTimer = setTimeout(() => {
|
|
130
|
+
this.reconnectTimer = null;
|
|
131
|
+
void this.host.performReconnectPull().then(() => this.start());
|
|
132
|
+
}, delay);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private deriveSseUrl(): string {
|
|
136
|
+
const url = new URL(this.host.baseUrl);
|
|
137
|
+
const port = parseInt(url.port || "4321", 10);
|
|
138
|
+
return `http://${url.hostname}:${port + 2}/events`;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Common interface every real-time transport implements + the host
|
|
2
|
+
// contract the engine offers each transport.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: WebSocket, SSE, and polling each used to live as
|
|
5
|
+
// large parallel methods on the engine, sharing state through engine
|
|
6
|
+
// fields (`this.ws`, `this.pingTimer`, `this.reconnectAttempts`,
|
|
7
|
+
// `this.pollTimer`). That coupling made the three modes hard to
|
|
8
|
+
// test, hard to swap, and a graveyard for state bugs (timer leaks on
|
|
9
|
+
// promotion, stale-socket sends after reconnect, ping racing close).
|
|
10
|
+
// The interface here gives each one a single owner of its own state
|
|
11
|
+
// + a tight contract with the engine for dispatch and lifecycle.
|
|
12
|
+
|
|
13
|
+
import type { ChangeEvent, SyncConnectionStatus } from "../types";
|
|
14
|
+
|
|
15
|
+
/** Real-time transport implementations all conform to this shape. The
|
|
16
|
+
* engine holds exactly one. The interface intentionally hides the
|
|
17
|
+
* underlying mechanism (WebSocket vs EventSource vs setInterval) so
|
|
18
|
+
* the engine can be told "go connect" without caring which one. */
|
|
19
|
+
export interface Transport {
|
|
20
|
+
/** Connect / start the transport. Idempotent — calling twice is a
|
|
21
|
+
* no-op for an already-running transport. */
|
|
22
|
+
start(): void;
|
|
23
|
+
/** Disconnect / stop the transport, clearing every timer and
|
|
24
|
+
* releasing the socket. Idempotent. After stop() the transport is
|
|
25
|
+
* re-startable. */
|
|
26
|
+
stop(): void;
|
|
27
|
+
/** Send a JSON message over the transport. No-op for non-bidirectional
|
|
28
|
+
* transports (SSE, polling) — the engine still sends subscribe
|
|
29
|
+
* frames + presence + topic + ping through this entry point, and
|
|
30
|
+
* those frames simply never reach the wire when the transport
|
|
31
|
+
* doesn't support uplink. */
|
|
32
|
+
send(msg: unknown): void;
|
|
33
|
+
/** True when the underlying connection is currently open for sending.
|
|
34
|
+
* Used by the engine's `connected` getter and by paths that need to
|
|
35
|
+
* short-circuit when no socket exists (e.g., avoid throwing on a
|
|
36
|
+
* send-without-WS). */
|
|
37
|
+
isOpen(): boolean;
|
|
38
|
+
/** Bump the reconnect backoff counter by N attempts. Called by the
|
|
39
|
+
* engine when it observes a hint that the NEXT reconnect should
|
|
40
|
+
* wait longer — typically a 429 on pull, which would otherwise
|
|
41
|
+
* drive a tight 429 / reconnect / 429 loop. Transports without a
|
|
42
|
+
* backoff (polling) implement this as a no-op. */
|
|
43
|
+
bumpReconnect(by?: number): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Engine-side surface the transport calls into. Transport is the
|
|
47
|
+
* thing that knows about sockets and timers; the host (engine) is the
|
|
48
|
+
* thing that knows what to DO with inbound frames and lifecycle
|
|
49
|
+
* events. Keeping this small + explicit makes the boundary clear:
|
|
50
|
+
* if a transport needs a new engine capability it gets added here,
|
|
51
|
+
* not via reaching back into the engine instance. */
|
|
52
|
+
export interface TransportHost {
|
|
53
|
+
/** Base URL of the Pylon HTTP API (e.g. `https://api.example.com`).
|
|
54
|
+
* Transports derive their own URLs from this (ws upgrade path, SSE
|
|
55
|
+
* endpoint, poll path). */
|
|
56
|
+
readonly baseUrl: string;
|
|
57
|
+
/** Optional explicit WS URL — overrides the derived one. */
|
|
58
|
+
readonly wsUrl?: string;
|
|
59
|
+
/** WS keepalive interval. Default is implementation-defined; the
|
|
60
|
+
* engine accepts `pingIntervalMs` from config to tune broadcast
|
|
61
|
+
* latency on single-threaded server fallbacks. */
|
|
62
|
+
readonly pingIntervalMs?: number;
|
|
63
|
+
/** Base delay for reconnect backoff (full-jitter). */
|
|
64
|
+
readonly reconnectDelayMs?: number;
|
|
65
|
+
/** Polling cadence (polling transport only). */
|
|
66
|
+
readonly pollIntervalMs?: number;
|
|
67
|
+
|
|
68
|
+
/** Current bearer token, or undefined when anonymous. The transport
|
|
69
|
+
* must call this fresh on each connect / reconnect so token rotation
|
|
70
|
+
* is picked up automatically. */
|
|
71
|
+
getToken(): string | undefined;
|
|
72
|
+
|
|
73
|
+
/** Is this tab the multi-tab leader. Followers don't open their own
|
|
74
|
+
* transport — they mirror the leader's broadcasts. Transports check
|
|
75
|
+
* this defensively at start() to avoid wasting socket budget. */
|
|
76
|
+
isLeader(): boolean;
|
|
77
|
+
/** Is the engine currently running. Set false on stop(); transports
|
|
78
|
+
* bail out of reconnect timers when this flips. */
|
|
79
|
+
isRunning(): boolean;
|
|
80
|
+
|
|
81
|
+
/** Inbound: a typed change event from the server's change log.
|
|
82
|
+
* Engine routes through the apply queue so order is preserved. */
|
|
83
|
+
onChangeEvent(ev: ChangeEvent): void;
|
|
84
|
+
/** Inbound: a typed JSON envelope (not a ChangeEvent). The transport
|
|
85
|
+
* doesn't try to interpret the envelope — engine has the dispatch
|
|
86
|
+
* table (session-changed, reactive-result, row-revoked, presence,
|
|
87
|
+
* pong, etc). */
|
|
88
|
+
onJsonMessage(msg: Record<string, unknown>): void;
|
|
89
|
+
/** Inbound: a binary frame. CRDT broadcast layer ships these for
|
|
90
|
+
* every Loro-mode write; the engine routes via its binaryHandlers
|
|
91
|
+
* + multi-tab forwarders. */
|
|
92
|
+
onBinaryFrame(bytes: Uint8Array): void;
|
|
93
|
+
|
|
94
|
+
/** Lifecycle: the transport just connected. Engine uses this to
|
|
95
|
+
* replay active subscriptions, fire a catch-up pull + reconcile,
|
|
96
|
+
* and flip the public connection status. */
|
|
97
|
+
onConnected(): void;
|
|
98
|
+
/** Lifecycle: the transport just disconnected. Engine flips status
|
|
99
|
+
* to `reconnecting` while the transport's own backoff timer is
|
|
100
|
+
* pending; or to `offline` when `isRunning()` is false. */
|
|
101
|
+
onDisconnected(): void;
|
|
102
|
+
/** Flip the engine's public `connectionStatus`. Transports drive
|
|
103
|
+
* this so the UI sees `connecting` / `connected` / `reconnecting`
|
|
104
|
+
* / `offline` at the right moments. */
|
|
105
|
+
setStatus(s: SyncConnectionStatus): void;
|
|
106
|
+
|
|
107
|
+
/** Polling transport only: one iteration of the push→pull cycle.
|
|
108
|
+
* Implementations of polling do nothing else; they just call this
|
|
109
|
+
* on a timer. */
|
|
110
|
+
performPollTick(): Promise<void>;
|
|
111
|
+
/** Reconnect-aware transports (WS, SSE) need to do a catch-up pull
|
|
112
|
+
* before the next connect attempt so they don't open a fresh socket
|
|
113
|
+
* and immediately discover their cursor is stale. The engine owns
|
|
114
|
+
* the pull queue + reconcile path; the transport invokes this. */
|
|
115
|
+
performReconnectPull(): Promise<void>;
|
|
116
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Unit tests for the WebSocketTransport. Uses a fake WebSocket
|
|
2
|
+
// implementation so the test runs without a real server. Pins:
|
|
3
|
+
// - start() opens a socket and wires onopen/onmessage/onclose/onerror
|
|
4
|
+
// - onopen flips status to "connected", replay fires via host.onConnected
|
|
5
|
+
// - binary frames route through host.onBinaryFrame
|
|
6
|
+
// - ChangeEvent JSON routes through host.onChangeEvent
|
|
7
|
+
// - Other JSON routes through host.onJsonMessage
|
|
8
|
+
// - Disconnect triggers reconnect after a backoff window
|
|
9
|
+
// - stop() suppresses the scheduled reconnect
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
afterEach,
|
|
13
|
+
beforeAll,
|
|
14
|
+
beforeEach,
|
|
15
|
+
describe,
|
|
16
|
+
expect,
|
|
17
|
+
test,
|
|
18
|
+
} from "bun:test";
|
|
19
|
+
|
|
20
|
+
import type { ChangeEvent, SyncConnectionStatus } from "../types";
|
|
21
|
+
import type { TransportHost } from "./types";
|
|
22
|
+
import { WebSocketTransport } from "./websocket";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Fake WebSocket. Captures every constructor call so tests can poke at the
|
|
26
|
+
// returned socket's onopen/onmessage/onclose/onerror handlers. The real
|
|
27
|
+
// browser API doesn't let us do that — but we control what `new WebSocket`
|
|
28
|
+
// returns in test land.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
interface FakeWebSocketInstance {
|
|
32
|
+
url: string;
|
|
33
|
+
protocols?: string | string[];
|
|
34
|
+
readyState: number;
|
|
35
|
+
binaryType: string;
|
|
36
|
+
sent: string[];
|
|
37
|
+
onopen: ((ev: Event) => void) | null;
|
|
38
|
+
onmessage: ((ev: MessageEvent) => void) | null;
|
|
39
|
+
onclose: ((ev: CloseEvent) => void) | null;
|
|
40
|
+
onerror: ((ev: Event) => void) | null;
|
|
41
|
+
send(data: string): void;
|
|
42
|
+
close(): void;
|
|
43
|
+
/** Test helper: simulate the server pushing a frame. */
|
|
44
|
+
simulateMessage(data: string | ArrayBuffer): void;
|
|
45
|
+
/** Test helper: simulate the open handshake completing. */
|
|
46
|
+
simulateOpen(): void;
|
|
47
|
+
/** Test helper: simulate an unexpected close. */
|
|
48
|
+
simulateClose(): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const fakeSockets: FakeWebSocketInstance[] = [];
|
|
52
|
+
|
|
53
|
+
class FakeWebSocket implements FakeWebSocketInstance {
|
|
54
|
+
url: string;
|
|
55
|
+
protocols?: string | string[];
|
|
56
|
+
readyState = 0; // CONNECTING
|
|
57
|
+
binaryType = "blob";
|
|
58
|
+
sent: string[] = [];
|
|
59
|
+
onopen: ((ev: Event) => void) | null = null;
|
|
60
|
+
onmessage: ((ev: MessageEvent) => void) | null = null;
|
|
61
|
+
onclose: ((ev: CloseEvent) => void) | null = null;
|
|
62
|
+
onerror: ((ev: Event) => void) | null = null;
|
|
63
|
+
|
|
64
|
+
constructor(url: string, protocols?: string | string[]) {
|
|
65
|
+
this.url = url;
|
|
66
|
+
this.protocols = protocols;
|
|
67
|
+
fakeSockets.push(this);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
send(data: string): void {
|
|
71
|
+
this.sent.push(data);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
close(): void {
|
|
75
|
+
this.readyState = 3; // CLOSED
|
|
76
|
+
// Bun's tests run synchronously enough that we don't fire onclose
|
|
77
|
+
// here — the production code's stop() nulls onclose before this
|
|
78
|
+
// is called, and the test's simulateClose covers the other path.
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
simulateOpen(): void {
|
|
82
|
+
this.readyState = 1; // OPEN
|
|
83
|
+
this.onopen?.(new Event("open"));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
simulateMessage(data: string | ArrayBuffer): void {
|
|
87
|
+
this.onmessage?.({ data } as MessageEvent);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
simulateClose(): void {
|
|
91
|
+
this.readyState = 3; // CLOSED
|
|
92
|
+
this.onclose?.(new CloseEvent("close"));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Patch globals once for this file. We intentionally don't restore —
|
|
97
|
+
// the test runner gives each file its own module evaluation, and other
|
|
98
|
+
// test files that need a real WebSocket already supply their own stub
|
|
99
|
+
// (e.g., the scenario harness installs its own). Restoring after each
|
|
100
|
+
// test caused us to clobber the FakeWebSocket mid-suite and break
|
|
101
|
+
// subsequent tests in this file.
|
|
102
|
+
beforeAll(() => {
|
|
103
|
+
Object.defineProperty(globalThis, "WebSocket", {
|
|
104
|
+
value: FakeWebSocket,
|
|
105
|
+
writable: true,
|
|
106
|
+
configurable: true,
|
|
107
|
+
});
|
|
108
|
+
// The transport checks `this.ws.readyState === WebSocket.OPEN`, so
|
|
109
|
+
// the static .OPEN constant has to exist on whatever class we
|
|
110
|
+
// install. Mirrors the browser's WebSocket.OPEN === 1.
|
|
111
|
+
Object.defineProperty(FakeWebSocket, "OPEN", {
|
|
112
|
+
value: 1,
|
|
113
|
+
writable: false,
|
|
114
|
+
configurable: true,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
interface TestState {
|
|
121
|
+
statuses: SyncConnectionStatus[];
|
|
122
|
+
changes: ChangeEvent[];
|
|
123
|
+
jsonMessages: Record<string, unknown>[];
|
|
124
|
+
binaries: Uint8Array[];
|
|
125
|
+
connectedCount: number;
|
|
126
|
+
reconnectPulls: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Build a TransportHost wired to a TestState recorder. Recorder is
|
|
130
|
+
* returned via `state` so tests can read counters after the transport
|
|
131
|
+
* has called back into the host. Spreading state into host would
|
|
132
|
+
* copy the counter values once at construction — the recorder needs
|
|
133
|
+
* shared mutable state. */
|
|
134
|
+
function makeHost(
|
|
135
|
+
overrides: Partial<TransportHost> = {},
|
|
136
|
+
): { host: TransportHost; state: TestState } {
|
|
137
|
+
const state: TestState = {
|
|
138
|
+
statuses: [],
|
|
139
|
+
changes: [],
|
|
140
|
+
jsonMessages: [],
|
|
141
|
+
binaries: [],
|
|
142
|
+
connectedCount: 0,
|
|
143
|
+
reconnectPulls: 0,
|
|
144
|
+
};
|
|
145
|
+
const host: TransportHost = {
|
|
146
|
+
baseUrl: "http://stub.invalid",
|
|
147
|
+
reconnectDelayMs: 10,
|
|
148
|
+
getToken: () => undefined,
|
|
149
|
+
isLeader: () => true,
|
|
150
|
+
isRunning: () => true,
|
|
151
|
+
onChangeEvent: (ev) => state.changes.push(ev),
|
|
152
|
+
onJsonMessage: (msg) => state.jsonMessages.push(msg),
|
|
153
|
+
onBinaryFrame: (bytes) => state.binaries.push(bytes),
|
|
154
|
+
onConnected: () => {
|
|
155
|
+
state.connectedCount += 1;
|
|
156
|
+
},
|
|
157
|
+
onDisconnected: () => {},
|
|
158
|
+
setStatus: (s) => state.statuses.push(s),
|
|
159
|
+
performPollTick: async () => {},
|
|
160
|
+
performReconnectPull: async () => {
|
|
161
|
+
state.reconnectPulls += 1;
|
|
162
|
+
},
|
|
163
|
+
...overrides,
|
|
164
|
+
};
|
|
165
|
+
return { host, state };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
describe("WebSocketTransport", () => {
|
|
169
|
+
let t: WebSocketTransport | null = null;
|
|
170
|
+
|
|
171
|
+
beforeEach(() => {
|
|
172
|
+
fakeSockets.length = 0;
|
|
173
|
+
t = null;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
afterEach(() => {
|
|
177
|
+
t?.stop();
|
|
178
|
+
t = null;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("start opens a socket with the derived URL", () => {
|
|
182
|
+
const { host, state } = makeHost();
|
|
183
|
+
t = new WebSocketTransport(host);
|
|
184
|
+
t.start();
|
|
185
|
+
expect(fakeSockets.length).toBe(1);
|
|
186
|
+
// baseUrl is http → ws scheme; multiplexed on /api/sync/ws.
|
|
187
|
+
expect(fakeSockets[0].url).toBe("ws://stub.invalid/api/sync/ws");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("start passes the bearer-subprotocol when host has a token", () => {
|
|
191
|
+
const { host } = makeHost({ getToken: () => "tok_abc" });
|
|
192
|
+
t = new WebSocketTransport(host);
|
|
193
|
+
t.start();
|
|
194
|
+
expect(fakeSockets[0].protocols).toBe("bearer.tok_abc");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("onopen flips status to connected and fires host.onConnected", () => {
|
|
198
|
+
const { host, state } = makeHost();
|
|
199
|
+
t = new WebSocketTransport(host);
|
|
200
|
+
t.start();
|
|
201
|
+
fakeSockets[0].simulateOpen();
|
|
202
|
+
expect(state.statuses).toContain("connected");
|
|
203
|
+
expect(state.connectedCount).toBe(1);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("ChangeEvent frames route through onChangeEvent", () => {
|
|
207
|
+
const { host, state } = makeHost();
|
|
208
|
+
t = new WebSocketTransport(host);
|
|
209
|
+
t.start();
|
|
210
|
+
fakeSockets[0].simulateOpen();
|
|
211
|
+
const ev = {
|
|
212
|
+
seq: 5,
|
|
213
|
+
entity: "Todo",
|
|
214
|
+
row_id: "r1",
|
|
215
|
+
kind: "insert",
|
|
216
|
+
data: { id: "r1" },
|
|
217
|
+
timestamp: "",
|
|
218
|
+
};
|
|
219
|
+
fakeSockets[0].simulateMessage(JSON.stringify(ev));
|
|
220
|
+
expect(state.changes.length).toBe(1);
|
|
221
|
+
expect(state.changes[0].seq).toBe(5);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("non-change JSON routes through onJsonMessage", () => {
|
|
225
|
+
const { host, state } = makeHost();
|
|
226
|
+
t = new WebSocketTransport(host);
|
|
227
|
+
t.start();
|
|
228
|
+
fakeSockets[0].simulateOpen();
|
|
229
|
+
fakeSockets[0].simulateMessage(
|
|
230
|
+
JSON.stringify({ type: "session-changed" }),
|
|
231
|
+
);
|
|
232
|
+
expect(state.jsonMessages.length).toBe(1);
|
|
233
|
+
expect(state.jsonMessages[0].type).toBe("session-changed");
|
|
234
|
+
expect(state.changes.length).toBe(0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("binary frames route through onBinaryFrame", () => {
|
|
238
|
+
const { host, state } = makeHost();
|
|
239
|
+
t = new WebSocketTransport(host);
|
|
240
|
+
t.start();
|
|
241
|
+
fakeSockets[0].simulateOpen();
|
|
242
|
+
const bytes = new Uint8Array([1, 2, 3, 4]).buffer;
|
|
243
|
+
fakeSockets[0].simulateMessage(bytes);
|
|
244
|
+
expect(state.binaries.length).toBe(1);
|
|
245
|
+
expect(state.binaries[0].length).toBe(4);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("send writes a JSON string to the socket when open", () => {
|
|
249
|
+
const { host, state } = makeHost();
|
|
250
|
+
t = new WebSocketTransport(host);
|
|
251
|
+
t.start();
|
|
252
|
+
fakeSockets[0].simulateOpen();
|
|
253
|
+
t.send({ type: "presence", x: 1 });
|
|
254
|
+
expect(fakeSockets[0].sent).toContain('{"type":"presence","x":1}');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("send is a no-op when the socket isn't open", () => {
|
|
258
|
+
const { host, state } = makeHost();
|
|
259
|
+
t = new WebSocketTransport(host);
|
|
260
|
+
t.start();
|
|
261
|
+
// Don't simulateOpen — readyState stays at CONNECTING.
|
|
262
|
+
t.send({ type: "presence" });
|
|
263
|
+
expect(fakeSockets[0].sent.length).toBe(0);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("unexpected close triggers reconnect via performReconnectPull", async () => {
|
|
267
|
+
const { host, state } = makeHost({ reconnectDelayMs: 5 });
|
|
268
|
+
t = new WebSocketTransport(host);
|
|
269
|
+
t.start();
|
|
270
|
+
fakeSockets[0].simulateOpen();
|
|
271
|
+
fakeSockets[0].simulateClose();
|
|
272
|
+
// Wait long enough for the backoff timer + reconnect to fire.
|
|
273
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
274
|
+
expect(state.reconnectPulls).toBeGreaterThanOrEqual(1);
|
|
275
|
+
// A second socket should have been opened.
|
|
276
|
+
expect(fakeSockets.length).toBeGreaterThanOrEqual(2);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("stop suppresses the scheduled reconnect", async () => {
|
|
280
|
+
const { host, state } = makeHost({ reconnectDelayMs: 5 });
|
|
281
|
+
t = new WebSocketTransport(host);
|
|
282
|
+
t.start();
|
|
283
|
+
fakeSockets[0].simulateOpen();
|
|
284
|
+
fakeSockets[0].simulateClose();
|
|
285
|
+
t.stop();
|
|
286
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
287
|
+
// No reconnect pull should have fired — stop cancelled the timer.
|
|
288
|
+
expect(state.reconnectPulls).toBe(0);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("does not open a socket when host is not leader", () => {
|
|
292
|
+
const { host } = makeHost({ isLeader: () => false });
|
|
293
|
+
t = new WebSocketTransport(host);
|
|
294
|
+
t.start();
|
|
295
|
+
expect(fakeSockets.length).toBe(0);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("isOpen reflects the underlying readyState", () => {
|
|
299
|
+
const { host, state } = makeHost();
|
|
300
|
+
t = new WebSocketTransport(host);
|
|
301
|
+
expect(t.isOpen()).toBe(false);
|
|
302
|
+
t.start();
|
|
303
|
+
expect(t.isOpen()).toBe(false); // CONNECTING
|
|
304
|
+
fakeSockets[0].simulateOpen();
|
|
305
|
+
expect(t.isOpen()).toBe(true); // OPEN
|
|
306
|
+
fakeSockets[0].simulateClose();
|
|
307
|
+
expect(t.isOpen()).toBe(false); // CLOSED
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|