@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 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 = event instanceof ErrorEvent && event.error instanceof Error ? event.error : event;
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
- this.pathScopes = options?.pathScope?.length ? options.pathScope.map((pattern) => normalizeChangePattern(pattern)) : null;
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
- return typeof parsed.workspace_id === "string" && parsed.workspace_id.length > 0 ? parsed.workspace_id : undefined;
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
- return typeof parsed.agent_name === "string" && parsed.agent_name.length > 0 ? parsed.agent_name : undefined;
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")), params.signal);
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.51",
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.51",
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.51",
68
- "@relayfile/mount-darwin-x64": "0.10.51",
69
- "@relayfile/mount-linux-arm64": "0.10.51",
70
- "@relayfile/mount-linux-x64": "0.10.51"
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",