@relayfile/sdk 0.6.12 → 0.6.13

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/README.md CHANGED
@@ -40,12 +40,17 @@ const mountEnv = workspace.mountEnv({
40
40
  remotePath: '/notion',
41
41
  })
42
42
 
43
- // Secret invite payload for a trusted co-worker agent
44
- const invite = workspace.agentInvite({
45
- agentName: 'review-agent',
46
- scopes: ['fs:read', 'relaycast:write'],
43
+ // Secret invite payload for a trusted co-worker agent — same scopes as this
44
+ // workspace's token (no per-invite downscoping).
45
+ const invite = workspace.agentInvite({ agentName: 'review-agent' })
46
+
47
+ // To grant a strictly narrower set of scopes, mint a fresh JWT via the cloud
48
+ // API. The cloud rejects requests that exceed the calling token's grant.
49
+ const scopedInvite = await workspace.agentInviteScoped({
50
+ agentName: 'notion-summarizer',
51
+ scopes: ['relayfile:fs:read:/notion/pages/*'],
47
52
  })
48
- // Never log inviteit contains credential material
53
+ // Never log invitesthey contain credential material
49
54
  ```
50
55
 
51
56
  For the full end-to-end journey, failure modes, and acceptance criteria see
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { RelayFileClient, DEFAULT_RELAYFILE_BASE_URL, type AccessTokenProvider,
2
2
  export { RelayfileSetup, RELAYFILE_SDK_VERSION, WorkspaceHandle } from "./setup.js";
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
- 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";
5
+ export { WORKSPACE_INTEGRATION_PROVIDERS, type AgentWorkspaceInvite, type AgentWorkspaceInviteOptions, type AgentWorkspaceScopedInviteOptions, 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
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";
package/dist/onWrite.js CHANGED
@@ -118,14 +118,31 @@ class OnWriteDispatcher {
118
118
  }
119
119
  // Token resolution order:
120
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;
121
+ // mint their own auth out-of-band.
122
+ // 2. fall through to undefined → RelayFileSync auto-derives from
123
+ // `client.getToken()` on every (re)connect. This is the recommended
124
+ // path: it is the same JWT the REST surface is using AND it is
125
+ // re-resolved on every reconnect, so an expired/rotated token gets
126
+ // refreshed automatically by whatever provider the client wraps
127
+ // (e.g. WorkspaceHandle's getOrRefreshToken).
128
+ //
129
+ // Bug 5: previously this fell through to a `readEnv("RELAYFILE_TOKEN")`
130
+ // literal when the caller omitted `options.token`. That env value is
131
+ // captured once at workspace-join time (e.g. by
132
+ // `WorkspaceHandle.mountEnv()`) and never refreshed, so it expires
133
+ // ~1 hour later and the WS upgrade then fails with an auth error
134
+ // (visible only as a JS-layer "ws error" with no successor close,
135
+ // leaving the dispatcher stalled forever). The env literal also
136
+ // shadowed the auto-derive fix from PR #96 whenever a caller happened
137
+ // to have RELAYFILE_TOKEN set in their environment, which is the
138
+ // common case for any workspace started via mountEnv. Auto-derive must
139
+ // win over the env literal whenever a client is available.
140
+ //
141
+ // The default-client path (constructed by `getDefaultClient()` when
142
+ // `options.client` is omitted) already wires RELAYFILE_TOKEN into the
143
+ // client's tokenProvider, so callers that depend on the env-only flow
144
+ // continue to work — `client.getToken()` returns the env value.
145
+ const token = options.token;
129
146
  this.sync = RelayFileSync.connect({
130
147
  client: this.client,
131
148
  workspaceId: options.workspaceId,
@@ -67,8 +67,25 @@ export interface WorkspaceMountEnvOptions {
67
67
  export type WorkspaceMountEnv = Record<string, string>;
68
68
  export interface AgentWorkspaceInviteOptions {
69
69
  agentName?: string;
70
+ relaycastBaseUrl?: string;
71
+ includeRelayfileToken?: boolean;
72
+ }
73
+ export interface AgentWorkspaceScopedInviteOptions {
74
+ /**
75
+ * Scopes to grant on the minted JWT. Must be a subset of the calling
76
+ * workspace token's grant; the cloud API rejects requests that exceed it.
77
+ * If omitted, falls back to the join-time scopes (effectively the same as
78
+ * the sync `agentInvite()`).
79
+ */
70
80
  scopes?: string[];
81
+ agentName?: string;
82
+ permissions?: WorkspacePermissions;
71
83
  relaycastBaseUrl?: string;
84
+ /**
85
+ * Set false to omit `relayfileToken` from the returned invite — useful
86
+ * when the receiving agent already has a token and only needs the
87
+ * connection metadata.
88
+ */
72
89
  includeRelayfileToken?: boolean;
73
90
  }
74
91
  export interface AgentWorkspaceInvite {
package/dist/setup.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { RelayFileClient, type AccessTokenProvider } from "./client.js";
2
2
  import { type RelayfileCloudLoginOptions, type RelayfileCloudTokenSet, type RelayfileCloudTokenSetupOptions } from "./cloud-login.js";
3
- import { type AgentWorkspaceInvite, type AgentWorkspaceInviteOptions, type ConnectIntegrationOptions, type ConnectIntegrationResult, type CreateWorkspaceOptions, type JoinWorkspaceOptions, type RelayfileSetupOptions, type WaitForConnectionOptions, type WorkspaceMountEnv, type WorkspaceMountEnvOptions, type WorkspaceInfo, type WorkspaceIntegrationProvider, type WorkspacePermissions } from "./setup-types.js";
3
+ import { type AgentWorkspaceInvite, type AgentWorkspaceInviteOptions, type AgentWorkspaceScopedInviteOptions, type ConnectIntegrationOptions, type ConnectIntegrationResult, type CreateWorkspaceOptions, type JoinWorkspaceOptions, type RelayfileSetupOptions, type WaitForConnectionOptions, type WorkspaceMountEnv, type WorkspaceMountEnvOptions, type WorkspaceInfo, type WorkspaceIntegrationProvider, type WorkspacePermissions } from "./setup-types.js";
4
4
  export { RELAYFILE_SDK_VERSION } from "./version.js";
5
5
  interface JoinWorkspaceResponse {
6
6
  workspaceId?: string;
@@ -41,7 +41,9 @@ export declare class RelayfileSetup {
41
41
  constructor(options?: RelayfileSetupOptions);
42
42
  createWorkspace(options?: CreateWorkspaceOptions): Promise<WorkspaceHandle>;
43
43
  joinWorkspace(workspaceId: string, options?: JoinWorkspaceOptions): Promise<WorkspaceHandle>;
44
- joinWorkspaceResponse(workspaceId: string, options: NormalizedJoinWorkspaceOptions): Promise<ValidatedJoinWorkspaceResponse>;
44
+ joinWorkspaceResponse(workspaceId: string, options: NormalizedJoinWorkspaceOptions, overrides?: {
45
+ tokenProvider?: AccessTokenProvider;
46
+ }): Promise<ValidatedJoinWorkspaceResponse>;
45
47
  requestJson(options: CloudRequestOptions): Promise<unknown>;
46
48
  getCloudApiUrl(): string;
47
49
  }
@@ -65,7 +67,34 @@ export declare class WorkspaceHandle {
65
67
  disconnectIntegration(provider: WorkspaceIntegrationProvider, _connectionId?: string): Promise<void>;
66
68
  getToken(): string;
67
69
  mountEnv(options?: WorkspaceMountEnvOptions): WorkspaceMountEnv;
70
+ /**
71
+ * Build an invite that hands a peer agent the calling workspace's existing
72
+ * JWT and metadata. The invite carries the SAME token this handle holds,
73
+ * with the SAME scopes — there is no per-invite downscoping. Use
74
+ * {@link agentInviteScoped} when the receiver should have a strictly
75
+ * narrower set of scopes than this workspace.
76
+ */
68
77
  agentInvite(options?: AgentWorkspaceInviteOptions): AgentWorkspaceInvite;
78
+ /**
79
+ * Mint a fresh, downscoped relayfile JWT for a peer agent and return an
80
+ * invite carrying that token. Round-trips through the cloud join endpoint
81
+ * (`POST /api/v1/workspaces/{id}/join`), which signs a new JWT whose
82
+ * `scopes` claim is the requested subset of this workspace's grant. The
83
+ * cloud API rejects requests that exceed the calling workspace's scopes.
84
+ *
85
+ * Prefer this over {@link agentInvite} whenever the receiver should not
86
+ * inherit the full workspace token's reach (e.g. one agent that only needs
87
+ * `relayfile:fs:read:/notion/pages/*`). The caller's token is unaffected;
88
+ * a separate JWT is issued for the invitee.
89
+ *
90
+ * @example
91
+ * const invite = await workspace.agentInviteScoped({
92
+ * agentName: 'notion-summarizer',
93
+ * scopes: ['relayfile:fs:read:/notion/pages/*'],
94
+ * })
95
+ * // invite.relayfileToken is a JWT with scopes=['relayfile:fs:read:/notion/pages/*']
96
+ */
97
+ agentInviteScoped(options?: AgentWorkspaceScopedInviteOptions): Promise<AgentWorkspaceInvite>;
69
98
  refreshToken(): Promise<void>;
70
99
  private performRefreshToken;
71
100
  private getOrRefreshToken;
package/dist/setup.js CHANGED
@@ -99,7 +99,7 @@ export class RelayfileSetup {
99
99
  joinOptions
100
100
  });
101
101
  }
