@statewalker/webrun-files-rpc 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,736 @@
1
+ import type { FileInfo, FilesApi } from "@statewalker/webrun-files";
2
+ import { FilesRpcError } from "./errors.js";
3
+ import { Keyed } from "./keyed.js";
4
+ import type {
5
+ AppendableFilesApi,
6
+ FilesRpc,
7
+ ListPage,
8
+ OpenOptions,
9
+ OpenReadOptions,
10
+ OpenWriteOptions,
11
+ ReadResult,
12
+ RequestOptions,
13
+ } from "./types.js";
14
+
15
+ export interface FilesRpcServerOptions {
16
+ /** The file system served. */
17
+ fs: FilesApi;
18
+ /** Largest chunk a read stream answers. Default 256 KiB. */
19
+ chunkSize?: number;
20
+ /** Chunks produced ahead, counting from the highest one asked. Default 4. */
21
+ readAhead?: number;
22
+ /** A chunk is released once the reader asked for one `retain` chunks past it. Default 16. */
23
+ retain?: number;
24
+ /**
25
+ * A read request may name a chunk at most this many past the lowest chunk not yet delivered
26
+ * (INVALID beyond). Bounds what one stream makes the server pull and hold. Default 64.
27
+ */
28
+ window?: number;
29
+ /**
30
+ * Chunks of a write kept ahead of the next one to write. Default 16. One window more may wait
31
+ * for room; a chunk beyond is refused (INVALID).
32
+ */
33
+ writeWindow?: number;
34
+ /** Largest chunk a write takes. Default 4 MiB. */
35
+ maxChunkSize?: number;
36
+ /** A stream not used for this long is dropped (reads closed, writes aborted). Default 2 min. */
37
+ idleTimeout?: number;
38
+ /** Open streams at most. Default 256. */
39
+ maxStreams?: number;
40
+ /** Largest listing page. Default 256. */
41
+ maxPageSize?: number;
42
+ /** Answers of metadata calls kept by their `requestId` at most (default 1024). */
43
+ maxRequests?: number;
44
+ /** Sweeps idle streams every this many ms (default: only when a stream is opened). */
45
+ sweepInterval?: number;
46
+ /** The clock (tests). Default `Date.now`. */
47
+ now?: () => number;
48
+ }
49
+
50
+ /** The server: the RPC methods, plus maintenance. */
51
+ export interface FilesRpcServer extends FilesRpc {
52
+ /** Drops idle streams; returns how many. Opening a stream does it too. */
53
+ sweep(): number;
54
+ /** Streams open now. */
55
+ openStreams(): number;
56
+ /**
57
+ * Ends every stream (reads closed, writes aborted) and stops the sweep timer. It does not wait
58
+ * for the FilesApi's writes to settle, and waits at most a second for sources to end.
59
+ */
60
+ close(): Promise<void>;
61
+ }
62
+
63
+ interface ReadStream {
64
+ kind: "read";
65
+ touched: number;
66
+ /** Calls in progress: a stream with any is not idle. */
67
+ busy: number;
68
+ source: AsyncIterator<Uint8Array>;
69
+ chunks: Keyed<ReadResult>;
70
+ /** Bytes pulled from the source, not yet a chunk. */
71
+ buffer: Uint8Array[];
72
+ buffered: number;
73
+ /**
74
+ * The next chunk id to produce; the highest asked; below `released`, chunks are dropped once
75
+ * delivered (`delivered`: ids answered at least once and not yet dropped).
76
+ */
77
+ produced: number;
78
+ /** Every chunk below `low` was delivered; `delivered`: those above it that were. */
79
+ low: number;
80
+ delivered: Set<number>;
81
+ asked: number;
82
+ released: number;
83
+ ended: boolean;
84
+ closed: boolean;
85
+ error?: unknown;
86
+ pumping?: Promise<void>;
87
+ }
88
+
89
+ interface WriteStream {
90
+ kind: "write";
91
+ touched: number;
92
+ busy: number;
93
+ /** Chunks received and not yet written, by id; the next id to write. */
94
+ chunks: Map<number, Uint8Array>;
95
+ next: number;
96
+ /** Set by closeWrite: the stream's chunk count. */
97
+ total?: number;
98
+ /** Whoever waits for the next chunk (the FilesApi's write) or for room in the window. */
99
+ wake?: () => void;
100
+ room: { chunkId: number; resolve: () => void; reject: (e: unknown) => void }[];
101
+ failure?: unknown;
102
+ done: Promise<void>;
103
+ /** An append the server emulates; `committing` once it writes the file back. */
104
+ emulated?: boolean;
105
+ committing?: boolean;
106
+ }
107
+
108
+ type Stream = ReadStream | WriteStream;
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
+
130
+ const EMPTY = new Uint8Array();
131
+ const STREAM_ID = /^[A-Za-z0-9_-]{16,128}$/;
132
+
133
+ function newStreamId(): string {
134
+ return crypto.randomUUID().replace(/-/g, "");
135
+ }
136
+
137
+ function toBytes(data: unknown): Uint8Array {
138
+ if (data instanceof Uint8Array) return data;
139
+ if (ArrayBuffer.isView(data))
140
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
141
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
142
+ throw new FilesRpcError("INVALID", "a chunk is a Uint8Array or an ArrayBuffer");
143
+ }
144
+
145
+ function chunkIdOf(v: unknown): number {
146
+ const n = Number(v);
147
+ if (!Number.isSafeInteger(n) || n < 0)
148
+ throw new FilesRpcError("INVALID", `a chunk id is an integer from 0 (got ${String(v)})`);
149
+ return n;
150
+ }
151
+
152
+ /** Orders paths by Unicode code point, as `FilesApi.list()` does (webrun-files' comparePaths). */
153
+ function comparePaths(a: string, b: string): number {
154
+ const x = a[Symbol.iterator]();
155
+ const y = b[Symbol.iterator]();
156
+ for (;;) {
157
+ const p = x.next();
158
+ const q = y.next();
159
+ if (p.done || q.done) return p.done && q.done ? 0 : p.done ? -1 : 1;
160
+ const d = (p.value.codePointAt(0) as number) - (q.value.codePointAt(0) as number);
161
+ if (d !== 0) return d;
162
+ }
163
+ }
164
+
165
+ /** Joins the first `n` buffered bytes into one chunk. */
166
+ function take(s: ReadStream, n: number): Uint8Array {
167
+ const out = new Uint8Array(n);
168
+ let at = 0;
169
+ while (at < n) {
170
+ const head = s.buffer[0] as Uint8Array;
171
+ const part = head.subarray(0, n - at);
172
+ out.set(part, at);
173
+ at += part.length;
174
+ if (part.length === head.length) s.buffer.shift();
175
+ else s.buffer[0] = head.subarray(part.length);
176
+ }
177
+ s.buffered -= n;
178
+ return out;
179
+ }
180
+
181
+ /** Serves a FilesApi as the FilesRpc methods. */
182
+ export function newFilesRpcServer(options: FilesRpcServerOptions): FilesRpcServer {
183
+ const fs = options.fs;
184
+ const chunkSize = Math.max(1, options.chunkSize ?? 256 * 1024);
185
+ const readAhead = Math.max(1, options.readAhead ?? 4);
186
+ const retain = Math.max(1, options.retain ?? 16);
187
+ const window = Math.max(1, options.window ?? 64);
188
+ const writeWindow = Math.max(1, options.writeWindow ?? 16);
189
+ const maxChunkSize = Math.max(1, options.maxChunkSize ?? 4 * 1024 * 1024);
190
+ const idleTimeout = options.idleTimeout ?? 2 * 60_000;
191
+ const maxStreams = options.maxStreams ?? 256;
192
+ const maxPageSize = Math.max(1, options.maxPageSize ?? 256);
193
+ const now = options.now ?? Date.now;
194
+
195
+ const streams = new Map<string, Stream>();
196
+ /**
197
+ * Streams that ended recently, by id: an open delivered again after its stream ended must not
198
+ * open it anew, and a closeWrite delivered again answers as the first one did.
199
+ */
200
+ const ended = new Map<string, { at: number; failure?: unknown }>();
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
+
240
+ const finish = (id: string, failure?: unknown) => {
241
+ streams.delete(id);
242
+ ended.set(id, { at: now(), failure });
243
+ };
244
+
245
+ const stream = <K extends Stream["kind"]>(id: string, kind: K): Extract<Stream, { kind: K }> => {
246
+ const s = streams.get(String(id));
247
+ if (!s || s.kind !== kind)
248
+ throw new FilesRpcError("UNKNOWN_STREAM", `no open ${kind} stream '${String(id)}'`);
249
+ s.touched = now();
250
+ return s as Extract<Stream, { kind: K }>;
251
+ };
252
+
253
+ /** Runs `work` as a call in progress on `s`: the stream is not idle meanwhile. */
254
+ const busy = async <T>(s: Stream, work: () => Promise<T>): Promise<T> => {
255
+ s.busy++;
256
+ try {
257
+ return await work();
258
+ } finally {
259
+ s.busy--;
260
+ s.touched = now();
261
+ }
262
+ };
263
+
264
+ /** An id for a new stream, or the stream an open delivered twice already opened. */
265
+ const claim = (
266
+ opts: OpenOptions | undefined,
267
+ kind: Stream["kind"],
268
+ ): { id: string; existing?: Stream } => {
269
+ const wanted = opts?.streamId;
270
+ if (wanted !== undefined) {
271
+ if (typeof wanted !== "string" || !STREAM_ID.test(wanted))
272
+ throw new FilesRpcError("INVALID", "a stream id is 16 to 128 characters of [A-Za-z0-9_-]");
273
+ const existing = streams.get(wanted);
274
+ if (existing) {
275
+ if (existing.kind !== kind)
276
+ throw new FilesRpcError("INVALID", `stream '${wanted}' is a ${existing.kind} stream`);
277
+ return { id: wanted, existing };
278
+ }
279
+ if (ended.has(wanted)) throw new FilesRpcError("INVALID", `stream '${wanted}' has ended`);
280
+ }
281
+ server.sweep();
282
+ if (streams.size >= maxStreams)
283
+ throw new FilesRpcError("TOO_MANY_STREAMS", `at most ${maxStreams} open streams`);
284
+ return { id: wanted ?? newStreamId() };
285
+ };
286
+
287
+ // ---- reads ----
288
+
289
+ /** Pulls the source until `readAhead` chunks from the highest asked exist, or it ends. */
290
+ const wanted = (s: ReadStream) =>
291
+ !s.ended && !s.closed && s.error === undefined && s.produced < s.asked + readAhead;
292
+ const pump = (s: ReadStream): Promise<void> => {
293
+ if (s.pumping) return s.pumping;
294
+ const run = (async () => {
295
+ // Started on the next tick: `s.pumping` is set before the loop can finish.
296
+ await undefined;
297
+ while (wanted(s)) {
298
+ // A chunk is only cut once more bytes follow it (or the source ended), so the last one
299
+ // says done.
300
+ if (s.buffered > chunkSize) {
301
+ const id = s.produced++;
302
+ s.chunks.put(id, { data: take(s, chunkSize), done: false });
303
+ continue;
304
+ }
305
+ let next: IteratorResult<Uint8Array>;
306
+ try {
307
+ next = await s.source.next();
308
+ } catch (e) {
309
+ // What came before the failure is still served.
310
+ while (s.buffered > 0) {
311
+ const id = s.produced++;
312
+ s.chunks.put(id, { data: take(s, Math.min(chunkSize, s.buffered)), done: false });
313
+ }
314
+ s.error = e;
315
+ s.chunks.fail(e);
316
+ return;
317
+ }
318
+ if (next.done) {
319
+ while (s.buffered > chunkSize) {
320
+ const id = s.produced++;
321
+ s.chunks.put(id, { data: take(s, chunkSize), done: false });
322
+ }
323
+ const id = s.produced++;
324
+ s.chunks.put(id, { data: s.buffered ? take(s, s.buffered) : EMPTY, done: true });
325
+ s.ended = true;
326
+ s.chunks.settleFrom(s.produced, () => ({ data: EMPTY, done: true }));
327
+ return;
328
+ }
329
+ let part: Uint8Array;
330
+ try {
331
+ part = toBytes(next.value);
332
+ } catch {
333
+ s.error = new FilesRpcError("INVALID", "the source yielded something that is not bytes");
334
+ s.chunks.fail(s.error);
335
+ return;
336
+ }
337
+ if (part.length) {
338
+ s.buffer.push(part);
339
+ s.buffered += part.length;
340
+ }
341
+ }
342
+ })();
343
+ s.pumping = run;
344
+ // A chunk asked while the loop was ending is produced by the next round.
345
+ void run.finally(() => {
346
+ s.pumping = undefined;
347
+ if (wanted(s)) void pump(s);
348
+ });
349
+ return run;
350
+ };
351
+
352
+ const closeSource = async (s: ReadStream) => {
353
+ s.closed = true;
354
+ s.chunks.fail(new FilesRpcError("UNKNOWN_STREAM", "the stream was closed"));
355
+ // Waits for a pull in progress, then ends the source (its finally blocks run).
356
+ await s.pumping?.catch(() => {});
357
+ await Promise.resolve(s.source.return?.()).catch(() => {});
358
+ };
359
+
360
+ // ---- writes ----
361
+
362
+ /** The chunks of a write stream in order, as they arrive: what the FilesApi's write consumes. */
363
+ async function* ordered(s: WriteStream): AsyncGenerator<Uint8Array> {
364
+ for (;;) {
365
+ s.touched = now(); // the file system took the last chunk: the stream is in use
366
+ if (s.failure !== undefined) throw s.failure;
367
+ const chunk = s.chunks.get(s.next);
368
+ if (chunk) {
369
+ s.chunks.delete(s.next);
370
+ s.next++;
371
+ for (const r of s.room.filter((r) => r.chunkId < s.next + writeWindow)) {
372
+ s.room.splice(s.room.indexOf(r), 1);
373
+ r.resolve();
374
+ }
375
+ yield chunk;
376
+ continue;
377
+ }
378
+ if (s.total !== undefined && s.next >= s.total) return;
379
+ await new Promise<void>((resolve) => {
380
+ s.wake = resolve;
381
+ });
382
+ s.wake = undefined;
383
+ }
384
+ }
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
+
451
+ const failWrite = (s: WriteStream, failure: unknown) => {
452
+ s.failure ??= failure;
453
+ for (const r of s.room.splice(0)) r.reject(s.failure);
454
+ s.wake?.();
455
+ };
456
+
457
+ const server: FilesRpcServer = {
458
+ async stats(path) {
459
+ return fs.stats(path);
460
+ },
461
+ async exists(path) {
462
+ return fs.exists(path);
463
+ },
464
+ async mkdir(path, opts?: RequestOptions) {
465
+ await once("mkdir", opts, () => fs.mkdir(path));
466
+ },
467
+ async remove(path, opts?: RequestOptions) {
468
+ return once("remove", opts, () => fs.remove(path));
469
+ },
470
+ async move(source, target, opts?: RequestOptions) {
471
+ return once("move", opts, () => fs.move(source, target));
472
+ },
473
+ async copy(source, target, opts?: RequestOptions) {
474
+ return once("copy", opts, () => fs.copy(source, target));
475
+ },
476
+ async list(path, opts = {}): Promise<ListPage> {
477
+ const limit = Math.min(
478
+ Math.max(1, Math.floor(Number(opts.limit) || maxPageSize)),
479
+ maxPageSize,
480
+ );
481
+ const entries: FileInfo[] = [];
482
+ let more = false;
483
+ const after = opts.after;
484
+ for await (const entry of fs.list(path, { recursive: !!opts.recursive, after })) {
485
+ // `after` is applied here too: a FilesApi that ignores it must not make pages repeat.
486
+ if (after !== undefined && comparePaths(entry.path, after) <= 0) continue;
487
+ if (entries.length === limit) {
488
+ more = true;
489
+ break;
490
+ }
491
+ entries.push({ ...entry });
492
+ }
493
+ return { entries, more };
494
+ },
495
+
496
+ async openRead(path, opts: OpenReadOptions = {}) {
497
+ const { id, existing } = claim(opts, "read");
498
+ if (existing) return { streamId: id };
499
+ const length = opts.length;
500
+ const source =
501
+ length === 0
502
+ ? (async function* () {})()
503
+ : fs.read(path, { start: opts.start, length })[Symbol.asyncIterator]();
504
+ streams.set(id, {
505
+ kind: "read",
506
+ touched: now(),
507
+ busy: 0,
508
+ source,
509
+ chunks: new Keyed<ReadResult>(),
510
+ buffer: [],
511
+ buffered: 0,
512
+ produced: 0,
513
+ low: 0,
514
+ delivered: new Set(),
515
+ asked: 0,
516
+ released: 0,
517
+ ended: false,
518
+ closed: false,
519
+ });
520
+ return { streamId: id };
521
+ },
522
+
523
+ async readChunk(streamId, chunkIdArg) {
524
+ const s = stream(streamId, "read");
525
+ const chunkId = chunkIdOf(chunkIdArg);
526
+ if (chunkId >= s.low + window)
527
+ throw new FilesRpcError(
528
+ "INVALID",
529
+ `chunk ${chunkId} is more than ${window} chunks past the lowest undelivered (${s.low})`,
530
+ );
531
+ // Behind the release line, a chunk is gone once it was delivered; one nobody received yet
532
+ // (asked out of order, or still to be produced) is still served.
533
+ if (chunkId < s.released && chunkId < s.produced && !s.chunks.has(chunkId))
534
+ throw new FilesRpcError(
535
+ "CHUNK_RELEASED",
536
+ `chunk ${chunkId} of '${streamId}' was released (the reader moved past it)`,
537
+ );
538
+ if (chunkId > s.asked) {
539
+ s.asked = chunkId;
540
+ // Chunks `retain` behind the highest asked are not asked again: release the delivered.
541
+ // (At most `window` steps: `asked` stays within the window.)
542
+ const keepFrom = s.asked - retain + 1;
543
+ for (; s.released < keepFrom; s.released++) {
544
+ if (s.released < s.low || s.delivered.has(s.released)) s.chunks.delete(s.released);
545
+ }
546
+ }
547
+ if (s.ended && chunkId >= s.produced) return { data: EMPTY, done: true };
548
+ return busy(s, async () => {
549
+ const answer = s.chunks.wait(chunkId);
550
+ void pump(s);
551
+ const result = await answer;
552
+ // Delivered: the lowest undelivered chunk moves up over every delivered one.
553
+ if (chunkId === s.low) {
554
+ s.low++;
555
+ while (s.delivered.delete(s.low)) s.low++;
556
+ } else if (chunkId > s.low) s.delivered.add(chunkId);
557
+ if (chunkId < s.released) s.chunks.delete(chunkId);
558
+ return result;
559
+ });
560
+ },
561
+
562
+ async closeRead(streamId) {
563
+ const s = streams.get(String(streamId));
564
+ if (s?.kind !== "read") return;
565
+ finish(String(streamId));
566
+ await closeSource(s);
567
+ },
568
+
569
+ async openWrite(path, opts: OpenWriteOptions = {}) {
570
+ const { id, existing } = claim(opts, "write");
571
+ if (existing) return { streamId: id };
572
+ const s: WriteStream = {
573
+ kind: "write",
574
+ touched: now(),
575
+ busy: 0,
576
+ chunks: new Map(),
577
+ next: 0,
578
+ room: [],
579
+ done: Promise.resolve(),
580
+ };
581
+ s.done = (opts.append ? append(path, ordered(s), id, s) : fs.write(path, ordered(s))).then(
582
+ () => {
583
+ if (s.failure !== undefined) throw s.failure;
584
+ },
585
+ (e) => {
586
+ failWrite(s, e);
587
+ throw s.failure;
588
+ },
589
+ );
590
+ // Settled by closeWrite (or nobody, after an abort): never an unhandled rejection. A
591
+ // failed stream stays until closed, aborted or swept, so its next call gets the failure.
592
+ s.done.catch(() => {});
593
+ streams.set(id, s);
594
+ return { streamId: id };
595
+ },
596
+
597
+ async writeChunk(streamId, chunkIdArg, data) {
598
+ const s = stream(streamId, "write");
599
+ const chunkId = chunkIdOf(chunkIdArg);
600
+ const bytes = toBytes(data);
601
+ if (bytes.length > maxChunkSize)
602
+ throw new FilesRpcError("INVALID", `a chunk is at most ${maxChunkSize} bytes`);
603
+ if (s.failure !== undefined) throw s.failure;
604
+ if (s.total !== undefined && chunkId >= s.total)
605
+ throw new FilesRpcError(
606
+ "INVALID",
607
+ `chunk ${chunkId} is past the stream's ${s.total} chunks`,
608
+ );
609
+ // Delivered twice: already written, or already kept.
610
+ if (chunkId < s.next || s.chunks.has(chunkId)) return;
611
+ // At most one window kept and one waiting for room: what a writer can make the server hold.
612
+ if (chunkId >= s.next + 2 * writeWindow)
613
+ throw new FilesRpcError(
614
+ "INVALID",
615
+ `chunk ${chunkId} is more than ${2 * writeWindow} chunks past the next to write (${s.next})`,
616
+ );
617
+ if (chunkId >= s.next + writeWindow) {
618
+ await busy(
619
+ s,
620
+ () => new Promise<void>((resolve, reject) => s.room.push({ chunkId, resolve, reject })),
621
+ );
622
+ if (chunkId < s.next || s.chunks.has(chunkId)) return;
623
+ }
624
+ // A copy: the caller's buffer may be reused, and a view of a larger buffer is cut out.
625
+ s.chunks.set(chunkId, bytes.slice());
626
+ if (chunkId === s.next) s.wake?.();
627
+ },
628
+
629
+ async closeWrite(streamId, chunksArg) {
630
+ const id = String(streamId);
631
+ const done = ended.get(id);
632
+ if (done && !streams.has(id)) {
633
+ if (done.failure !== undefined) throw done.failure;
634
+ return;
635
+ }
636
+ const s = stream(id, "write");
637
+ const total = chunkIdOf(chunksArg);
638
+ const highest = Math.max(s.next - 1, ...s.chunks.keys());
639
+ if (highest >= total) {
640
+ const error = new FilesRpcError(
641
+ "INVALID",
642
+ `the stream has chunk ${highest}, so more than ${total} chunks`,
643
+ );
644
+ failWrite(s, error);
645
+ finish(id, error);
646
+ throw error;
647
+ }
648
+ s.total = total;
649
+ // A chunk waiting for room past the count will never be written.
650
+ for (const r of s.room.filter((r) => r.chunkId >= total)) {
651
+ s.room.splice(s.room.indexOf(r), 1);
652
+ r.reject(
653
+ new FilesRpcError("INVALID", `chunk ${r.chunkId} is past the stream's ${total} chunks`),
654
+ );
655
+ }
656
+ s.wake?.();
657
+ try {
658
+ await busy(s, () => s.done);
659
+ if (streams.get(id) === s) finish(id);
660
+ } catch (e) {
661
+ if (streams.get(id) === s) finish(id, e);
662
+ throw e;
663
+ }
664
+ },
665
+
666
+ async abortWrite(streamId, reason) {
667
+ const id = String(streamId);
668
+ const s = streams.get(id);
669
+ if (s?.kind !== "write") 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;
673
+ const error = new FilesRpcError("ABORTED", reason ? String(reason) : "the write was aborted");
674
+ finish(id, error);
675
+ failWrite(s, error);
676
+ },
677
+
678
+ sweep() {
679
+ const t = now();
680
+ let dropped = 0;
681
+ for (const [id, s] of [...streams]) {
682
+ if (t - s.touched <= idleTimeout) continue;
683
+ // Busy: the file system is working for it. A write whose calls only wait for the holder's
684
+ // next chunk (waiting for room, or closeWrite waiting for a missing chunk) is idle.
685
+ if (s.kind === "read" && s.busy > 0) continue;
686
+ if (
687
+ s.kind === "write" &&
688
+ (s.chunks.has(s.next) || (s.total !== undefined && s.next >= s.total))
689
+ )
690
+ continue;
691
+ dropped++;
692
+ const error = new FilesRpcError("ABORTED", "the stream was idle too long");
693
+ finish(id, error);
694
+ if (s.kind === "read") void closeSource(s);
695
+ else failWrite(s, error);
696
+ }
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);
700
+ return dropped;
701
+ },
702
+
703
+ openStreams() {
704
+ return streams.size;
705
+ },
706
+
707
+ async close() {
708
+ if (timer) clearInterval(timer);
709
+ const sources: Promise<unknown>[] = [];
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
+ }
716
+ const error = new FilesRpcError("ABORTED", "the server closed");
717
+ finish(id, error);
718
+ if (s.kind === "read") sources.push(closeSource(s));
719
+ else failWrite(s, error); // not awaited: a FilesApi write may never settle
720
+ }
721
+ await Promise.race([
722
+ Promise.all(sources),
723
+ new Promise((r) => {
724
+ const t = setTimeout(r, 1000);
725
+ (t as { unref?: () => void }).unref?.();
726
+ }),
727
+ ]);
728
+ },
729
+ };
730
+ const timer =
731
+ options.sweepInterval && options.sweepInterval > 0
732
+ ? setInterval(() => server.sweep(), options.sweepInterval)
733
+ : undefined;
734
+ (timer as { unref?: () => void } | undefined)?.unref?.();
735
+ return server;
736
+ }