@relayfile/sdk 0.6.10 → 0.6.11
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/dist/onWrite.d.ts +2 -0
- package/dist/onWrite.js +40 -2
- package/dist/sync.d.ts +5 -0
- package/dist/sync.js +128 -3
- package/package.json +2 -2
package/dist/onWrite.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface OnWriteOptions {
|
|
|
19
19
|
baseUrl?: string;
|
|
20
20
|
token?: string;
|
|
21
21
|
webSocketFactory?: (url: string) => RelayFileSyncSocket;
|
|
22
|
+
pingIntervalMs?: number;
|
|
23
|
+
pongTimeoutMs?: number;
|
|
22
24
|
}
|
|
23
25
|
export declare function pathMatches(pattern: string, path: string): boolean;
|
|
24
26
|
export declare function onWrite(pattern: string, handler: OnWriteHandler, options?: OnWriteOptions): () => void;
|
package/dist/onWrite.js
CHANGED
|
@@ -6,6 +6,23 @@ const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000;
|
|
|
6
6
|
const dispatchers = new WeakMap();
|
|
7
7
|
let nextRegistrationId = 1;
|
|
8
8
|
let defaultClient;
|
|
9
|
+
const debugEnabled = (() => {
|
|
10
|
+
try {
|
|
11
|
+
const value = globalThis.process?.env?.RELAYFILE_SDK_DEBUG;
|
|
12
|
+
return value === "1" || value === "true";
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
})();
|
|
18
|
+
function debugLog(...args) {
|
|
19
|
+
if (!debugEnabled) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (typeof console !== "undefined" && typeof console.error === "function") {
|
|
23
|
+
console.error("[relayfile-sdk:onWrite]", ...args);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
9
26
|
export function pathMatches(pattern, path) {
|
|
10
27
|
const patternSegments = normalizePattern(pattern);
|
|
11
28
|
const pathSegments = normalizePath(path);
|
|
@@ -50,8 +67,11 @@ export function onWrite(pattern, handler, options = {}) {
|
|
|
50
67
|
signal: options.signal,
|
|
51
68
|
baseUrl: options.baseUrl,
|
|
52
69
|
token: options.token,
|
|
53
|
-
webSocketFactory: options.webSocketFactory
|
|
70
|
+
webSocketFactory: options.webSocketFactory,
|
|
71
|
+
pingIntervalMs: options.pingIntervalMs,
|
|
72
|
+
pongTimeoutMs: options.pongTimeoutMs
|
|
54
73
|
});
|
|
74
|
+
debugLog("registered", { id: registration.id, pattern: normalizedPattern, workspaceId });
|
|
55
75
|
return () => {
|
|
56
76
|
dispatcher?.unregister(registration.id);
|
|
57
77
|
};
|
|
@@ -105,6 +125,8 @@ class OnWriteDispatcher {
|
|
|
105
125
|
maxDelayMs: DEFAULT_RECONNECT_MAX_DELAY_MS
|
|
106
126
|
},
|
|
107
127
|
webSocketFactory: options.webSocketFactory,
|
|
128
|
+
pingIntervalMs: options.pingIntervalMs,
|
|
129
|
+
pongTimeoutMs: options.pongTimeoutMs,
|
|
108
130
|
onEvent: (event) => {
|
|
109
131
|
void this.dispatch(event);
|
|
110
132
|
}
|
|
@@ -119,10 +141,26 @@ class OnWriteDispatcher {
|
|
|
119
141
|
if (!registration.operations.has(writeEvent.operation) || !pathMatches(registration.pattern, writeEvent.path)) {
|
|
120
142
|
continue;
|
|
121
143
|
}
|
|
144
|
+
debugLog("dispatch", {
|
|
145
|
+
registrationId: registration.id,
|
|
146
|
+
pattern: registration.pattern,
|
|
147
|
+
path: writeEvent.path,
|
|
148
|
+
operation: writeEvent.operation
|
|
149
|
+
});
|
|
150
|
+
// patternChains serializes handlers per pattern. runHandler is the only
|
|
151
|
+
// thing chained here and it already swallows handler errors, so the
|
|
152
|
+
// chain itself can never reject — but we still defensively `.catch`
|
|
153
|
+
// before chaining so a future refactor of runHandler cannot silently
|
|
154
|
+
// break the chain (which is the failure mode hypothesis 3 in the bug
|
|
155
|
+
// report).
|
|
122
156
|
const previous = this.patternChains.get(registration.pattern) ?? Promise.resolve();
|
|
123
157
|
const next = previous
|
|
124
158
|
.catch(() => undefined)
|
|
125
|
-
.then(() => this.runHandler(registration, writeEvent))
|
|
159
|
+
.then(() => this.runHandler(registration, writeEvent))
|
|
160
|
+
.catch((error) => {
|
|
161
|
+
// Belt-and-braces: should be unreachable because runHandler catches.
|
|
162
|
+
debugLog("patternChain unexpectedly rejected", { pattern: registration.pattern, error });
|
|
163
|
+
});
|
|
126
164
|
this.patternChains.set(registration.pattern, next);
|
|
127
165
|
void next.finally(() => {
|
|
128
166
|
if (this.patternChains.get(registration.pattern) === next) {
|
package/dist/sync.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface RelayFileSyncOptions {
|
|
|
19
19
|
preferPolling?: boolean;
|
|
20
20
|
pollIntervalMs?: number;
|
|
21
21
|
pingIntervalMs?: number;
|
|
22
|
+
pongTimeoutMs?: number;
|
|
22
23
|
reconnect?: boolean | RelayFileSyncReconnectOptions;
|
|
23
24
|
signal?: AbortSignal;
|
|
24
25
|
webSocketFactory?: (url: string) => RelayFileSyncSocket;
|
|
@@ -48,6 +49,7 @@ export declare class RelayFileSync {
|
|
|
48
49
|
private readonly token?;
|
|
49
50
|
private readonly pollIntervalMs;
|
|
50
51
|
private readonly pingIntervalMs;
|
|
52
|
+
private readonly pongTimeoutMs;
|
|
51
53
|
private readonly reconnect;
|
|
52
54
|
private readonly preferPolling;
|
|
53
55
|
private readonly signal?;
|
|
@@ -61,6 +63,8 @@ export declare class RelayFileSync {
|
|
|
61
63
|
private pollingPromise?;
|
|
62
64
|
private reconnectTimer?;
|
|
63
65
|
private pingTimer?;
|
|
66
|
+
private lastFrameAt;
|
|
67
|
+
private lastPingSentAt;
|
|
64
68
|
private reconnectAttempts;
|
|
65
69
|
private readonly abortHandler?;
|
|
66
70
|
constructor(options: RelayFileSyncOptions);
|
|
@@ -75,6 +79,7 @@ export declare class RelayFileSync {
|
|
|
75
79
|
private pollLoop;
|
|
76
80
|
private handleSocketMessage;
|
|
77
81
|
private startPingLoop;
|
|
82
|
+
private forceReconnect;
|
|
78
83
|
private scheduleReconnect;
|
|
79
84
|
private computeReconnectDelayMs;
|
|
80
85
|
private sleep;
|
package/dist/sync.js
CHANGED
|
@@ -2,6 +2,23 @@ const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
|
2
2
|
const DEFAULT_PING_INTERVAL_MS = 30000;
|
|
3
3
|
const DEFAULT_RECONNECT_MIN_DELAY_MS = 250;
|
|
4
4
|
const DEFAULT_RECONNECT_MAX_DELAY_MS = 5000;
|
|
5
|
+
const debugEnabled = (() => {
|
|
6
|
+
try {
|
|
7
|
+
const value = globalThis.process?.env?.RELAYFILE_SDK_DEBUG;
|
|
8
|
+
return value === "1" || value === "true";
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
})();
|
|
14
|
+
function debugLog(...args) {
|
|
15
|
+
if (!debugEnabled) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (typeof console !== "undefined" && typeof console.error === "function") {
|
|
19
|
+
console.error("[relayfile-sdk:sync]", ...args);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
5
22
|
function normalizeReconnectOptions(reconnect) {
|
|
6
23
|
if (reconnect === false) {
|
|
7
24
|
return {
|
|
@@ -61,6 +78,7 @@ export class RelayFileSync {
|
|
|
61
78
|
token;
|
|
62
79
|
pollIntervalMs;
|
|
63
80
|
pingIntervalMs;
|
|
81
|
+
pongTimeoutMs;
|
|
64
82
|
reconnect;
|
|
65
83
|
preferPolling;
|
|
66
84
|
signal;
|
|
@@ -81,6 +99,13 @@ export class RelayFileSync {
|
|
|
81
99
|
pollingPromise;
|
|
82
100
|
reconnectTimer;
|
|
83
101
|
pingTimer;
|
|
102
|
+
// Tracks the last time *any* WebSocket frame was received (event, pong, or
|
|
103
|
+
// otherwise). The watchdog uses this to detect silent socket death.
|
|
104
|
+
lastFrameAt = 0;
|
|
105
|
+
// Tracks when the most recent ping was sent. We only enforce the pong
|
|
106
|
+
// timeout once a ping has actually gone out; otherwise a quiet workspace
|
|
107
|
+
// (no broadcasts and no pings yet) would falsely trip the watchdog.
|
|
108
|
+
lastPingSentAt = 0;
|
|
84
109
|
reconnectAttempts = 0;
|
|
85
110
|
abortHandler;
|
|
86
111
|
constructor(options) {
|
|
@@ -91,6 +116,10 @@ export class RelayFileSync {
|
|
|
91
116
|
this.cursor = options.cursor;
|
|
92
117
|
this.pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS));
|
|
93
118
|
this.pingIntervalMs = Math.max(1, Math.floor(options.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS));
|
|
119
|
+
// Default the pong timeout to 2x ping interval so a single missed pong
|
|
120
|
+
// does not cause a flap, but two consecutive misses force a reconnect.
|
|
121
|
+
const defaultPongTimeoutMs = this.pingIntervalMs * 2;
|
|
122
|
+
this.pongTimeoutMs = Math.max(1, Math.floor(options.pongTimeoutMs ?? defaultPongTimeoutMs));
|
|
94
123
|
this.reconnect = normalizeReconnectOptions(options.reconnect);
|
|
95
124
|
this.preferPolling = options.preferPolling ?? false;
|
|
96
125
|
this.signal = options.signal;
|
|
@@ -193,27 +222,51 @@ export class RelayFileSync {
|
|
|
193
222
|
return;
|
|
194
223
|
}
|
|
195
224
|
this.reconnectAttempts = 0;
|
|
225
|
+
// Reset frame/ping bookkeeping for the freshly opened socket so the
|
|
226
|
+
// watchdog has a clean baseline. lastPingSentAt=0 disables the pong
|
|
227
|
+
// timeout until we actually send our first ping.
|
|
228
|
+
this.lastFrameAt = Date.now();
|
|
229
|
+
this.lastPingSentAt = 0;
|
|
196
230
|
this.setState("open");
|
|
197
231
|
this.startPingLoop(socket);
|
|
232
|
+
debugLog("ws open", { workspaceId: this.workspaceId });
|
|
198
233
|
this.emit("open", event);
|
|
199
234
|
});
|
|
200
235
|
socket.addEventListener("message", (event) => {
|
|
201
236
|
if (this.socket !== socket || this.stopped) {
|
|
202
237
|
return;
|
|
203
238
|
}
|
|
239
|
+
// Stamp lastFrameAt for *any* inbound frame so the watchdog sees both
|
|
240
|
+
// application events and pongs as proof of life.
|
|
241
|
+
this.lastFrameAt = Date.now();
|
|
204
242
|
this.handleSocketMessage(event);
|
|
205
243
|
});
|
|
206
244
|
socket.addEventListener("error", (event) => {
|
|
207
245
|
if (this.socket !== socket || this.stopped) {
|
|
208
246
|
return;
|
|
209
247
|
}
|
|
248
|
+
debugLog("ws error", event);
|
|
210
249
|
this.emit("error", normalizeError(event));
|
|
211
250
|
});
|
|
212
251
|
socket.addEventListener("close", (event) => {
|
|
213
|
-
|
|
214
|
-
|
|
252
|
+
// forceReconnect (called from the watchdog or a failed ping send) nulls
|
|
253
|
+
// out this.socket and opens a replacement before the OS-layer close
|
|
254
|
+
// event for the old socket actually fires. If this handler treated a
|
|
255
|
+
// stale close as authoritative it would (a) clearPingTimer() and kill
|
|
256
|
+
// the new socket's heartbeat and (b) potentially scheduleReconnect()
|
|
257
|
+
// a second time (the timer guard would skip it most of the time, but
|
|
258
|
+
// not after the timer has already fired). Either way: don't touch
|
|
259
|
+
// shared state when the socket we're attached to is no longer current.
|
|
260
|
+
if (this.socket !== socket) {
|
|
261
|
+
debugLog("ws close (stale, ignored)", {
|
|
262
|
+
code: event?.code,
|
|
263
|
+
reason: event?.reason,
|
|
264
|
+
});
|
|
265
|
+
return;
|
|
215
266
|
}
|
|
267
|
+
this.socket = undefined;
|
|
216
268
|
this.clearPingTimer();
|
|
269
|
+
debugLog("ws close", { code: event?.code, reason: event?.reason, stopped: this.stopped });
|
|
217
270
|
this.emit("close", event);
|
|
218
271
|
if (this.stopped) {
|
|
219
272
|
this.setState("closed");
|
|
@@ -302,29 +355,100 @@ export class RelayFileSync {
|
|
|
302
355
|
return;
|
|
303
356
|
}
|
|
304
357
|
if (parsed.type === "pong") {
|
|
358
|
+
debugLog("pong", { timestamp: parsed.timestamp ?? parsed.ts });
|
|
305
359
|
this.emit("pong", {
|
|
306
360
|
type: "pong",
|
|
307
361
|
timestamp: parsed.timestamp ?? parsed.ts
|
|
308
362
|
});
|
|
309
363
|
return;
|
|
310
364
|
}
|
|
311
|
-
|
|
365
|
+
const normalized = normalizeFilesystemEvent(parsed);
|
|
366
|
+
debugLog("event", { type: normalized.type, path: normalized.path, revision: normalized.revision });
|
|
367
|
+
this.emit("event", normalized);
|
|
312
368
|
}
|
|
313
369
|
startPingLoop(socket) {
|
|
314
370
|
this.clearPingTimer();
|
|
371
|
+
// The "ping loop" doubles as the heartbeat watchdog. The contract for
|
|
372
|
+
// pongTimeoutMs is "how long to wait, after sending a ping, before
|
|
373
|
+
// assuming the socket is dead." So the timeout window must be measured
|
|
374
|
+
// from `lastPingSentAt` — the moment we sent the unanswered ping — not
|
|
375
|
+
// from the last inbound frame. (CodeRabbit P1 on PR #93: with
|
|
376
|
+
// pingIntervalMs=30_000 + pongTimeoutMs=45_000, measuring from
|
|
377
|
+
// lastFrameAt would reconnect at t=60s — only 30s after the first
|
|
378
|
+
// ping went out, which violates the documented "wait 45s after a
|
|
379
|
+
// ping" semantics.)
|
|
380
|
+
//
|
|
381
|
+
// An unanswered ping = `lastPingSentAt > lastFrameAt`. Any inbound
|
|
382
|
+
// frame (event OR pong) advances lastFrameAt past lastPingSentAt and
|
|
383
|
+
// proves the socket is alive. While a ping is unanswered we DO NOT
|
|
384
|
+
// pile up another — that would just race the timeout window and make
|
|
385
|
+
// tuning meaningless.
|
|
386
|
+
//
|
|
387
|
+
// Load-bearing for the silent socket-death failure mode where the
|
|
388
|
+
// server keeps broadcasting frames the JS layer never receives (e.g.
|
|
389
|
+
// NAT/LB idle drop, half-open TCP) and neither `error` nor `close`
|
|
390
|
+
// ever fires.
|
|
315
391
|
this.pingTimer = setInterval(() => {
|
|
316
392
|
if (this.socket !== socket || this.stopped) {
|
|
317
393
|
this.clearPingTimer();
|
|
318
394
|
return;
|
|
319
395
|
}
|
|
396
|
+
const now = Date.now();
|
|
397
|
+
const hasOutstandingPing = this.lastPingSentAt > this.lastFrameAt;
|
|
398
|
+
if (hasOutstandingPing) {
|
|
399
|
+
const sincePing = now - this.lastPingSentAt;
|
|
400
|
+
if (sincePing > this.pongTimeoutMs) {
|
|
401
|
+
debugLog("watchdog tripped — forcing reconnect", {
|
|
402
|
+
workspaceId: this.workspaceId,
|
|
403
|
+
sincePingMs: sincePing,
|
|
404
|
+
pongTimeoutMs: this.pongTimeoutMs
|
|
405
|
+
});
|
|
406
|
+
this.forceReconnect(socket, "heartbeat-timeout");
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
// Within the timeout — still waiting for the pong/frame. Don't
|
|
410
|
+
// pile up a second ping while one is outstanding.
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
320
413
|
try {
|
|
321
414
|
socket.send(JSON.stringify({ type: "ping" }));
|
|
415
|
+
this.lastPingSentAt = now;
|
|
322
416
|
}
|
|
323
417
|
catch (error) {
|
|
418
|
+
debugLog("ping send failed", error);
|
|
324
419
|
this.emit("error", error instanceof Error ? error : new Error("Failed to send WebSocket ping."));
|
|
420
|
+
// A failed send strongly implies the socket is dead. Force a
|
|
421
|
+
// reconnect rather than waiting for the next tick to notice.
|
|
422
|
+
this.forceReconnect(socket, "ping-send-failed");
|
|
325
423
|
}
|
|
326
424
|
}, this.pingIntervalMs);
|
|
327
425
|
}
|
|
426
|
+
// Tear down a socket that the watchdog or a failed send has decided is
|
|
427
|
+
// dead. We close it (so any later async events from the OS layer become
|
|
428
|
+
// no-ops via the `this.socket !== socket` guards) and trigger the standard
|
|
429
|
+
// reconnect path. Catches errors from `close()` because some socket
|
|
430
|
+
// implementations throw when closing an already-broken connection.
|
|
431
|
+
forceReconnect(socket, reason) {
|
|
432
|
+
this.clearPingTimer();
|
|
433
|
+
if (this.socket === socket) {
|
|
434
|
+
this.socket = undefined;
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
socket.close(4000, reason);
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
debugLog("socket.close threw during forceReconnect", error);
|
|
441
|
+
}
|
|
442
|
+
if (this.stopped) {
|
|
443
|
+
this.setState("closed");
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (!this.reconnect.enabled) {
|
|
447
|
+
this.startPolling();
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
this.scheduleReconnect();
|
|
451
|
+
}
|
|
328
452
|
scheduleReconnect() {
|
|
329
453
|
if (this.stopped || this.reconnectTimer) {
|
|
330
454
|
return;
|
|
@@ -332,6 +456,7 @@ export class RelayFileSync {
|
|
|
332
456
|
this.reconnectAttempts += 1;
|
|
333
457
|
this.setState("reconnecting");
|
|
334
458
|
const delayMs = this.computeReconnectDelayMs(this.reconnectAttempts);
|
|
459
|
+
debugLog("scheduling reconnect", { attempt: this.reconnectAttempts, delayMs });
|
|
335
460
|
this.reconnectTimer = setTimeout(() => {
|
|
336
461
|
this.reconnectTimer = undefined;
|
|
337
462
|
if (this.stopped) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@relayfile/sdk",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.11",
|
|
4
4
|
"description": "TypeScript SDK for relayfile — real-time filesystem for humans and agents",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"prepublishOnly": "npm run build"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@relayfile/core": "0.6.
|
|
23
|
+
"@relayfile/core": "0.6.11"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"typescript": "^5.7.3",
|