102
- async joinWorkspaceResponse(workspaceId, options) {
102
+ async joinWorkspaceResponse(workspaceId, options, overrides = {}) {
103
103
  return validateJoinWorkspaceResponse(await this.requestJson({
104
104
  operation: "joinWorkspace",
105
105
  method: "POST",
@@ -108,7 +108,8 @@ export class RelayfileSetup {
108
108
  agentName: options.agentName,
109
109
  scopes: [...options.scopes],
110
110
  permissions: clonePermissions(options.permissions)
111
- })
111
+ }),
112
+ tokenProvider: overrides.tokenProvider
112
113
  }));
113
114
  }
114
115
  async requestJson(options) {
@@ -316,6 +317,13 @@ export class WorkspaceHandle {
316
317
  RELAY_BASE_URL: relaycastBaseUrl
317
318
  });
318
319
  }
320
+ /**
321
+ * Build an invite that hands a peer agent the calling workspace's existing
322
+ * JWT and metadata. The invite carries the SAME token this handle holds,
323
+ * with the SAME scopes — there is no per-invite downscoping. Use
324
+ * {@link agentInviteScoped} when the receiver should have a strictly
325
+ * narrower set of scopes than this workspace.
326
+ */
319
327
  agentInvite(options = {}) {
320
328
  const relaycastBaseUrl = this.resolveRelaycastBaseUrl(options.relaycastBaseUrl);
321
329
  return compactObject({
@@ -325,14 +333,68 @@ export class WorkspaceHandle {
325
333
  relaycastApiKey: this.info.relaycastApiKey,
326
334
  relaycastBaseUrl,
327
335
  agentName: options.agentName ?? this._joinOptions.agentName,
328
- scopes: options.scopes && options.scopes.length > 0
329
- ? [...options.scopes]
330
- : [...this._joinOptions.scopes],
336
+ scopes: [...this._joinOptions.scopes],
331
337
  relayfileToken: options.includeRelayfileToken === false ? undefined : this.getToken(),
332
338
  createdAt: this.info.createdAt,
333
339
  name: this.info.name
334
340
  });
335
341
  }
342
+ /**
343
+ * Mint a fresh, downscoped relayfile JWT for a peer agent and return an
344
+ * invite carrying that token. Round-trips through the cloud join endpoint
345
+ * (`POST /api/v1/workspaces/{id}/join`), which signs a new JWT whose
346
+ * `scopes` claim is the requested subset of this workspace's grant. The
347
+ * cloud API rejects requests that exceed the calling workspace's scopes.
348
+ *
349
+ * Prefer this over {@link agentInvite} whenever the receiver should not
350
+ * inherit the full workspace token's reach (e.g. one agent that only needs
351
+ * `relayfile:fs:read:/notion/pages/*`). The caller's token is unaffected;
352
+ * a separate JWT is issued for the invitee.
353
+ *
354
+ * @example
355
+ * const invite = await workspace.agentInviteScoped({
356
+ * agentName: 'notion-summarizer',
357
+ * scopes: ['relayfile:fs:read:/notion/pages/*'],
358
+ * })
359
+ * // invite.relayfileToken is a JWT with scopes=['relayfile:fs:read:/notion/pages/*']
360
+ */
361
+ async agentInviteScoped(options = {}) {
362
+ const requestedScopes = options.scopes && options.scopes.length > 0
363
+ ? [...options.scopes]
364
+ : [...this._joinOptions.scopes];
365
+ const agentName = options.agentName ?? this._joinOptions.agentName;
366
+ const permissions = options.permissions ?? this._joinOptions.permissions;
367
+ // Authenticate the join with this handle's workspace JWT. Without it,
368
+ // the cloud cannot verify that requestedScopes ⊆ caller's grant, and
369
+ // anonymous (or permissive) endpoints would silently mint a wide token —
370
+ // exactly the failure this method is designed to prevent. The setup-
371
+ // level accessToken (used for createWorkspace/joinWorkspace at the
372
+ // workspace-bootstrap layer) is the wrong identity here; we want the
373
+ // workspace JWT itself to be the parent the cloud downscopes from.
374
+ const joinResponse = await this._setup.joinWorkspaceResponse(this.workspaceId, {
375
+ agentName,
376
+ scopes: requestedScopes,
377
+ permissions
378
+ }, { tokenProvider: async () => this.getOrRefreshToken() });
379
+ // Prefer a relaycastBaseUrl returned by the cloud over the cached one —
380
+ // the cloud is authoritative if it shipped a fresher value with the
381
+ // join response.
382
+ const relaycastBaseUrl = this.resolveRelaycastBaseUrl(options.relaycastBaseUrl ?? joinResponse.relaycastBaseUrl);
383
+ return compactObject({
384
+ workspaceId: this.workspaceId,
385
+ cloudApiUrl: this._setup.getCloudApiUrl(),
386
+ relayfileUrl: joinResponse.relayfileUrl ?? this.info.relayfileUrl,
387
+ relaycastApiKey: joinResponse.relaycastApiKey ?? this.info.relaycastApiKey,
388
+ relaycastBaseUrl,
389
+ agentName,
390
+ scopes: requestedScopes,
391
+ relayfileToken: options.includeRelayfileToken === false
392
+ ? undefined
393
+ : joinResponse.token,
394
+ createdAt: this.info.createdAt,
395
+ name: this.info.name
396
+ });
397
+ }
336
398
  async refreshToken() {
337
399
  if (!this._refreshPromise) {
338
400
  this._refreshPromise = this.performRefreshToken();
package/dist/sync.d.ts CHANGED
@@ -70,6 +70,7 @@ type RelayFileSyncHandlerMap = {
70
70
  close: (event: CloseEvent) => void;
71
71
  pong: (event: RelayFileSyncPong) => void;
72
72
  };
73
+ export declare function normalizeError(event: unknown): unknown;
73
74
  export declare class RelayFileSync {
74
75
  private readonly client;
75
76
  private readonly workspaceId;
@@ -95,6 +96,8 @@ export declare class RelayFileSync {
95
96
  private pollingPromise?;
96
97
  private reconnectTimer?;
97
98
  private pingTimer?;
99
+ private errorRecoveryTimer?;
100
+ private currentSocketHasOpened;
98
101
  private lastFrameAt;
99
102
  private lastPingSentAt;
100
103
  private reconnectAttempts;
@@ -122,6 +125,7 @@ export declare class RelayFileSync {
122
125
  private setState;
123
126
  private clearReconnectTimer;
124
127
  private clearPingTimer;
128
+ private clearErrorRecoveryTimer;
125
129
  private emit;
126
130
  }
127
131
  export {};
package/dist/sync.js CHANGED
@@ -7,6 +7,11 @@ const DEFAULT_RECONNECT_MAX_DELAY_MS = 5000;
7
7
  // (the events API is itself capped at ~1000 per page), small enough to keep
8
8
  // memory bounded across long-lived processes.
9
9
  const POLLING_DEDUPE_CACHE_LIMIT = 2048;
10
+ // How long we wait after a WS `error` for the matching `close` to arrive
11
+ // before forcing a recovery. Some implementations (notably Node's built-in
12
+ // WebSocket on auth-rejected upgrades) emit `error` with no successor
13
+ // `close`; without this watchdog the dispatcher stalls indefinitely.
14
+ const ERROR_TO_CLOSE_GRACE_MS = 250;
10
15
  function warnPollingFallback(reason, cause) {
11
16
  // Always-on warning (NOT gated by RELAYFILE_SDK_DEBUG) because silent
12
17
  // degradation to polling has historically masked real auth/connectivity
@@ -85,8 +90,20 @@ function normalizeFilesystemEvent(message) {
85
90
  timestamp: message.timestamp ?? message.ts ?? new Date().toISOString()
86
91
  };
87
92
  }
88
- function normalizeError(event) {
89
- return event instanceof ErrorEvent && event.error instanceof Error ? event.error : event;
93
+ // Exported for testing. `ErrorEvent` is a DOM/browser global that is not
94
+ // available on every Node 22 runtime (for example Node 22.22.1), so we
95
+ // duck-type the shape rather than referencing the global directly. Using
96
+ // `instanceof ErrorEvent` here would throw
97
+ // `ReferenceError: ErrorEvent is not defined` whenever the underlying
98
+ // WebSocket emits an error event under Node.
99
+ export function normalizeError(event) {
100
+ if (event !== null &&
101
+ typeof event === "object" &&
102
+ "error" in event &&
103
+ event.error instanceof Error) {
104
+ return event.error;
105
+ }
106
+ return event;
90
107
  }
91
108
  export class RelayFileSync {
92
109
  client;
@@ -123,6 +140,16 @@ export class RelayFileSync {
123
140
  pollingPromise;
124
141
  reconnectTimer;
125
142
  pingTimer;
143
+ // See ERROR_TO_CLOSE_GRACE_MS: armed in the WS `error` handler, cleared
144
+ // by the matching `close` (or by `stop()`); fires forceReconnect when
145
+ // the OS layer fails to deliver a close after the error.
146
+ errorRecoveryTimer;
147
+ // Tracks whether this.socket has reached the OPEN state. Reset every
148
+ // time we attach to a fresh socket; flipped true in the `open` handler.
149
+ // The error watchdog checks this so that a pre-open failure (auth reject,
150
+ // proxy RST during handshake, etc.) routes to polling rather than an
151
+ // infinite reconnect loop against a server that won't accept the WS.
152
+ currentSocketHasOpened = false;
126
153
  // Tracks the last time *any* WebSocket frame was received (event, pong, or
127
154
  // otherwise). The watchdog uses this to detect silent socket death.
128
155
  lastFrameAt = 0;
@@ -212,6 +239,7 @@ export class RelayFileSync {
212
239
  this.stopped = true;
213
240
  this.clearReconnectTimer();
214
241
  this.clearPingTimer();
242
+ this.clearErrorRecoveryTimer();
215
243
  const socket = this.socket;
216
244
  this.socket = undefined;
217
245
  if (socket) {
@@ -309,10 +337,12 @@ export class RelayFileSync {
309
337
  return;
310
338
  }
311
339
  this.socket = socket;
340
+ this.currentSocketHasOpened = false;
312
341
  socket.addEventListener("open", (event) => {
313
342
  if (this.socket !== socket || this.stopped) {
314
343
  return;
315
344
  }
345
+ this.currentSocketHasOpened = true;
316
346
  this.reconnectAttempts = 0;
317
347
  // Reset frame/ping bookkeeping for the freshly opened socket so the
318
348
  // watchdog has a clean baseline. lastPingSentAt=0 disables the pong
@@ -339,6 +369,46 @@ export class RelayFileSync {
339
369
  }
340
370
  debugLog("ws error", event);
341
371
  this.emit("error", normalizeError(event));
372
+ // Per WHATWG, an `error` should be followed by a `close` and the
373
+ // close handler is the one that schedules reconnect / starts polling.
374
+ // In practice though, some WebSocket implementations (notably Node's
375
+ // built-in/undici WebSocket on auth-rejected upgrades, and some
376
+ // proxies that abruptly RST after the upgrade) deliver `error` with
377
+ // no successor `close`. The dispatcher would then sit silent forever
378
+ // — exactly the failure mode in the cortical-demo bug report (WS
379
+ // error fires, no close, no reconnect, no polling fallback).
380
+ //
381
+ // To recover, we arm a short grace timer here. If the close handler
382
+ // for THIS socket runs before it fires (the well-behaved path), it
383
+ // clears the timer. Otherwise the timer fires, sees the socket is
384
+ // still current, and forces the same recovery path the close handler
385
+ // would have taken (reconnect or polling). The grace window is
386
+ // intentionally short — by the time `error` has fired, the socket
387
+ // is already known-bad; we are only giving the OS layer a beat to
388
+ // deliver its close.
389
+ if (this.errorRecoveryTimer) {
390
+ return;
391
+ }
392
+ this.errorRecoveryTimer = setTimeout(() => {
393
+ this.errorRecoveryTimer = undefined;
394
+ if (this.stopped || this.socket !== socket) {
395
+ return;
396
+ }
397
+ // If the socket never reached OPEN, this is a handshake-stage
398
+ // failure (auth rejection on upgrade, proxy RST, server cold
399
+ // start). Reconnecting in a tight loop just retries the same
400
+ // failing handshake. Drop straight into polling instead — that
401
+ // path tolerates 401/403/timeout and surfaces the error to the
402
+ // caller without burning the loop.
403
+ const preOpen = !this.currentSocketHasOpened;
404
+ debugLog("ws error with no successor close — forcing recovery", {
405
+ workspaceId: this.workspaceId,
406
+ preOpen
407
+ });
408
+ this.forceReconnect(socket, preOpen ? "ws-error-pre-open" : "ws-error-no-close", {
409
+ preferPolling: preOpen
410
+ });
411
+ }, ERROR_TO_CLOSE_GRACE_MS);
342
412
  });
343
413
  socket.addEventListener("close", (event) => {
344
414
  // forceReconnect (called from the watchdog or a failed ping send) nulls
@@ -358,6 +428,7 @@ export class RelayFileSync {
358
428
  }
359
429
  this.socket = undefined;
360
430
  this.clearPingTimer();
431
+ this.clearErrorRecoveryTimer();
361
432
  debugLog("ws close", { code: event?.code, reason: event?.reason, stopped: this.stopped });
362
433
  this.emit("close", event);
363
434
  if (this.stopped) {
@@ -588,8 +659,14 @@ export class RelayFileSync {
588
659
  // no-ops via the `this.socket !== socket` guards) and trigger the standard
589
660
  // reconnect path. Catches errors from `close()` because some socket
590
661
  // implementations throw when closing an already-broken connection.
591
- forceReconnect(socket, reason) {
662
+ //
663
+ // `options.preferPolling` lets the caller force the polling fallback even
664
+ // when reconnect is enabled. Used by the error watchdog when the failed
665
+ // socket never reached OPEN (handshake-stage failure) — retrying the WS
666
+ // upgrade in a loop won't help when the server is rejecting at the upgrade.
667
+ forceReconnect(socket, reason, options) {
592
668
  this.clearPingTimer();
669
+ this.clearErrorRecoveryTimer();
593
670
  if (this.socket === socket) {
594
671
  this.socket = undefined;
595
672
  }
@@ -603,8 +680,8 @@ export class RelayFileSync {
603
680
  this.setState("closed");
604
681
  return;
605
682
  }
606
- if (!this.reconnect.enabled) {
607
- this.startPolling("forced-reconnect-no-retry", reason);
683
+ if (!this.reconnect.enabled || options?.preferPolling) {
684
+ this.startPolling(options?.preferPolling ? "forced-polling-pre-open" : "forced-reconnect-no-retry", reason);
608
685
  return;
609
686
  }
610
687
  this.scheduleReconnect();
@@ -670,6 +747,12 @@ export class RelayFileSync {
670
747
  this.pingTimer = undefined;
671
748
  }
672
749
  }
750
+ clearErrorRecoveryTimer() {
751
+ if (this.errorRecoveryTimer) {
752
+ clearTimeout(this.errorRecoveryTimer);
753
+ this.errorRecoveryTimer = undefined;
754
+ }
755
+ }
673
756
  emit(event, payload) {
674
757
  const handlers = this.handlers[event];
675
758
  for (const handler of handlers) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/sdk",
3
- "version": "0.6.12",
3
+ "version": "0.6.13",
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.12"
23
+ "@relayfile/core": "0.6.13"
24
24
  },
25
25
  "devDependencies": {
26
26
  "typescript": "^5.7.3",