@relayfile/sdk 0.6.10 → 0.6.12

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/client.d.ts CHANGED
@@ -51,6 +51,18 @@ export declare class RelayFileClient {
51
51
  private readonly userAgent?;
52
52
  private readonly retryOptions;
53
53
  constructor(options: RelayFileClientOptions);
54
+ /**
55
+ * Resolve the current access token via the configured token provider.
56
+ *
57
+ * Components that need a fresh JWT for out-of-band auth (the WebSocket
58
+ * upgrade handshake, signed URLs, downstream services that proxy Relayfile
59
+ * tokens) should call this on every connection rather than caching the
60
+ * value, so token rotation/refresh propagates without restart.
61
+ *
62
+ * Always returns a Promise so callers don't need to special-case the
63
+ * sync-vs-async tokenProvider shapes.
64
+ */
65
+ getToken(): Promise<string>;
54
66
  listTree(workspaceId: string, options?: ListTreeOptions): Promise<TreeResponse>;
55
67
  readFile(workspaceId: string, path: string, correlationId?: string, signal?: AbortSignal): Promise<FileReadResponse>;
56
68
  readFile(input: ReadFileInput): Promise<FileReadResponse>;
package/dist/client.js CHANGED
@@ -171,6 +171,20 @@ export class RelayFileClient {
171
171
  this.userAgent = options.userAgent;
172
172
  this.retryOptions = normalizeRetryOptions(options.retry);
173
173
  }
174
+ /**
175
+ * Resolve the current access token via the configured token provider.
176
+ *
177
+ * Components that need a fresh JWT for out-of-band auth (the WebSocket
178
+ * upgrade handshake, signed URLs, downstream services that proxy Relayfile
179
+ * tokens) should call this on every connection rather than caching the
180
+ * value, so token rotation/refresh propagates without restart.
181
+ *
182
+ * Always returns a Promise so callers don't need to special-case the
183
+ * sync-vs-async tokenProvider shapes.
184
+ */
185
+ async getToken() {
186
+ return resolveToken(this.tokenProvider);
187
+ }
174
188
  async listTree(workspaceId, options = {}) {
175
189
  const query = buildQuery({
176
190
  path: options.path ?? "/",
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { RelayfileSetup, RELAYFILE_SDK_VERSION, WorkspaceHandle } from "./setup.
3
3
  export { type RelayfileCloudLoginOptions, type RelayfileCloudTokenSet, type RelayfileCloudTokenSetupOptions } from "./cloud-login.js";
4
4
  export { CloudAbortError, CloudApiError, CloudTimeoutError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.js";
5
5
  export { WORKSPACE_INTEGRATION_PROVIDERS, type AgentWorkspaceInvite, type AgentWorkspaceInviteOptions, type ConnectIntegrationOptions, type ConnectIntegrationResult, type CreateWorkspaceOptions, type JoinWorkspaceOptions, type RelayfileSetupOptions, type RelayfileSetupRetryOptions, type WaitForConnectionOptions, type WorkspaceInfo, type WorkspaceIntegrationProvider, type WorkspaceMountEnv, type WorkspaceMountEnvOptions, type WorkspacePermissions } from "./setup-types.js";
6
- export { RelayFileSync, type RelayFileSyncOptions, type RelayFileSyncPong, type RelayFileSyncReconnectOptions, type RelayFileSyncSocket, type RelayFileSyncState } from "./sync.js";
6
+ export { RelayFileSync, type RelayFileSyncOptions, type RelayFileSyncPong, type RelayFileSyncReconnectOptions, type RelayFileSyncSocket, type RelayFileSyncState, type RelayFileSyncTokenProvider } from "./sync.js";
7
7
  export { onWrite, pathMatches, type OnWriteClient, type OnWriteHandler, type OnWriteHandlerError, type OnWriteOptions } from "./onWrite.js";
8
8
  export { InvalidStateError, PayloadTooLargeError, QueueFullError, RelayFileApiError, RevisionConflictError } from "./errors.js";
9
9
  export { IntegrationProvider, computeCanonicalPath } from "./provider.js";
package/dist/onWrite.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { WriteEvent, WriteEventOperation } from "@relayfile/core";
2
2
  import { RelayFileClient } from "./client.js";
3
- import { type RelayFileSyncSocket } from "./sync.js";
3
+ import { type RelayFileSyncSocket, type RelayFileSyncTokenProvider } from "./sync.js";
4
4
  export type OnWriteHandler = (event: WriteEvent) => void | Promise<void>;
5
5
  export interface OnWriteHandlerError {
6
6
  pattern: string;
@@ -17,8 +17,30 @@ export interface OnWriteOptions {
17
17
  operations?: WriteEventOperation[];
18
18
  signal?: AbortSignal;
19
19
  baseUrl?: string;
20
- token?: string;
20
+ /**
21
+ * Optional WebSocket auth override. Accepts the same `string | () =>
22
+ * string | Promise<string>` shape as the underlying sync.
23
+ *
24
+ * If omitted, onWrite auto-derives WS auth from `client.getToken()` — the
25
+ * same JWT the REST API is using — and re-resolves it on every reconnect
26
+ * so token rotation propagates without restart. **Most callers should
27
+ * leave this unset.** Passing a literal string is back-compat for older
28
+ * code; passing a factory is the right shape for production.
29
+ */
30
+ token?: RelayFileSyncTokenProvider;
21
31
  webSocketFactory?: (url: string) => RelayFileSyncSocket;
32
+ pingIntervalMs?: number;
33
+ pongTimeoutMs?: number;
34
+ /**
35
+ * Notification hook fired when the underlying sync degrades to HTTP
36
+ * polling because the WebSocket failed to open. Useful for surfacing a
37
+ * "live updates paused" banner. The SDK also `console.warn`s and emits
38
+ * an `error` regardless.
39
+ */
40
+ onPollingFallback?: (info: {
41
+ reason: string;
42
+ cause?: unknown;
43
+ }) => void;
22
44
  }
23
45
  export declare function pathMatches(pattern: string, path: string): boolean;
24
46
  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,12 @@ 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,
73
+ onPollingFallback: options.onPollingFallback
54
74
  });
75
+ debugLog("registered", { id: registration.id, pattern: normalizedPattern, workspaceId });
55
76
  return () => {
56
77
  dispatcher?.unregister(registration.id);
57
78
  };
@@ -95,16 +116,29 @@ class OnWriteDispatcher {
95
116
  if (this.sync) {
96
117
  return;
97
118
  }
119
+ // Token resolution order:
120
+ // 1. options.token (literal or factory) — back-compat for callers that
121
+ // mint their own auth.
122
+ // 2. RELAYFILE_TOKEN env var.
123
+ // 3. fall through to undefined → RelayFileSync auto-derives from
124
+ // `client.getToken()` (the recommended path; same JWT as REST).
125
+ // (Bug 1 fix: previously, omitting `token` here passed `undefined` and
126
+ // the WS handshake silently failed with no auth, dropping us into
127
+ // backwards-walking polling forever.)
128
+ const token = options.token ?? readEnv("RELAYFILE_TOKEN") ?? undefined;
98
129
  this.sync = RelayFileSync.connect({
99
130
  client: this.client,
100
131
  workspaceId: options.workspaceId,
101
132
  baseUrl: options.baseUrl ?? readEnv("RELAYFILE_BASE_URL") ?? DEFAULT_RELAYFILE_BASE_URL,
102
- token: options.token ?? readEnv("RELAYFILE_TOKEN"),
133
+ token,
103
134
  reconnect: {
104
135
  minDelayMs: DEFAULT_RECONNECT_MIN_DELAY_MS,
105
136
  maxDelayMs: DEFAULT_RECONNECT_MAX_DELAY_MS
106
137
  },
107
138
  webSocketFactory: options.webSocketFactory,
139
+ pingIntervalMs: options.pingIntervalMs,
140
+ pongTimeoutMs: options.pongTimeoutMs,
141
+ onPollingFallback: options.onPollingFallback,
108
142
  onEvent: (event) => {
109
143
  void this.dispatch(event);
110
144
  }
@@ -119,10 +153,26 @@ class OnWriteDispatcher {
119
153
  if (!registration.operations.has(writeEvent.operation) || !pathMatches(registration.pattern, writeEvent.path)) {
120
154
  continue;
121
155
  }
156
+ debugLog("dispatch", {
157
+ registrationId: registration.id,
158
+ pattern: registration.pattern,
159
+ path: writeEvent.path,
160
+ operation: writeEvent.operation
161
+ });
162
+ // patternChains serializes handlers per pattern. runHandler is the only
163
+ // thing chained here and it already swallows handler errors, so the
164
+ // chain itself can never reject — but we still defensively `.catch`
165
+ // before chaining so a future refactor of runHandler cannot silently
166
+ // break the chain (which is the failure mode hypothesis 3 in the bug
167
+ // report).
122
168
  const previous = this.patternChains.get(registration.pattern) ?? Promise.resolve();
123
169
  const next = previous
124
170
  .catch(() => undefined)
125
- .then(() => this.runHandler(registration, writeEvent));
171
+ .then(() => this.runHandler(registration, writeEvent))
172
+ .catch((error) => {
173
+ // Belt-and-braces: should be unreachable because runHandler catches.
174
+ debugLog("patternChain unexpectedly rejected", { pattern: registration.pattern, error });
175
+ });
126
176
  this.patternChains.set(registration.pattern, next);
127
177
  void next.finally(() => {
128
178
  if (this.patternChains.get(registration.pattern) === next) {
package/dist/sync.d.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import type { RelayFileClient } from "./client.js";
2
2
  import type { FilesystemEvent } from "./types.js";
3
+ /**
4
+ * WebSocket auth source for {@link RelayFileSyncOptions.token}.
5
+ *
6
+ * The function form is preferred for production: it is re-invoked on every
7
+ * (re)connect, so token rotation/refresh propagates without restarting the
8
+ * sync. The plain string form is kept for backward compatibility and tests.
9
+ */
10
+ export type RelayFileSyncTokenProvider = string | (() => string | undefined | Promise<string | undefined>);
3
11
  export type RelayFileSyncState = "idle" | "connecting" | "open" | "polling" | "reconnecting" | "closed";
4
12
  export interface RelayFileSyncPong {
5
13
  type: "pong";
@@ -14,15 +22,36 @@ export interface RelayFileSyncOptions {
14
22
  client: RelayFileClient;
15
23
  workspaceId: string;
16
24
  baseUrl?: string;
17
- token?: string;
25
+ /**
26
+ * WebSocket auth token. Accepts either a literal string or a (sync/async)
27
+ * factory. The factory form is re-invoked on every (re)connect so token
28
+ * rotation propagates transparently.
29
+ *
30
+ * If omitted, sync resolves the token via `client.getToken()` on each
31
+ * connect — i.e. the same JWT the REST methods are using. Callers should
32
+ * normally NOT pass this and let it inherit from the client.
33
+ */
34
+ token?: RelayFileSyncTokenProvider;
18
35
  cursor?: string;
19
36
  preferPolling?: boolean;
20
37
  pollIntervalMs?: number;
21
38
  pingIntervalMs?: number;
39
+ pongTimeoutMs?: number;
22
40
  reconnect?: boolean | RelayFileSyncReconnectOptions;
23
41
  signal?: AbortSignal;
24
42
  webSocketFactory?: (url: string) => RelayFileSyncSocket;
25
43
  onEvent?: (event: FilesystemEvent) => void;
44
+ /**
45
+ * Notification hook invoked when the sync degrades to HTTP polling because
46
+ * the WebSocket failed to open or was rejected by the server. Live events
47
+ * will be delayed by `pollIntervalMs` while in this mode. Use this to
48
+ * surface a UI banner or alert; the SDK also emits `console.warn` and an
49
+ * `error` event regardless of whether this is wired.
50
+ */
51
+ onPollingFallback?: (info: {
52
+ reason: string;
53
+ cause?: unknown;
54
+ }) => void;
26
55
  }
27
56
  export interface RelayFileSyncSocket {
28
57
  addEventListener(type: "open", handler: (event: Event) => void): void;
@@ -45,22 +74,29 @@ export declare class RelayFileSync {
45
74
  private readonly client;
46
75
  private readonly workspaceId;
47
76
  private readonly baseUrl?;
48
- private readonly token?;
77
+ private readonly tokenProvider?;
49
78
  private readonly pollIntervalMs;
50
79
  private readonly pingIntervalMs;
80
+ private readonly pongTimeoutMs;
51
81
  private readonly reconnect;
52
82
  private readonly preferPolling;
53
83
  private readonly signal?;
54
84
  private readonly webSocketFactory;
85
+ private readonly onPollingFallback?;
55
86
  private readonly handlers;
56
87
  private state;
57
88
  private cursor?;
89
+ private readonly polledEventIds;
90
+ private readonly polledEventOrder;
91
+ private firstPollComplete;
58
92
  private socket?;
59
93
  private started;
60
94
  private stopped;
61
95
  private pollingPromise?;
62
96
  private reconnectTimer?;
63
97
  private pingTimer?;
98
+ private lastFrameAt;
99
+ private lastPingSentAt;
64
100
  private reconnectAttempts;
65
101
  private readonly abortHandler?;
66
102
  constructor(options: RelayFileSyncOptions);
@@ -70,11 +106,15 @@ export declare class RelayFileSync {
70
106
  stop(): Promise<void>;
71
107
  on<TEventName extends RelayFileSyncEventName>(event: TEventName, handler: RelayFileSyncHandlerMap[TEventName]): () => void;
72
108
  private shouldUsePolling;
109
+ private resolveWsTokenMaybeSync;
73
110
  private openWebSocket;
111
+ private openWebSocketWithToken;
74
112
  private startPolling;
75
113
  private pollLoop;
114
+ private rememberPolledEvent;
76
115
  private handleSocketMessage;
77
116
  private startPingLoop;
117
+ private forceReconnect;
78
118
  private scheduleReconnect;
79
119
  private computeReconnectDelayMs;
80
120
  private sleep;
package/dist/sync.js CHANGED
@@ -2,6 +2,40 @@ 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
+ // Cap on the dedupe cache used in polling mode. Sized large enough that no
6
+ // realistic workspace burst can churn through it within one poll interval
7
+ // (the events API is itself capped at ~1000 per page), small enough to keep
8
+ // memory bounded across long-lived processes.
9
+ const POLLING_DEDUPE_CACHE_LIMIT = 2048;
10
+ function warnPollingFallback(reason, cause) {
11
+ // Always-on warning (NOT gated by RELAYFILE_SDK_DEBUG) because silent
12
+ // degradation to polling has historically masked real auth/connectivity
13
+ // bugs for hours at a time. Customers running the SDK in a Node service
14
+ // see the warning in their normal logs without any opt-in.
15
+ if (typeof console === "undefined" || typeof console.warn !== "function") {
16
+ return;
17
+ }
18
+ const detail = cause instanceof Error ? cause.message : cause !== undefined ? String(cause) : "";
19
+ const suffix = detail ? ` (${reason}: ${detail})` : ` (${reason})`;
20
+ console.warn(`[relayfile-sdk] WebSocket connect failed; falling back to HTTP polling. Live events will be delayed.${suffix}`);
21
+ }
22
+ const debugEnabled = (() => {
23
+ try {
24
+ const value = globalThis.process?.env?.RELAYFILE_SDK_DEBUG;
25
+ return value === "1" || value === "true";
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ })();
31
+ function debugLog(...args) {
32
+ if (!debugEnabled) {
33
+ return;
34
+ }
35
+ if (typeof console !== "undefined" && typeof console.error === "function") {
36
+ console.error("[relayfile-sdk:sync]", ...args);
37
+ }
38
+ }
5
39
  function normalizeReconnectOptions(reconnect) {
6
40
  if (reconnect === false) {
7
41
  return {
@@ -58,13 +92,18 @@ export class RelayFileSync {
58
92
  client;
59
93
  workspaceId;
60
94
  baseUrl;
61
- token;
95
+ // Resolved on every connect attempt (string form is wrapped into a constant
96
+ // factory at construction time). `undefined` means "fall back to
97
+ // client.getToken()" — same JWT the REST methods use.
98
+ tokenProvider;
62
99
  pollIntervalMs;
63
100
  pingIntervalMs;
101
+ pongTimeoutMs;
64
102
  reconnect;
65
103
  preferPolling;
66
104
  signal;
67
105
  webSocketFactory;
106
+ onPollingFallback;
68
107
  handlers = {
69
108
  event: new Set(),
70
109
  error: new Set(),
@@ -75,22 +114,49 @@ export class RelayFileSync {
75
114
  };
76
115
  state = "idle";
77
116
  cursor;
117
+ polledEventIds = new Set();
118
+ polledEventOrder = [];
119
+ firstPollComplete = false;
78
120
  socket;
79
121
  started = false;
80
122
  stopped = false;
81
123
  pollingPromise;
82
124
  reconnectTimer;
83
125
  pingTimer;
126
+ // Tracks the last time *any* WebSocket frame was received (event, pong, or
127
+ // otherwise). The watchdog uses this to detect silent socket death.
128
+ lastFrameAt = 0;
129
+ // Tracks when the most recent ping was sent. We only enforce the pong
130
+ // timeout once a ping has actually gone out; otherwise a quiet workspace
131
+ // (no broadcasts and no pings yet) would falsely trip the watchdog.
132
+ lastPingSentAt = 0;
84
133
  reconnectAttempts = 0;
85
134
  abortHandler;
86
135
  constructor(options) {
87
136
  this.client = options.client;
88
137
  this.workspaceId = options.workspaceId;
89
138
  this.baseUrl = options.baseUrl?.replace(/\/+$/, "");
90
- this.token = options.token;
139
+ // Normalize token into a factory. `undefined` is preserved so the open
140
+ // path knows to fall back to `client.getToken()` (the recommended path —
141
+ // the WebSocket then auto-uses the same JWT the REST API is using).
142
+ if (options.token === undefined) {
143
+ this.tokenProvider = undefined;
144
+ }
145
+ else if (typeof options.token === "function") {
146
+ this.tokenProvider = options.token;
147
+ }
148
+ else {
149
+ const literal = options.token;
150
+ this.tokenProvider = () => literal;
151
+ }
91
152
  this.cursor = options.cursor;
153
+ this.onPollingFallback = options.onPollingFallback;
92
154
  this.pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS));
93
155
  this.pingIntervalMs = Math.max(1, Math.floor(options.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS));
156
+ // Default the pong timeout to 2x ping interval so a single missed pong
157
+ // does not cause a flap, but two consecutive misses force a reconnect.
158
+ const defaultPongTimeoutMs = this.pingIntervalMs * 2;
159
+ this.pongTimeoutMs = Math.max(1, Math.floor(options.pongTimeoutMs ?? defaultPongTimeoutMs));
94
160
  this.reconnect = normalizeReconnectOptions(options.reconnect);
95
161
  this.preferPolling = options.preferPolling ?? false;
96
162
  this.signal = options.signal;
@@ -132,7 +198,9 @@ export class RelayFileSync {
132
198
  }
133
199
  this.started = true;
134
200
  if (this.shouldUsePolling()) {
135
- this.startPolling();
201
+ // Caller explicitly opted into polling, or there's no baseUrl to
202
+ // upgrade to wss. NOT a fallback — no warn here.
203
+ this.startPolling("explicit");
136
204
  return;
137
205
  }
138
206
  this.openWebSocket(false);
@@ -164,9 +232,63 @@ export class RelayFileSync {
164
232
  };
165
233
  }
166
234
  shouldUsePolling() {
167
- return this.preferPolling || !this.baseUrl || !this.token;
235
+ // Token availability is no longer required up-front — the WS opener
236
+ // resolves it on demand from either `options.token` (a literal/factory)
237
+ // or `client.getToken()`. We only force polling when the caller asked
238
+ // for it or there is no baseUrl to derive a wss URL from.
239
+ return this.preferPolling || !this.baseUrl;
240
+ }
241
+ // Resolve a token for the WS upgrade. Returns either a string directly
242
+ // (sync fast-path; preserves the synchronous "start() → factory called"
243
+ // contract that pre-existed Bug 1's fix) or a Promise (async slow-path;
244
+ // factory is invoked on the next microtask). `undefined` means "no token
245
+ // available — the server may still accept the upgrade if it's configured
246
+ // for unauthenticated mode in tests/local-dev".
247
+ resolveWsTokenMaybeSync() {
248
+ if (this.tokenProvider) {
249
+ // Most production providers return synchronously (JWT pulled from a
250
+ // mutable cell that a refresh task updates in the background), so the
251
+ // sync path is the common case.
252
+ return this.tokenProvider();
253
+ }
254
+ // Auto-derive from the client's tokenProvider — the same JWT the REST
255
+ // surface is using. Bug 1 fix: callers no longer have to thread
256
+ // `token: await client.tokenProvider()` through every onWrite() call.
257
+ // client.getToken is always async (returns a Promise), so we land on
258
+ // the slow path here. That's fine: the WS open is async anyway.
259
+ return this.client.getToken();
168
260
  }
169
261
  openWebSocket(isReconnect) {
262
+ if (this.stopped) {
263
+ return;
264
+ }
265
+ this.setState(isReconnect ? "reconnecting" : "connecting");
266
+ // Resolve a fresh token on every (re)connect attempt. This is what
267
+ // makes mid-session token rotation transparent: the watchdog/close
268
+ // handler reconnects, we re-call the factory, and the new socket comes
269
+ // up with the new JWT. (Bug 4: pre-fix, the token was captured once at
270
+ // construction and a 4001/auth close on rotation triggered an infinite
271
+ // reconnect loop with the stale token.)
272
+ let resolved;
273
+ try {
274
+ resolved = this.resolveWsTokenMaybeSync();
275
+ }
276
+ catch (error) {
277
+ this.emit("error", error instanceof Error ? error : new Error("Failed to resolve WebSocket auth token."));
278
+ this.startPolling("token-resolution-failed", error);
279
+ return;
280
+ }
281
+ if (resolved && typeof resolved.then === "function") {
282
+ // Async path. The factory call gets deferred to the next microtask.
283
+ resolved.then((token) => this.openWebSocketWithToken(token), (error) => {
284
+ this.emit("error", error instanceof Error ? error : new Error("Failed to resolve WebSocket auth token."));
285
+ this.startPolling("token-resolution-failed", error);
286
+ });
287
+ return;
288
+ }
289
+ this.openWebSocketWithToken(resolved);
290
+ }
291
+ openWebSocketWithToken(token) {
170
292
  if (this.stopped) {
171
293
  return;
172
294
  }
@@ -174,17 +296,16 @@ export class RelayFileSync {
174
296
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
175
297
  // Pass token in query string — the server authenticates during the HTTP
176
298
  // upgrade handshake via r.URL.Query().Get("token").
177
- if (this.token) {
178
- url.searchParams.set("token", this.token);
299
+ if (token) {
300
+ url.searchParams.set("token", token);
179
301
  }
180
- this.setState(isReconnect ? "reconnecting" : "connecting");
181
302
  let socket;
182
303
  try {
183
304
  socket = this.webSocketFactory(url.toString());
184
305
  }
185
306
  catch (error) {
186
307
  this.emit("error", error instanceof Error ? error : new Error("Failed to create WebSocket connection."));
187
- this.startPolling();
308
+ this.startPolling("ws-factory-threw", error);
188
309
  return;
189
310
  }
190
311
  this.socket = socket;
@@ -193,43 +314,80 @@ export class RelayFileSync {
193
314
  return;
194
315
  }
195
316
  this.reconnectAttempts = 0;
317
+ // Reset frame/ping bookkeeping for the freshly opened socket so the
318
+ // watchdog has a clean baseline. lastPingSentAt=0 disables the pong
319
+ // timeout until we actually send our first ping.
320
+ this.lastFrameAt = Date.now();
321
+ this.lastPingSentAt = 0;
196
322
  this.setState("open");
197
323
  this.startPingLoop(socket);
324
+ debugLog("ws open", { workspaceId: this.workspaceId });
198
325
  this.emit("open", event);
199
326
  });
200
327
  socket.addEventListener("message", (event) => {
201
328
  if (this.socket !== socket || this.stopped) {
202
329
  return;
203
330
  }
331
+ // Stamp lastFrameAt for *any* inbound frame so the watchdog sees both
332
+ // application events and pongs as proof of life.
333
+ this.lastFrameAt = Date.now();
204
334
  this.handleSocketMessage(event);
205
335
  });
206
336
  socket.addEventListener("error", (event) => {
207
337
  if (this.socket !== socket || this.stopped) {
208
338
  return;
209
339
  }
340
+ debugLog("ws error", event);
210
341
  this.emit("error", normalizeError(event));
211
342
  });
212
343
  socket.addEventListener("close", (event) => {
213
- if (this.socket === socket) {
214
- this.socket = undefined;
344
+ // forceReconnect (called from the watchdog or a failed ping send) nulls
345
+ // out this.socket and opens a replacement before the OS-layer close
346
+ // event for the old socket actually fires. If this handler treated a
347
+ // stale close as authoritative it would (a) clearPingTimer() and kill
348
+ // the new socket's heartbeat and (b) potentially scheduleReconnect()
349
+ // a second time (the timer guard would skip it most of the time, but
350
+ // not after the timer has already fired). Either way: don't touch
351
+ // shared state when the socket we're attached to is no longer current.
352
+ if (this.socket !== socket) {
353
+ debugLog("ws close (stale, ignored)", {
354
+ code: event?.code,
355
+ reason: event?.reason,
356
+ });
357
+ return;
215
358
  }
359
+ this.socket = undefined;
216
360
  this.clearPingTimer();
361
+ debugLog("ws close", { code: event?.code, reason: event?.reason, stopped: this.stopped });
217
362
  this.emit("close", event);
218
363
  if (this.stopped) {
219
364
  this.setState("closed");
220
365
  return;
221
366
  }
222
367
  if (!this.reconnect.enabled) {
223
- this.startPolling();
368
+ this.startPolling("ws-closed-no-reconnect", { code: event?.code, reason: event?.reason });
224
369
  return;
225
370
  }
226
371
  this.scheduleReconnect();
227
372
  });
228
373
  }
229
- startPolling() {
374
+ startPolling(reason = "fallback", cause) {
230
375
  if (this.pollingPromise || this.stopped) {
231
376
  return;
232
377
  }
378
+ // Always-on warning + structured callback whenever we drop into polling
379
+ // *involuntarily*. `explicit` means the caller asked for it (preferPolling
380
+ // or no baseUrl) and we stay quiet — anything else is a real degradation
381
+ // signal that previously took hours to detect.
382
+ if (reason !== "explicit") {
383
+ warnPollingFallback(reason, cause);
384
+ try {
385
+ this.onPollingFallback?.({ reason, cause });
386
+ }
387
+ catch (error) {
388
+ debugLog("onPollingFallback handler threw", error);
389
+ }
390
+ }
233
391
  this.setState("polling");
234
392
  this.pollingPromise = this.pollLoop().finally(() => {
235
393
  this.pollingPromise = undefined;
@@ -245,16 +403,58 @@ export class RelayFileSync {
245
403
  throw createAbortError();
246
404
  }
247
405
  try {
248
- const response = await this.client.getEvents(this.workspaceId, {
249
- cursor: this.cursor,
250
- signal: this.signal
251
- });
406
+ // The current server implementation paginates events from oldest to
407
+ // newest. Empty cursor starts at index 0, and nextCursor advances
408
+ // forward through history. To avoid replaying the whole event log on
409
+ // startup while still preserving live forward progress, the first poll
410
+ // drains to the tip and seeds `this.cursor` without emitting. Later
411
+ // polls resume from that live cursor and emit only newly appended
412
+ // events.
413
+ let cursor = this.cursor;
414
+ let latestCursor = cursor;
415
+ const pending = [];
416
+ for (;;) {
417
+ const response = await this.client.getEvents(this.workspaceId, {
418
+ cursor,
419
+ limit: 1000,
420
+ signal: this.signal
421
+ });
422
+ const events = response.events ?? [];
423
+ if (!this.firstPollComplete) {
424
+ for (const event of events) {
425
+ this.rememberPolledEvent(event.eventId);
426
+ }
427
+ }
428
+ else {
429
+ for (const event of events) {
430
+ if (!event.eventId || this.polledEventIds.has(event.eventId)) {
431
+ continue;
432
+ }
433
+ this.rememberPolledEvent(event.eventId);
434
+ pending.push(event);
435
+ }
436
+ }
437
+ const nextCursor = response.nextCursor || null;
438
+ if (events.length > 0) {
439
+ latestCursor = events[events.length - 1]?.eventId ?? latestCursor;
440
+ }
441
+ if (nextCursor) {
442
+ latestCursor = nextCursor;
443
+ }
444
+ if (!nextCursor || nextCursor === cursor) {
445
+ break;
446
+ }
447
+ cursor = nextCursor;
448
+ }
252
449
  retryAttempt = 0;
253
- for (const event of response.events) {
254
- this.emit("event", event);
450
+ this.cursor = latestCursor;
451
+ if (!this.firstPollComplete) {
452
+ this.firstPollComplete = true;
255
453
  }
256
- if (response.nextCursor) {
257
- this.cursor = response.nextCursor;
454
+ else {
455
+ for (const event of pending) {
456
+ this.emit("event", event);
457
+ }
258
458
  }
259
459
  await this.sleep(this.pollIntervalMs);
260
460
  }
@@ -269,6 +469,19 @@ export class RelayFileSync {
269
469
  }
270
470
  }
271
471
  }
472
+ rememberPolledEvent(eventId) {
473
+ if (!eventId || this.polledEventIds.has(eventId)) {
474
+ return;
475
+ }
476
+ this.polledEventIds.add(eventId);
477
+ this.polledEventOrder.push(eventId);
478
+ while (this.polledEventOrder.length > POLLING_DEDUPE_CACHE_LIMIT) {
479
+ const evicted = this.polledEventOrder.shift();
480
+ if (evicted) {
481
+ this.polledEventIds.delete(evicted);
482
+ }
483
+ }
484
+ }
272
485
  handleSocketMessage(event) {
273
486
  if (typeof event.data !== "string") {
274
487
  return;
@@ -302,29 +515,100 @@ export class RelayFileSync {
302
515
  return;
303
516
  }
304
517
  if (parsed.type === "pong") {
518
+ debugLog("pong", { timestamp: parsed.timestamp ?? parsed.ts });
305
519
  this.emit("pong", {
306
520
  type: "pong",
307
521
  timestamp: parsed.timestamp ?? parsed.ts
308
522
  });
309
523
  return;
310
524
  }
311
- this.emit("event", normalizeFilesystemEvent(parsed));
525
+ const normalized = normalizeFilesystemEvent(parsed);
526
+ debugLog("event", { type: normalized.type, path: normalized.path, revision: normalized.revision });
527
+ this.emit("event", normalized);
312
528
  }
313
529
  startPingLoop(socket) {
314
530
  this.clearPingTimer();
531
+ // The "ping loop" doubles as the heartbeat watchdog. The contract for
532
+ // pongTimeoutMs is "how long to wait, after sending a ping, before
533
+ // assuming the socket is dead." So the timeout window must be measured
534
+ // from `lastPingSentAt` — the moment we sent the unanswered ping — not
535
+ // from the last inbound frame. (CodeRabbit P1 on PR #93: with
536
+ // pingIntervalMs=30_000 + pongTimeoutMs=45_000, measuring from
537
+ // lastFrameAt would reconnect at t=60s — only 30s after the first
538
+ // ping went out, which violates the documented "wait 45s after a
539
+ // ping" semantics.)
540
+ //
541
+ // An unanswered ping = `lastPingSentAt > lastFrameAt`. Any inbound
542
+ // frame (event OR pong) advances lastFrameAt past lastPingSentAt and
543
+ // proves the socket is alive. While a ping is unanswered we DO NOT
544
+ // pile up another — that would just race the timeout window and make
545
+ // tuning meaningless.
546
+ //
547
+ // Load-bearing for the silent socket-death failure mode where the
548
+ // server keeps broadcasting frames the JS layer never receives (e.g.
549
+ // NAT/LB idle drop, half-open TCP) and neither `error` nor `close`
550
+ // ever fires.
315
551
  this.pingTimer = setInterval(() => {
316
552
  if (this.socket !== socket || this.stopped) {
317
553
  this.clearPingTimer();
318
554
  return;
319
555
  }
556
+ const now = Date.now();
557
+ const hasOutstandingPing = this.lastPingSentAt > this.lastFrameAt;
558
+ if (hasOutstandingPing) {
559
+ const sincePing = now - this.lastPingSentAt;
560
+ if (sincePing > this.pongTimeoutMs) {
561
+ debugLog("watchdog tripped — forcing reconnect", {
562
+ workspaceId: this.workspaceId,
563
+ sincePingMs: sincePing,
564
+ pongTimeoutMs: this.pongTimeoutMs
565
+ });
566
+ this.forceReconnect(socket, "heartbeat-timeout");
567
+ return;
568
+ }
569
+ // Within the timeout — still waiting for the pong/frame. Don't
570
+ // pile up a second ping while one is outstanding.
571
+ return;
572
+ }
320
573
  try {
321
574
  socket.send(JSON.stringify({ type: "ping" }));
575
+ this.lastPingSentAt = now;
322
576
  }
323
577
  catch (error) {
578
+ debugLog("ping send failed", error);
324
579
  this.emit("error", error instanceof Error ? error : new Error("Failed to send WebSocket ping."));
580
+ // A failed send strongly implies the socket is dead. Force a
581
+ // reconnect rather than waiting for the next tick to notice.
582
+ this.forceReconnect(socket, "ping-send-failed");
325
583
  }
326
584
  }, this.pingIntervalMs);
327
585
  }
586
+ // Tear down a socket that the watchdog or a failed send has decided is
587
+ // dead. We close it (so any later async events from the OS layer become
588
+ // no-ops via the `this.socket !== socket` guards) and trigger the standard
589
+ // reconnect path. Catches errors from `close()` because some socket
590
+ // implementations throw when closing an already-broken connection.
591
+ forceReconnect(socket, reason) {
592
+ this.clearPingTimer();
593
+ if (this.socket === socket) {
594
+ this.socket = undefined;
595
+ }
596
+ try {
597
+ socket.close(4000, reason);
598
+ }
599
+ catch (error) {
600
+ debugLog("socket.close threw during forceReconnect", error);
601
+ }
602
+ if (this.stopped) {
603
+ this.setState("closed");
604
+ return;
605
+ }
606
+ if (!this.reconnect.enabled) {
607
+ this.startPolling("forced-reconnect-no-retry", reason);
608
+ return;
609
+ }
610
+ this.scheduleReconnect();
611
+ }
328
612
  scheduleReconnect() {
329
613
  if (this.stopped || this.reconnectTimer) {
330
614
  return;
@@ -332,6 +616,7 @@ export class RelayFileSync {
332
616
  this.reconnectAttempts += 1;
333
617
  this.setState("reconnecting");
334
618
  const delayMs = this.computeReconnectDelayMs(this.reconnectAttempts);
619
+ debugLog("scheduling reconnect", { attempt: this.reconnectAttempts, delayMs });
335
620
  this.reconnectTimer = setTimeout(() => {
336
621
  this.reconnectTimer = undefined;
337
622
  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.12",
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.12"
24
24
  },
25
25
  "devDependencies": {
26
26
  "typescript": "^5.7.3",