@relayfile/sdk 0.10.50 → 0.10.52

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;
@@ -75,6 +84,25 @@ function getFileReadCache(client) {
75
84
  return cached;
76
85
  return false;
77
86
  }
87
+ async function withFileReadCacheInvalidated(client, workspaceId, paths, attempt) {
88
+ const cache = getFileReadCache(client);
89
+ if (cache === false)
90
+ return attempt();
91
+ const evict = () => {
92
+ for (const path of paths) {
93
+ cache.evict(workspaceId, path);
94
+ }
95
+ };
96
+ // The attempt itself invalidates our knowledge, before its outcome is known.
97
+ evict();
98
+ try {
99
+ return await attempt();
100
+ }
101
+ finally {
102
+ // A concurrent read can repopulate while the request is in flight.
103
+ evict();
104
+ }
105
+ }
78
106
  function initFileReadCache(client, options) {
79
107
  if (options.readCache === false) {
80
108
  fileReadCaches.set(client, false);
@@ -236,7 +264,9 @@ class RelayFileWebSocketConnection {
236
264
  }
237
265
  });
238
266
  socket.addEventListener("error", (event) => {
239
- 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;
240
270
  for (const handler of this.handlers.error) {
241
271
  handler(errorEvent);
242
272
  }
@@ -758,7 +788,14 @@ function getWorkspaceIdFromToken(token) {
758
788
  }
759
789
  try {
760
790
  const parsed = JSON.parse(decodeBase64Url(parts[1] ?? ""));
761
- return typeof parsed.workspace_id === "string" && parsed.workspace_id.length > 0 ? parsed.workspace_id : undefined;
791
+ // Prefer the canonical `workspace_id` claim; fall back to the `wks` claim
792
+ // minted delegated tokens (relay_pa_*) actually carry, then `workspace`.
793
+ for (const candidate of [parsed.workspace_id, parsed.wks, parsed.workspace]) {
794
+ if (typeof candidate === "string" && candidate.length > 0) {
795
+ return candidate;
796
+ }
797
+ }
798
+ return undefined;
762
799
  }
763
800
  catch {
764
801
  return undefined;
@@ -771,7 +808,15 @@ function getAgentIdFromToken(token) {
771
808
  }
772
809
  try {
773
810
  const parsed = JSON.parse(decodeBase64Url(parts[1] ?? ""));
774
- return typeof parsed.agent_name === "string" && parsed.agent_name.length > 0 ? parsed.agent_name : undefined;
811
+ if (typeof parsed.agent_name === "string" && parsed.agent_name.length > 0) {
812
+ return parsed.agent_name;
813
+ }
814
+ // Minted delegated tokens omit `agent_name` and carry the agent as the JWT
815
+ // subject, e.g. sub: "agent_gil-ramp-bookkeeper".
816
+ if (typeof parsed.sub === "string" && parsed.sub.startsWith("agent_") && parsed.sub.length > "agent_".length) {
817
+ return parsed.sub.slice("agent_".length);
818
+ }
819
+ return undefined;
775
820
  }
776
821
  catch {
777
822
  return undefined;
@@ -1248,7 +1293,7 @@ export class RelayFileClient {
1248
1293
  async writeFile(input) {
1249
1294
  const { workspaceId, path, correlationId, baseRevision, content, contentType, encoding, contentIdentity, signal } = input;
1250
1295
  const query = buildQuery({ path, forkId: input.forkId });
1251
- const result = await this.request({
1296
+ return withFileReadCacheInvalidated(this, workspaceId, [path], () => this.request({
1252
1297
  method: "PUT",
1253
1298
  path: `/v1/workspaces/${encodeURIComponent(workspaceId)}/fs/file${query}`,
1254
1299
  correlationId,
@@ -1264,15 +1309,11 @@ export class RelayFileClient {
1264
1309
  ...(contentIdentity ? { contentIdentity } : {})
1265
1310
  },
1266
1311
  signal
1267
- });
1268
- const cache = getFileReadCache(this);
1269
- if (cache !== false)
1270
- cache.evict(workspaceId, path);
1271
- return result;
1312
+ }));
1272
1313
  }
1273
1314
  async mergeFile(input) {
1274
1315
  const query = buildQuery({ path: input.path });
1275
- const result = await this.request({
1316
+ return withFileReadCacheInvalidated(this, input.workspaceId, [input.path], () => this.request({
1276
1317
  method: "POST",
1277
1318
  path: `/v1/workspaces/${encodeURIComponent(input.workspaceId)}/fs/merge${query}`,
1278
1319
  correlationId: input.correlationId,
@@ -1291,31 +1332,22 @@ export class RelayFileClient {
1291
1332
  contentIdentity: input.contentIdentity
1292
1333
  },
1293
1334
  signal: input.signal
1294
- });
1295
- const cache = getFileReadCache(this);
1296
- if (cache !== false)
1297
- cache.evict(input.workspaceId, input.path);
1298
- return result;
1335
+ }));
1299
1336
  }
1300
1337
  async bulkWrite(input) {
1301
1338
  const query = buildQuery({ forkId: input.forkId });
1302
- const response = await this.performRequest({
1303
- method: "POST",
1304
- path: `/v1/workspaces/${encodeURIComponent(input.workspaceId)}/fs/bulk${query}`,
1305
- correlationId: input.correlationId,
1306
- body: {
1307
- files: input.files
1308
- },
1309
- signal: input.signal
1339
+ return withFileReadCacheInvalidated(this, input.workspaceId, input.files.map((file) => file.path), async () => {
1340
+ const response = await this.performRequest({
1341
+ method: "POST",
1342
+ path: `/v1/workspaces/${encodeURIComponent(input.workspaceId)}/fs/bulk${query}`,
1343
+ correlationId: input.correlationId,
1344
+ body: {
1345
+ files: input.files
1346
+ },
1347
+ signal: input.signal
1348
+ });
1349
+ return await this.readPayload(response);
1310
1350
  });
1311
- const result = await this.readPayload(response);
1312
- const cache = getFileReadCache(this);
1313
- if (cache !== false) {
1314
- for (const file of input.files) {
1315
- cache.evict(input.workspaceId, file.path);
1316
- }
1317
- }
1318
- return result;
1319
1351
  }
1320
1352
  async issueCheckpointSeal(input) {
1321
1353
  return this.request({
@@ -1420,7 +1452,7 @@ export class RelayFileClient {
1420
1452
  }
1421
1453
  async deleteFile(input) {
1422
1454
  const query = buildQuery({ path: input.path, forkId: input.forkId });
1423
- const result = await this.request({
1455
+ return withFileReadCacheInvalidated(this, input.workspaceId, [input.path], () => this.request({
1424
1456
  method: "DELETE",
1425
1457
  path: `/v1/workspaces/${encodeURIComponent(input.workspaceId)}/fs/file${query}`,
1426
1458
  correlationId: input.correlationId,
@@ -1428,11 +1460,7 @@ export class RelayFileClient {
1428
1460
  "If-Match": input.baseRevision
1429
1461
  },
1430
1462
  signal: input.signal
1431
- });
1432
- const cache = getFileReadCache(this);
1433
- if (cache !== false)
1434
- cache.evict(input.workspaceId, input.path);
1435
- return result;
1463
+ }));
1436
1464
  }
1437
1465
  async createFork(input) {
1438
1466
  const body = {
@@ -2175,7 +2203,11 @@ export class RelayFileClient {
2175
2203
  const payload = await this.readPayload(response);
2176
2204
  if (this.shouldRetryStatus(response.status, retries, params.signal)) {
2177
2205
  retries += 1;
2178
- await this.sleep(this.computeRetryDelayMs(retries, response.headers.get("retry-after")), params.signal);
2206
+ await this.sleep(this.computeRetryDelayMs(retries, response.headers.get("retry-after"),
2207
+ // `details.retryAfterSeconds` is a 429-only backpressure signal
2208
+ // (`workspace_busy` / `queue_full`). Only consult the body on a 429
2209
+ // so a 5xx body can never bypass `maxDelayMs` via this path.
2210
+ response.status === 429 ? payload : undefined), params.signal);
2179
2211
  continue;
2180
2212
  }
2181
2213
  this.throwForError(response.status, payload, response.headers);
@@ -2199,17 +2231,53 @@ export class RelayFileClient {
2199
2231
  }
2200
2232
  return retries < this.retryOptions.maxRetries;
2201
2233
  }
2202
- computeRetryDelayMs(retryAttempt, retryAfterHeader) {
2234
+ computeRetryDelayMs(retryAttempt, retryAfterHeader, payload) {
2235
+ // A server-advertised `Retry-After` HEADER is the standard, explicit
2236
+ // signal and takes precedence: parse it first and leave its handling
2237
+ // unchanged (bounded by our own `maxDelayMs`). Only when the header is
2238
+ // absent or unparseable do we consult the body below, so a body hint never
2239
+ // silently overrides a shorter, explicit header the server already sent.
2203
2240
  const retryAfterMs = this.parseRetryAfterMs(retryAfterHeader);
2204
2241
  if (retryAfterMs !== null) {
2205
2242
  return Math.min(this.retryOptions.maxDelayMs, retryAfterMs);
2206
2243
  }
2244
+ // With no usable header, a 429 body can still advertise an explicit
2245
+ // backpressure delay as `details.retryAfterSeconds` (e.g. `workspace_busy`
2246
+ // when the workspace durable object is overloaded, or `queue_full`). Honor
2247
+ // it as an instruction, bounded only by RETRY_AFTER_MAX_MS — NOT truncated
2248
+ // to `maxDelayMs`, which governs our own exponential backoff. Truncating it
2249
+ // (maxDelayMs defaults to 2s vs. a typical 5s advertised delay) retries
2250
+ // into the still-busy resource and exhausts the retry budget.
2251
+ const advertisedMs = this.parseRetryAfterSecondsFromBody(payload);
2252
+ if (advertisedMs !== null) {
2253
+ return Math.max(0, Math.min(RETRY_AFTER_MAX_MS, advertisedMs));
2254
+ }
2207
2255
  const backoff = this.retryOptions.baseDelayMs * Math.pow(2, Math.max(0, retryAttempt - 1));
2208
2256
  const capped = Math.min(this.retryOptions.maxDelayMs, backoff);
2209
2257
  const jitter = this.retryOptions.jitterRatio;
2210
2258
  const factor = 1 + (Math.random() * 2 - 1) * jitter;
2211
2259
  return Math.max(0, Math.round(capped * factor));
2212
2260
  }
2261
+ /**
2262
+ * Extract a server-advertised retry delay from a parsed 429 error body.
2263
+ * Relayfile advertises backpressure delays as `details.retryAfterSeconds`
2264
+ * (in seconds) on `workspace_busy` / `queue_full` responses. Returns
2265
+ * milliseconds, or null when absent/malformed.
2266
+ */
2267
+ parseRetryAfterSecondsFromBody(payload) {
2268
+ if (!payload || typeof payload !== "object") {
2269
+ return null;
2270
+ }
2271
+ const details = payload.details;
2272
+ if (!details || typeof details !== "object") {
2273
+ return null;
2274
+ }
2275
+ const seconds = details.retryAfterSeconds;
2276
+ if (typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0) {
2277
+ return seconds * 1000;
2278
+ }
2279
+ return null;
2280
+ }
2213
2281
  parseRetryAfterMs(retryAfterHeader) {
2214
2282
  if (!retryAfterHeader) {
2215
2283
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/sdk",
3
- "version": "0.10.50",
3
+ "version": "0.10.52",
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.50",
62
+ "@relayfile/core": "0.10.52",
63
63
  "ignore": "^7.0.5",
64
64
  "tar": "^7.5.10"
65
65
  },
66
66
  "optionalDependencies": {
67
- "@relayfile/mount-darwin-arm64": "0.10.50",
68
- "@relayfile/mount-darwin-x64": "0.10.50",
69
- "@relayfile/mount-linux-arm64": "0.10.50",
70
- "@relayfile/mount-linux-x64": "0.10.50"
67
+ "@relayfile/mount-darwin-arm64": "0.10.52",
68
+ "@relayfile/mount-darwin-x64": "0.10.52",
69
+ "@relayfile/mount-linux-arm64": "0.10.52",
70
+ "@relayfile/mount-linux-x64": "0.10.52"
71
71
  },
72
72
  "devDependencies": {
73
73
  "typescript": "^5.7.3",