@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 +10 -5
- package/dist/index.d.ts +1 -1
- package/dist/onWrite.js +25 -8
- package/dist/setup-types.d.ts +17 -0
- package/dist/setup.d.ts +31 -2
- package/dist/setup.js +67 -5
- package/dist/sync.d.ts +4 -0
- package/dist/sync.js +88 -5
- package/package.json +2 -2
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
53
|
+
// Never log invites — they 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.
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
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,
|
package/dist/setup-types.d.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
23
|
+
"@relayfile/core": "0.6.13"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"typescript": "^5.7.3",
|