@relayfile/sdk 0.10.51 → 0.10.53
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 +7 -0
- package/dist/client.js +117 -6
- package/dist/types.d.ts +16 -0
- package/package.json +6 -6
package/dist/client.d.ts
CHANGED
|
@@ -178,6 +178,13 @@ export declare class RelayFileClient {
|
|
|
178
178
|
private shouldRetryStatus;
|
|
179
179
|
private shouldRetryError;
|
|
180
180
|
private computeRetryDelayMs;
|
|
181
|
+
/**
|
|
182
|
+
* Extract a server-advertised retry delay from a parsed 429 error body.
|
|
183
|
+
* Relayfile advertises backpressure delays as `details.retryAfterSeconds`
|
|
184
|
+
* (in seconds) on `workspace_busy` / `queue_full` responses. Returns
|
|
185
|
+
* milliseconds, or null when absent/malformed.
|
|
186
|
+
*/
|
|
187
|
+
private parseRetryAfterSecondsFromBody;
|
|
181
188
|
private parseRetryAfterMs;
|
|
182
189
|
private sleep;
|
|
183
190
|
private readPayload;
|
package/dist/client.js
CHANGED
|
@@ -8,6 +8,15 @@ const DEFAULT_RETRY_OPTIONS = {
|
|
|
8
8
|
maxDelayMs: 2000,
|
|
9
9
|
jitterRatio: 0.2
|
|
10
10
|
};
|
|
11
|
+
/**
|
|
12
|
+
* Hard ceiling for a *server-advertised* retry delay (a 429 body's
|
|
13
|
+
* `details.retryAfterSeconds`). `maxDelayMs` bounds our OWN exponential
|
|
14
|
+
* backoff; an explicit server instruction ("retry after N seconds") is honored
|
|
15
|
+
* above that cap — truncating it just retries into the same overloaded resource
|
|
16
|
+
* and burns the retry budget — but is still bounded here so a pathological or
|
|
17
|
+
* hostile value cannot stall the client indefinitely.
|
|
18
|
+
*/
|
|
19
|
+
const RETRY_AFTER_MAX_MS = 30_000;
|
|
11
20
|
const DEFAULT_CHANGE_COALESCE_MS = 200;
|
|
12
21
|
const DEFAULT_CHANGE_LOG_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
13
22
|
const DEFAULT_CHANGE_LOG_MAX_ENTRIES = 10_000;
|
|
@@ -255,7 +264,9 @@ class RelayFileWebSocketConnection {
|
|
|
255
264
|
}
|
|
256
265
|
});
|
|
257
266
|
socket.addEventListener("error", (event) => {
|
|
258
|
-
const errorEvent =
|
|
267
|
+
const errorEvent = typeof ErrorEvent !== "undefined" && event instanceof ErrorEvent && event.error instanceof Error
|
|
268
|
+
? event.error
|
|
269
|
+
: event;
|
|
259
270
|
for (const handler of this.handlers.error) {
|
|
260
271
|
handler(errorEvent);
|
|
261
272
|
}
|
|
@@ -386,7 +397,13 @@ class RelayFileChangeSubscription {
|
|
|
386
397
|
this.onChange = onChange;
|
|
387
398
|
this.options = options;
|
|
388
399
|
this.globPatterns = globs.map((pattern) => normalizeChangePattern(pattern));
|
|
389
|
-
|
|
400
|
+
// A wildcard-free pathScope entry (e.g. "/ramp/transactions") is a DIRECTORY
|
|
401
|
+
// SUBTREE scope: expand it into the exact path plus its "/**" subtree filter
|
|
402
|
+
// so it matches the directory and everything under it. A bare path is never
|
|
403
|
+
// exact-file; use a glob for that. See expandDirectoryScope for the rule.
|
|
404
|
+
this.pathScopes = options?.pathScope?.length
|
|
405
|
+
? options.pathScope.flatMap((pattern) => expandDirectoryScope(normalizeChangePattern(pattern)))
|
|
406
|
+
: null;
|
|
390
407
|
this.shouldCoalesce = (options?.coalesce ?? "fire-once") !== "none";
|
|
391
408
|
this.coalesceMs = Math.max(0, Math.floor(options?.coalesceMs ?? DEFAULT_CHANGE_COALESCE_MS));
|
|
392
409
|
}
|
|
@@ -736,6 +753,45 @@ function normalizeChangePattern(pattern) {
|
|
|
736
753
|
}
|
|
737
754
|
return segments;
|
|
738
755
|
}
|
|
756
|
+
/**
|
|
757
|
+
* A wildcard-free `pathScope` entry is DEFINED as a DIRECTORY SUBTREE scope: it
|
|
758
|
+
* matches the path itself AND everything under it. To scope to an exact file,
|
|
759
|
+
* pass that exact path as a glob in the first `subscribe(globs, ...)` argument
|
|
760
|
+
* (the `globs` list is matched exactly unless an entry has a `*`/`**` wildcard);
|
|
761
|
+
* a bare `pathScope` path is never exact-file. This is a deliberate API-design
|
|
762
|
+
* choice (Option A), not an
|
|
763
|
+
* accident of the server matcher: it makes the common "watch this directory"
|
|
764
|
+
* intent Just Work and avoids a silent zero-event foot-gun.
|
|
765
|
+
*
|
|
766
|
+
* Why it MUST be subtree, and why "exact-file" is not a safe alternative:
|
|
767
|
+
* Relayfile creates descendants under file-looking paths (e.g.
|
|
768
|
+
* `/linear/issues/ENG-1.json/replies/draft.json`), so a bare path can never be
|
|
769
|
+
* assumed to name a leaf. Treating a wildcard-free entry as directory-scope is
|
|
770
|
+
* the only consistent rule.
|
|
771
|
+
*
|
|
772
|
+
* The data plane (see `webSocketPathMatches` in internal/httpapi/websocket.go)
|
|
773
|
+
* matches a `path=` filter against an event path with these semantics:
|
|
774
|
+
* - exact match: `path` == the event path, OR
|
|
775
|
+
* - trailing `**`: `/ramp/transactions/**` matches any STRICT descendant, OR
|
|
776
|
+
* - per-segment `*` wildcards, requiring equal segment counts otherwise.
|
|
777
|
+
* A bare directory prefix like `/ramp/transactions` (no wildcard) would
|
|
778
|
+
* therefore match ONLY an event whose path is exactly `/ramp/transactions` —
|
|
779
|
+
* ZERO children — so a caller scoping to `["/ramp/transactions"]` opens a WS
|
|
780
|
+
* that silently receives no events (proven: `path=/ramp/transactions` -> 0
|
|
781
|
+
* events; `/**` -> 6). The same count-must-match rule in `matchChangeSegments`
|
|
782
|
+
* makes the client-side filter agree, so the miss would be doubly silent.
|
|
783
|
+
*
|
|
784
|
+
* Implementation: for a wildcard-free entry, emit BOTH the exact path AND the
|
|
785
|
+
* `.../**` subtree filter — the union matches the directory node itself plus its
|
|
786
|
+
* entire subtree. Entries that already contain a `*`/`**` wildcard are honored
|
|
787
|
+
* verbatim — an intentionally precise glob is never broadened.
|
|
788
|
+
*/
|
|
789
|
+
function expandDirectoryScope(segments) {
|
|
790
|
+
if (segments.some((segment) => segment === "*" || segment === "**")) {
|
|
791
|
+
return [segments];
|
|
792
|
+
}
|
|
793
|
+
return [segments, [...segments, "**"]];
|
|
794
|
+
}
|
|
739
795
|
function normalizeChangePath(path) {
|
|
740
796
|
const normalized = path.startsWith("/") ? path : `/${path}`;
|
|
741
797
|
const trimmed = normalized.replace(/\/+$/, "");
|
|
@@ -777,7 +833,14 @@ function getWorkspaceIdFromToken(token) {
|
|
|
777
833
|
}
|
|
778
834
|
try {
|
|
779
835
|
const parsed = JSON.parse(decodeBase64Url(parts[1] ?? ""));
|
|
780
|
-
|
|
836
|
+
// Prefer the canonical `workspace_id` claim; fall back to the `wks` claim
|
|
837
|
+
// minted delegated tokens (relay_pa_*) actually carry, then `workspace`.
|
|
838
|
+
for (const candidate of [parsed.workspace_id, parsed.wks, parsed.workspace]) {
|
|
839
|
+
if (typeof candidate === "string" && candidate.length > 0) {
|
|
840
|
+
return candidate;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return undefined;
|
|
781
844
|
}
|
|
782
845
|
catch {
|
|
783
846
|
return undefined;
|
|
@@ -790,7 +853,15 @@ function getAgentIdFromToken(token) {
|
|
|
790
853
|
}
|
|
791
854
|
try {
|
|
792
855
|
const parsed = JSON.parse(decodeBase64Url(parts[1] ?? ""));
|
|
793
|
-
|
|
856
|
+
if (typeof parsed.agent_name === "string" && parsed.agent_name.length > 0) {
|
|
857
|
+
return parsed.agent_name;
|
|
858
|
+
}
|
|
859
|
+
// Minted delegated tokens omit `agent_name` and carry the agent as the JWT
|
|
860
|
+
// subject, e.g. sub: "agent_gil-ramp-bookkeeper".
|
|
861
|
+
if (typeof parsed.sub === "string" && parsed.sub.startsWith("agent_") && parsed.sub.length > "agent_".length) {
|
|
862
|
+
return parsed.sub.slice("agent_".length);
|
|
863
|
+
}
|
|
864
|
+
return undefined;
|
|
794
865
|
}
|
|
795
866
|
catch {
|
|
796
867
|
return undefined;
|
|
@@ -2177,7 +2248,11 @@ export class RelayFileClient {
|
|
|
2177
2248
|
const payload = await this.readPayload(response);
|
|
2178
2249
|
if (this.shouldRetryStatus(response.status, retries, params.signal)) {
|
|
2179
2250
|
retries += 1;
|
|
2180
|
-
await this.sleep(this.computeRetryDelayMs(retries, response.headers.get("retry-after")
|
|
2251
|
+
await this.sleep(this.computeRetryDelayMs(retries, response.headers.get("retry-after"),
|
|
2252
|
+
// `details.retryAfterSeconds` is a 429-only backpressure signal
|
|
2253
|
+
// (`workspace_busy` / `queue_full`). Only consult the body on a 429
|
|
2254
|
+
// so a 5xx body can never bypass `maxDelayMs` via this path.
|
|
2255
|
+
response.status === 429 ? payload : undefined), params.signal);
|
|
2181
2256
|
continue;
|
|
2182
2257
|
}
|
|
2183
2258
|
this.throwForError(response.status, payload, response.headers);
|
|
@@ -2201,17 +2276,53 @@ export class RelayFileClient {
|
|
|
2201
2276
|
}
|
|
2202
2277
|
return retries < this.retryOptions.maxRetries;
|
|
2203
2278
|
}
|
|
2204
|
-
computeRetryDelayMs(retryAttempt, retryAfterHeader) {
|
|
2279
|
+
computeRetryDelayMs(retryAttempt, retryAfterHeader, payload) {
|
|
2280
|
+
// A server-advertised `Retry-After` HEADER is the standard, explicit
|
|
2281
|
+
// signal and takes precedence: parse it first and leave its handling
|
|
2282
|
+
// unchanged (bounded by our own `maxDelayMs`). Only when the header is
|
|
2283
|
+
// absent or unparseable do we consult the body below, so a body hint never
|
|
2284
|
+
// silently overrides a shorter, explicit header the server already sent.
|
|
2205
2285
|
const retryAfterMs = this.parseRetryAfterMs(retryAfterHeader);
|
|
2206
2286
|
if (retryAfterMs !== null) {
|
|
2207
2287
|
return Math.min(this.retryOptions.maxDelayMs, retryAfterMs);
|
|
2208
2288
|
}
|
|
2289
|
+
// With no usable header, a 429 body can still advertise an explicit
|
|
2290
|
+
// backpressure delay as `details.retryAfterSeconds` (e.g. `workspace_busy`
|
|
2291
|
+
// when the workspace durable object is overloaded, or `queue_full`). Honor
|
|
2292
|
+
// it as an instruction, bounded only by RETRY_AFTER_MAX_MS — NOT truncated
|
|
2293
|
+
// to `maxDelayMs`, which governs our own exponential backoff. Truncating it
|
|
2294
|
+
// (maxDelayMs defaults to 2s vs. a typical 5s advertised delay) retries
|
|
2295
|
+
// into the still-busy resource and exhausts the retry budget.
|
|
2296
|
+
const advertisedMs = this.parseRetryAfterSecondsFromBody(payload);
|
|
2297
|
+
if (advertisedMs !== null) {
|
|
2298
|
+
return Math.max(0, Math.min(RETRY_AFTER_MAX_MS, advertisedMs));
|
|
2299
|
+
}
|
|
2209
2300
|
const backoff = this.retryOptions.baseDelayMs * Math.pow(2, Math.max(0, retryAttempt - 1));
|
|
2210
2301
|
const capped = Math.min(this.retryOptions.maxDelayMs, backoff);
|
|
2211
2302
|
const jitter = this.retryOptions.jitterRatio;
|
|
2212
2303
|
const factor = 1 + (Math.random() * 2 - 1) * jitter;
|
|
2213
2304
|
return Math.max(0, Math.round(capped * factor));
|
|
2214
2305
|
}
|
|
2306
|
+
/**
|
|
2307
|
+
* Extract a server-advertised retry delay from a parsed 429 error body.
|
|
2308
|
+
* Relayfile advertises backpressure delays as `details.retryAfterSeconds`
|
|
2309
|
+
* (in seconds) on `workspace_busy` / `queue_full` responses. Returns
|
|
2310
|
+
* milliseconds, or null when absent/malformed.
|
|
2311
|
+
*/
|
|
2312
|
+
parseRetryAfterSecondsFromBody(payload) {
|
|
2313
|
+
if (!payload || typeof payload !== "object") {
|
|
2314
|
+
return null;
|
|
2315
|
+
}
|
|
2316
|
+
const details = payload.details;
|
|
2317
|
+
if (!details || typeof details !== "object") {
|
|
2318
|
+
return null;
|
|
2319
|
+
}
|
|
2320
|
+
const seconds = details.retryAfterSeconds;
|
|
2321
|
+
if (typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0) {
|
|
2322
|
+
return seconds * 1000;
|
|
2323
|
+
}
|
|
2324
|
+
return null;
|
|
2325
|
+
}
|
|
2215
2326
|
parseRetryAfterMs(retryAfterHeader) {
|
|
2216
2327
|
if (!retryAfterHeader) {
|
|
2217
2328
|
return null;
|
package/dist/types.d.ts
CHANGED
|
@@ -416,6 +416,22 @@ export interface LayoutManifest {
|
|
|
416
416
|
export interface SubscribeOptions {
|
|
417
417
|
coalesce?: "none" | "fire-once";
|
|
418
418
|
coalesceMs?: number;
|
|
419
|
+
/**
|
|
420
|
+
* Narrow the server-side event stream to one or more path scopes.
|
|
421
|
+
*
|
|
422
|
+
* A **wildcard-free** entry is a DIRECTORY SUBTREE scope: `"/ramp/transactions"`
|
|
423
|
+
* matches that path AND everything under it. A bare path is NOT treated as an
|
|
424
|
+
* exact-file match — Relayfile creates descendants under file-looking paths
|
|
425
|
+
* (e.g. `/linear/issues/ENG-1.json/replies/draft.json`), so a wildcard-free
|
|
426
|
+
* scope always means "this directory and its subtree".
|
|
427
|
+
*
|
|
428
|
+
* To scope to something more precise, pass an explicit glob: a trailing `**`
|
|
429
|
+
* (`"/ramp/transactions/**"`) for a subtree, or per-segment `*` wildcards
|
|
430
|
+
* (`"/github/repos/acme/api/pulls/*"`); these are honored verbatim. For an
|
|
431
|
+
* exact single file, pass that exact path as a glob in the first `subscribe`
|
|
432
|
+
* argument (`subscribe(globs, ...)`, e.g. `["/linear/issues/ENG-1.json"]`) —
|
|
433
|
+
* the `globs` list is matched exactly unless it contains a `*`/`**` wildcard.
|
|
434
|
+
*/
|
|
419
435
|
pathScope?: string[];
|
|
420
436
|
from?: "now" | "legacy";
|
|
421
437
|
cursor?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@relayfile/sdk",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.53",
|
|
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",
|
|
@@ -59,15 +59,15 @@
|
|
|
59
59
|
"prepublishOnly": "npm run build"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@relayfile/core": "0.10.
|
|
62
|
+
"@relayfile/core": "0.10.53",
|
|
63
63
|
"ignore": "^7.0.5",
|
|
64
64
|
"tar": "^7.5.10"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"@relayfile/mount-darwin-arm64": "0.10.
|
|
68
|
-
"@relayfile/mount-darwin-x64": "0.10.
|
|
69
|
-
"@relayfile/mount-linux-arm64": "0.10.
|
|
70
|
-
"@relayfile/mount-linux-x64": "0.10.
|
|
67
|
+
"@relayfile/mount-darwin-arm64": "0.10.53",
|
|
68
|
+
"@relayfile/mount-darwin-x64": "0.10.53",
|
|
69
|
+
"@relayfile/mount-linux-arm64": "0.10.53",
|
|
70
|
+
"@relayfile/mount-linux-x64": "0.10.53"
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
|
73
73
|
"typescript": "^5.7.3",
|