@relayfile/sdk 0.6.11 → 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/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
@@ -2,8 +2,8 @@ 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";
6
- export { RelayFileSync, type RelayFileSyncOptions, type RelayFileSyncPong, type RelayFileSyncReconnectOptions, type RelayFileSyncSocket, type RelayFileSyncState } from "./sync.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
+ 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,38 @@ 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 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;
118
146
  this.sync = RelayFileSync.connect({
119
147
  client: this.client,
120
148
  workspaceId: options.workspaceId,
121
149
  baseUrl: options.baseUrl ?? readEnv("RELAYFILE_BASE_URL") ?? DEFAULT_RELAYFILE_BASE_URL,
122
- token: options.token ?? readEnv("RELAYFILE_TOKEN"),
150
+ token,
123
151
  reconnect: {
124
152
  minDelayMs: DEFAULT_RECONNECT_MIN_DELAY_MS,
125
153
  maxDelayMs: DEFAULT_RECONNECT_MAX_DELAY_MS
@@ -127,6 +155,7 @@ class OnWriteDispatcher {
127
155
  webSocketFactory: options.webSocketFactory,
128
156
  pingIntervalMs: options.pingIntervalMs,
129
157
  pongTimeoutMs: options.pongTimeoutMs,
158
+ onPollingFallback: options.onPollingFallback,
130
159
  onEvent: (event) => {
131
160
  void this.dispatch(event);
132
161
  }
@@ -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
@@ -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;
@@ -42,11 +70,12 @@ type RelayFileSyncHandlerMap = {
42
70
  close: (event: CloseEvent) => void;
43
71
  pong: (event: RelayFileSyncPong) => void;
44
72
  };
73
+ export declare function normalizeError(event: unknown): unknown;
45
74
  export declare class RelayFileSync {
46
75
  private readonly client;
47
76
  private readonly workspaceId;
48
77
  private readonly baseUrl?;
49
- private readonly token?;
78
+ private readonly tokenProvider?;
50
79
  private readonly pollIntervalMs;
51
80
  private readonly pingIntervalMs;
52
81
  private readonly pongTimeoutMs;
@@ -54,15 +83,21 @@ export declare class RelayFileSync {
54
83
  private readonly preferPolling;
55
84
  private readonly signal?;
56
85
  private readonly webSocketFactory;
86
+ private readonly onPollingFallback?;
57
87
  private readonly handlers;
58
88
  private state;
59
89
  private cursor?;
90
+ private readonly polledEventIds;
91
+ private readonly polledEventOrder;
92
+ private firstPollComplete;
60
93
  private socket?;
61
94
  private started;
62
95
  private stopped;
63
96
  private pollingPromise?;
64
97
  private reconnectTimer?;
65
98
  private pingTimer?;
99
+ private errorRecoveryTimer?;
100
+ private currentSocketHasOpened;
66
101
  private lastFrameAt;
67
102
  private lastPingSentAt;
68
103
  private reconnectAttempts;
@@ -74,9 +109,12 @@ export declare class RelayFileSync {
74
109
  stop(): Promise<void>;
75
110
  on<TEventName extends RelayFileSyncEventName>(event: TEventName, handler: RelayFileSyncHandlerMap[TEventName]): () => void;
76
111
  private shouldUsePolling;
112
+ private resolveWsTokenMaybeSync;
77
113
  private openWebSocket;
114
+ private openWebSocketWithToken;
78
115
  private startPolling;
79
116
  private pollLoop;
117
+ private rememberPolledEvent;
80
118
  private handleSocketMessage;
81
119
  private startPingLoop;
82
120
  private forceReconnect;
@@ -87,6 +125,7 @@ export declare class RelayFileSync {
87
125
  private setState;
88
126
  private clearReconnectTimer;
89
127
  private clearPingTimer;
128
+ private clearErrorRecoveryTimer;
90
129
  private emit;
91
130
  }
92
131
  export {};
package/dist/sync.js CHANGED
@@ -2,6 +2,28 @@ 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
+ // 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;
15
+ function warnPollingFallback(reason, cause) {
16
+ // Always-on warning (NOT gated by RELAYFILE_SDK_DEBUG) because silent
17
+ // degradation to polling has historically masked real auth/connectivity
18
+ // bugs for hours at a time. Customers running the SDK in a Node service
19
+ // see the warning in their normal logs without any opt-in.
20
+ if (typeof console === "undefined" || typeof console.warn !== "function") {
21
+ return;
22
+ }
23
+ const detail = cause instanceof Error ? cause.message : cause !== undefined ? String(cause) : "";
24
+ const suffix = detail ? ` (${reason}: ${detail})` : ` (${reason})`;
25
+ console.warn(`[relayfile-sdk] WebSocket connect failed; falling back to HTTP polling. Live events will be delayed.${suffix}`);
26
+ }
5
27
  const debugEnabled = (() => {
6
28
  try {
7
29
  const value = globalThis.process?.env?.RELAYFILE_SDK_DEBUG;
@@ -68,14 +90,29 @@ function normalizeFilesystemEvent(message) {
68
90
  timestamp: message.timestamp ?? message.ts ?? new Date().toISOString()
69
91
  };
70
92
  }
71
- function normalizeError(event) {
72
- 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;
73
107
  }
74
108
  export class RelayFileSync {
75
109
  client;
76
110
  workspaceId;
77
111
  baseUrl;
78
- token;
112
+ // Resolved on every connect attempt (string form is wrapped into a constant
113
+ // factory at construction time). `undefined` means "fall back to
114
+ // client.getToken()" — same JWT the REST methods use.
115
+ tokenProvider;
79
116
  pollIntervalMs;
80
117
  pingIntervalMs;
81
118
  pongTimeoutMs;
@@ -83,6 +120,7 @@ export class RelayFileSync {
83
120
  preferPolling;
84
121
  signal;
85
122
  webSocketFactory;
123
+ onPollingFallback;
86
124
  handlers = {
87
125
  event: new Set(),
88
126
  error: new Set(),
@@ -93,12 +131,25 @@ export class RelayFileSync {
93
131
  };
94
132
  state = "idle";
95
133
  cursor;
134
+ polledEventIds = new Set();
135
+ polledEventOrder = [];
136
+ firstPollComplete = false;
96
137
  socket;
97
138
  started = false;
98
139
  stopped = false;
99
140
  pollingPromise;
100
141
  reconnectTimer;
101
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;
102
153
  // Tracks the last time *any* WebSocket frame was received (event, pong, or
103
154
  // otherwise). The watchdog uses this to detect silent socket death.
104
155
  lastFrameAt = 0;
@@ -112,8 +163,21 @@ export class RelayFileSync {
112
163
  this.client = options.client;
113
164
  this.workspaceId = options.workspaceId;
114
165
  this.baseUrl = options.baseUrl?.replace(/\/+$/, "");
115
- this.token = options.token;
166
+ // Normalize token into a factory. `undefined` is preserved so the open
167
+ // path knows to fall back to `client.getToken()` (the recommended path —
168
+ // the WebSocket then auto-uses the same JWT the REST API is using).
169
+ if (options.token === undefined) {
170
+ this.tokenProvider = undefined;
171
+ }
172
+ else if (typeof options.token === "function") {
173
+ this.tokenProvider = options.token;
174
+ }
175
+ else {
176
+ const literal = options.token;
177
+ this.tokenProvider = () => literal;
178
+ }
116
179
  this.cursor = options.cursor;
180
+ this.onPollingFallback = options.onPollingFallback;
117
181
  this.pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS));
118
182
  this.pingIntervalMs = Math.max(1, Math.floor(options.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS));
119
183
  // Default the pong timeout to 2x ping interval so a single missed pong
@@ -161,7 +225,9 @@ export class RelayFileSync {
161
225
  }
162
226
  this.started = true;
163
227
  if (this.shouldUsePolling()) {
164
- this.startPolling();
228
+ // Caller explicitly opted into polling, or there's no baseUrl to
229
+ // upgrade to wss. NOT a fallback — no warn here.
230
+ this.startPolling("explicit");
165
231
  return;
166
232
  }
167
233
  this.openWebSocket(false);
@@ -173,6 +239,7 @@ export class RelayFileSync {
173
239
  this.stopped = true;
174
240
  this.clearReconnectTimer();
175
241
  this.clearPingTimer();
242
+ this.clearErrorRecoveryTimer();
176
243
  const socket = this.socket;
177
244
  this.socket = undefined;
178
245
  if (socket) {
@@ -193,9 +260,63 @@ export class RelayFileSync {
193
260
  };
194
261
  }
195
262
  shouldUsePolling() {
196
- return this.preferPolling || !this.baseUrl || !this.token;
263
+ // Token availability is no longer required up-front — the WS opener
264
+ // resolves it on demand from either `options.token` (a literal/factory)
265
+ // or `client.getToken()`. We only force polling when the caller asked
266
+ // for it or there is no baseUrl to derive a wss URL from.
267
+ return this.preferPolling || !this.baseUrl;
268
+ }
269
+ // Resolve a token for the WS upgrade. Returns either a string directly
270
+ // (sync fast-path; preserves the synchronous "start() → factory called"
271
+ // contract that pre-existed Bug 1's fix) or a Promise (async slow-path;
272
+ // factory is invoked on the next microtask). `undefined` means "no token
273
+ // available — the server may still accept the upgrade if it's configured
274
+ // for unauthenticated mode in tests/local-dev".
275
+ resolveWsTokenMaybeSync() {
276
+ if (this.tokenProvider) {
277
+ // Most production providers return synchronously (JWT pulled from a
278
+ // mutable cell that a refresh task updates in the background), so the
279
+ // sync path is the common case.
280
+ return this.tokenProvider();
281
+ }
282
+ // Auto-derive from the client's tokenProvider — the same JWT the REST
283
+ // surface is using. Bug 1 fix: callers no longer have to thread
284
+ // `token: await client.tokenProvider()` through every onWrite() call.
285
+ // client.getToken is always async (returns a Promise), so we land on
286
+ // the slow path here. That's fine: the WS open is async anyway.
287
+ return this.client.getToken();
197
288
  }
198
289
  openWebSocket(isReconnect) {
290
+ if (this.stopped) {
291
+ return;
292
+ }
293
+ this.setState(isReconnect ? "reconnecting" : "connecting");
294
+ // Resolve a fresh token on every (re)connect attempt. This is what
295
+ // makes mid-session token rotation transparent: the watchdog/close
296
+ // handler reconnects, we re-call the factory, and the new socket comes
297
+ // up with the new JWT. (Bug 4: pre-fix, the token was captured once at
298
+ // construction and a 4001/auth close on rotation triggered an infinite
299
+ // reconnect loop with the stale token.)
300
+ let resolved;
301
+ try {
302
+ resolved = this.resolveWsTokenMaybeSync();
303
+ }
304
+ catch (error) {
305
+ this.emit("error", error instanceof Error ? error : new Error("Failed to resolve WebSocket auth token."));
306
+ this.startPolling("token-resolution-failed", error);
307
+ return;
308
+ }
309
+ if (resolved && typeof resolved.then === "function") {
310
+ // Async path. The factory call gets deferred to the next microtask.
311
+ resolved.then((token) => this.openWebSocketWithToken(token), (error) => {
312
+ this.emit("error", error instanceof Error ? error : new Error("Failed to resolve WebSocket auth token."));
313
+ this.startPolling("token-resolution-failed", error);
314
+ });
315
+ return;
316
+ }
317
+ this.openWebSocketWithToken(resolved);
318
+ }
319
+ openWebSocketWithToken(token) {
199
320
  if (this.stopped) {
200
321
  return;
201
322
  }
@@ -203,24 +324,25 @@ export class RelayFileSync {
203
324
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
204
325
  // Pass token in query string — the server authenticates during the HTTP
205
326
  // upgrade handshake via r.URL.Query().Get("token").
206
- if (this.token) {
207
- url.searchParams.set("token", this.token);
327
+ if (token) {
328
+ url.searchParams.set("token", token);
208
329
  }
209
- this.setState(isReconnect ? "reconnecting" : "connecting");
210
330
  let socket;
211
331
  try {
212
332
  socket = this.webSocketFactory(url.toString());
213
333
  }
214
334
  catch (error) {
215
335
  this.emit("error", error instanceof Error ? error : new Error("Failed to create WebSocket connection."));
216
- this.startPolling();
336
+ this.startPolling("ws-factory-threw", error);
217
337
  return;
218
338
  }
219
339
  this.socket = socket;
340
+ this.currentSocketHasOpened = false;
220
341
  socket.addEventListener("open", (event) => {
221
342
  if (this.socket !== socket || this.stopped) {
222
343
  return;
223
344
  }
345
+ this.currentSocketHasOpened = true;
224
346
  this.reconnectAttempts = 0;
225
347
  // Reset frame/ping bookkeeping for the freshly opened socket so the
226
348
  // watchdog has a clean baseline. lastPingSentAt=0 disables the pong
@@ -247,6 +369,46 @@ export class RelayFileSync {
247
369
  }
248
370
  debugLog("ws error", event);
249
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);
250
412
  });
251
413
  socket.addEventListener("close", (event) => {
252
414
  // forceReconnect (called from the watchdog or a failed ping send) nulls
@@ -266,6 +428,7 @@ export class RelayFileSync {
266
428
  }
267
429
  this.socket = undefined;
268
430
  this.clearPingTimer();
431
+ this.clearErrorRecoveryTimer();
269
432
  debugLog("ws close", { code: event?.code, reason: event?.reason, stopped: this.stopped });
270
433
  this.emit("close", event);
271
434
  if (this.stopped) {
@@ -273,16 +436,29 @@ export class RelayFileSync {
273
436
  return;
274
437
  }
275
438
  if (!this.reconnect.enabled) {
276
- this.startPolling();
439
+ this.startPolling("ws-closed-no-reconnect", { code: event?.code, reason: event?.reason });
277
440
  return;
278
441
  }
279
442
  this.scheduleReconnect();
280
443
  });
281
444
  }
282
- startPolling() {
445
+ startPolling(reason = "fallback", cause) {
283
446
  if (this.pollingPromise || this.stopped) {
284
447
  return;
285
448
  }
449
+ // Always-on warning + structured callback whenever we drop into polling
450
+ // *involuntarily*. `explicit` means the caller asked for it (preferPolling
451
+ // or no baseUrl) and we stay quiet — anything else is a real degradation
452
+ // signal that previously took hours to detect.
453
+ if (reason !== "explicit") {
454
+ warnPollingFallback(reason, cause);
455
+ try {
456
+ this.onPollingFallback?.({ reason, cause });
457
+ }
458
+ catch (error) {
459
+ debugLog("onPollingFallback handler threw", error);
460
+ }
461
+ }
286
462
  this.setState("polling");
287
463
  this.pollingPromise = this.pollLoop().finally(() => {
288
464
  this.pollingPromise = undefined;
@@ -298,16 +474,58 @@ export class RelayFileSync {
298
474
  throw createAbortError();
299
475
  }
300
476
  try {
301
- const response = await this.client.getEvents(this.workspaceId, {
302
- cursor: this.cursor,
303
- signal: this.signal
304
- });
477
+ // The current server implementation paginates events from oldest to
478
+ // newest. Empty cursor starts at index 0, and nextCursor advances
479
+ // forward through history. To avoid replaying the whole event log on
480
+ // startup while still preserving live forward progress, the first poll
481
+ // drains to the tip and seeds `this.cursor` without emitting. Later
482
+ // polls resume from that live cursor and emit only newly appended
483
+ // events.
484
+ let cursor = this.cursor;
485
+ let latestCursor = cursor;
486
+ const pending = [];
487
+ for (;;) {
488
+ const response = await this.client.getEvents(this.workspaceId, {
489
+ cursor,
490
+ limit: 1000,
491
+ signal: this.signal
492
+ });
493
+ const events = response.events ?? [];
494
+ if (!this.firstPollComplete) {
495
+ for (const event of events) {
496
+ this.rememberPolledEvent(event.eventId);
497
+ }
498
+ }
499
+ else {
500
+ for (const event of events) {
501
+ if (!event.eventId || this.polledEventIds.has(event.eventId)) {
502
+ continue;
503
+ }
504
+ this.rememberPolledEvent(event.eventId);
505
+ pending.push(event);
506
+ }
507
+ }
508
+ const nextCursor = response.nextCursor || null;
509
+ if (events.length > 0) {
510
+ latestCursor = events[events.length - 1]?.eventId ?? latestCursor;
511
+ }
512
+ if (nextCursor) {
513
+ latestCursor = nextCursor;
514
+ }
515
+ if (!nextCursor || nextCursor === cursor) {
516
+ break;
517
+ }
518
+ cursor = nextCursor;
519
+ }
305
520
  retryAttempt = 0;
306
- for (const event of response.events) {
307
- this.emit("event", event);
521
+ this.cursor = latestCursor;
522
+ if (!this.firstPollComplete) {
523
+ this.firstPollComplete = true;
308
524
  }
309
- if (response.nextCursor) {
310
- this.cursor = response.nextCursor;
525
+ else {
526
+ for (const event of pending) {
527
+ this.emit("event", event);
528
+ }
311
529
  }
312
530
  await this.sleep(this.pollIntervalMs);
313
531
  }
@@ -322,6 +540,19 @@ export class RelayFileSync {
322
540
  }
323
541
  }
324
542
  }
543
+ rememberPolledEvent(eventId) {
544
+ if (!eventId || this.polledEventIds.has(eventId)) {
545
+ return;
546
+ }
547
+ this.polledEventIds.add(eventId);
548
+ this.polledEventOrder.push(eventId);
549
+ while (this.polledEventOrder.length > POLLING_DEDUPE_CACHE_LIMIT) {
550
+ const evicted = this.polledEventOrder.shift();
551
+ if (evicted) {
552
+ this.polledEventIds.delete(evicted);
553
+ }
554
+ }
555
+ }
325
556
  handleSocketMessage(event) {
326
557
  if (typeof event.data !== "string") {
327
558
  return;
@@ -428,8 +659,14 @@ export class RelayFileSync {
428
659
  // no-ops via the `this.socket !== socket` guards) and trigger the standard
429
660
  // reconnect path. Catches errors from `close()` because some socket
430
661
  // implementations throw when closing an already-broken connection.
431
- 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) {
432
668
  this.clearPingTimer();
669
+ this.clearErrorRecoveryTimer();
433
670
  if (this.socket === socket) {
434
671
  this.socket = undefined;
435
672
  }
@@ -443,8 +680,8 @@ export class RelayFileSync {
443
680
  this.setState("closed");
444
681
  return;
445
682
  }
446
- if (!this.reconnect.enabled) {
447
- this.startPolling();
683
+ if (!this.reconnect.enabled || options?.preferPolling) {
684
+ this.startPolling(options?.preferPolling ? "forced-polling-pre-open" : "forced-reconnect-no-retry", reason);
448
685
  return;
449
686
  }
450
687
  this.scheduleReconnect();
@@ -510,6 +747,12 @@ export class RelayFileSync {
510
747
  this.pingTimer = undefined;
511
748
  }
512
749
  }
750
+ clearErrorRecoveryTimer() {
751
+ if (this.errorRecoveryTimer) {
752
+ clearTimeout(this.errorRecoveryTimer);
753
+ this.errorRecoveryTimer = undefined;
754
+ }
755
+ }
513
756
  emit(event, payload) {
514
757
  const handlers = this.handlers[event];
515
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.11",
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.11"
23
+ "@relayfile/core": "0.6.13"
24
24
  },
25
25
  "devDependencies": {
26
26
  "typescript": "^5.7.3",