@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/README.md +43 -13
- package/dist/cjs/index.cjs +147 -26
- package/dist/client.d.ts +5 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/esm/index.js +147 -26
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/types.d.ts +35 -7
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/client.ts +31 -6
- package/src/errors.ts +13 -4
- package/src/index.ts +3 -0
- package/src/server.ts +158 -13
- package/src/types.ts +38 -7
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 {
|
|
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:
|
|
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
|
-
//
|
|
534
|
-
|
|
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
|
-
/**
|
|
70
|
-
|
|
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
|