@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 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
- if (this.socket === socket) {
214
- this.socket = undefined;
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
- this.emit("event", normalizeFilesystemEvent(parsed));
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.10",
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.10"
23
+ "@relayfile/core": "0.6.11"
24
24
  },
25
25
  "devDependencies": {
26
26
  "typescript": "^5.7.3",