@relayfile/sdk 0.6.10 → 0.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +12 -0
- package/dist/client.js +14 -0
- package/dist/index.d.ts +1 -1
- package/dist/onWrite.d.ts +24 -2
- package/dist/onWrite.js +53 -3
- package/dist/sync.d.ts +42 -2
- package/dist/sync.js +306 -21
- package/package.json +2 -2
package/dist/client.d.ts
CHANGED
|
@@ -51,6 +51,18 @@ export declare class RelayFileClient {
|
|
|
51
51
|
private readonly userAgent?;
|
|
52
52
|
private readonly retryOptions;
|
|
53
53
|
constructor(options: RelayFileClientOptions);
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the current access token via the configured token provider.
|
|
56
|
+
*
|
|
57
|
+
* Components that need a fresh JWT for out-of-band auth (the WebSocket
|
|
58
|
+
* upgrade handshake, signed URLs, downstream services that proxy Relayfile
|
|
59
|
+
* tokens) should call this on every connection rather than caching the
|
|
60
|
+
* value, so token rotation/refresh propagates without restart.
|
|
61
|
+
*
|
|
62
|
+
* Always returns a Promise so callers don't need to special-case the
|
|
63
|
+
* sync-vs-async tokenProvider shapes.
|
|
64
|
+
*/
|
|
65
|
+
getToken(): Promise<string>;
|
|
54
66
|
listTree(workspaceId: string, options?: ListTreeOptions): Promise<TreeResponse>;
|
|
55
67
|
readFile(workspaceId: string, path: string, correlationId?: string, signal?: AbortSignal): Promise<FileReadResponse>;
|
|
56
68
|
readFile(input: ReadFileInput): Promise<FileReadResponse>;
|
package/dist/client.js
CHANGED
|
@@ -171,6 +171,20 @@ export class RelayFileClient {
|
|
|
171
171
|
this.userAgent = options.userAgent;
|
|
172
172
|
this.retryOptions = normalizeRetryOptions(options.retry);
|
|
173
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Resolve the current access token via the configured token provider.
|
|
176
|
+
*
|
|
177
|
+
* Components that need a fresh JWT for out-of-band auth (the WebSocket
|
|
178
|
+
* upgrade handshake, signed URLs, downstream services that proxy Relayfile
|
|
179
|
+
* tokens) should call this on every connection rather than caching the
|
|
180
|
+
* value, so token rotation/refresh propagates without restart.
|
|
181
|
+
*
|
|
182
|
+
* Always returns a Promise so callers don't need to special-case the
|
|
183
|
+
* sync-vs-async tokenProvider shapes.
|
|
184
|
+
*/
|
|
185
|
+
async getToken() {
|
|
186
|
+
return resolveToken(this.tokenProvider);
|
|
187
|
+
}
|
|
174
188
|
async listTree(workspaceId, options = {}) {
|
|
175
189
|
const query = buildQuery({
|
|
176
190
|
path: options.path ?? "/",
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { RelayfileSetup, RELAYFILE_SDK_VERSION, WorkspaceHandle } from "./setup.
|
|
|
3
3
|
export { type RelayfileCloudLoginOptions, type RelayfileCloudTokenSet, type RelayfileCloudTokenSetupOptions } from "./cloud-login.js";
|
|
4
4
|
export { CloudAbortError, CloudApiError, CloudTimeoutError, IntegrationConnectionTimeoutError, MalformedCloudResponseError, MissingConnectionIdError, RelayfileSetupError, UnknownProviderError } from "./setup-errors.js";
|
|
5
5
|
export { WORKSPACE_INTEGRATION_PROVIDERS, type AgentWorkspaceInvite, type AgentWorkspaceInviteOptions, type ConnectIntegrationOptions, type ConnectIntegrationResult, type CreateWorkspaceOptions, type JoinWorkspaceOptions, type RelayfileSetupOptions, type RelayfileSetupRetryOptions, type WaitForConnectionOptions, type WorkspaceInfo, type WorkspaceIntegrationProvider, type WorkspaceMountEnv, type WorkspaceMountEnvOptions, type WorkspacePermissions } from "./setup-types.js";
|
|
6
|
-
export { RelayFileSync, type RelayFileSyncOptions, type RelayFileSyncPong, type RelayFileSyncReconnectOptions, type RelayFileSyncSocket, type RelayFileSyncState } from "./sync.js";
|
|
6
|
+
export { RelayFileSync, type RelayFileSyncOptions, type RelayFileSyncPong, type RelayFileSyncReconnectOptions, type RelayFileSyncSocket, type RelayFileSyncState, type RelayFileSyncTokenProvider } from "./sync.js";
|
|
7
7
|
export { onWrite, pathMatches, type OnWriteClient, type OnWriteHandler, type OnWriteHandlerError, type OnWriteOptions } from "./onWrite.js";
|
|
8
8
|
export { InvalidStateError, PayloadTooLargeError, QueueFullError, RelayFileApiError, RevisionConflictError } from "./errors.js";
|
|
9
9
|
export { IntegrationProvider, computeCanonicalPath } from "./provider.js";
|
package/dist/onWrite.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { WriteEvent, WriteEventOperation } from "@relayfile/core";
|
|
2
2
|
import { RelayFileClient } from "./client.js";
|
|
3
|
-
import { type RelayFileSyncSocket } from "./sync.js";
|
|
3
|
+
import { type RelayFileSyncSocket, type RelayFileSyncTokenProvider } from "./sync.js";
|
|
4
4
|
export type OnWriteHandler = (event: WriteEvent) => void | Promise<void>;
|
|
5
5
|
export interface OnWriteHandlerError {
|
|
6
6
|
pattern: string;
|
|
@@ -17,8 +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;
|
|
32
|
+
pingIntervalMs?: number;
|
|
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;
|
|
22
44
|
}
|
|
23
45
|
export declare function pathMatches(pattern: string, path: string): boolean;
|
|
24
46
|
export declare function onWrite(pattern: string, handler: OnWriteHandler, options?: OnWriteOptions): () => void;
|
package/dist/onWrite.js
CHANGED
|
@@ -6,6 +6,23 @@ const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000;
|
|
|
6
6
|
const dispatchers = new WeakMap();
|
|
7
7
|
let nextRegistrationId = 1;
|
|
8
8
|
let defaultClient;
|
|
9
|
+
const debugEnabled = (() => {
|
|
10
|
+
try {
|
|
11
|
+
const value = globalThis.process?.env?.RELAYFILE_SDK_DEBUG;
|
|
12
|
+
return value === "1" || value === "true";
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
})();
|
|
18
|
+
function debugLog(...args) {
|
|
19
|
+
if (!debugEnabled) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (typeof console !== "undefined" && typeof console.error === "function") {
|
|
23
|
+
console.error("[relayfile-sdk:onWrite]", ...args);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
9
26
|
export function pathMatches(pattern, path) {
|
|
10
27
|
const patternSegments = normalizePattern(pattern);
|
|
11
28
|
const pathSegments = normalizePath(path);
|
|
@@ -50,8 +67,12 @@ export function onWrite(pattern, handler, options = {}) {
|
|
|
50
67
|
signal: options.signal,
|
|
51
68
|
baseUrl: options.baseUrl,
|
|
52
69
|
token: options.token,
|
|
53
|
-
webSocketFactory: options.webSocketFactory
|
|
70
|
+
webSocketFactory: options.webSocketFactory,
|
|
71
|
+
pingIntervalMs: options.pingIntervalMs,
|
|
72
|
+
pongTimeoutMs: options.pongTimeoutMs,
|
|
73
|
+
onPollingFallback: options.onPollingFallback
|
|
54
74
|
});
|
|
75
|
+
debugLog("registered", { id: registration.id, pattern: normalizedPattern, workspaceId });
|
|
55
76
|
return () => {
|
|
56
77
|
dispatcher?.unregister(registration.id);
|
|
57
78
|
};
|
|
@@ -95,16 +116,29 @@ class OnWriteDispatcher {
|
|
|
95
116
|
if (this.sync) {
|
|
96
117
|
return;
|
|
97
118
|
}
|
|
119
|
+
// Token resolution order:
|
|
120
|
+
// 1. options.token (literal or factory) — back-compat for callers that
|
|
121
|
+
// mint their own auth.
|
|
122
|
+
// 2. RELAYFILE_TOKEN env var.
|
|
123
|
+
// 3. fall through to undefined → RelayFileSync auto-derives from
|
|
124
|
+
// `client.getToken()` (the recommended path; same JWT as REST).
|
|
125
|
+
// (Bug 1 fix: previously, omitting `token` here passed `undefined` and
|
|
126
|
+
// the WS handshake silently failed with no auth, dropping us into
|
|
127
|
+
// backwards-walking polling forever.)
|
|
128
|
+
const token = options.token ?? readEnv("RELAYFILE_TOKEN") ?? undefined;
|
|
98
129
|
this.sync = RelayFileSync.connect({
|
|
99
130
|
client: this.client,
|
|
100
131
|
workspaceId: options.workspaceId,
|
|
101
132
|
baseUrl: options.baseUrl ?? readEnv("RELAYFILE_BASE_URL") ?? DEFAULT_RELAYFILE_BASE_URL,
|
|
102
|
-
token
|
|
133
|
+
token,
|
|
103
134
|
reconnect: {
|
|
104
135
|
minDelayMs: DEFAULT_RECONNECT_MIN_DELAY_MS,
|
|
105
136
|
maxDelayMs: DEFAULT_RECONNECT_MAX_DELAY_MS
|
|
106
137
|
},
|
|
107
138
|
webSocketFactory: options.webSocketFactory,
|
|
139
|
+
pingIntervalMs: options.pingIntervalMs,
|
|
140
|
+
pongTimeoutMs: options.pongTimeoutMs,
|
|
141
|
+
onPollingFallback: options.onPollingFallback,
|
|
108
142
|
onEvent: (event) => {
|
|
109
143
|
void this.dispatch(event);
|
|
110
144
|
}
|
|
@@ -119,10 +153,26 @@ class OnWriteDispatcher {
|
|
|
119
153
|
if (!registration.operations.has(writeEvent.operation) || !pathMatches(registration.pattern, writeEvent.path)) {
|
|
120
154
|
continue;
|
|
121
155
|
}
|
|
156
|
+
debugLog("dispatch", {
|
|
157
|
+
registrationId: registration.id,
|
|
158
|
+
pattern: registration.pattern,
|
|
159
|
+
path: writeEvent.path,
|
|
160
|
+
operation: writeEvent.operation
|
|
161
|
+
});
|
|
162
|
+
// patternChains serializes handlers per pattern. runHandler is the only
|
|
163
|
+
// thing chained here and it already swallows handler errors, so the
|
|
164
|
+
// chain itself can never reject — but we still defensively `.catch`
|
|
165
|
+
// before chaining so a future refactor of runHandler cannot silently
|
|
166
|
+
// break the chain (which is the failure mode hypothesis 3 in the bug
|
|
167
|
+
// report).
|
|
122
168
|
const previous = this.patternChains.get(registration.pattern) ?? Promise.resolve();
|
|
123
169
|
const next = previous
|
|
124
170
|
.catch(() => undefined)
|
|
125
|
-
.then(() => this.runHandler(registration, writeEvent))
|
|
171
|
+
.then(() => this.runHandler(registration, writeEvent))
|
|
172
|
+
.catch((error) => {
|
|
173
|
+
// Belt-and-braces: should be unreachable because runHandler catches.
|
|
174
|
+
debugLog("patternChain unexpectedly rejected", { pattern: registration.pattern, error });
|
|
175
|
+
});
|
|
126
176
|
this.patternChains.set(registration.pattern, next);
|
|
127
177
|
void next.finally(() => {
|
|
128
178
|
if (this.patternChains.get(registration.pattern) === next) {
|
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,15 +22,36 @@ 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;
|
|
21
38
|
pingIntervalMs?: number;
|
|
39
|
+
pongTimeoutMs?: number;
|
|
22
40
|
reconnect?: boolean | RelayFileSyncReconnectOptions;
|
|
23
41
|
signal?: AbortSignal;
|
|
24
42
|
webSocketFactory?: (url: string) => RelayFileSyncSocket;
|
|
25
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;
|
|
26
55
|
}
|
|
27
56
|
export interface RelayFileSyncSocket {
|
|
28
57
|
addEventListener(type: "open", handler: (event: Event) => void): void;
|
|
@@ -45,22 +74,29 @@ export declare class RelayFileSync {
|
|
|
45
74
|
private readonly client;
|
|
46
75
|
private readonly workspaceId;
|
|
47
76
|
private readonly baseUrl?;
|
|
48
|
-
private readonly
|
|
77
|
+
private readonly tokenProvider?;
|
|
49
78
|
private readonly pollIntervalMs;
|
|
50
79
|
private readonly pingIntervalMs;
|
|
80
|
+
private readonly pongTimeoutMs;
|
|
51
81
|
private readonly reconnect;
|
|
52
82
|
private readonly preferPolling;
|
|
53
83
|
private readonly signal?;
|
|
54
84
|
private readonly webSocketFactory;
|
|
85
|
+
private readonly onPollingFallback?;
|
|
55
86
|
private readonly handlers;
|
|
56
87
|
private state;
|
|
57
88
|
private cursor?;
|
|
89
|
+
private readonly polledEventIds;
|
|
90
|
+
private readonly polledEventOrder;
|
|
91
|
+
private firstPollComplete;
|
|
58
92
|
private socket?;
|
|
59
93
|
private started;
|
|
60
94
|
private stopped;
|
|
61
95
|
private pollingPromise?;
|
|
62
96
|
private reconnectTimer?;
|
|
63
97
|
private pingTimer?;
|
|
98
|
+
private lastFrameAt;
|
|
99
|
+
private lastPingSentAt;
|
|
64
100
|
private reconnectAttempts;
|
|
65
101
|
private readonly abortHandler?;
|
|
66
102
|
constructor(options: RelayFileSyncOptions);
|
|
@@ -70,11 +106,15 @@ export declare class RelayFileSync {
|
|
|
70
106
|
stop(): Promise<void>;
|
|
71
107
|
on<TEventName extends RelayFileSyncEventName>(event: TEventName, handler: RelayFileSyncHandlerMap[TEventName]): () => void;
|
|
72
108
|
private shouldUsePolling;
|
|
109
|
+
private resolveWsTokenMaybeSync;
|
|
73
110
|
private openWebSocket;
|
|
111
|
+
private openWebSocketWithToken;
|
|
74
112
|
private startPolling;
|
|
75
113
|
private pollLoop;
|
|
114
|
+
private rememberPolledEvent;
|
|
76
115
|
private handleSocketMessage;
|
|
77
116
|
private startPingLoop;
|
|
117
|
+
private forceReconnect;
|
|
78
118
|
private scheduleReconnect;
|
|
79
119
|
private computeReconnectDelayMs;
|
|
80
120
|
private sleep;
|
package/dist/sync.js
CHANGED
|
@@ -2,6 +2,40 @@ const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
|
2
2
|
const DEFAULT_PING_INTERVAL_MS = 30000;
|
|
3
3
|
const DEFAULT_RECONNECT_MIN_DELAY_MS = 250;
|
|
4
4
|
const DEFAULT_RECONNECT_MAX_DELAY_MS = 5000;
|
|
5
|
+
// Cap on the dedupe cache used in polling mode. Sized large enough that no
|
|
6
|
+
// realistic workspace burst can churn through it within one poll interval
|
|
7
|
+
// (the events API is itself capped at ~1000 per page), small enough to keep
|
|
8
|
+
// memory bounded across long-lived processes.
|
|
9
|
+
const POLLING_DEDUPE_CACHE_LIMIT = 2048;
|
|
10
|
+
function warnPollingFallback(reason, cause) {
|
|
11
|
+
// Always-on warning (NOT gated by RELAYFILE_SDK_DEBUG) because silent
|
|
12
|
+
// degradation to polling has historically masked real auth/connectivity
|
|
13
|
+
// bugs for hours at a time. Customers running the SDK in a Node service
|
|
14
|
+
// see the warning in their normal logs without any opt-in.
|
|
15
|
+
if (typeof console === "undefined" || typeof console.warn !== "function") {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const detail = cause instanceof Error ? cause.message : cause !== undefined ? String(cause) : "";
|
|
19
|
+
const suffix = detail ? ` (${reason}: ${detail})` : ` (${reason})`;
|
|
20
|
+
console.warn(`[relayfile-sdk] WebSocket connect failed; falling back to HTTP polling. Live events will be delayed.${suffix}`);
|
|
21
|
+
}
|
|
22
|
+
const debugEnabled = (() => {
|
|
23
|
+
try {
|
|
24
|
+
const value = globalThis.process?.env?.RELAYFILE_SDK_DEBUG;
|
|
25
|
+
return value === "1" || value === "true";
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
function debugLog(...args) {
|
|
32
|
+
if (!debugEnabled) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (typeof console !== "undefined" && typeof console.error === "function") {
|
|
36
|
+
console.error("[relayfile-sdk:sync]", ...args);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
5
39
|
function normalizeReconnectOptions(reconnect) {
|
|
6
40
|
if (reconnect === false) {
|
|
7
41
|
return {
|
|
@@ -58,13 +92,18 @@ export class RelayFileSync {
|
|
|
58
92
|
client;
|
|
59
93
|
workspaceId;
|
|
60
94
|
baseUrl;
|
|
61
|
-
|
|
95
|
+
// Resolved on every connect attempt (string form is wrapped into a constant
|
|
96
|
+
// factory at construction time). `undefined` means "fall back to
|
|
97
|
+
// client.getToken()" — same JWT the REST methods use.
|
|
98
|
+
tokenProvider;
|
|
62
99
|
pollIntervalMs;
|
|
63
100
|
pingIntervalMs;
|
|
101
|
+
pongTimeoutMs;
|
|
64
102
|
reconnect;
|
|
65
103
|
preferPolling;
|
|
66
104
|
signal;
|
|
67
105
|
webSocketFactory;
|
|
106
|
+
onPollingFallback;
|
|
68
107
|
handlers = {
|
|
69
108
|
event: new Set(),
|
|
70
109
|
error: new Set(),
|
|
@@ -75,22 +114,49 @@ export class RelayFileSync {
|
|
|
75
114
|
};
|
|
76
115
|
state = "idle";
|
|
77
116
|
cursor;
|
|
117
|
+
polledEventIds = new Set();
|
|
118
|
+
polledEventOrder = [];
|
|
119
|
+
firstPollComplete = false;
|
|
78
120
|
socket;
|
|
79
121
|
started = false;
|
|
80
122
|
stopped = false;
|
|
81
123
|
pollingPromise;
|
|
82
124
|
reconnectTimer;
|
|
83
125
|
pingTimer;
|
|
126
|
+
// Tracks the last time *any* WebSocket frame was received (event, pong, or
|
|
127
|
+
// otherwise). The watchdog uses this to detect silent socket death.
|
|
128
|
+
lastFrameAt = 0;
|
|
129
|
+
// Tracks when the most recent ping was sent. We only enforce the pong
|
|
130
|
+
// timeout once a ping has actually gone out; otherwise a quiet workspace
|
|
131
|
+
// (no broadcasts and no pings yet) would falsely trip the watchdog.
|
|
132
|
+
lastPingSentAt = 0;
|
|
84
133
|
reconnectAttempts = 0;
|
|
85
134
|
abortHandler;
|
|
86
135
|
constructor(options) {
|
|
87
136
|
this.client = options.client;
|
|
88
137
|
this.workspaceId = options.workspaceId;
|
|
89
138
|
this.baseUrl = options.baseUrl?.replace(/\/+$/, "");
|
|
90
|
-
|
|
139
|
+
// Normalize token into a factory. `undefined` is preserved so the open
|
|
140
|
+
// path knows to fall back to `client.getToken()` (the recommended path —
|
|
141
|
+
// the WebSocket then auto-uses the same JWT the REST API is using).
|
|
142
|
+
if (options.token === undefined) {
|
|
143
|
+
this.tokenProvider = undefined;
|
|
144
|
+
}
|
|
145
|
+
else if (typeof options.token === "function") {
|
|
146
|
+
this.tokenProvider = options.token;
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
const literal = options.token;
|
|
150
|
+
this.tokenProvider = () => literal;
|
|
151
|
+
}
|
|
91
152
|
this.cursor = options.cursor;
|
|
153
|
+
this.onPollingFallback = options.onPollingFallback;
|
|
92
154
|
this.pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS));
|
|
93
155
|
this.pingIntervalMs = Math.max(1, Math.floor(options.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS));
|
|
156
|
+
// Default the pong timeout to 2x ping interval so a single missed pong
|
|
157
|
+
// does not cause a flap, but two consecutive misses force a reconnect.
|
|
158
|
+
const defaultPongTimeoutMs = this.pingIntervalMs * 2;
|
|
159
|
+
this.pongTimeoutMs = Math.max(1, Math.floor(options.pongTimeoutMs ?? defaultPongTimeoutMs));
|
|
94
160
|
this.reconnect = normalizeReconnectOptions(options.reconnect);
|
|
95
161
|
this.preferPolling = options.preferPolling ?? false;
|
|
96
162
|
this.signal = options.signal;
|
|
@@ -132,7 +198,9 @@ export class RelayFileSync {
|
|
|
132
198
|
}
|
|
133
199
|
this.started = true;
|
|
134
200
|
if (this.shouldUsePolling()) {
|
|
135
|
-
|
|
201
|
+
// Caller explicitly opted into polling, or there's no baseUrl to
|
|
202
|
+
// upgrade to wss. NOT a fallback — no warn here.
|
|
203
|
+
this.startPolling("explicit");
|
|
136
204
|
return;
|
|
137
205
|
}
|
|
138
206
|
this.openWebSocket(false);
|
|
@@ -164,9 +232,63 @@ export class RelayFileSync {
|
|
|
164
232
|
};
|
|
165
233
|
}
|
|
166
234
|
shouldUsePolling() {
|
|
167
|
-
|
|
235
|
+
// Token availability is no longer required up-front — the WS opener
|
|
236
|
+
// resolves it on demand from either `options.token` (a literal/factory)
|
|
237
|
+
// or `client.getToken()`. We only force polling when the caller asked
|
|
238
|
+
// for it or there is no baseUrl to derive a wss URL from.
|
|
239
|
+
return this.preferPolling || !this.baseUrl;
|
|
240
|
+
}
|
|
241
|
+
// Resolve a token for the WS upgrade. Returns either a string directly
|
|
242
|
+
// (sync fast-path; preserves the synchronous "start() → factory called"
|
|
243
|
+
// contract that pre-existed Bug 1's fix) or a Promise (async slow-path;
|
|
244
|
+
// factory is invoked on the next microtask). `undefined` means "no token
|
|
245
|
+
// available — the server may still accept the upgrade if it's configured
|
|
246
|
+
// for unauthenticated mode in tests/local-dev".
|
|
247
|
+
resolveWsTokenMaybeSync() {
|
|
248
|
+
if (this.tokenProvider) {
|
|
249
|
+
// Most production providers return synchronously (JWT pulled from a
|
|
250
|
+
// mutable cell that a refresh task updates in the background), so the
|
|
251
|
+
// sync path is the common case.
|
|
252
|
+
return this.tokenProvider();
|
|
253
|
+
}
|
|
254
|
+
// Auto-derive from the client's tokenProvider — the same JWT the REST
|
|
255
|
+
// surface is using. Bug 1 fix: callers no longer have to thread
|
|
256
|
+
// `token: await client.tokenProvider()` through every onWrite() call.
|
|
257
|
+
// client.getToken is always async (returns a Promise), so we land on
|
|
258
|
+
// the slow path here. That's fine: the WS open is async anyway.
|
|
259
|
+
return this.client.getToken();
|
|
168
260
|
}
|
|
169
261
|
openWebSocket(isReconnect) {
|
|
262
|
+
if (this.stopped) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
this.setState(isReconnect ? "reconnecting" : "connecting");
|
|
266
|
+
// Resolve a fresh token on every (re)connect attempt. This is what
|
|
267
|
+
// makes mid-session token rotation transparent: the watchdog/close
|
|
268
|
+
// handler reconnects, we re-call the factory, and the new socket comes
|
|
269
|
+
// up with the new JWT. (Bug 4: pre-fix, the token was captured once at
|
|
270
|
+
// construction and a 4001/auth close on rotation triggered an infinite
|
|
271
|
+
// reconnect loop with the stale token.)
|
|
272
|
+
let resolved;
|
|
273
|
+
try {
|
|
274
|
+
resolved = this.resolveWsTokenMaybeSync();
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
this.emit("error", error instanceof Error ? error : new Error("Failed to resolve WebSocket auth token."));
|
|
278
|
+
this.startPolling("token-resolution-failed", error);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (resolved && typeof resolved.then === "function") {
|
|
282
|
+
// Async path. The factory call gets deferred to the next microtask.
|
|
283
|
+
resolved.then((token) => this.openWebSocketWithToken(token), (error) => {
|
|
284
|
+
this.emit("error", error instanceof Error ? error : new Error("Failed to resolve WebSocket auth token."));
|
|
285
|
+
this.startPolling("token-resolution-failed", error);
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
this.openWebSocketWithToken(resolved);
|
|
290
|
+
}
|
|
291
|
+
openWebSocketWithToken(token) {
|
|
170
292
|
if (this.stopped) {
|
|
171
293
|
return;
|
|
172
294
|
}
|
|
@@ -174,17 +296,16 @@ export class RelayFileSync {
|
|
|
174
296
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
175
297
|
// Pass token in query string — the server authenticates during the HTTP
|
|
176
298
|
// upgrade handshake via r.URL.Query().Get("token").
|
|
177
|
-
if (
|
|
178
|
-
url.searchParams.set("token",
|
|
299
|
+
if (token) {
|
|
300
|
+
url.searchParams.set("token", token);
|
|
179
301
|
}
|
|
180
|
-
this.setState(isReconnect ? "reconnecting" : "connecting");
|
|
181
302
|
let socket;
|
|
182
303
|
try {
|
|
183
304
|
socket = this.webSocketFactory(url.toString());
|
|
184
305
|
}
|
|
185
306
|
catch (error) {
|
|
186
307
|
this.emit("error", error instanceof Error ? error : new Error("Failed to create WebSocket connection."));
|
|
187
|
-
this.startPolling();
|
|
308
|
+
this.startPolling("ws-factory-threw", error);
|
|
188
309
|
return;
|
|
189
310
|
}
|
|
190
311
|
this.socket = socket;
|
|
@@ -193,43 +314,80 @@ export class RelayFileSync {
|
|
|
193
314
|
return;
|
|
194
315
|
}
|
|
195
316
|
this.reconnectAttempts = 0;
|
|
317
|
+
// Reset frame/ping bookkeeping for the freshly opened socket so the
|
|
318
|
+
// watchdog has a clean baseline. lastPingSentAt=0 disables the pong
|
|
319
|
+
// timeout until we actually send our first ping.
|
|
320
|
+
this.lastFrameAt = Date.now();
|
|
321
|
+
this.lastPingSentAt = 0;
|
|
196
322
|
this.setState("open");
|
|
197
323
|
this.startPingLoop(socket);
|
|
324
|
+
debugLog("ws open", { workspaceId: this.workspaceId });
|
|
198
325
|
this.emit("open", event);
|
|
199
326
|
});
|
|
200
327
|
socket.addEventListener("message", (event) => {
|
|
201
328
|
if (this.socket !== socket || this.stopped) {
|
|
202
329
|
return;
|
|
203
330
|
}
|
|
331
|
+
// Stamp lastFrameAt for *any* inbound frame so the watchdog sees both
|
|
332
|
+
// application events and pongs as proof of life.
|
|
333
|
+
this.lastFrameAt = Date.now();
|
|
204
334
|
this.handleSocketMessage(event);
|
|
205
335
|
});
|
|
206
336
|
socket.addEventListener("error", (event) => {
|
|
207
337
|
if (this.socket !== socket || this.stopped) {
|
|
208
338
|
return;
|
|
209
339
|
}
|
|
340
|
+
debugLog("ws error", event);
|
|
210
341
|
this.emit("error", normalizeError(event));
|
|
211
342
|
});
|
|
212
343
|
socket.addEventListener("close", (event) => {
|
|
213
|
-
|
|
214
|
-
|
|
344
|
+
// forceReconnect (called from the watchdog or a failed ping send) nulls
|
|
345
|
+
// out this.socket and opens a replacement before the OS-layer close
|
|
346
|
+
// event for the old socket actually fires. If this handler treated a
|
|
347
|
+
// stale close as authoritative it would (a) clearPingTimer() and kill
|
|
348
|
+
// the new socket's heartbeat and (b) potentially scheduleReconnect()
|
|
349
|
+
// a second time (the timer guard would skip it most of the time, but
|
|
350
|
+
// not after the timer has already fired). Either way: don't touch
|
|
351
|
+
// shared state when the socket we're attached to is no longer current.
|
|
352
|
+
if (this.socket !== socket) {
|
|
353
|
+
debugLog("ws close (stale, ignored)", {
|
|
354
|
+
code: event?.code,
|
|
355
|
+
reason: event?.reason,
|
|
356
|
+
});
|
|
357
|
+
return;
|
|
215
358
|
}
|
|
359
|
+
this.socket = undefined;
|
|
216
360
|
this.clearPingTimer();
|
|
361
|
+
debugLog("ws close", { code: event?.code, reason: event?.reason, stopped: this.stopped });
|
|
217
362
|
this.emit("close", event);
|
|
218
363
|
if (this.stopped) {
|
|
219
364
|
this.setState("closed");
|
|
220
365
|
return;
|
|
221
366
|
}
|
|
222
367
|
if (!this.reconnect.enabled) {
|
|
223
|
-
this.startPolling();
|
|
368
|
+
this.startPolling("ws-closed-no-reconnect", { code: event?.code, reason: event?.reason });
|
|
224
369
|
return;
|
|
225
370
|
}
|
|
226
371
|
this.scheduleReconnect();
|
|
227
372
|
});
|
|
228
373
|
}
|
|
229
|
-
startPolling() {
|
|
374
|
+
startPolling(reason = "fallback", cause) {
|
|
230
375
|
if (this.pollingPromise || this.stopped) {
|
|
231
376
|
return;
|
|
232
377
|
}
|
|
378
|
+
// Always-on warning + structured callback whenever we drop into polling
|
|
379
|
+
// *involuntarily*. `explicit` means the caller asked for it (preferPolling
|
|
380
|
+
// or no baseUrl) and we stay quiet — anything else is a real degradation
|
|
381
|
+
// signal that previously took hours to detect.
|
|
382
|
+
if (reason !== "explicit") {
|
|
383
|
+
warnPollingFallback(reason, cause);
|
|
384
|
+
try {
|
|
385
|
+
this.onPollingFallback?.({ reason, cause });
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
debugLog("onPollingFallback handler threw", error);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
233
391
|
this.setState("polling");
|
|
234
392
|
this.pollingPromise = this.pollLoop().finally(() => {
|
|
235
393
|
this.pollingPromise = undefined;
|
|
@@ -245,16 +403,58 @@ export class RelayFileSync {
|
|
|
245
403
|
throw createAbortError();
|
|
246
404
|
}
|
|
247
405
|
try {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
406
|
+
// The current server implementation paginates events from oldest to
|
|
407
|
+
// newest. Empty cursor starts at index 0, and nextCursor advances
|
|
408
|
+
// forward through history. To avoid replaying the whole event log on
|
|
409
|
+
// startup while still preserving live forward progress, the first poll
|
|
410
|
+
// drains to the tip and seeds `this.cursor` without emitting. Later
|
|
411
|
+
// polls resume from that live cursor and emit only newly appended
|
|
412
|
+
// events.
|
|
413
|
+
let cursor = this.cursor;
|
|
414
|
+
let latestCursor = cursor;
|
|
415
|
+
const pending = [];
|
|
416
|
+
for (;;) {
|
|
417
|
+
const response = await this.client.getEvents(this.workspaceId, {
|
|
418
|
+
cursor,
|
|
419
|
+
limit: 1000,
|
|
420
|
+
signal: this.signal
|
|
421
|
+
});
|
|
422
|
+
const events = response.events ?? [];
|
|
423
|
+
if (!this.firstPollComplete) {
|
|
424
|
+
for (const event of events) {
|
|
425
|
+
this.rememberPolledEvent(event.eventId);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
for (const event of events) {
|
|
430
|
+
if (!event.eventId || this.polledEventIds.has(event.eventId)) {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
this.rememberPolledEvent(event.eventId);
|
|
434
|
+
pending.push(event);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const nextCursor = response.nextCursor || null;
|
|
438
|
+
if (events.length > 0) {
|
|
439
|
+
latestCursor = events[events.length - 1]?.eventId ?? latestCursor;
|
|
440
|
+
}
|
|
441
|
+
if (nextCursor) {
|
|
442
|
+
latestCursor = nextCursor;
|
|
443
|
+
}
|
|
444
|
+
if (!nextCursor || nextCursor === cursor) {
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
cursor = nextCursor;
|
|
448
|
+
}
|
|
252
449
|
retryAttempt = 0;
|
|
253
|
-
|
|
254
|
-
|
|
450
|
+
this.cursor = latestCursor;
|
|
451
|
+
if (!this.firstPollComplete) {
|
|
452
|
+
this.firstPollComplete = true;
|
|
255
453
|
}
|
|
256
|
-
|
|
257
|
-
|
|
454
|
+
else {
|
|
455
|
+
for (const event of pending) {
|
|
456
|
+
this.emit("event", event);
|
|
457
|
+
}
|
|
258
458
|
}
|
|
259
459
|
await this.sleep(this.pollIntervalMs);
|
|
260
460
|
}
|
|
@@ -269,6 +469,19 @@ export class RelayFileSync {
|
|
|
269
469
|
}
|
|
270
470
|
}
|
|
271
471
|
}
|
|
472
|
+
rememberPolledEvent(eventId) {
|
|
473
|
+
if (!eventId || this.polledEventIds.has(eventId)) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
this.polledEventIds.add(eventId);
|
|
477
|
+
this.polledEventOrder.push(eventId);
|
|
478
|
+
while (this.polledEventOrder.length > POLLING_DEDUPE_CACHE_LIMIT) {
|
|
479
|
+
const evicted = this.polledEventOrder.shift();
|
|
480
|
+
if (evicted) {
|
|
481
|
+
this.polledEventIds.delete(evicted);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
272
485
|
handleSocketMessage(event) {
|
|
273
486
|
if (typeof event.data !== "string") {
|
|
274
487
|
return;
|
|
@@ -302,29 +515,100 @@ export class RelayFileSync {
|
|
|
302
515
|
return;
|
|
303
516
|
}
|
|
304
517
|
if (parsed.type === "pong") {
|
|
518
|
+
debugLog("pong", { timestamp: parsed.timestamp ?? parsed.ts });
|
|
305
519
|
this.emit("pong", {
|
|
306
520
|
type: "pong",
|
|
307
521
|
timestamp: parsed.timestamp ?? parsed.ts
|
|
308
522
|
});
|
|
309
523
|
return;
|
|
310
524
|
}
|
|
311
|
-
|
|
525
|
+
const normalized = normalizeFilesystemEvent(parsed);
|
|
526
|
+
debugLog("event", { type: normalized.type, path: normalized.path, revision: normalized.revision });
|
|
527
|
+
this.emit("event", normalized);
|
|
312
528
|
}
|
|
313
529
|
startPingLoop(socket) {
|
|
314
530
|
this.clearPingTimer();
|
|
531
|
+
// The "ping loop" doubles as the heartbeat watchdog. The contract for
|
|
532
|
+
// pongTimeoutMs is "how long to wait, after sending a ping, before
|
|
533
|
+
// assuming the socket is dead." So the timeout window must be measured
|
|
534
|
+
// from `lastPingSentAt` — the moment we sent the unanswered ping — not
|
|
535
|
+
// from the last inbound frame. (CodeRabbit P1 on PR #93: with
|
|
536
|
+
// pingIntervalMs=30_000 + pongTimeoutMs=45_000, measuring from
|
|
537
|
+
// lastFrameAt would reconnect at t=60s — only 30s after the first
|
|
538
|
+
// ping went out, which violates the documented "wait 45s after a
|
|
539
|
+
// ping" semantics.)
|
|
540
|
+
//
|
|
541
|
+
// An unanswered ping = `lastPingSentAt > lastFrameAt`. Any inbound
|
|
542
|
+
// frame (event OR pong) advances lastFrameAt past lastPingSentAt and
|
|
543
|
+
// proves the socket is alive. While a ping is unanswered we DO NOT
|
|
544
|
+
// pile up another — that would just race the timeout window and make
|
|
545
|
+
// tuning meaningless.
|
|
546
|
+
//
|
|
547
|
+
// Load-bearing for the silent socket-death failure mode where the
|
|
548
|
+
// server keeps broadcasting frames the JS layer never receives (e.g.
|
|
549
|
+
// NAT/LB idle drop, half-open TCP) and neither `error` nor `close`
|
|
550
|
+
// ever fires.
|
|
315
551
|
this.pingTimer = setInterval(() => {
|
|
316
552
|
if (this.socket !== socket || this.stopped) {
|
|
317
553
|
this.clearPingTimer();
|
|
318
554
|
return;
|
|
319
555
|
}
|
|
556
|
+
const now = Date.now();
|
|
557
|
+
const hasOutstandingPing = this.lastPingSentAt > this.lastFrameAt;
|
|
558
|
+
if (hasOutstandingPing) {
|
|
559
|
+
const sincePing = now - this.lastPingSentAt;
|
|
560
|
+
if (sincePing > this.pongTimeoutMs) {
|
|
561
|
+
debugLog("watchdog tripped — forcing reconnect", {
|
|
562
|
+
workspaceId: this.workspaceId,
|
|
563
|
+
sincePingMs: sincePing,
|
|
564
|
+
pongTimeoutMs: this.pongTimeoutMs
|
|
565
|
+
});
|
|
566
|
+
this.forceReconnect(socket, "heartbeat-timeout");
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
// Within the timeout — still waiting for the pong/frame. Don't
|
|
570
|
+
// pile up a second ping while one is outstanding.
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
320
573
|
try {
|
|
321
574
|
socket.send(JSON.stringify({ type: "ping" }));
|
|
575
|
+
this.lastPingSentAt = now;
|
|
322
576
|
}
|
|
323
577
|
catch (error) {
|
|
578
|
+
debugLog("ping send failed", error);
|
|
324
579
|
this.emit("error", error instanceof Error ? error : new Error("Failed to send WebSocket ping."));
|
|
580
|
+
// A failed send strongly implies the socket is dead. Force a
|
|
581
|
+
// reconnect rather than waiting for the next tick to notice.
|
|
582
|
+
this.forceReconnect(socket, "ping-send-failed");
|
|
325
583
|
}
|
|
326
584
|
}, this.pingIntervalMs);
|
|
327
585
|
}
|
|
586
|
+
// Tear down a socket that the watchdog or a failed send has decided is
|
|
587
|
+
// dead. We close it (so any later async events from the OS layer become
|
|
588
|
+
// no-ops via the `this.socket !== socket` guards) and trigger the standard
|
|
589
|
+
// reconnect path. Catches errors from `close()` because some socket
|
|
590
|
+
// implementations throw when closing an already-broken connection.
|
|
591
|
+
forceReconnect(socket, reason) {
|
|
592
|
+
this.clearPingTimer();
|
|
593
|
+
if (this.socket === socket) {
|
|
594
|
+
this.socket = undefined;
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
socket.close(4000, reason);
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
debugLog("socket.close threw during forceReconnect", error);
|
|
601
|
+
}
|
|
602
|
+
if (this.stopped) {
|
|
603
|
+
this.setState("closed");
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (!this.reconnect.enabled) {
|
|
607
|
+
this.startPolling("forced-reconnect-no-retry", reason);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
this.scheduleReconnect();
|
|
611
|
+
}
|
|
328
612
|
scheduleReconnect() {
|
|
329
613
|
if (this.stopped || this.reconnectTimer) {
|
|
330
614
|
return;
|
|
@@ -332,6 +616,7 @@ export class RelayFileSync {
|
|
|
332
616
|
this.reconnectAttempts += 1;
|
|
333
617
|
this.setState("reconnecting");
|
|
334
618
|
const delayMs = this.computeReconnectDelayMs(this.reconnectAttempts);
|
|
619
|
+
debugLog("scheduling reconnect", { attempt: this.reconnectAttempts, delayMs });
|
|
335
620
|
this.reconnectTimer = setTimeout(() => {
|
|
336
621
|
this.reconnectTimer = undefined;
|
|
337
622
|
if (this.stopped) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@relayfile/sdk",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.12",
|
|
4
4
|
"description": "TypeScript SDK for relayfile — real-time filesystem for humans and agents",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"prepublishOnly": "npm run build"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@relayfile/core": "0.6.
|
|
23
|
+
"@relayfile/core": "0.6.12"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"typescript": "^5.7.3",
|