@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 +10 -5
- package/dist/client.d.ts +12 -0
- package/dist/client.js +14 -0
- package/dist/index.d.ts +2 -2
- package/dist/onWrite.d.ts +22 -2
- package/dist/onWrite.js +31 -2
- 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 +41 -2
- package/dist/sync.js +266 -23
- 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/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
|
-
|
|
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
|
|
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
|
}
|
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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
207
|
-
url.searchParams.set("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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
307
|
-
|
|
521
|
+
this.cursor = latestCursor;
|
|
522
|
+
if (!this.firstPollComplete) {
|
|
523
|
+
this.firstPollComplete = true;
|
|
308
524
|
}
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|