@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/src/server.ts CHANGED
@@ -1,7 +1,16 @@
1
1
  import type { FileInfo, FilesApi } from "@statewalker/webrun-files";
2
2
  import { FilesRpcError } from "./errors.js";
3
3
  import { Keyed } from "./keyed.js";
4
- import type { FilesRpc, ListPage, OpenOptions, OpenReadOptions, ReadResult } from "./types.js";
4
+ import type {
5
+ AppendableFilesApi,
6
+ FilesRpc,
7
+ ListPage,
8
+ OpenOptions,
9
+ OpenReadOptions,
10
+ OpenWriteOptions,
11
+ ReadResult,
12
+ RequestOptions,
13
+ } from "./types.js";
5
14
 
6
15
  export interface FilesRpcServerOptions {
7
16
  /** The file system served. */
@@ -30,6 +39,8 @@ export interface FilesRpcServerOptions {
30
39
  maxStreams?: number;
31
40
  /** Largest listing page. Default 256. */
32
41
  maxPageSize?: number;
42
+ /** Answers of metadata calls kept by their `requestId` at most (default 1024). */
43
+ maxRequests?: number;
33
44
  /** Sweeps idle streams every this many ms (default: only when a stream is opened). */
34
45
  sweepInterval?: number;
35
46
  /** The clock (tests). Default `Date.now`. */
@@ -89,10 +100,33 @@ interface WriteStream {
89
100
  room: { chunkId: number; resolve: () => void; reject: (e: unknown) => void }[];
90
101
  failure?: unknown;
91
102
  done: Promise<void>;
103
+ /** An append the server emulates; `committing` once it writes the file back. */
104
+ emulated?: boolean;
105
+ committing?: boolean;
92
106
  }
93
107
 
94
108
  type Stream = ReadStream | WriteStream;
95
109
 
110
+ /**
111
+ * Whether a write's outcome is the FilesApi's alone: every chunk was handed to it (an emulated
112
+ * append, until its write-back starts, can still be cancelled).
113
+ */
114
+ const committed = (s: WriteStream): boolean =>
115
+ s.total !== undefined && s.next >= s.total && (!s.emulated || s.committing === true);
116
+
117
+ /** `path` in normal form: one leading slash, no empty, "." or ".." segments, no trailing slash. */
118
+ function normalPath(path: string): string {
119
+ const out: string[] = [];
120
+ for (const seg of String(path).split("/")) {
121
+ if (!seg || seg === ".") continue;
122
+ if (seg === "..") out.pop();
123
+ else out.push(seg);
124
+ }
125
+ return `/${out.join("/")}`;
126
+ }
127
+
128
+ const messageOf = (e: unknown) => String((e as Error)?.message ?? e);
129
+
96
130
  const EMPTY = new Uint8Array();
97
131
  const STREAM_ID = /^[A-Za-z0-9_-]{16,128}$/;
98
132
 
@@ -165,6 +199,44 @@ export function newFilesRpcServer(options: FilesRpcServerOptions): FilesRpcServe
165
199
  */
166
200
  const ended = new Map<string, { at: number; failure?: unknown }>();
167
201
 
202
+ /** Answers of metadata calls, by method and requestId, oldest first. */
203
+ const requests = new Map<string, { at: number; settled: boolean; answer: Promise<unknown> }>();
204
+ const maxRequests = Math.max(1, options.maxRequests ?? 1024);
205
+ /** Runs `call` once per requestId (within idleTimeout): a call delivered again gets its answer. */
206
+ const once = <T>(method: string, opts: RequestOptions | undefined, call: () => Promise<T>) => {
207
+ const id = opts?.requestId;
208
+ if (id === undefined) return call();
209
+ if (typeof id !== "string" || !STREAM_ID.test(id))
210
+ return Promise.reject(
211
+ new FilesRpcError("INVALID", "a requestId is 16 to 128 characters of [A-Za-z0-9_-]"),
212
+ );
213
+ const key = `${method} ${id}`;
214
+ const kept = requests.get(key);
215
+ if (kept) return kept.answer as Promise<T>;
216
+ const answer = call();
217
+ answer.catch(() => {});
218
+ const entry = { at: now(), settled: false, answer };
219
+ requests.set(key, entry);
220
+ // Kept for idleTimeout after it settled (a call still running is not forgotten).
221
+ const settle = () => {
222
+ entry.at = now();
223
+ entry.settled = true;
224
+ };
225
+ void answer.then(settle, settle);
226
+ // Past the cap, the oldest settled answer goes: forgetting a call still running would let a
227
+ // retry run it again. Only when every call is running does the oldest of them go.
228
+ while (requests.size > maxRequests) {
229
+ let victim: string | undefined;
230
+ for (const [k, r] of requests)
231
+ if (r.settled) {
232
+ victim = k;
233
+ break;
234
+ }
235
+ requests.delete(victim ?? (requests.keys().next().value as string));
236
+ }
237
+ return answer;
238
+ };
239
+
168
240
  const finish = (id: string, failure?: unknown) => {
169
241
  streams.delete(id);
170
242
  ended.set(id, { at: now(), failure });
@@ -311,6 +383,71 @@ export function newFilesRpcServer(options: FilesRpcServerOptions): FilesRpcServe
311
383
  }
312
384
  }
313
385
 
386
+ /** Emulated appends in progress, by normal path: one at a time per file. */
387
+ const appending = new Map<string, Promise<void>>();
388
+
389
+ /**
390
+ * Appends `content` to `path`: through the FilesApi's own `append`, or with read, write and
391
+ * remove only. The emulation never reads and writes one path at once (a FilesApi may empty a
392
+ * file when a write starts):
393
+ * 1. the file's content and the chunks go to a temporary sibling;
394
+ * 2. that sibling is written back over the file, then removed.
395
+ * A failure or an abort in step 1 leaves the file as it was and removes the sibling. A failure
396
+ * in step 2 keeps the sibling (the only complete copy) and names it in the error. Appends to
397
+ * one file through this server run one after the other; other writers are not excluded.
398
+ */
399
+ const append = async (
400
+ path: string,
401
+ content: AsyncIterable<Uint8Array>,
402
+ id: string,
403
+ s: WriteStream,
404
+ ): Promise<void> => {
405
+ const own = (fs as Partial<AppendableFilesApi>).append;
406
+ if (typeof own === "function") return own.call(fs, path, content);
407
+ s.emulated = true;
408
+ const file = normalPath(path);
409
+ const previous = appending.get(file) ?? Promise.resolve();
410
+ const run = previous.then(async () => {
411
+ // Only a directory is refused: whether the file exists is not trusted (reading a missing
412
+ // file yields nothing, so a missing file is created all the same).
413
+ if ((await fs.stats(file))?.kind === "directory")
414
+ throw new FilesRpcError("INVALID", `cannot append to a directory: ${file}`);
415
+ const slash = file.lastIndexOf("/");
416
+ const temp = `${file.slice(0, slash + 1)}.${file.slice(slash + 1, slash + 65)}.append-${id.slice(0, 16)}`;
417
+ try {
418
+ await fs.write(
419
+ temp,
420
+ (async function* () {
421
+ yield* fs.read(file);
422
+ yield* content;
423
+ })(),
424
+ );
425
+ if (s.failure !== undefined) throw s.failure; // aborted before the write-back
426
+ } catch (e) {
427
+ await fs.remove(temp).catch(() => {});
428
+ throw e;
429
+ }
430
+ s.committing = true;
431
+ try {
432
+ await fs.write(file, fs.read(temp));
433
+ } catch (e) {
434
+ throw Object.assign(
435
+ new Error(
436
+ `${messageOf(e)} (the append failed while writing ${file} back: it may be incomplete; its content and the appended chunks are kept in ${temp})`,
437
+ ),
438
+ { cause: e },
439
+ );
440
+ }
441
+ await fs.remove(temp).catch(() => {});
442
+ });
443
+ const settled = run.catch(() => {});
444
+ appending.set(file, settled);
445
+ void settled.then(() => {
446
+ if (appending.get(file) === settled) appending.delete(file);
447
+ });
448
+ return run;
449
+ };
450
+
314
451
  const failWrite = (s: WriteStream, failure: unknown) => {
315
452
  s.failure ??= failure;
316
453
  for (const r of s.room.splice(0)) r.reject(s.failure);
@@ -324,17 +461,17 @@ export function newFilesRpcServer(options: FilesRpcServerOptions): FilesRpcServe
324
461
  async exists(path) {
325
462
  return fs.exists(path);
326
463
  },
327
- async mkdir(path) {
328
- await fs.mkdir(path);
464
+ async mkdir(path, opts?: RequestOptions) {
465
+ await once("mkdir", opts, () => fs.mkdir(path));
329
466
  },
330
- async remove(path) {
331
- return fs.remove(path);
467
+ async remove(path, opts?: RequestOptions) {
468
+ return once("remove", opts, () => fs.remove(path));
332
469
  },
333
- async move(source, target) {
334
- return fs.move(source, target);
470
+ async move(source, target, opts?: RequestOptions) {
471
+ return once("move", opts, () => fs.move(source, target));
335
472
  },
336
- async copy(source, target) {
337
- return fs.copy(source, target);
473
+ async copy(source, target, opts?: RequestOptions) {
474
+ return once("copy", opts, () => fs.copy(source, target));
338
475
  },
339
476
  async list(path, opts = {}): Promise<ListPage> {
340
477
  const limit = Math.min(
@@ -429,7 +566,7 @@ export function newFilesRpcServer(options: FilesRpcServerOptions): FilesRpcServe
429
566
  await closeSource(s);
430
567
  },
431
568
 
432
- async openWrite(path, opts: OpenOptions = {}) {
569
+ async openWrite(path, opts: OpenWriteOptions = {}) {
433
570
  const { id, existing } = claim(opts, "write");
434
571
  if (existing) return { streamId: id };
435
572
  const s: WriteStream = {
@@ -441,7 +578,7 @@ export function newFilesRpcServer(options: FilesRpcServerOptions): FilesRpcServe
441
578
  room: [],
442
579
  done: Promise.resolve(),
443
580
  };
444
- s.done = fs.write(path, ordered(s)).then(
581
+ s.done = (opts.append ? append(path, ordered(s), id, s) : fs.write(path, ordered(s))).then(
445
582
  () => {
446
583
  if (s.failure !== undefined) throw s.failure;
447
584
  },
@@ -530,8 +667,9 @@ export function newFilesRpcServer(options: FilesRpcServerOptions): FilesRpcServe
530
667
  const id = String(streamId);
531
668
  const s = streams.get(id);
532
669
  if (s?.kind !== "write") return;
533
- // Every chunk was handed to the FilesApi: the commit is under way; an abort is too late.
534
- if (s.total !== undefined && s.next >= s.total) return;
670
+ // The outcome is the FilesApi's now (every chunk handed over; an emulated append already
671
+ // writing back): an abort is too late.
672
+ if (committed(s)) return;
535
673
  const error = new FilesRpcError("ABORTED", reason ? String(reason) : "the write was aborted");
536
674
  finish(id, error);
537
675
  failWrite(s, error);
@@ -557,6 +695,8 @@ export function newFilesRpcServer(options: FilesRpcServerOptions): FilesRpcServe
557
695
  else failWrite(s, error);
558
696
  }
559
697
  for (const [id, e] of [...ended]) if (t - e.at > idleTimeout) ended.delete(id);
698
+ for (const [key, r] of [...requests])
699
+ if (r.settled && t - r.at > idleTimeout) requests.delete(key);
560
700
  return dropped;
561
701
  },
562
702
 
@@ -568,6 +708,11 @@ export function newFilesRpcServer(options: FilesRpcServerOptions): FilesRpcServe
568
708
  if (timer) clearInterval(timer);
569
709
  const sources: Promise<unknown>[] = [];
570
710
  for (const [id, s] of [...streams]) {
711
+ // A write whose outcome is the FilesApi's keeps it: a closeWrite waiting on it gets that.
712
+ if (s.kind === "write" && committed(s)) {
713
+ streams.delete(id);
714
+ continue;
715
+ }
571
716
  const error = new FilesRpcError("ABORTED", "the server closed");
572
717
  finish(id, error);
573
718
  if (s.kind === "read") sources.push(closeSource(s));
package/src/types.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
 
3
3
  /** One chunk of a read stream: its bytes, and whether it is the last. */
4
4
  export interface ReadResult {
@@ -29,6 +29,34 @@ export interface OpenOptions {
29
29
  streamId?: string;
30
30
  }
31
31
 
32
+ /** Options of the calls that change metadata (`mkdir`, `remove`, `move`, `copy`). */
33
+ export interface RequestOptions {
34
+ /**
35
+ * The call's id, chosen by the caller (the same form as a stream id). The server keeps each
36
+ * id's answer for `idleTimeout` (at most `maxRequests` of them): the same call delivered again
37
+ * gets that answer and does not run again.
38
+ */
39
+ requestId?: string;
40
+ }
41
+
42
+ export interface OpenWriteOptions extends OpenOptions {
43
+ /**
44
+ * Add the stream's chunks to the end of the file instead of replacing it (a file that does not
45
+ * exist is created). See `AppendableFilesApi` for how the server appends.
46
+ */
47
+ append?: boolean;
48
+ }
49
+
50
+ /**
51
+ * A FilesApi that can append itself. `FilesApi` has no append; a served FilesApi with this method
52
+ * gets appends through it. Any other is appended to with read, write and remove only: the old
53
+ * content and the new chunks go to a temporary sibling (`.<name>.append-<streamId>`), which is
54
+ * then written back over the file and removed.
55
+ */
56
+ export interface AppendableFilesApi extends FilesApi {
57
+ append(path: string, content: AsyncIterable<Uint8Array>): Promise<void>;
58
+ }
59
+
32
60
  export interface OpenReadOptions extends OpenOptions {
33
61
  /** First byte (default 0). */
34
62
  start?: number;
@@ -50,10 +78,10 @@ export interface OpenReadOptions extends OpenOptions {
50
78
  export interface FilesRpc {
51
79
  stats(path: string): Promise<FileStats | undefined>;
52
80
  exists(path: string): Promise<boolean>;
53
- mkdir(path: string): Promise<void>;
54
- remove(path: string): Promise<boolean>;
55
- move(source: string, target: string): Promise<boolean>;
56
- copy(source: string, target: string): Promise<boolean>;
81
+ mkdir(path: string, options?: RequestOptions): Promise<void>;
82
+ remove(path: string, options?: RequestOptions): Promise<boolean>;
83
+ move(source: string, target: string, options?: RequestOptions): Promise<boolean>;
84
+ copy(source: string, target: string, options?: RequestOptions): Promise<boolean>;
57
85
  list(path: string, options?: ListPageOptions): Promise<ListPage>;
58
86
 
59
87
  /** Opens `path` for reading. A file that does not exist reads as nothing. */
@@ -66,8 +94,11 @@ export interface FilesRpc {
66
94
  /** Ends the stream (unknown or closed: nothing happens). */
67
95
  closeRead(streamId: string): Promise<void>;
68
96
 
69
- /** Opens `path` for writing; the FilesApi's `write` consumes the chunks in order. */
70
- openWrite(path: string, options?: OpenOptions): Promise<{ streamId: string }>;
97
+ /**
98
+ * Opens `path` for writing (or, with `append`, for appending); the FilesApi consumes the chunks
99
+ * in order.
100
+ */
101
+ openWrite(path: string, options?: OpenWriteOptions): Promise<{ streamId: string }>;
71
102
  /**
72
103
  * Hands over chunk `chunkId`. Resolves once it is kept (not yet written): at once if it is
73
104
  * within `writeWindow` chunks of the next to write, else when it is. A chunk already received