@statewalker/webrun-files-rpc 0.1.0 → 0.1.2

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/esm/index.js CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/client.ts
1
+ // src/client.ts
2
2
  const toBytes$1 = (v) => v instanceof Uint8Array ? v : ArrayBuffer.isView(v) ? new Uint8Array(v.buffer, v.byteOffset, v.byteLength) : new Uint8Array(v);
3
3
  const newStreamId$1 = () => crypto.randomUUID().replace(/-/g, "");
4
4
  /** Cuts any sequence of byte arrays into chunks of exactly `size` bytes (the last may be shorter). */
@@ -55,8 +55,9 @@ var FilesRpcClient = class {
55
55
  streamId: newStreamId$1()
56
56
  });
57
57
  const pending = /* @__PURE__ */ new Map();
58
+ let fail;
58
59
  const aborted = signal ? new Promise((_, reject) => {
59
- const fail = () => reject(signal.reason ?? /* @__PURE__ */ new Error("aborted"));
60
+ fail = () => reject(signal.reason ?? /* @__PURE__ */ new Error("aborted"));
60
61
  if (signal.aborted) fail();
61
62
  else signal.addEventListener("abort", fail, { once: true });
62
63
  }) : void 0;
@@ -81,6 +82,7 @@ var FilesRpcClient = class {
81
82
  signal?.throwIfAborted();
82
83
  }
83
84
  } finally {
85
+ if (fail) signal?.removeEventListener("abort", fail);
84
86
  await this.#rpc.closeRead(streamId).catch(() => {});
85
87
  }
86
88
  }
@@ -89,7 +91,20 @@ var FilesRpcClient = class {
89
91
  * then `closeWrite` with their count; any failure aborts the write.
90
92
  */
91
93
  async write(path, content) {
92
- const { streamId } = await this.#rpc.openWrite(path, { streamId: newStreamId$1() });
94
+ return this.#send(path, content, false);
95
+ }
96
+ /**
97
+ * Adds `content` to the end of `path` (creating it if it does not exist), streamed as `write`
98
+ * streams. The server appends through the served FilesApi's own `append`, or emulates it.
99
+ */
100
+ async append(path, content) {
101
+ return this.#send(path, content, true);
102
+ }
103
+ async #send(path, content, append) {
104
+ const { streamId } = await this.#rpc.openWrite(path, {
105
+ streamId: newStreamId$1(),
106
+ ...append ? { append: true } : {}
107
+ });
93
108
  const inFlight = /* @__PURE__ */ new Set();
94
109
  let failure;
95
110
  let count = 0;
@@ -114,7 +129,7 @@ var FilesRpcClient = class {
114
129
  }
115
130
  }
116
131
  async mkdir(path) {
117
- await this.#rpc.mkdir(path);
132
+ await this.#rpc.mkdir(path, { requestId: newStreamId$1() });
118
133
  }
119
134
  /** Pages of `pageSize`, each asked after the last path of the one before. */
120
135
  async *list(path, options = {}) {
@@ -137,21 +152,20 @@ var FilesRpcClient = class {
137
152
  return this.#rpc.exists(path);
138
153
  }
139
154
  remove(path) {
140
- return this.#rpc.remove(path);
155
+ return this.#rpc.remove(path, { requestId: newStreamId$1() });
141
156
  }
142
157
  move(source, target) {
143
- return this.#rpc.move(source, target);
158
+ return this.#rpc.move(source, target, { requestId: newStreamId$1() });
144
159
  }
145
160
  copy(source, target) {
146
- return this.#rpc.copy(source, target);
161
+ return this.#rpc.copy(source, target, { requestId: newStreamId$1() });
147
162
  }
148
163
  };
149
164
  /** A FilesApi over a FilesRpc. */
150
165
  function newFilesRpcClient(options) {
151
166
  return new FilesRpcClient(options);
152
167
  }
153
- //#endregion
154
- //#region src/errors.ts
168
+ // src/errors.ts
155
169
  /**
156
170
  * An error of the protocol itself. The code also leads the message (`UNKNOWN_STREAM: …`), since
157
171
  * a structured clone keeps an error's name and message but not its other properties:
@@ -168,11 +182,19 @@ var FilesRpcError = class extends Error {
168
182
  /** The FilesRpcError code of an error, also after it crossed a transport; undefined otherwise. */
