@relayfile/sdk 0.6.11 → 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,10 +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;
22
32
  pingIntervalMs?: number;
23
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;
24
44
  }
25
45
  export declare function pathMatches(pattern: string, path: string): boolean;
26
46
  export declare function onWrite(pattern: string, handler: OnWriteHandler, options?: OnWriteOptions): () => void;
package/dist/onWrite.js CHANGED
@@ -69,7 +69,8 @@ export function onWrite(pattern, handler, options = {}) {
69
69
  token: options.token,
70
70
  webSocketFactory: options.webSocketFactory,
71
71
  pingIntervalMs: options.pingIntervalMs,
72
- pongTimeoutMs: options.pongTimeoutMs
72
+ pongTimeoutMs: options.pongTimeoutMs,
73
+ onPollingFallback: options.onPollingFallback
73
74
  });
74
75
  debugLog("registered", { id: registration.id, pattern: normalizedPattern, workspaceId });
75
76
  return () => {
@@ -115,11 +116,21 @@ class OnWriteDispatcher {
115
116
  if (this.sync) {
116
117
  return;
117
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;
118
129
  this.sync = RelayFileSync.connect({
119
130
  client: this.client,
120
131
  workspaceId: options.workspaceId,
121
132
  baseUrl: options.baseUrl ?? readEnv("RELAYFILE_BASE_URL") ?? DEFAULT_RELAYFILE_BASE_URL,
122
- token: options.token ?? readEnv("RELAYFILE_TOKEN"),
133
+ token,
123
134
  reconnect: {
124
135
  minDelayMs: DEFAULT_RECONNECT_MIN_DELAY_MS,
125
136
  maxDelayMs: DEFAULT_RECONNECT_MAX_DELAY_MS
@@ -127,6 +138,7 @@ class OnWriteDispatcher {
127
138
  webSocketFactory: options.webSocketFactory,
128
139
  pingIntervalMs: options.pingIntervalMs,
129
140
  pongTimeoutMs: options.pongTimeoutMs,
141
+ onPollingFallback: options.onPollingFallback,
130
142
  onEvent: (event) => {
131
143
  void this.dispatch(event);
132
144
  }
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,7 +22,16 @@ 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;
@@ -24,6 +41,17 @@ export interface RelayFileSyncOptions {
24
41
  signal?: AbortSignal;
25
42
  webSocketFactory?: (url: string) => RelayFileSyncSocket;
26
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;
27
55
  }
28
56
  export interface RelayFileSyncSocket {
29
57
  addEventListener(type: "open", handler: (event: Event) => void): void;
@@ -46,7 +74,7 @@ export declare class RelayFileSync {
46
74
  private readonly client;
47
75
  private readonly workspaceId;
48
76
  private readonly baseUrl?;
49
- private readonly token?;
77
+ private readonly tokenProvider?;
50
78
  private readonly pollIntervalMs;
51
79
  private readonly pingIntervalMs;
52
80
  private readonly pongTimeoutMs;
@@ -54,9 +82,13 @@ export declare class RelayFileSync {
54
82
  private readonly preferPolling;
55
83
  private readonly signal?;
56
84
  private readonly webSocketFactory;
85
+ private readonly onPollingFallback?;
57
86
  private readonly handlers;
58
87
  private state;
59
88
  private cursor?;
89
+ private readonly polledEventIds;
90
+ private readonly polledEventOrder;
91
+ private firstPollComplete;
60
92
  private socket?;
61
93
  private started;
62
94
  private stopped;
@@ -74,9 +106,12 @@ export declare class RelayFileSync {
74
106
  stop(): Promise<void>;
75
107
  on<TEventName extends RelayFileSyncEventName>(event: TEventName, handler: RelayFileSyncHandlerMap[TEventName]): () => void;
76
108
  private shouldUsePolling;
109
+ private resolveWsTokenMaybeSync;
77
110
  private openWebSocket;
111
+ private openWebSocketWithToken;
78
112
  private startPolling;
79
113
  private pollLoop;
114
+ private rememberPolledEvent;
80
115
  private handleSocketMessage;
81
116
  private startPingLoop;
82
117
  private forceReconnect;
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
+ // 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
+ }
5
22
  const debugEnabled = (() => {
6
23
  try {
7
24
  const value = globalThis.process?.env?.RELAYFILE_SDK_DEBUG;
@@ -75,7 +92,10 @@ export class RelayFileSync {
75
92
  client;
76
93
  workspaceId;
77
94
  baseUrl;
78
- 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;
79
99
  pollIntervalMs;
80
100
  pingIntervalMs;
81
101
  pongTimeoutMs;
@@ -83,6 +103,7 @@ export class RelayFileSync {
83
103
  preferPolling;
84
104
  signal;
85
105
  webSocketFactory;
106
+ onPollingFallback;
86
107
  handlers = {
87
108
  event: new Set(),
88
109
  error: new Set(),
@@ -93,6 +114,9 @@ export class RelayFileSync {
93
114
  };
94
115
  state = "idle";
95
116
  cursor;
117
+ polledEventIds = new Set();
118
+ polledEventOrder = [];
119
+ firstPollComplete = false;
96
120
  socket;
97
121
  started = false;
98
122
  stopped = false;
@@ -112,8 +136,21 @@ export class RelayFileSync {
112
136
  this.client = options.client;
113
137
  this.workspaceId = options.workspaceId;
114
138
  this.baseUrl = options.baseUrl?.replace(/\/+$/, "");
115
- 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
+ }
116
152
  this.cursor = options.cursor;
153
+ this.onPollingFallback = options.onPollingFallback;
117
154
  this.pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS));
118
155
  this.pingIntervalMs = Math.max(1, Math.floor(options.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS));
119
156
  // Default the pong timeout to 2x ping interval so a single missed pong
@@ -161,7 +198,9 @@ export class RelayFileSync {
161
198
  }
162
199
  this.started = true;
163
200
  if (this.shouldUsePolling()) {
164
- 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");
165
204
  return;
166
205
  }
167
206
  this.openWebSocket(false);
@@ -193,9 +232,63 @@ export class RelayFileSync {
193
232
  };
194
233
  }
195
234
  shouldUsePolling() {
196
- 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();
197
260
  }
198
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) {
199
292
  if (this.stopped) {
200
293
  return;
201
294
  }
@@ -203,17 +296,16 @@ export class RelayFileSync {
203
296
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
204
297
  // Pass token in query string — the server authenticates during the HTTP
205
298
  // upgrade handshake via r.URL.Query().Get("token").
206
- if (this.token) {
207
- url.searchParams.set("token", this.token);
299
+ if (token) {
300
+ url.searchParams.set("token", token);
208
301
  }
209
- this.setState(isReconnect ? "reconnecting" : "connecting");
210
302
  let socket;
211
303
  try {
212
304
  socket = this.webSocketFactory(url.toString());
213
305
  }
214
306
  catch (error) {
215
307
  this.emit("error", error instanceof Error ? error : new Error("Failed to create WebSocket connection."));
216
- this.startPolling();
308
+ this.startPolling("ws-factory-threw", error);
217
309
  return;
218
310
  }
219
311
  this.socket = socket;
@@ -273,16 +365,29 @@ export class RelayFileSync {
273
365
  return;
274
366
  }
275
367
  if (!this.reconnect.enabled) {
276
- this.startPolling();
368
+ this.startPolling("ws-closed-no-reconnect", { code: event?.code, reason: event?.reason });
277
369
  return;
278
370
  }
279
371
  this.scheduleReconnect();
280
372
  });
281
373
  }
282
- startPolling() {
374
+ startPolling(reason = "fallback", cause) {
283
375
  if (this.pollingPromise || this.stopped) {
284
376
  return;
285
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
+ }
286
391
  this.setState("polling");
287
392
  this.pollingPromise = this.pollLoop().finally(() => {
288
393
  this.pollingPromise = undefined;
@@ -298,16 +403,58 @@ export class RelayFileSync {
298
403
  throw createAbortError();
299
404
  }
300
405
  try {
301
- const response = await this.client.getEvents(this.workspaceId, {
302
- cursor: this.cursor,
303
- signal: this.signal
304
- });
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
+ }
305
449
  retryAttempt = 0;
306
- for (const event of response.events) {
307
- this.emit("event", event);
450
+ this.cursor = latestCursor;
451
+ if (!this.firstPollComplete) {
452
+ this.firstPollComplete = true;
308
453
  }
309
- if (response.nextCursor) {
310
- this.cursor = response.nextCursor;
454
+ else {
455
+ for (const event of pending) {
456
+ this.emit("event", event);
457
+ }
311
458
  }
312
459
  await this.sleep(this.pollIntervalMs);
313
460
  }
@@ -322,6 +469,19 @@ export class RelayFileSync {
322
469
  }
323
470
  }
324
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
+ }
325
485
  handleSocketMessage(event) {
326
486
  if (typeof event.data !== "string") {
327
487
  return;
@@ -444,7 +604,7 @@ export class RelayFileSync {
444
604
  return;
445
605
  }
446
606
  if (!this.reconnect.enabled) {
447
- this.startPolling();
607
+ this.startPolling("forced-reconnect-no-retry", reason);
448
608
  return;
449
609
  }
450
610
  this.scheduleReconnect();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/sdk",
3
- "version": "0.6.11",
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.11"
23
+ "@relayfile/core": "0.6.12"
24
24
  },
25
25
  "devDependencies": {
26
26
  "typescript": "^5.7.3",