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

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,591 @@
1
+ import type { FileInfo, FilesApi } from "@statewalker/webrun-files";
2
+ import { FilesRpcError } from "./errors.js";
3
+ import { Keyed } from "./keyed.js";
4
+ import type { FilesRpc, ListPage, OpenOptions, OpenReadOptions, ReadResult } from "./types.js";
5
+
6
+ export interface FilesRpcServerOptions {
7
+ /** The file system served. */
8
+ fs: FilesApi;
9
+ /** Largest chunk a read stream answers. Default 256 KiB. */
10
+ chunkSize?: number;
11
+ /** Chunks produced ahead, counting from the highest one asked. Default 4. */
12
+ readAhead?: number;
13
+ /** A chunk is released once the reader asked for one `retain` chunks past it. Default 16. */
14
+ retain?: number;
15
+ /**
16
+ * A read request may name a chunk at most this many past the lowest chunk not yet delivered
17
+ * (INVALID beyond). Bounds what one stream makes the server pull and hold. Default 64.
18
+ */
19
+ window?: number;
20
+ /**
21
+ * Chunks of a write kept ahead of the next one to write. Default 16. One window more may wait
22
+ * for room; a chunk beyond is refused (INVALID).
23
+ */
24
+ writeWindow?: number;
25
+ /** Largest chunk a write takes. Default 4 MiB. */
26
+ maxChunkSize?: number;
27
+ /** A stream not used for this long is dropped (reads closed, writes aborted). Default 2 min. */
28
+ idleTimeout?: number;
29
+ /** Open streams at most. Default 256. */
30
+ maxStreams?: number;
31
+ /** Largest listing page. Default 256. */
32
+ maxPageSize?: number;
33
+ /** Sweeps idle streams every this many ms (default: only when a stream is opened). */
34
+ sweepInterval?: number;
35
+ /** The clock (tests). Default `Date.now`. */
36
+ now?: () => number;
37
+ }
38
+
39
+ /** The server: the RPC methods, plus maintenance. */
40
+ export interface FilesRpcServer extends FilesRpc {
41
+ /** Drops idle streams; returns how many. Opening a stream does it too. */
42
+ sweep(): number;
43
+ /** Streams open now. */
44
+ openStreams(): number;
45
+ /**
46
+ * Ends every stream (reads closed, writes aborted) and stops the sweep timer. It does not wait
47
+ * for the FilesApi's writes to settle, and waits at most a second for sources to end.
48
+ */
49
+ close(): Promise<void>;
50
+ }
51
+
52
+ interface ReadStream {
53
+ kind: "read";
54
+ touched: number;
55
+ /** Calls in progress: a stream with any is not idle. */
56
+ busy: number;
57
+ source: AsyncIterator<Uint8Array>;
58
+ chunks: Keyed<ReadResult>;
59
+ /** Bytes pulled from the source, not yet a chunk. */
60
+ buffer: Uint8Array[];
61
+ buffered: number;
62
+ /**
63
+ * The next chunk id to produce; the highest asked; below `released`, chunks are dropped once
64
+ * delivered (`delivered`: ids answered at least once and not yet dropped).
65
+ */
66
+ produced: number;
67
+ /** Every chunk below `low` was delivered; `delivered`: those above it that were. */
68
+ low: number;
69
+ delivered: Set<number>;
70
+ asked: number;
71
+ released: number;
72
+ ended: boolean;
73
+ closed: boolean;
74
+ error?: unknown;
75
+ pumping?: Promise<void>;
76
+ }
77
+
78
+ interface WriteStream {
79
+ kind: "write";
80
+ touched: number;
81
+ busy: number;
82
+ /** Chunks received and not yet written, by id; the next id to write. */
83
+ chunks: Map<number, Uint8Array>;
84
+ next: number;
85
+ /** Set by closeWrite: the stream's chunk count. */
86
+ total?: number;
87
+ /** Whoever waits for the next chunk (the FilesApi's write) or for room in the window. */
88
+ wake?: () => void;
89
+ room: { chunkId: number; resolve: () => void; reject: (e: unknown) => void }[];
90
+ failure?: unknown;
91
+ done: Promise<void>;
92
+ }
93
+
94
+ type Stream = ReadStream | WriteStream;
95
+
96
+ const EMPTY = new Uint8Array();
97
+ const STREAM_ID = /^[A-Za-z0-9_-]{16,128}$/;
98
+
99
+ function newStreamId(): string {
100
+ return crypto.randomUUID().replace(/-/g, "");
101
+ }
102
+
103
+ function toBytes(data: unknown): Uint8Array {
104
+ if (data instanceof Uint8Array) return data;
105
+ if (ArrayBuffer.isView(data))
106
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
107
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
108
+ throw new FilesRpcError("INVALID", "a chunk is a Uint8Array or an ArrayBuffer");
109
+ }
110
+
111
+ function chunkIdOf(v: unknown): number {
112
+ const n = Number(v);
113
+ if (!Number.isSafeInteger(n) || n < 0)
114
+ throw new FilesRpcError("INVALID", `a chunk id is an integer from 0 (got ${String(v)})`);
115
+ return n;
116
+ }
117
+
118
+ /** Orders paths by Unicode code point, as `FilesApi.list()` does (webrun-files' comparePaths). */
119
+ function comparePaths(a: string, b: string): number {
120
+ const x = a[Symbol.iterator]();
121
+ const y = b[Symbol.iterator]();
122
+ for (;;) {
123
+ const p = x.next();
124
+ const q = y.next();
125
+ if (p.done || q.done) return p.done && q.done ? 0 : p.done ? -1 : 1;
126
+ const d = (p.value.codePointAt(0) as number) - (q.value.codePointAt(0) as number);
127
+ if (d !== 0) return d;
128
+ }
129
+ }
130
+
131
+ /** Joins the first `n` buffered bytes into one chunk. */
132
+ function take(s: ReadStream, n: number): Uint8Array {
133
+ const out = new Uint8Array(n);
134
+ let at = 0;
135
+ while (at < n) {
136
+ const head = s.buffer[0] as Uint8Array;
137
+ const part = head.subarray(0, n - at);
138
+ out.set(part, at);
139
+ at += part.length;
140
+ if (part.length === head.length) s.buffer.shift();
141
+ else s.buffer[0] = head.subarray(part.length);
142
+ }
143
+ s.buffered -= n;
144
+ return out;
145
+ }
146
+
147
+ /** Serves a FilesApi as the FilesRpc methods. */
148
+ export function newFilesRpcServer(options: FilesRpcServerOptions): FilesRpcServer {
149
+ const fs = options.fs;
150
+ const chunkSize = Math.max(1, options.chunkSize ?? 256 * 1024);
151
+ const readAhead = Math.max(1, options.readAhead ?? 4);
152
+ const retain = Math.max(1, options.retain ?? 16);
153
+ const window = Math.max(1, options.window ?? 64);
154
+ const writeWindow = Math.max(1, options.writeWindow ?? 16);
155
+ const maxChunkSize = Math.max(1, options.maxChunkSize ?? 4 * 1024 * 1024);
156
+ const idleTimeout = options.idleTimeout ?? 2 * 60_000;
157
+ const maxStreams = options.maxStreams ?? 256;
158
+ const maxPageSize = Math.max(1, options.maxPageSize ?? 256);
159
+ const now = options.now ?? Date.now;
160
+
161
+ const streams = new Map<string, Stream>();
162
+ /**
163
+ * Streams that ended recently, by id: an open delivered again after its stream ended must not
164
+ * open it anew, and a closeWrite delivered again answers as the first one did.
165
+ */
166
+ const ended = new Map<string, { at: number; failure?: unknown }>();
167
+
168
+ const finish = (id: string, failure?: unknown) => {
169
+ streams.delete(id);
170
+ ended.set(id, { at: now(), failure });
171
+ };
172
+
173
+ const stream = <K extends Stream["kind"]>(id: string, kind: K): Extract<Stream, { kind: K }> => {
174
+ const s = streams.get(String(id));
175
+ if (!s || s.kind !== kind)
176
+ throw new FilesRpcError("UNKNOWN_STREAM", `no open ${kind} stream '${String(id)}'`);
177
+ s.touched = now();
178
+ return s as Extract<Stream, { kind: K }>;
179
+ };
180
+
181
+ /** Runs `work` as a call in progress on `s`: the stream is not idle meanwhile. */
182
+ const busy = async <T>(s: Stream, work: () => Promise<T>): Promise<T> => {
183
+ s.busy++;
184
+ try {
185
+ return await work();
186
+ } finally {
187
+ s.busy--;
188
+ s.touched = now();
189
+ }
190
+ };
191
+
192
+ /** An id for a new stream, or the stream an open delivered twice already opened. */
193
+ const claim = (
194
+ opts: OpenOptions | undefined,
195
+ kind: Stream["kind"],
196
+ ): { id: string; existing?: Stream } => {
197
+ const wanted = opts?.streamId;
198
+ if (wanted !== undefined) {
199
+ if (typeof wanted !== "string" || !STREAM_ID.test(wanted))
200
+ throw new FilesRpcError("INVALID", "a stream id is 16 to 128 characters of [A-Za-z0-9_-]");
201
+ const existing = streams.get(wanted);
202
+ if (existing) {
203
+ if (existing.kind !== kind)
204
+ throw new FilesRpcError("INVALID", `stream '${wanted}' is a ${existing.kind} stream`);
205
+ return { id: wanted, existing };
206
+ }
207
+ if (ended.has(wanted)) throw new FilesRpcError("INVALID", `stream '${wanted}' has ended`);
208
+ }
209
+ server.sweep();
210
+ if (streams.size >= maxStreams)
211
+ throw new FilesRpcError("TOO_MANY_STREAMS", `at most ${maxStreams} open streams`);
212
+ return { id: wanted ?? newStreamId() };
213
+ };
214
+
215
+ // ---- reads ----
216
+
217
+ /** Pulls the source until `readAhead` chunks from the highest asked exist, or it ends. */
218
+ const wanted = (s: ReadStream) =>
219
+ !s.ended && !s.closed && s.error === undefined && s.produced < s.asked + readAhead;
220
+ const pump = (s: ReadStream): Promise<void> => {
221
+ if (s.pumping) return s.pumping;
222
+ const run = (async () => {
223
+ // Started on the next tick: `s.pumping` is set before the loop can finish.
224
+ await undefined;
225
+ while (wanted(s)) {
226
+ // A chunk is only cut once more bytes follow it (or the source ended), so the last one
227
+ // says done.
228
+ if (s.buffered > chunkSize) {
229
+ const id = s.produced++;
230
+ s.chunks.put(id, { data: take(s, chunkSize), done: false });
231
+ continue;
232
+ }
233
+ let next: IteratorResult<Uint8Array>;
234
+ try {
235
+ next = await s.source.next();
236
+ } catch (e) {
237
+ // What came before the failure is still served.
238
+ while (s.buffered > 0) {
239
+ const id = s.produced++;
240
+ s.chunks.put(id, { data: take(s, Math.min(chunkSize, s.buffered)), done: false });
241
+ }
242
+ s.error = e;
243
+ s.chunks.fail(e);
244
+ return;
245
+ }
246
+ if (next.done) {
247
+ while (s.buffered > chunkSize) {
248
+ const id = s.produced++;
249
+ s.chunks.put(id, { data: take(s, chunkSize), done: false });
250
+ }
251
+ const id = s.produced++;
252
+ s.chunks.put(id, { data: s.buffered ? take(s, s.buffered) : EMPTY, done: true });
253
+ s.ended = true;
254
+ s.chunks.settleFrom(s.produced, () => ({ data: EMPTY, done: true }));
255
+ return;
256
+ }
257
+ let part: Uint8Array;
258
+ try {
259
+ part = toBytes(next.value);
260
+ } catch {
261
+ s.error = new FilesRpcError("INVALID", "the source yielded something that is not bytes");
262
+ s.chunks.fail(s.error);
263
+ return;
264
+ }
265
+ if (part.length) {
266
+ s.buffer.push(part);
267
+ s.buffered += part.length;
268
+ }
269
+ }
270
+ })();
271
+ s.pumping = run;
272
+ // A chunk asked while the loop was ending is produced by the next round.
273
+ void run.finally(() => {
274
+ s.pumping = undefined;
275
+ if (wanted(s)) void pump(s);
276
+ });
277
+ return run;
278
+ };
279
+
280
+ const closeSource = async (s: ReadStream) => {
281
+ s.closed = true;
282
+ s.chunks.fail(new FilesRpcError("UNKNOWN_STREAM", "the stream was closed"));
283
+ // Waits for a pull in progress, then ends the source (its finally blocks run).
284
+ await s.pumping?.catch(() => {});
285
+ await Promise.resolve(s.source.return?.()).catch(() => {});
286
+ };
287
+
288
+ // ---- writes ----
289
+
290
+ /** The chunks of a write stream in order, as they arrive: what the FilesApi's write consumes. */
291
+ async function* ordered(s: WriteStream): AsyncGenerator<Uint8Array> {
292
+ for (;;) {
293
+ s.touched = now(); // the file system took the last chunk: the stream is in use
294
+ if (s.failure !== undefined) throw s.failure;
295
+ const chunk = s.chunks.get(s.next);
296
+ if (chunk) {
297
+ s.chunks.delete(s.next);
298
+ s.next++;
299
+ for (const r of s.room.filter((r) => r.chunkId < s.next + writeWindow)) {
300
+ s.room.splice(s.room.indexOf(r), 1);
301
+ r.resolve();
302
+ }
303
+ yield chunk;
304
+ continue;
305
+ }
306
+ if (s.total !== undefined && s.next >= s.total) return;
307
+ await new Promise<void>((resolve) => {
308
+ s.wake = resolve;
309
+ });
310
+ s.wake = undefined;
311
+ }
312
+ }
313
+
314
+ const failWrite = (s: WriteStream, failure: unknown) => {
315
+ s.failure ??= failure;
316
+ for (const r of s.room.splice(0)) r.reject(s.failure);
317
+ s.wake?.();
318
+ };
319
+
320
+ const server: FilesRpcServer = {
321
+ async stats(path) {
322
+ return fs.stats(path);
323
+ },
324
+ async exists(path) {
325
+ return fs.exists(path);
326
+ },
327
+ async mkdir(path) {
328
+ await fs.mkdir(path);
329
+ },
330
+ async remove(path) {
331
+ return fs.remove(path);
332
+ },
333
+ async move(source, target) {
334
+ return fs.move(source, target);
335
+ },
336
+ async copy(source, target) {
337
+ return fs.copy(source, target);
338
+ },
339
+ async list(path, opts = {}): Promise<ListPage> {
340
+ const limit = Math.min(
341
+ Math.max(1, Math.floor(Number(opts.limit) || maxPageSize)),
342
+ maxPageSize,
343
+ );
344
+ const entries: FileInfo[] = [];
345
+ let more = false;
346
+ const after = opts.after;
347
+ for await (const entry of fs.list(path, { recursive: !!opts.recursive, after })) {
348
+ // `after` is applied here too: a FilesApi that ignores it must not make pages repeat.
349
+ if (after !== undefined && comparePaths(entry.path, after) <= 0) continue;
350
+ if (entries.length === limit) {
351
+ more = true;
352
+ break;
353
+ }
354
+ entries.push({ ...entry });
355
+ }
356
+ return { entries, more };
357
+ },
358
+
359
+ async openRead(path, opts: OpenReadOptions = {}) {
360
+ const { id, existing } = claim(opts, "read");
361
+ if (existing) return { streamId: id };
362
+ const length = opts.length;
363
+ const source =
364
+ length === 0
365
+ ? (async function* () {})()
366
+ : fs.read(path, { start: opts.start, length })[Symbol.asyncIterator]();
367
+ streams.set(id, {
368
+ kind: "read",
369
+ touched: now(),
370
+ busy: 0,
371
+ source,
372
+ chunks: new Keyed<ReadResult>(),
373
+ buffer: [],
374
+ buffered: 0,
375
+ produced: 0,
376
+ low: 0,
377
+ delivered: new Set(),
378
+ asked: 0,
379
+ released: 0,
380
+ ended: false,
381
+ closed: false,
382
+ });
383
+ return { streamId: id };
384
+ },
385
+
386
+ async readChunk(streamId, chunkIdArg) {
387
+ const s = stream(streamId, "read");
388
+ const chunkId = chunkIdOf(chunkIdArg);
389
+ if (chunkId >= s.low + window)
390
+ throw new FilesRpcError(
391
+ "INVALID",
392
+ `chunk ${chunkId} is more than ${window} chunks past the lowest undelivered (${s.low})`,
393
+ );
394
+ // Behind the release line, a chunk is gone once it was delivered; one nobody received yet
395
+ // (asked out of order, or still to be produced) is still served.
396
+ if (chunkId < s.released && chunkId < s.produced && !s.chunks.has(chunkId))
397
+ throw new FilesRpcError(
398
+ "CHUNK_RELEASED",
399
+ `chunk ${chunkId} of '${streamId}' was released (the reader moved past it)`,
400
+ );
401
+ if (chunkId > s.asked) {
402
+ s.asked = chunkId;
403
+ // Chunks `retain` behind the highest asked are not asked again: release the delivered.
404
+ // (At most `window` steps: `asked` stays within the window.)
405
+ const keepFrom = s.asked - retain + 1;
406
+ for (; s.released < keepFrom; s.released++) {
407
+ if (s.released < s.low || s.delivered.has(s.released)) s.chunks.delete(s.released);
408
+ }
409
+ }
410
+ if (s.ended && chunkId >= s.produced) return { data: EMPTY, done: true };
411
+ return busy(s, async () => {
412
+ const answer = s.chunks.wait(chunkId);
413
+ void pump(s);
414
+ const result = await answer;
415
+ // Delivered: the lowest undelivered chunk moves up over every delivered one.
416
+ if (chunkId === s.low) {
417
+ s.low++;
418
+ while (s.delivered.delete(s.low)) s.low++;
419
+ } else if (chunkId > s.low) s.delivered.add(chunkId);
420
+ if (chunkId < s.released) s.chunks.delete(chunkId);
421
+ return result;
422
+ });
423
+ },
424
+
425
+ async closeRead(streamId) {
426
+ const s = streams.get(String(streamId));
427
+ if (s?.kind !== "read") return;
428
+ finish(String(streamId));
429
+ await closeSource(s);
430
+ },
431
+
432
+ async openWrite(path, opts: OpenOptions = {}) {
433
+ const { id, existing } = claim(opts, "write");
434
+ if (existing) return { streamId: id };
435
+ const s: WriteStream = {
436
+ kind: "write",
437
+ touched: now(),
438
+ busy: 0,
439
+ chunks: new Map(),
440
+ next: 0,
441
+ room: [],
442
+ done: Promise.resolve(),
443
+ };
444
+ s.done = fs.write(path, ordered(s)).then(
445
+ () => {
446
+ if (s.failure !== undefined) throw s.failure;
447
+ },
448
+ (e) => {
449
+ failWrite(s, e);
450
+ throw s.failure;
451
+ },
452
+ );
453
+ // Settled by closeWrite (or nobody, after an abort): never an unhandled rejection. A
454
+ // failed stream stays until closed, aborted or swept, so its next call gets the failure.
455
+ s.done.catch(() => {});
456
+ streams.set(id, s);
457
+ return { streamId: id };
458
+ },
459
+
460
+ async writeChunk(streamId, chunkIdArg, data) {
461
+ const s = stream(streamId, "write");
462
+ const chunkId = chunkIdOf(chunkIdArg);
463
+ const bytes = toBytes(data);
464
+ if (bytes.length > maxChunkSize)
465
+ throw new FilesRpcError("INVALID", `a chunk is at most ${maxChunkSize} bytes`);
466
+ if (s.failure !== undefined) throw s.failure;
467
+ if (s.total !== undefined && chunkId >= s.total)
468
+ throw new FilesRpcError(
469
+ "INVALID",
470
+ `chunk ${chunkId} is past the stream's ${s.total} chunks`,
471
+ );
472
+ // Delivered twice: already written, or already kept.
473
+ if (chunkId < s.next || s.chunks.has(chunkId)) return;
474
+ // At most one window kept and one waiting for room: what a writer can make the server hold.
475
+ if (chunkId >= s.next + 2 * writeWindow)
476
+ throw new FilesRpcError(
477
+ "INVALID",
478
+ `chunk ${chunkId} is more than ${2 * writeWindow} chunks past the next to write (${s.next})`,
479
+ );
480
+ if (chunkId >= s.next + writeWindow) {
481
+ await busy(
482
+ s,
483
+ () => new Promise<void>((resolve, reject) => s.room.push({ chunkId, resolve, reject })),
484
+ );
485
+ if (chunkId < s.next || s.chunks.has(chunkId)) return;
486
+ }
487
+ // A copy: the caller's buffer may be reused, and a view of a larger buffer is cut out.
488
+ s.chunks.set(chunkId, bytes.slice());
489
+ if (chunkId === s.next) s.wake?.();
490
+ },
491
+
492
+ async closeWrite(streamId, chunksArg) {
493
+ const id = String(streamId);
494
+ const done = ended.get(id);
495
+ if (done && !streams.has(id)) {
496
+ if (done.failure !== undefined) throw done.failure;
497
+ return;
498
+ }
499
+ const s = stream(id, "write");
500
+ const total = chunkIdOf(chunksArg);
501
+ const highest = Math.max(s.next - 1, ...s.chunks.keys());
502
+ if (highest >= total) {
503
+ const error = new FilesRpcError(
504
+ "INVALID",
505
+ `the stream has chunk ${highest}, so more than ${total} chunks`,
506
+ );
507
+ failWrite(s, error);
508
+ finish(id, error);
509
+ throw error;
510
+ }
511
+ s.total = total;
512
+ // A chunk waiting for room past the count will never be written.
513
+ for (const r of s.room.filter((r) => r.chunkId >= total)) {
514
+ s.room.splice(s.room.indexOf(r), 1);
515
+ r.reject(
516
+ new FilesRpcError("INVALID", `chunk ${r.chunkId} is past the stream's ${total} chunks`),
517
+ );
518
+ }
519
+ s.wake?.();
520
+ try {
521
+ await busy(s, () => s.done);
522
+ if (streams.get(id) === s) finish(id);
523
+ } catch (e) {
524
+ if (streams.get(id) === s) finish(id, e);
525
+ throw e;
526
+ }
527
+ },
528
+
529
+ async abortWrite(streamId, reason) {
530
+ const id = String(streamId);
531
+ const s = streams.get(id);
532
+ 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;
535
+ const error = new FilesRpcError("ABORTED", reason ? String(reason) : "the write was aborted");
536
+ finish(id, error);
537
+ failWrite(s, error);
538
+ },
539
+
540
+ sweep() {
541
+ const t = now();
542
+ let dropped = 0;
543
+ for (const [id, s] of [...streams]) {
544
+ if (t - s.touched <= idleTimeout) continue;
545
+ // Busy: the file system is working for it. A write whose calls only wait for the holder's
546
+ // next chunk (waiting for room, or closeWrite waiting for a missing chunk) is idle.
547
+ if (s.kind === "read" && s.busy > 0) continue;
548
+ if (
549
+ s.kind === "write" &&
550
+ (s.chunks.has(s.next) || (s.total !== undefined && s.next >= s.total))
551
+ )
552
+ continue;
553
+ dropped++;
554
+ const error = new FilesRpcError("ABORTED", "the stream was idle too long");
555
+ finish(id, error);
556
+ if (s.kind === "read") void closeSource(s);
557
+ else failWrite(s, error);
558
+ }
559
+ for (const [id, e] of [...ended]) if (t - e.at > idleTimeout) ended.delete(id);
560
+ return dropped;
561
+ },
562
+
563
+ openStreams() {
564
+ return streams.size;
565
+ },
566
+
567
+ async close() {
568
+ if (timer) clearInterval(timer);
569
+ const sources: Promise<unknown>[] = [];
570
+ for (const [id, s] of [...streams]) {
571
+ const error = new FilesRpcError("ABORTED", "the server closed");
572
+ finish(id, error);
573
+ if (s.kind === "read") sources.push(closeSource(s));
574
+ else failWrite(s, error); // not awaited: a FilesApi write may never settle
575
+ }
576
+ await Promise.race([
577
+ Promise.all(sources),
578
+ new Promise((r) => {
579
+ const t = setTimeout(r, 1000);
580
+ (t as { unref?: () => void }).unref?.();
581
+ }),
582
+ ]);
583
+ },
584
+ };
585
+ const timer =
586
+ options.sweepInterval && options.sweepInterval > 0
587
+ ? setInterval(() => server.sweep(), options.sweepInterval)
588
+ : undefined;
589
+ (timer as { unref?: () => void } | undefined)?.unref?.();
590
+ return server;
591
+ }
package/src/types.ts ADDED
@@ -0,0 +1,89 @@
1
+ import type { FileInfo, FileStats } from "@statewalker/webrun-files";
2
+
3
+ /** One chunk of a read stream: its bytes, and whether it is the last. */
4
+ export interface ReadResult {
5
+ data: Uint8Array;
6
+ done: boolean;
7
+ }
8
+
9
+ /** One page of a listing. `more`: ask again with `after` = the last path of this page. */
10
+ export interface ListPage {
11
+ entries: FileInfo[];
12
+ more: boolean;
13
+ }
14
+
15
+ /** One page of `list`: `after` the given path (exclusive), at most `limit` entries. */
16
+ export interface ListPageOptions {
17
+ recursive?: boolean;
18
+ after?: string;
19
+ limit?: number;
20
+ }
21
+
22
+ /** Open options shared by reads and writes. */
23
+ export interface OpenOptions {
24
+ /**
25
+ * The stream's id, chosen by the caller (16 to 128 characters of `[A-Za-z0-9_-]`, random:
26
+ * `crypto.randomUUID()`), so that an open delivered twice opens one stream. Without it the
27
+ * server chooses one.
28
+ */
29
+ streamId?: string;
30
+ }
31
+
32
+ export interface OpenReadOptions extends OpenOptions {
33
+ /** First byte (default 0). */
34
+ start?: number;
35
+ /** Bytes to read (default: to the end). */
36
+ length?: number;
37
+ }
38
+
39
+ /**
40
+ * What the server exposes and the client calls: a FilesApi cut into calls an RPC can carry.
41
+ * Every argument and result is a plain value (strings, numbers, booleans, plain objects,
42
+ * Uint8Array), so any transport that passes structured-clonable values carries it: Durable
43
+ * Object and Workers RPC, postMessage, comlink, or a direct call.
44
+ *
45
+ * Content moves as chunks keyed by (streamId, chunkId). chunkId counts from 0 in each stream.
46
+ * Both sides keep chunks by their key and wait for the ones that have not arrived: the server
47
+ * answers `readChunk(id, k)` once chunk k exists, and writes chunk k once chunks 0..k-1 are
48
+ * written, whatever order they came in. Calls delivered twice are harmless.
49
+ */
50
+ export interface FilesRpc {
51
+ stats(path: string): Promise<FileStats | undefined>;
52
+ 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>;
57
+ list(path: string, options?: ListPageOptions): Promise<ListPage>;
58
+
59
+ /** Opens `path` for reading. A file that does not exist reads as nothing. */
60
+ openRead(path: string, options?: OpenReadOptions): Promise<{ streamId: string }>;
61
+ /**
62
+ * Chunk `chunkId` of the stream, once it exists. Past the end: empty and done. A chunk can be
63
+ * asked again (a retry) until the reader has moved `retain` chunks past it.
64
+ */
65
+ readChunk(streamId: string, chunkId: number): Promise<ReadResult>;
66
+ /** Ends the stream (unknown or closed: nothing happens). */
67
+ closeRead(streamId: string): Promise<void>;
68
+
69
+ /** Opens `path` for writing; the FilesApi's `write` consumes the chunks in order. */
70
+ openWrite(path: string, options?: OpenOptions): Promise<{ streamId: string }>;
71
+ /**
72
+ * Hands over chunk `chunkId`. Resolves once it is kept (not yet written): at once if it is
73
+ * within `writeWindow` chunks of the next to write, else when it is. A chunk already received
74
+ * is ignored.
75
+ */
76
+ writeChunk(streamId: string, chunkId: number, data: Uint8Array | ArrayBuffer): Promise<void>;
77
+ /** The stream had `chunks` chunks: resolves when all of them are written. */
78
+ closeWrite(streamId: string, chunks: number): Promise<void>;
79
+ /** Cancels the write (unknown or closed: nothing happens). */
80
+ abortWrite(streamId: string, reason?: string): Promise<void>;
81
+ }
82
+
83
+ /** Error codes of FilesRpcError. */
84
+ export type FilesRpcErrorCode =
85
+ | "UNKNOWN_STREAM"
86
+ | "CHUNK_RELEASED"
87
+ | "TOO_MANY_STREAMS"
88
+ | "ABORTED"
89
+ | "INVALID";