@relayfile/sdk 0.6.11 → 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 +22 -2
- package/dist/onWrite.js +14 -2
- package/dist/sync.d.ts +37 -2
- package/dist/sync.js +179 -19
- 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,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,21 @@ 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.
|
|
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;
|
|
118
129
|
this.sync = RelayFileSync.connect({
|
|
119
130
|
client: this.client,
|
|
120
131
|
workspaceId: options.workspaceId,
|
|
121
132
|
baseUrl: options.baseUrl ?? readEnv("RELAYFILE_BASE_URL") ?? DEFAULT_RELAYFILE_BASE_URL,
|
|
122
|
-
token
|
|
133
|
+
token,
|
|
123
134
|
reconnect: {
|
|
124
135
|
minDelayMs: DEFAULT_RECONNECT_MIN_DELAY_MS,
|
|
125
136
|
maxDelayMs: DEFAULT_RECONNECT_MAX_DELAY_MS
|
|
@@ -127,6 +138,7 @@ class OnWriteDispatcher {
|
|
|
127
138
|
webSocketFactory: options.webSocketFactory,
|
|
128
139
|
pingIntervalMs: options.pingIntervalMs,
|
|
129
140
|
pongTimeoutMs: options.pongTimeoutMs,
|
|
141
|
+
onPollingFallback: options.onPollingFallback,
|
|
130
142
|
onEvent: (event) => {
|
|
131
143
|
void this.dispatch(event);
|
|
132
144
|
}
|
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;
|
|
@@ -46,7 +74,7 @@ export declare class RelayFileSync {
|
|
|
46
74
|
private readonly client;
|
|
47
75
|
private readonly workspaceId;
|
|
48
76
|
private readonly baseUrl?;
|
|
49
|
-
private readonly
|
|
77
|
+
private readonly tokenProvider?;
|
|
50
78
|
private readonly pollIntervalMs;
|
|
51
79
|
private readonly pingIntervalMs;
|
|
52
80
|
private readonly pongTimeoutMs;
|
|
@@ -54,9 +82,13 @@ export declare class RelayFileSync {
|
|
|
54
82
|
private readonly preferPolling;
|
|
55
83
|
private readonly signal?;
|
|
56
84
|
private readonly webSocketFactory;
|
|
85
|
+
private readonly onPollingFallback?;
|
|
57
86
|
private readonly handlers;
|
|
58
87
|
private state;
|
|
59
88
|
private cursor?;
|
|
89
|
+
private readonly polledEventIds;
|
|
90
|
+
private readonly polledEventOrder;
|
|
91
|
+
private firstPollComplete;
|
|
60
92
|
private socket?;
|
|
61
93
|
private started;
|
|
62
94
|
private stopped;
|
|
@@ -74,9 +106,12 @@ export declare class RelayFileSync {
|
|
|
74
106
|
stop(): Promise<void>;
|
|
75
107
|
on<TEventName extends RelayFileSyncEventName>(event: TEventName, handler: RelayFileSyncHandlerMap[TEventName]): () => void;
|
|
76
108
|
private shouldUsePolling;
|
|
109
|
+
private resolveWsTokenMaybeSync;
|
|
77
110
|
private openWebSocket;
|
|
111
|
+
private openWebSocketWithToken;
|
|
78
112
|
private startPolling;
|
|
79
113
|
private pollLoop;
|
|
114
|
+
private rememberPolledEvent;
|
|
80
115
|
private handleSocketMessage;
|
|
81
116
|
private startPingLoop;
|
|
82
117
|
private forceReconnect;
|
package/dist/sync.js
CHANGED
|
@@ -2,6 +2,23 @@ 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
|
+
}
|
|
5
22
|
const debugEnabled = (() => {
|
|
6
23
|
try {
|
|
7
24
|
const value = globalThis.process?.env?.RELAYFILE_SDK_DEBUG;
|
|
@@ -75,7 +92,10 @@ export class RelayFileSync {
|
|
|
75
92
|
client;
|
|
76
93
|
workspaceId;
|
|
77
94
|
baseUrl;
|
|
78
|
-
|
|
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;
|
|
79
99
|
pollIntervalMs;
|
|
80
100
|
pingIntervalMs;
|
|
81
101
|
pongTimeoutMs;
|
|
@@ -83,6 +103,7 @@ export class RelayFileSync {
|
|
|
83
103
|
preferPolling;
|
|
84
104
|
signal;
|
|
85
105
|
webSocketFactory;
|
|
106
|
+
onPollingFallback;
|
|
86
107
|
handlers = {
|
|
87
108
|
event: new Set(),
|
|
88
109
|
error: new Set(),
|
|
@@ -93,6 +114,9 @@ export class RelayFileSync {
|
|
|
93
114
|
};
|
|
94
115
|
state = "idle";
|
|
95
116
|
cursor;
|
|
117
|
+
polledEventIds = new Set();
|
|
118
|
+
polledEventOrder = [];
|
|
119
|
+
firstPollComplete = false;
|
|
96
120
|
socket;
|
|
97
121
|
started = false;
|
|
98
122
|
stopped = false;
|
|
@@ -112,8 +136,21 @@ export class RelayFileSync {
|
|
|
112
136
|
this.client = options.client;
|
|
113
137
|
this.workspaceId = options.workspaceId;
|
|
114
138
|
this.baseUrl = options.baseUrl?.replace(/\/+$/, "");
|
|
115
|
-
|
|
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
|
+
}
|
|
116
152
|
this.cursor = options.cursor;
|
|
153
|
+
this.onPollingFallback = options.onPollingFallback;
|
|
117
154
|
this.pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS));
|
|
118
155
|
this.pingIntervalMs = Math.max(1, Math.floor(options.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS));
|
|
119
156
|
// Default the pong timeout to 2x ping interval so a single missed pong
|
|
@@ -161,7 +198,9 @@ export class RelayFileSync {
|
|
|
161
198
|
}
|
|
162
199
|
this.started = true;
|
|
163
200
|
if (this.shouldUsePolling()) {
|
|
164
|
-
|
|
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");
|
|
165
204
|
return;
|
|
166
205
|
}
|
|
167
206
|
this.openWebSocket(false);
|
|
@@ -193,9 +232,63 @@ export class RelayFileSync {
|
|
|
193
232
|
};
|
|
194
233
|
}
|
|
195
234
|
shouldUsePolling() {
|
|
196
|
-
|
|
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();
|
|
197
260
|
}
|
|
198
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) {
|
|
199
292
|
if (this.stopped) {
|
|
200
293
|
return;
|
|
201
294
|
}
|
|
@@ -203,17 +296,16 @@ export class RelayFileSync {
|
|
|
203
296
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
204
297
|
// Pass token in query string — the server authenticates during the HTTP
|
|
205
298
|
// upgrade handshake via r.URL.Query().Get("token").
|
|
206
|
-
if (
|
|
207
|
-
url.searchParams.set("token",
|
|
299
|
+
if (token) {
|
|
300
|
+
url.searchParams.set("token", token);
|
|
208
301
|
}
|
|
209
|
-
this.setState(isReconnect ? "reconnecting" : "connecting");
|
|
210
302
|
let socket;
|
|
211
303
|
try {
|
|
212
304
|
socket = this.webSocketFactory(url.toString());
|
|
213
305
|
}
|
|
214
306
|
catch (error) {
|
|
215
307
|
this.emit("error", error instanceof Error ? error : new Error("Failed to create WebSocket connection."));
|
|
216
|
-
this.startPolling();
|
|
308
|
+
this.startPolling("ws-factory-threw", error);
|
|
217
309
|
return;
|
|
218
310
|
}
|
|
219
311
|
this.socket = socket;
|
|
@@ -273,16 +365,29 @@ export class RelayFileSync {
|
|
|
273
365
|
return;
|
|
274
366
|
}
|
|
275
367
|
if (!this.reconnect.enabled) {
|
|
276
|
-
this.startPolling();
|
|
368
|
+
this.startPolling("ws-closed-no-reconnect", { code: event?.code, reason: event?.reason });
|
|
277
369
|
return;
|
|
278
370
|
}
|
|
279
371
|
this.scheduleReconnect();
|
|
280
372
|
});
|
|
281
373
|
}
|
|
282
|
-
startPolling() {
|
|
374
|
+
startPolling(reason = "fallback", cause) {
|
|
283
375
|
if (this.pollingPromise || this.stopped) {
|
|
284
376
|
return;
|
|
285
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
|
+
}
|
|
286
391
|
this.setState("polling");
|
|
287
392
|
this.pollingPromise = this.pollLoop().finally(() => {
|
|
288
393
|
this.pollingPromise = undefined;
|
|
@@ -298,16 +403,58 @@ export class RelayFileSync {
|
|
|
298
403
|
throw createAbortError();
|
|
299
404
|
}
|
|
300
405
|
try {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
+
}
|
|
305
449
|
retryAttempt = 0;
|
|
306
|
-
|
|
307
|
-
|
|
450
|
+
this.cursor = latestCursor;
|
|
451
|
+
if (!this.firstPollComplete) {
|
|
452
|
+
this.firstPollComplete = true;
|
|
308
453
|
}
|
|
309
|
-
|
|
310
|
-
|
|
454
|
+
else {
|
|
455
|
+
for (const event of pending) {
|
|
456
|
+
this.emit("event", event);
|
|
457
|
+
}
|
|
311
458
|
}
|
|
312
459
|
await this.sleep(this.pollIntervalMs);
|
|
313
460
|
}
|
|
@@ -322,6 +469,19 @@ export class RelayFileSync {
|
|
|
322
469
|
}
|
|
323
470
|
}
|
|
324
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
|
+
}
|
|
325
485
|
handleSocketMessage(event) {
|
|
326
486
|
if (typeof event.data !== "string") {
|
|
327
487
|
return;
|
|
@@ -444,7 +604,7 @@ export class RelayFileSync {
|
|
|
444
604
|
return;
|
|
445
605
|
}
|
|
446
606
|
if (!this.reconnect.enabled) {
|
|
447
|
-
this.startPolling();
|
|
607
|
+
this.startPolling("forced-reconnect-no-retry", reason);
|
|
448
608
|
return;
|
|
449
609
|
}
|
|
450
610
|
this.scheduleReconnect();
|
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",
|