169
183
  function errorCode(e) {
170
184
  const code = e?.code;
171
- if (typeof code === "string") return code;
172
- return /^(UNKNOWN_STREAM|CHUNK_RELEASED|TOO_MANY_STREAMS|ABORTED|INVALID):/.exec(String(e?.message ?? ""))?.[1];
185
+ if (typeof code === "string" && CODES.has(code)) return code;
186
+ return LEADING_CODE.exec(String(e?.message ?? ""))?.[1];
173
187
  }
174
- //#endregion
175
- //#region src/keyed.ts
188
+ const CODES = /* @__PURE__ */ new Set([
189
+ "UNKNOWN_STREAM",
190
+ "CHUNK_RELEASED",
191
+ "TOO_MANY_STREAMS",
192
+ "ABORTED",
193
+ "INVALID"
194
+ ]);
195
+ /** A message led by a code, as FilesRpcError writes it (`CODE: …`). */
196
+ const LEADING_CODE = new RegExp(`^(${[...CODES].join("|")}):`);
197
+ // src/keyed.ts
176
198
  /**
177
199
  * Values kept by key, and waiters for keys that have no value yet: what both ends of a stream
178
200
  * use to keep chunks by chunkId and wait for the ones still on their way.
@@ -235,8 +257,23 @@ var Keyed = class {
235
257
  this.#waiters.clear();
236
258
  }
237
259
  };
238
- //#endregion
239
- //#region src/server.ts
260
+ // src/server.ts
261
+ /**
262
+ * Whether a write's outcome is the FilesApi's alone: every chunk was handed to it (an emulated
263
+ * append, until its write-back starts, can still be cancelled).
264
+ */
265
+ const committed = (s) => s.total !== void 0 && s.next >= s.total && (!s.emulated || s.committing === true);
266
+ /** `path` in normal form: one leading slash, no empty, "." or ".." segments, no trailing slash. */
267
+ function normalPath(path) {
268
+ const out = [];
269
+ for (const seg of String(path).split("/")) {
270
+ if (!seg || seg === ".") continue;
271
+ if (seg === "..") out.pop();
272
+ else out.push(seg);
273
+ }
274
+ return `/${out.join("/")}`;
275
+ }
276
+ const messageOf = (e) => String(e?.message ?? e);
240
277
  const EMPTY = /* @__PURE__ */ new Uint8Array();
241
278
  const STREAM_ID = /^[A-Za-z0-9_-]{16,128}$/;
242
279
  function newStreamId() {
@@ -299,6 +336,40 @@ function newFilesRpcServer(options) {
299
336
  * open it anew, and a closeWrite delivered again answers as the first one did.
300
337
  */
301
338
  const ended = /* @__PURE__ */ new Map();
339
+ /** Answers of metadata calls, by method and requestId, oldest first. */
340
+ const requests = /* @__PURE__ */ new Map();
341
+ const maxRequests = Math.max(1, options.maxRequests ?? 1024);
342
+ /** Runs `call` once per requestId (within idleTimeout): a call delivered again gets its answer. */
343
+ const once = (method, opts, call) => {
344
+ const id = opts?.requestId;
345
+ if (id === void 0) return call();
346
+ if (typeof id !== "string" || !STREAM_ID.test(id)) return Promise.reject(new FilesRpcError("INVALID", "a requestId is 16 to 128 characters of [A-Za-z0-9_-]"));
347
+ const key = `${method} ${id}`;
348
+ const kept = requests.get(key);
349
+ if (kept) return kept.answer;
350
+ const answer = call();
351
+ answer.catch(() => {});
352
+ const entry = {
353
+ at: now(),
354
+ settled: false,
355
+ answer
356
+ };
357
+ requests.set(key, entry);
358
+ const settle = () => {
359
+ entry.at = now();
360
+ entry.settled = true;
361
+ };
362
+ answer.then(settle, settle);
363
+ while (requests.size > maxRequests) {
364
+ let victim;
365
+ for (const [k, r] of requests) if (r.settled) {
366
+ victim = k;
367
+ break;
368
+ }
369
+ requests.delete(victim ?? requests.keys().next().value);
370
+ }
371
+ return answer;
372
+ };
302
373
  const finish = (id, failure) => {
303
374
  streams.delete(id);
304
375
  ended.set(id, {
@@ -441,6 +512,52 @@ function newFilesRpcServer(options) {
441
512
  s.wake = void 0;
442
513
  }
443
514
  }
515
+ /** Emulated appends in progress, by normal path: one at a time per file. */
516
+ const appending = /* @__PURE__ */ new Map();
517
+ /**
518
+ * Appends `content` to `path`: through the FilesApi's own `append`, or with read, write and
519
+ * remove only. The emulation never reads and writes one path at once (a FilesApi may empty a
520
+ * file when a write starts):
521
+ * 1. the file's content and the chunks go to a temporary sibling;
522
+ * 2. that sibling is written back over the file, then removed.
523
+ * A failure or an abort in step 1 leaves the file as it was and removes the sibling. A failure
524
+ * in step 2 keeps the sibling (the only complete copy) and names it in the error. Appends to
525
+ * one file through this server run one after the other; other writers are not excluded.
526
+ */
527
+ const append = async (path, content, id, s) => {
528
+ const own = fs.append;
529
+ if (typeof own === "function") return own.call(fs, path, content);
530
+ s.emulated = true;
531
+ const file = normalPath(path);
532
+ const run = (appending.get(file) ?? Promise.resolve()).then(async () => {
533
+ if ((await fs.stats(file))?.kind === "directory") throw new FilesRpcError("INVALID", `cannot append to a directory: ${file}`);
534
+ const slash = file.lastIndexOf("/");
535
+ const temp = `${file.slice(0, slash + 1)}.${file.slice(slash + 1, slash + 65)}.append-${id.slice(0, 16)}`;
536
+ try {
537
+ await fs.write(temp, (async function* () {
538
+ yield* fs.read(file);
539
+ yield* content;
540
+ })());
541
+ if (s.failure !== void 0) throw s.failure;
542
+ } catch (e) {
543
+ await fs.remove(temp).catch(() => {});
544
+ throw e;
545
+ }
546
+ s.committing = true;
547
+ try {
548
+ await fs.write(file, fs.read(temp));
549
+ } catch (e) {
550
+ throw Object.assign(/* @__PURE__ */ new Error(`${messageOf(e)} (the append failed while writing ${file} back: it may be incomplete; its content and the appended chunks are kept in ${temp})`), { cause: e });
551
+ }
552
+ await fs.remove(temp).catch(() => {});
553
+ });
554
+ const settled = run.catch(() => {});
555
+ appending.set(file, settled);
556
+ settled.then(() => {
557
+ if (appending.get(file) === settled) appending.delete(file);
558
+ });
559
+ return run;
560
+ };
444
561
  const failWrite = (s, failure) => {
445
562
  s.failure ??= failure;
446
563
  for (const r of s.room.splice(0)) r.reject(s.failure);
@@ -453,17 +570,17 @@ function newFilesRpcServer(options) {
453
570
  async exists(path) {
454
571
  return fs.exists(path);
455
572
  },
456
- async mkdir(path) {
457
- await fs.mkdir(path);
573
+ async mkdir(path, opts) {
574
+ await once("mkdir", opts, () => fs.mkdir(path));
458
575
  },
459
- async remove(path) {
460
- return fs.remove(path);
576
+ async remove(path, opts) {
577
+ return once("remove", opts, () => fs.remove(path));
461
578
  },
462
- async move(source, target) {
463
- return fs.move(source, target);
579
+ async move(source, target, opts) {
580
+ return once("move", opts, () => fs.move(source, target));
464
581
  },
465
- async copy(source, target) {
466
- return fs.copy(source, target);
582
+ async copy(source, target, opts) {
583
+ return once("copy", opts, () => fs.copy(source, target));
467
584
  },
468
585
  async list(path, opts = {}) {
469
586
  const limit = Math.min(Math.max(1, Math.floor(Number(opts.limit) || maxPageSize)), maxPageSize);
@@ -556,7 +673,7 @@ function newFilesRpcServer(options) {
556
673
  room: [],
557
674
  done: Promise.resolve()
558
675
  };
559
- s.done = fs.write(path, ordered(s)).then(() => {
676
+ s.done = (opts.append ? append(path, ordered(s), id, s) : fs.write(path, ordered(s))).then(() => {
560
677
  if (s.failure !== void 0) throw s.failure;
561
678
  }, (e) => {
562
679
  failWrite(s, e);
@@ -620,7 +737,7 @@ function newFilesRpcServer(options) {
620
737
  const id = String(streamId);
621
738
  const s = streams.get(id);
622
739
  if (s?.kind !== "write") return;
623
- if (s.total !== void 0 && s.next >= s.total) return;
740
+ if (committed(s)) return;
624
741
  const error = new FilesRpcError("ABORTED", reason ? String(reason) : "the write was aborted");
625
742
  finish(id, error);
626
743
  failWrite(s, error);
@@ -639,6 +756,7 @@ function newFilesRpcServer(options) {
639
756
  else failWrite(s, error);
640
757
  }
641
758
  for (const [id, e] of [...ended]) if (t - e.at > idleTimeout) ended.delete(id);
759
+ for (const [key, r] of [...requests]) if (r.settled && t - r.at > idleTimeout) requests.delete(key);
642
760
  return dropped;
643
761
  },
644
762
  openStreams() {
@@ -648,6 +766,10 @@ function newFilesRpcServer(options) {
648
766
  if (timer) clearInterval(timer);
649
767
  const sources = [];
650
768
  for (const [id, s] of [...streams]) {
769
+ if (s.kind === "write" && committed(s)) {
770
+ streams.delete(id);
771
+ continue;
772
+ }
651
773
  const error = new FilesRpcError("ABORTED", "the server closed");
652
774
  finish(id, error);
653
775
  if (s.kind === "read") sources.push(closeSource(s));
@@ -662,5 +784,4 @@ function newFilesRpcServer(options) {
662
784
  timer?.unref?.();
663
785
  return server;
664
786
  }
665
- //#endregion
666
787
  export { FilesRpcClient, FilesRpcError, errorCode, newFilesRpcClient, newFilesRpcServer };
package/dist/index.d.ts CHANGED
@@ -7,5 +7,5 @@
7
7
  export { FilesRpcClient, type FilesRpcClientOptions, newFilesRpcClient } from "./client.js";
8
8
  export { errorCode, FilesRpcError } from "./errors.js";
9
9
  export { type FilesRpcServer, type FilesRpcServerOptions, newFilesRpcServer } from "./server.js";
10
- export type { FilesRpc, FilesRpcErrorCode, ListPage, ListPageOptions, OpenOptions, OpenReadOptions, ReadResult, } from "./types.js";
10
+ export type { AppendableFilesApi, FilesRpc, FilesRpcErrorCode, ListPage, ListPageOptions, OpenOptions, OpenReadOptions, OpenWriteOptions, ReadResult, RequestOptions, } from "./types.js";
11
11
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,cAAc,EAAE,KAAK,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAC5F,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,EAAE,KAAK,cAAc,EAAE,KAAK,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACjG,YAAY,EACV,QAAQ,EACR,iBAAiB,EACjB,QAAQ,EACR,eAAe,EACf,WAAW,EACX,eAAe,EACf,UAAU,GACX,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,cAAc,EAAE,KAAK,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAC5F,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,EAAE,KAAK,cAAc,EAAE,KAAK,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACjG,YAAY,EACV,kBAAkB,EAClB,QAAQ,EACR,iBAAiB,EACjB,QAAQ,EACR,eAAe,EACf,WAAW,EACX,eAAe,EACf,gBAAgB,EAChB,UAAU,EACV,cAAc,GACf,MAAM,YAAY,CAAC"}
package/dist/server.d.ts CHANGED
@@ -27,6 +27,8 @@ export interface FilesRpcServerOptions {
27
27
  maxStreams?: number;
28
28
  /** Largest listing page. Default 256. */
29
29
  maxPageSize?: number;
30
+ /** Answers of metadata calls kept by their `requestId` at most (default 1024). */
31
+ maxRequests?: number;
30
32
  /** Sweeps idle streams every this many ms (default: only when a stream is opened). */
31
33
  sweepInterval?: number;
32
34
  /** The clock (tests). Default `Date.now`. */
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAY,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAGpE,OAAO,KAAK,EAAE,QAAQ,EAAsD,MAAM,YAAY,CAAC;AAE/F,MAAM,WAAW,qBAAqB;IACpC,8BAA8B;IAC9B,EAAE,EAAE,QAAQ,CAAC;IACb,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6FAA6F;IAC7F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kDAAkD;IAClD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gGAAgG;IAChG,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yCAAyC;IACzC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,yCAAyC;IACzC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sFAAsF;IACtF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,6CAA6C;IAC7C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,qDAAqD;AACrD,MAAM,WAAW,cAAe,SAAQ,QAAQ;IAC9C,0EAA0E;IAC1E,KAAK,IAAI,MAAM,CAAC;IAChB,wBAAwB;IACxB,WAAW,IAAI,MAAM,CAAC;IACtB;;;OAGG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAiGD,iDAAiD;AACjD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,qBAAqB,GAAG,cAAc,CA2bhF"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAY,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAGpE,OAAO,KAAK,EAEV,QAAQ,EAOT,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,qBAAqB;IACpC,8BAA8B;IAC9B,EAAE,EAAE,QAAQ,CAAC;IACb,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6FAA6F;IAC7F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kDAAkD;IAClD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gGAAgG;IAChG,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yCAAyC;IACzC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,yCAAyC;IACzC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kFAAkF;IAClF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sFAAsF;IACtF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,6CAA6C;IAC7C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,qDAAqD;AACrD,MAAM,WAAW,cAAe,SAAQ,QAAQ;IAC9C,0EAA0E;IAC1E,KAAK,IAAI,MAAM,CAAC;IAChB,wBAAwB;IACxB,WAAW,IAAI,MAAM,CAAC;IACtB;;;OAGG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAwHD,iDAAiD;AACjD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,qBAAqB,GAAG,cAAc,CA0iBhF"}
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { FileInfo, FileStats } from "@statewalker/webrun-files";
1
+ import type { FileInfo, FileStats, FilesApi } from "@statewalker/webrun-files";
2
2
  /** One chunk of a read stream: its bytes, and whether it is the last. */
3
3
  export interface ReadResult {
4
4
  data: Uint8Array;
@@ -24,6 +24,31 @@ export interface OpenOptions {
24
24
  */
25
25
  streamId?: string;
26
26
  }
27
+ /** Options of the calls that change metadata (`mkdir`, `remove`, `move`, `copy`). */
28
+ export interface RequestOptions {
29
+ /**
30
+ * The call's id, chosen by the caller (the same form as a stream id). The server keeps each
31
+ * id's answer for `idleTimeout` (at most `maxRequests` of them): the same call delivered again
32
+ * gets that answer and does not run again.
33
+ */
34
+ requestId?: string;
35
+ }
36
+ export interface OpenWriteOptions extends OpenOptions {
37
+ /**
38
+ * Add the stream's chunks to the end of the file instead of replacing it (a file that does not
39
+ * exist is created). See `AppendableFilesApi` for how the server appends.
40
+ */
41
+ append?: boolean;
42
+ }
43
+ /**
44
+ * A FilesApi that can append itself. `FilesApi` has no append; a served FilesApi with this method
45
+ * gets appends through it. Any other is appended to with read, write and remove only: the old
46
+ * content and the new chunks go to a temporary sibling (`.<name>.append-<streamId>`), which is
47
+ * then written back over the file and removed.
48
+ */
49
+ export interface AppendableFilesApi extends FilesApi {
50
+ append(path: string, content: AsyncIterable<Uint8Array>): Promise<void>;
51
+ }
27
52
  export interface OpenReadOptions extends OpenOptions {
28
53
  /** First byte (default 0). */
29
54
  start?: number;
@@ -44,10 +69,10 @@ export interface OpenReadOptions extends OpenOptions {
44
69
  export interface FilesRpc {
45
70
  stats(path: string): Promise<FileStats | undefined>;
46
71
  exists(path: string): Promise<boolean>;
47
- mkdir(path: string): Promise<void>;
48
- remove(path: string): Promise<boolean>;
49
- move(source: string, target: string): Promise<boolean>;
50
- copy(source: string, target: string): Promise<boolean>;
72
+ mkdir(path: string, options?: RequestOptions): Promise<void>;
73
+ remove(path: string, options?: RequestOptions): Promise<boolean>;
74
+ move(source: string, target: string, options?: RequestOptions): Promise<boolean>;
75
+ copy(source: string, target: string, options?: RequestOptions): Promise<boolean>;
51
76
  list(path: string, options?: ListPageOptions): Promise<ListPage>;
52
77
  /** Opens `path` for reading. A file that does not exist reads as nothing. */
53
78
  openRead(path: string, options?: OpenReadOptions): Promise<{
@@ -60,8 +85,11 @@ export interface FilesRpc {
60
85
  readChunk(streamId: string, chunkId: number): Promise<ReadResult>;
61
86
  /** Ends the stream (unknown or closed: nothing happens). */
62
87
  closeRead(streamId: string): Promise<void>;
63
- /** Opens `path` for writing; the FilesApi's `write` consumes the chunks in order. */
64
- openWrite(path: string, options?: OpenOptions): Promise<{
88
+ /**
89
+ * Opens `path` for writing (or, with `append`, for appending); the FilesApi consumes the chunks
90
+ * in order.
91
+ */
92
+ openWrite(path: string, options?: OpenWriteOptions): Promise<{
65
93
  streamId: string;
66
94
  }>;
67
95
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAErE,yEAAyE;AACzE,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,UAAU,CAAC;IACjB,IAAI,EAAE,OAAO,CAAC;CACf;AAED,0FAA0F;AAC1F,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,QAAQ,EAAE,CAAC;IACpB,IAAI,EAAE,OAAO,CAAC;CACf;AAED,uFAAuF;AACvF,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,+CAA+C;AAC/C,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,eAAgB,SAAQ,WAAW;IAClD,8BAA8B;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2CAA2C;IAC3C,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,QAAQ;IACvB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAAC;IACpD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACvC,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACvC,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACvD,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACvD,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAEjE,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjF;;;OAGG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IAClE,4DAA4D;IAC5D,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE3C,qFAAqF;IACrF,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC9E;;;;OAIG;IACH,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7F,6EAA6E;IAC7E,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5D,8DAA8D;IAC9D,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9D;AAED,oCAAoC;AACpC,MAAM,MAAM,iBAAiB,GACzB,gBAAgB,GAChB,gBAAgB,GAChB,kBAAkB,GAClB,SAAS,GACT,SAAS,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-files-rpc",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "FilesApi over any RPC: streamed reads and writes as chunks kept and awaited by (streamId, chunkId)",
@@ -34,7 +34,7 @@
34
34
  "src"
35
35
  ],
36
36
  "dependencies": {
37
- "@statewalker/webrun-files": "^0.10.4"
37
+ "@statewalker/webrun-files": "^0.10.5"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "^26.6.4",
@@ -42,8 +42,8 @@
42
42
  "rolldown": "^1.2.12",
43
43
  "typescript": "^7.0.2",
44
44
  "vitest": "^4.1.4 || ^5.0.3",
45
- "@statewalker/webrun-files-mem": "^0.10.4",
46
- "@statewalker/webrun-files-tests": "^3.0.0"
45
+ "@statewalker/webrun-files-tests": "^3.0.0",
46
+ "@statewalker/webrun-files-mem": "^0.10.5"
47
47
  },
48
48
  "sideEffects": false,
49
49
  "publishConfig": {
package/src/client.ts CHANGED
@@ -90,9 +90,10 @@ export class FilesRpcClient implements FilesApi {
90
90
  });
91
91
  const pending = new Map<number, Promise<ReadResult>>();
92
92
  // An abort interrupts the wait for an answer, not only the loop between answers.
93
+ let fail: (() => void) | undefined;
93
94
  const aborted = signal
94
95
  ? new Promise<never>((_, reject) => {
95
- const fail = () => reject(signal.reason ?? new Error("aborted"));
96
+ fail = () => reject(signal.reason ?? new Error("aborted"));
96
97
  if (signal.aborted) fail();
97
98
  else signal.addEventListener("abort", fail, { once: true });
98
99
  })
@@ -118,6 +119,8 @@ export class FilesRpcClient implements FilesApi {
118
119
  signal?.throwIfAborted();
119
120
  }
120
121
  } finally {
122
+ // A signal may outlive many reads: each takes its listener away.
123
+ if (fail) signal?.removeEventListener("abort", fail);
121
124
  await this.#rpc.closeRead(streamId).catch(() => {});
122
125
  }
123
126
  }
@@ -130,7 +133,29 @@ export class FilesRpcClient implements FilesApi {
130
133
  path: string,
131
134
  content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
132
135
  ): Promise<void> {
133
- const { streamId } = await this.#rpc.openWrite(path, { streamId: newStreamId() });
136
+ return this.#send(path, content, false);
137
+ }
138
+
139
+ /**
140
+ * Adds `content` to the end of `path` (creating it if it does not exist), streamed as `write`
141
+ * streams. The server appends through the served FilesApi's own `append`, or emulates it.
142
+ */
143
+ async append(
144
+ path: string,
145
+ content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
146
+ ): Promise<void> {
147
+ return this.#send(path, content, true);
148
+ }
149
+
150
+ async #send(
151
+ path: string,
152
+ content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
153
+ append: boolean,
154
+ ): Promise<void> {
155
+ const { streamId } = await this.#rpc.openWrite(path, {
156
+ streamId: newStreamId(),
157
+ ...(append ? { append: true } : {}),
158
+ });
134
159
  const inFlight = new Set<Promise<void>>();
135
160
  let failure: { error: unknown } | undefined;
136
161
  let count = 0;
@@ -159,7 +184,7 @@ export class FilesRpcClient implements FilesApi {
159
184
  }
160
185
 
161
186
  async mkdir(path: string): Promise<void> {
162
- await this.#rpc.mkdir(path);
187
+ await this.#rpc.mkdir(path, { requestId: newStreamId() });
163
188
  }
164
189
 
165
190
  /** Pages of `pageSize`, each asked after the last path of the one before. */
@@ -184,13 +209,13 @@ export class FilesRpcClient implements FilesApi {
184
209
  return this.#rpc.exists(path);
185
210
  }
186
211
  remove(path: string): Promise<boolean> {
187
- return this.#rpc.remove(path);
212
+ return this.#rpc.remove(path, { requestId: newStreamId() });
188
213
  }
189
214
  move(source: string, target: string): Promise<boolean> {
190
- return this.#rpc.move(source, target);
215
+ return this.#rpc.move(source, target, { requestId: newStreamId() });
191
216
  }
192
217
  copy(source: string, target: string): Promise<boolean> {
193
- return this.#rpc.copy(source, target);
218
+ return this.#rpc.copy(source, target, { requestId: newStreamId() });
194
219
  }
195
220
  }
196
221
 
package/src/errors.ts CHANGED
@@ -16,10 +16,19 @@ export class FilesRpcError extends Error {
16
16
 
17
17
  /** The FilesRpcError code of an error, also after it crossed a transport; undefined otherwise. */
18
18
  export function errorCode(e: unknown): FilesRpcErrorCode | undefined {
19
+ // Only a protocol code: other errors carry codes of their own (a file system's `ENOENT`).
19
20
  const code = (e as { code?: unknown })?.code;
20
- if (typeof code === "string") return code as FilesRpcErrorCode;
21
- const m = /^(UNKNOWN_STREAM|CHUNK_RELEASED|TOO_MANY_STREAMS|ABORTED|INVALID):/.exec(
22
- String((e as Error)?.message ?? ""),
23
- );
21
+ if (typeof code === "string" && CODES.has(code)) return code as FilesRpcErrorCode;
22
+ const m = LEADING_CODE.exec(String((e as Error)?.message ?? ""));
24
23
  return m?.[1] as FilesRpcErrorCode | undefined;
25
24
  }
25
+
26
+ const CODES: ReadonlySet<string> = new Set<FilesRpcErrorCode>([
27
+ "UNKNOWN_STREAM",
28
+ "CHUNK_RELEASED",
29
+ "TOO_MANY_STREAMS",
30
+ "ABORTED",
31
+ "INVALID",
32
+ ]);
33
+ /** A message led by a code, as FilesRpcError writes it (`CODE: …`). */
34
+ const LEADING_CODE = new RegExp(`^(${[...CODES].join("|")}):`);
package/src/index.ts CHANGED
@@ -8,11 +8,14 @@ export { FilesRpcClient, type FilesRpcClientOptions, newFilesRpcClient } from ".
8
8
  export { errorCode, FilesRpcError } from "./errors.js";
9
9
  export { type FilesRpcServer, type FilesRpcServerOptions, newFilesRpcServer } from "./server.js";
10
10
  export type {
11
+ AppendableFilesApi,
11
12
  FilesRpc,
12
13
  FilesRpcErrorCode,
13
14
  ListPage,
14
15
  ListPageOptions,
15
16
  OpenOptions,
16
17
  OpenReadOptions,
18
+ OpenWriteOptions,
17
19
  ReadResult,
20
+ RequestOptions,
18
21
  } from "./types.js";