@statewalker/webrun-files-rpc 0.1.0 → 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/README.md +43 -13
- package/dist/cjs/index.cjs +143 -18
- 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 +143 -18
- 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 +1 -1
- 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/README.md
CHANGED
|
@@ -7,7 +7,7 @@ A `FilesApi` (from `@statewalker/webrun-files`) over any RPC, with streamed read
|
|
|
7
7
|
- **Server** (`newFilesRpcServer({ fs })`): serves any `FilesApi` as plain method calls.
|
|
8
8
|
- **Client** (`newFilesRpcClient({ rpc })`): a `FilesApi` whose every call goes through those
|
|
9
9
|
methods: a Durable Object or Workers RPC stub, comlink, `@statewalker/webrun-rpc`, or the server
|
|
10
|
-
object itself.
|
|
10
|
+
object itself. It also appends (`append(path, content)`), which `FilesApi` does not.
|
|
11
11
|
|
|
12
12
|
Content moves as **chunks keyed by `(streamId, chunkId)`**. Both sides keep chunks by their key
|
|
13
13
|
and wait for the ones still on their way:
|
|
@@ -37,10 +37,13 @@ Keys make the calls safe on real transports:
|
|
|
37
37
|
- a chunk read twice is answered twice;
|
|
38
38
|
- an open with the same `streamId` opens one stream;
|
|
39
39
|
- a `closeWrite` delivered again answers as the first did.
|
|
40
|
-
-
|
|
41
|
-
|
|
42
|
-
`
|
|
43
|
-
|
|
40
|
+
- a metadata call (`mkdir`, `remove`, `move`, `copy`) with a `requestId` runs once: delivered
|
|
41
|
+
again, concurrently or later, it gets the first answer (or failure). The server keeps answers
|
|
42
|
+
for `idleTimeout`, at most `maxRequests` of them. The client sends a fresh `requestId` with
|
|
43
|
+
every such call.
|
|
44
|
+
- Without a `requestId`, a metadata call delivered twice runs twice: the end state is the same
|
|
45
|
+
when nothing else writes in between, but the second may report `false` though the first did
|
|
46
|
+
it, and a late `copy` retry copies again.
|
|
44
47
|
|
|
45
48
|
## How to use
|
|
46
49
|
|
|
@@ -58,8 +61,8 @@ Exports:
|
|
|
58
61
|
- `newFilesRpcServer`, `newFilesRpcClient`, `FilesRpcClient`;
|
|
59
62
|
- `FilesRpcError` and `errorCode`;
|
|
60
63
|
- types `FilesRpc`, `FilesRpcServer`, `FilesRpcServerOptions`, `FilesRpcClientOptions`,
|
|
61
|
-
`ReadResult`, `ListPage`, `ListPageOptions`, `OpenOptions`,
|
|
62
|
-
`FilesRpcErrorCode`.
|
|
64
|
+
`AppendableFilesApi`, `ReadResult`, `ListPage`, `ListPageOptions`, `OpenOptions`,
|
|
65
|
+
`OpenReadOptions`, `OpenWriteOptions`, `RequestOptions`, `FilesRpcErrorCode`.
|
|
63
66
|
|
|
64
67
|
### `newFilesRpcServer(options)`
|
|
65
68
|
|
|
@@ -75,6 +78,7 @@ Exports:
|
|
|
75
78
|
| `idleTimeout` | 2 min | A stream not used for this long is dropped (a read closed, a write aborted). |
|
|
76
79
|
| `maxStreams` | 256 | Open streams at most (`TOO_MANY_STREAMS` beyond). |
|
|
77
80
|
| `maxPageSize` | 256 | Largest listing page. |
|
|
81
|
+
| `maxRequests` | 1024 | Answers of metadata calls kept by `requestId` (oldest dropped beyond). |
|
|
78
82
|
| `sweepInterval` | none | Sweep idle streams every this many ms. Without it, opening a stream sweeps; a server that stops receiving opens needs this (or calls to `sweep()`). |
|
|
79
83
|
| `now` | `Date.now` | The clock (tests). |
|
|
80
84
|
|
|
@@ -94,6 +98,9 @@ The server is a `FilesRpc` plus:
|
|
|
94
98
|
| `writeAhead` | 4 | `writeChunk` calls not yet answered while writing. |
|
|
95
99
|
| `pageSize` | 256 | Listing page size. |
|
|
96
100
|
|
|
101
|
+
Besides the `FilesApi` methods, the client has `append(path, content)`: it adds `content` to the
|
|
102
|
+
end of `path` (creating it if it does not exist), streamed exactly as `write` streams.
|
|
103
|
+
|
|
97
104
|
### The protocol (`FilesRpc`)
|
|
98
105
|
|
|
99
106
|
Every argument and result is a plain value: strings, numbers, booleans, plain objects and
|
|
@@ -104,12 +111,13 @@ Every argument and result is a plain value: strings, numbers, booleans, plain ob
|
|
|
104
111
|
| `openRead(path, {start?, length?, streamId?})` | `{streamId}`; a file that does not exist reads as nothing |
|
|
105
112
|
| `readChunk(streamId, chunkId)` | `{data, done}` once chunk `chunkId` exists; past the end `{data: empty, done: true}` |
|
|
106
113
|
| `closeRead(streamId)` | ends the stream (unknown or ended: nothing happens) |
|
|
107
|
-
| `openWrite(path, {streamId?})` | `{streamId}`; the `FilesApi`'s `write` starts consuming chunks in order |
|
|
114
|
+
| `openWrite(path, {streamId?, append?})` | `{streamId}`; the `FilesApi`'s `write` (or, with `append`, the append) starts consuming chunks in order |
|
|
108
115
|
| `writeChunk(streamId, chunkId, data)` | resolves once the chunk is kept (within `writeWindow` of the next to write: at once) |
|
|
109
116
|
| `closeWrite(streamId, chunks)` | resolves once all `chunks` chunks are written |
|
|
110
117
|
| `abortWrite(streamId, reason?)` | cancels the write (unknown or ended: nothing happens) |
|
|
111
118
|
| `list(path, {recursive?, after?, limit?})` | `{entries, more}`; ask again with `after` = the last path |
|
|
112
|
-
| `stats`, `exists
|
|
119
|
+
| `stats`, `exists` | as the `FilesApi` |
|
|
120
|
+
| `mkdir(path, {requestId?})`, `remove(path, {requestId?})`, `move(source, target, {requestId?})`, `copy(source, target, {requestId?})` | as the `FilesApi`; with a `requestId`, once (a call delivered again gets the first answer) |
|
|
113
121
|
|
|
114
122
|
- **Stream ids:** `streamId` may be chosen by the caller: 16 to 128 characters of `[A-Za-z0-9_-]`,
|
|
115
123
|
random, such as `crypto.randomUUID()` without dashes. Then an open delivered twice opens one
|
|
@@ -216,6 +224,27 @@ await server.closeRead(r);
|
|
|
216
224
|
keeps what it had.
|
|
217
225
|
- `write()` starts at `openWrite`, not at the first chunk. A FilesApi that truncates when a
|
|
218
226
|
write starts has emptied the file already if the write is then aborted.
|
|
227
|
+
- **Appends:** `openWrite(path, { append: true })` streams like a write, and the chunks go to the
|
|
228
|
+
end of the file.
|
|
229
|
+
- A served FilesApi with an `append(path, content)` method (`AppendableFilesApi`) appends
|
|
230
|
+
through it, with whatever atomicity it gives.
|
|
231
|
+
- Any other FilesApi is appended to with `read`, `write` and `remove` only, in two steps:
|
|
232
|
+
1. the file's content and the chunks are written to a temporary sibling,
|
|
233
|
+
`.<name>.append-<id>` (the name cut to 64 characters, the stream id to 16);
|
|
234
|
+
2. the sibling is written back over the file, then removed.
|
|
235
|
+
- It never reads and writes one path at once, so a FilesApi that empties a file when a write
|
|
236
|
+
starts is safe. Only a directory is refused (`INVALID`); `stats` saying the file is missing is
|
|
237
|
+
not trusted, since reading a missing file yields nothing and the file is created all the same.
|
|
238
|
+
- A failure or an abort during step 1 leaves the file as it was and removes the sibling. An
|
|
239
|
+
abort counts until step 2 starts, even after every chunk was sent. A failure during step 2
|
|
240
|
+
may leave the file incomplete, so the sibling, the only complete copy, is kept and its path
|
|
241
|
+
is in the error.
|
|
242
|
+
- Appends to one file (by its normal path) through one server run one after the other. Other
|
|
243
|
+
writers are not excluded. The emulation copies the file twice and shows the sibling in
|
|
244
|
+
listings while it runs.
|
|
245
|
+
- `close()` and expiry leave a write whose outcome is the FilesApi's alone (every chunk handed
|
|
246
|
+
over, or an emulated append writing back), so a `closeWrite` waiting on it gets the real
|
|
247
|
+
outcome.
|
|
219
248
|
- **Ended streams:** ids that ended are remembered for `idleTimeout`. Within that time, an open
|
|
220
249
|
delivered after its stream ended does not reopen it, and a `closeWrite` delivered again gets the
|
|
221
250
|
first answer.
|
|
@@ -235,8 +264,8 @@ await server.closeRead(r);
|
|
|
235
264
|
- **The client:**
|
|
236
265
|
- **Read:** it keeps `readAhead` `readChunk` calls in flight and yields the answers in key
|
|
237
266
|
order. Breaking out of the loop, or aborting through `signal`, closes the stream.
|
|
238
|
-
- **Write:** it cuts any sequence of byte arrays into chunks of exactly `chunkSize`
|
|
239
|
-
`writeAhead` unanswered. Any failure (of the source, or of a call) aborts the write.
|
|
267
|
+
- **Write and append:** it cuts any sequence of byte arrays into chunks of exactly `chunkSize`
|
|
268
|
+
and keeps `writeAhead` unanswered. Any failure (of the source, or of a call) aborts the write.
|
|
240
269
|
- **List:** it asks page after page with `after`. The server also applies `after` itself, in
|
|
241
270
|
code-point order, so a FilesApi that ignores it cannot make pages repeat.
|
|
242
271
|
- **Not covered:**
|
|
@@ -257,9 +286,10 @@ encoding come from `webrun-files-tests`.
|
|
|
257
286
|
| `server-read.test.ts` | numbered chunks with `done`, out-of-order and concurrent requests, ranges, missing files, retries, release, bounded read-ahead, closing, failing sources |
|
|
258
287
|
| `server-write.test.ts` | in-order and out-of-order chunks, duplicates, backpressure, `closeWrite` waiting for a gap or refusing a wrong count, empty files, abort, failing writes, byte types |
|
|
259
288
|
| `server-lifecycle.test.ts` | idle expiry, the stream limit, `close()`, unguessable ids, paged listing, metadata |
|
|
260
|
-
| `protocol.test.ts` | caller-chosen keys (opens and `closeWrite` delivered twice, after the end), what is refused, waiting requests failed by a close, error codes across a structured clone, many streams at once, release never dropping an undelivered chunk, metadata calls delivered twice |
|
|
289
|
+
| `protocol.test.ts` | caller-chosen keys (opens and `closeWrite` delivered twice, after the end), what is refused, waiting requests failed by a close, error codes across a structured clone, many streams at once, release never dropping an undelivered chunk, metadata calls delivered twice (with a `requestId`: once, the same answer, failures too, kept for `idleTimeout` and `maxRequests`; without: the same end state) |
|
|
261
290
|
| `robustness.test.ts` | the window (huge chunk ids refused at once, bounded pulls), busy streams not swept, writes too far ahead refused, writes waiting for the holder swept, the sweep timer, a failing write's error reaching the client, a late abort not undoing a commit, a source yielding non-bytes, waiters past the count, aborting a hanging read, `close()` not hanging, listing with a FilesApi that ignores `after` |
|
|
262
291
|
| `client.test.ts` | the `FilesApi` round trip, iterables, ranges, pipelining depth, early stop and abort, failing sources, write depth, listing; delayed, reordered and duplicated calls (five seeds) |
|
|
263
|
-
| `
|
|
292
|
+
| `append.test.ts` | appends through the FilesApi's own `append` and through the emulation, also on a FilesApi that writes as it goes: existing and missing files, large files, aborts (also after every chunk was sent) and failing sources leaving the file unchanged and no temporary file, a failed write-back keeping the temporary copy, `close()` during the write-back, two appends at once, `stats` not trusted, odd paths and long names, directories refused; over the transport simulator; an open delivered twice |
|
|
293
|
+
| `conformance.test.ts` | the repository's `FilesApi` suite (`webrun-files-tests`) on the client over the server: directly, and through the transport simulator (cloned, reordered, every call delivered twice at random) |
|
|
264
294
|
| `big-files.test.ts` | the repository's big-file suite (64 MiB) on the client over the server, every call structured-cloned |
|
|
265
295
|
| `readme.test.ts` | the examples above |
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -56,8 +56,9 @@ var FilesRpcClient = class {
|
|
|
56
56
|
streamId: newStreamId$1()
|
|
57
57
|
});
|
|
58
58
|
const pending = /* @__PURE__ */ new Map();
|
|
59
|
+
let fail;
|
|
59
60
|
const aborted = signal ? new Promise((_, reject) => {
|
|
60
|
-
|
|
61
|
+
fail = () => reject(signal.reason ?? /* @__PURE__ */ new Error("aborted"));
|
|
61
62
|
if (signal.aborted) fail();
|
|
62
63
|
else signal.addEventListener("abort", fail, { once: true });
|
|
63
64
|
}) : void 0;
|
|
@@ -82,6 +83,7 @@ var FilesRpcClient = class {
|
|
|
82
83
|
signal?.throwIfAborted();
|
|
83
84
|
}
|
|
84
85
|
} finally {
|
|
86
|
+
if (fail) signal?.removeEventListener("abort", fail);
|
|
85
87
|
await this.#rpc.closeRead(streamId).catch(() => {});
|
|
86
88
|
}
|
|
87
89
|
}
|
|
@@ -90,7 +92,20 @@ var FilesRpcClient = class {
|
|
|
90
92
|
* then `closeWrite` with their count; any failure aborts the write.
|
|
91
93
|
*/
|
|
92
94
|
async write(path, content) {
|
|
93
|
-
|
|
95
|
+
return this.#send(path, content, false);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Adds `content` to the end of `path` (creating it if it does not exist), streamed as `write`
|
|
99
|
+
* streams. The server appends through the served FilesApi's own `append`, or emulates it.
|
|
100
|
+
*/
|
|
101
|
+
async append(path, content) {
|
|
102
|
+
return this.#send(path, content, true);
|
|
103
|
+
}
|
|
104
|
+
async #send(path, content, append) {
|
|
105
|
+
const { streamId } = await this.#rpc.openWrite(path, {
|
|
106
|
+
streamId: newStreamId$1(),
|
|
107
|
+
...append ? { append: true } : {}
|
|
108
|
+
});
|
|
94
109
|
const inFlight = /* @__PURE__ */ new Set();
|
|
95
110
|
let failure;
|
|
96
111
|
let count = 0;
|
|
@@ -115,7 +130,7 @@ var FilesRpcClient = class {
|
|
|
115
130
|
}
|
|
116
131
|
}
|
|
117
132
|
async mkdir(path) {
|
|
118
|
-
await this.#rpc.mkdir(path);
|
|
133
|
+
await this.#rpc.mkdir(path, { requestId: newStreamId$1() });
|
|
119
134
|
}
|
|
120
135
|
/** Pages of `pageSize`, each asked after the last path of the one before. */
|
|
121
136
|
async *list(path, options = {}) {
|
|
@@ -138,13 +153,13 @@ var FilesRpcClient = class {
|
|
|
138
153
|
return this.#rpc.exists(path);
|
|
139
154
|
}
|
|
140
155
|
remove(path) {
|
|
141
|
-
return this.#rpc.remove(path);
|
|
156
|
+
return this.#rpc.remove(path, { requestId: newStreamId$1() });
|
|
142
157
|
}
|
|
143
158
|
move(source, target) {
|
|
144
|
-
return this.#rpc.move(source, target);
|
|
159
|
+
return this.#rpc.move(source, target, { requestId: newStreamId$1() });
|
|
145
160
|
}
|
|
146
161
|
copy(source, target) {
|
|
147
|
-
return this.#rpc.copy(source, target);
|
|
162
|
+
return this.#rpc.copy(source, target, { requestId: newStreamId$1() });
|
|
148
163
|
}
|
|
149
164
|
};
|
|
150
165
|
/** A FilesApi over a FilesRpc. */
|
|
@@ -169,9 +184,18 @@ var FilesRpcError = class extends Error {
|
|
|
169
184
|
/** The FilesRpcError code of an error, also after it crossed a transport; undefined otherwise. */
|
|
170
185
|
function errorCode(e) {
|
|
171
186
|
const code = e?.code;
|
|
172
|
-
if (typeof code === "string") return code;
|
|
173
|
-
return
|
|
187
|
+
if (typeof code === "string" && CODES.has(code)) return code;
|
|
188
|
+
return LEADING_CODE.exec(String(e?.message ?? ""))?.[1];
|
|
174
189
|
}
|
|
190
|
+
const CODES = /* @__PURE__ */ new Set([
|
|
191
|
+
"UNKNOWN_STREAM",
|
|
192
|
+
"CHUNK_RELEASED",
|
|
193
|
+
"TOO_MANY_STREAMS",
|
|
194
|
+
"ABORTED",
|
|
195
|
+
"INVALID"
|
|
196
|
+
]);
|
|
197
|
+
/** A message led by a code, as FilesRpcError writes it (`CODE: …`). */
|
|
198
|
+
const LEADING_CODE = new RegExp(`^(${[...CODES].join("|")}):`);
|
|
175
199
|
//#endregion
|
|
176
200
|
//#region src/keyed.ts
|
|
177
201
|
/**
|
|
@@ -238,6 +262,22 @@ var Keyed = class {
|
|
|
238
262
|
};
|
|
239
263
|
//#endregion
|
|
240
264
|
//#region src/server.ts
|
|
265
|
+
/**
|
|
266
|
+
* Whether a write's outcome is the FilesApi's alone: every chunk was handed to it (an emulated
|
|
267
|
+
* append, until its write-back starts, can still be cancelled).
|
|
268
|
+
*/
|
|
269
|
+
const committed = (s) => s.total !== void 0 && s.next >= s.total && (!s.emulated || s.committing === true);
|
|
270
|
+
/** `path` in normal form: one leading slash, no empty, "." or ".." segments, no trailing slash. */
|
|
271
|
+
function normalPath(path) {
|
|
272
|
+
const out = [];
|
|
273
|
+
for (const seg of String(path).split("/")) {
|
|
274
|
+
if (!seg || seg === ".") continue;
|
|
275
|
+
if (seg === "..") out.pop();
|
|
276
|
+
else out.push(seg);
|
|
277
|
+
}
|
|
278
|
+
return `/${out.join("/")}`;
|
|
279
|
+
}
|
|
280
|
+
const messageOf = (e) => String(e?.message ?? e);
|
|
241
281
|
const EMPTY = /* @__PURE__ */ new Uint8Array();
|
|
242
282
|
const STREAM_ID = /^[A-Za-z0-9_-]{16,128}$/;
|
|
243
283
|
function newStreamId() {
|
|
@@ -300,6 +340,40 @@ function newFilesRpcServer(options) {
|
|
|
300
340
|
* open it anew, and a closeWrite delivered again answers as the first one did.
|
|
301
341
|
*/
|
|
302
342
|
const ended = /* @__PURE__ */ new Map();
|
|
343
|
+
/** Answers of metadata calls, by method and requestId, oldest first. */
|
|
344
|
+
const requests = /* @__PURE__ */ new Map();
|
|
345
|
+
const maxRequests = Math.max(1, options.maxRequests ?? 1024);
|
|
346
|
+
/** Runs `call` once per requestId (within idleTimeout): a call delivered again gets its answer. */
|
|
347
|
+
const once = (method, opts, call) => {
|
|
348
|
+
const id = opts?.requestId;
|
|
349
|
+
if (id === void 0) return call();
|
|
350
|
+
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_-]"));
|
|
351
|
+
const key = `${method} ${id}`;
|
|
352
|
+
const kept = requests.get(key);
|
|
353
|
+
if (kept) return kept.answer;
|
|
354
|
+
const answer = call();
|
|
355
|
+
answer.catch(() => {});
|
|
356
|
+
const entry = {
|
|
357
|
+
at: now(),
|
|
358
|
+
settled: false,
|
|
359
|
+
answer
|
|
360
|
+
};
|
|
361
|
+
requests.set(key, entry);
|
|
362
|
+
const settle = () => {
|
|
363
|
+
entry.at = now();
|
|
364
|
+
entry.settled = true;
|
|
365
|
+
};
|
|
366
|
+
answer.then(settle, settle);
|
|
367
|
+
while (requests.size > maxRequests) {
|
|
368
|
+
let victim;
|
|
369
|
+
for (const [k, r] of requests) if (r.settled) {
|
|
370
|
+
victim = k;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
requests.delete(victim ?? requests.keys().next().value);
|
|
374
|
+
}
|
|
375
|
+
return answer;
|
|
376
|
+
};
|
|
303
377
|
const finish = (id, failure) => {
|
|
304
378
|
streams.delete(id);
|
|
305
379
|
ended.set(id, {
|
|
@@ -442,6 +516,52 @@ function newFilesRpcServer(options) {
|
|
|
442
516
|
s.wake = void 0;
|
|
443
517
|
}
|
|
444
518
|
}
|
|
519
|
+
/** Emulated appends in progress, by normal path: one at a time per file. */
|
|
520
|
+
const appending = /* @__PURE__ */ new Map();
|
|
521
|
+
/**
|
|
522
|
+
* Appends `content` to `path`: through the FilesApi's own `append`, or with read, write and
|
|
523
|
+
* remove only. The emulation never reads and writes one path at once (a FilesApi may empty a
|
|
524
|
+
* file when a write starts):
|
|
525
|
+
* 1. the file's content and the chunks go to a temporary sibling;
|
|
526
|
+
* 2. that sibling is written back over the file, then removed.
|
|
527
|
+
* A failure or an abort in step 1 leaves the file as it was and removes the sibling. A failure
|
|
528
|
+
* in step 2 keeps the sibling (the only complete copy) and names it in the error. Appends to
|
|
529
|
+
* one file through this server run one after the other; other writers are not excluded.
|
|
530
|
+
*/
|
|
531
|
+
const append = async (path, content, id, s) => {
|
|
532
|
+
const own = fs.append;
|
|
533
|
+
if (typeof own === "function") return own.call(fs, path, content);
|
|
534
|
+
s.emulated = true;
|
|
535
|
+
const file = normalPath(path);
|
|
536
|
+
const run = (appending.get(file) ?? Promise.resolve()).then(async () => {
|
|
537
|
+
if ((await fs.stats(file))?.kind === "directory") throw new FilesRpcError("INVALID", `cannot append to a directory: ${file}`);
|
|
538
|
+
const slash = file.lastIndexOf("/");
|
|
539
|
+
const temp = `${file.slice(0, slash + 1)}.${file.slice(slash + 1, slash + 65)}.append-${id.slice(0, 16)}`;
|
|
540
|
+
try {
|
|
541
|
+
await fs.write(temp, (async function* () {
|
|
542
|
+
yield* fs.read(file);
|
|
543
|
+
yield* content;
|
|
544
|
+
})());
|
|
545
|
+
if (s.failure !== void 0) throw s.failure;
|
|
546
|
+
} catch (e) {
|
|
547
|
+
await fs.remove(temp).catch(() => {});
|
|
548
|
+
throw e;
|
|
549
|
+
}
|
|
550
|
+
s.committing = true;
|
|
551
|
+
try {
|
|
552
|
+
await fs.write(file, fs.read(temp));
|
|
553
|
+
} catch (e) {
|
|
554
|
+
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 });
|
|
555
|
+
}
|
|
556
|
+
await fs.remove(temp).catch(() => {});
|
|
557
|
+
});
|
|
558
|
+
const settled = run.catch(() => {});
|
|
559
|
+
appending.set(file, settled);
|
|
560
|
+
settled.then(() => {
|
|
561
|
+
if (appending.get(file) === settled) appending.delete(file);
|
|
562
|
+
});
|
|
563
|
+
return run;
|
|
564
|
+
};
|
|
445
565
|
const failWrite = (s, failure) => {
|
|
446
566
|
s.failure ??= failure;
|
|
447
567
|
for (const r of s.room.splice(0)) r.reject(s.failure);
|
|
@@ -454,17 +574,17 @@ function newFilesRpcServer(options) {
|
|
|
454
574
|
async exists(path) {
|
|
455
575
|
return fs.exists(path);
|
|
456
576
|
},
|
|
457
|
-
async mkdir(path) {
|
|
458
|
-
await fs.mkdir(path);
|
|
577
|
+
async mkdir(path, opts) {
|
|
578
|
+
await once("mkdir", opts, () => fs.mkdir(path));
|
|
459
579
|
},
|
|
460
|
-
async remove(path) {
|
|
461
|
-
return fs.remove(path);
|
|
580
|
+
async remove(path, opts) {
|
|
581
|
+
return once("remove", opts, () => fs.remove(path));
|
|
462
582
|
},
|
|
463
|
-
async move(source, target) {
|
|
464
|
-
return fs.move(source, target);
|
|
583
|
+
async move(source, target, opts) {
|
|
584
|
+
return once("move", opts, () => fs.move(source, target));
|
|
465
585
|
},
|
|
466
|
-
async copy(source, target) {
|
|
467
|
-
return fs.copy(source, target);
|
|
586
|
+
async copy(source, target, opts) {
|
|
587
|
+
return once("copy", opts, () => fs.copy(source, target));
|
|
468
588
|
},
|
|
469
589
|
async list(path, opts = {}) {
|
|
470
590
|
const limit = Math.min(Math.max(1, Math.floor(Number(opts.limit) || maxPageSize)), maxPageSize);
|
|
@@ -557,7 +677,7 @@ function newFilesRpcServer(options) {
|
|
|
557
677
|
room: [],
|
|
558
678
|
done: Promise.resolve()
|
|
559
679
|
};
|
|
560
|
-
s.done = fs.write(path, ordered(s)).then(() => {
|
|
680
|
+
s.done = (opts.append ? append(path, ordered(s), id, s) : fs.write(path, ordered(s))).then(() => {
|
|
561
681
|
if (s.failure !== void 0) throw s.failure;
|
|
562
682
|
}, (e) => {
|
|
563
683
|
failWrite(s, e);
|
|
@@ -621,7 +741,7 @@ function newFilesRpcServer(options) {
|
|
|
621
741
|
const id = String(streamId);
|
|
622
742
|
const s = streams.get(id);
|
|
623
743
|
if (s?.kind !== "write") return;
|
|
624
|
-
if (s
|
|
744
|
+
if (committed(s)) return;
|
|
625
745
|
const error = new FilesRpcError("ABORTED", reason ? String(reason) : "the write was aborted");
|
|
626
746
|
finish(id, error);
|
|
627
747
|
failWrite(s, error);
|
|
@@ -640,6 +760,7 @@ function newFilesRpcServer(options) {
|
|
|
640
760
|
else failWrite(s, error);
|
|
641
761
|
}
|
|
642
762
|
for (const [id, e] of [...ended]) if (t - e.at > idleTimeout) ended.delete(id);
|
|
763
|
+
for (const [key, r] of [...requests]) if (r.settled && t - r.at > idleTimeout) requests.delete(key);
|
|
643
764
|
return dropped;
|
|
644
765
|
},
|
|
645
766
|
openStreams() {
|
|
@@ -649,6 +770,10 @@ function newFilesRpcServer(options) {
|
|
|
649
770
|
if (timer) clearInterval(timer);
|
|
650
771
|
const sources = [];
|
|
651
772
|
for (const [id, s] of [...streams]) {
|
|
773
|
+
if (s.kind === "write" && committed(s)) {
|
|
774
|
+
streams.delete(id);
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
652
777
|
const error = new FilesRpcError("ABORTED", "the server closed");
|
|
653
778
|
finish(id, error);
|
|
654
779
|
if (s.kind === "read") sources.push(closeSource(s));
|
package/dist/client.d.ts
CHANGED
|
@@ -26,6 +26,11 @@ export declare class FilesRpcClient implements FilesApi {
|
|
|
26
26
|
* then `closeWrite` with their count; any failure aborts the write.
|
|
27
27
|
*/
|
|
28
28
|
write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Adds `content` to the end of `path` (creating it if it does not exist), streamed as `write`
|
|
31
|
+
* streams. The server appends through the served FilesApi's own `append`, or emulates it.
|
|
32
|
+
*/
|
|
33
|
+
append(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
|
|
29
34
|
mkdir(path: string): Promise<void>;
|
|
30
35
|
/** Pages of `pageSize`, each asked after the last path of the one before. */
|
|
31
36
|
list(path: string, options?: ListOptions): AsyncIterable<FileInfo>;
|
package/dist/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,QAAQ,EAAc,MAAM,YAAY,CAAC;AAEvD,MAAM,WAAW,qBAAqB;IACpC,wFAAwF;IACxF,GAAG,EAAE,QAAQ,CAAC;IACd,8FAA8F;IAC9F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4FAA4F;IAC5F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sCAAsC;IACtC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAyCD,0FAA0F;AAC1F,qBAAa,cAAe,YAAW,QAAQ;;IAO7C,YAAY,OAAO,EAAE,qBAAqB,EAMzC;IAED;;;OAGG;IACI,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,aAAa,CAAC,UAAU,CAAC,
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,QAAQ,EAAc,MAAM,YAAY,CAAC;AAEvD,MAAM,WAAW,qBAAqB;IACpC,wFAAwF;IACxF,GAAG,EAAE,QAAQ,CAAC;IACd,8FAA8F;IAC9F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4FAA4F;IAC5F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sCAAsC;IACtC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAyCD,0FAA0F;AAC1F,qBAAa,cAAe,YAAW,QAAQ;;IAO7C,YAAY,OAAO,EAAE,qBAAqB,EAMzC;IAED;;;OAGG;IACI,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,aAAa,CAAC,UAAU,CAAC,CA4C9E;IAED;;;OAGG;IACG,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC,CAEf;IAED;;;OAGG;IACG,MAAM,CACV,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC,CAEf;IAsCK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEvC;IAED,6EAA6E;IACtE,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,aAAa,CAAC,QAAQ,CAAC,CAY5E;IAED,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAElD;IACD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAErC;IACD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAErC;IACD,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAErD;IACD,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAErD;CACF;AAED,kCAAkC;AAClC,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,qBAAqB,GAAG,cAAc,CAEhF"}
|
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD;;;;GAIG;AACH,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAC;IACjC,YAAY,IAAI,EAAE,iBAAiB,EAAE,OAAO,EAAE,MAAM,EAInD;CACF;AAED,kGAAkG;AAClG,wBAAgB,SAAS,CAAC,CAAC,EAAE,OAAO,GAAG,iBAAiB,GAAG,SAAS,
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD;;;;GAIG;AACH,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAC;IACjC,YAAY,IAAI,EAAE,iBAAiB,EAAE,OAAO,EAAE,MAAM,EAInD;CACF;AAED,kGAAkG;AAClG,wBAAgB,SAAS,CAAC,CAAC,EAAE,OAAO,GAAG,iBAAiB,GAAG,SAAS,CAMnE"}
|
package/dist/esm/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,13 +152,13 @@ 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. */
|
|
@@ -168,9 +183,18 @@ var FilesRpcError = class extends Error {
|
|
|
168
183
|
/** The FilesRpcError code of an error, also after it crossed a transport; undefined otherwise. */
|
|
169
184
|
function errorCode(e) {
|
|
170
185
|
const code = e?.code;
|
|
171
|
-
if (typeof code === "string") return code;
|
|
172
|
-
return
|
|
186
|
+
if (typeof code === "string" && CODES.has(code)) return code;
|
|
187
|
+
return LEADING_CODE.exec(String(e?.message ?? ""))?.[1];
|
|
173
188
|
}
|
|
189
|
+
const CODES = /* @__PURE__ */ new Set([
|
|
190
|
+
"UNKNOWN_STREAM",
|
|
191
|
+
"CHUNK_RELEASED",
|
|
192
|
+
"TOO_MANY_STREAMS",
|
|
193
|
+
"ABORTED",
|
|
194
|
+
"INVALID"
|
|
195
|
+
]);
|
|
196
|
+
/** A message led by a code, as FilesRpcError writes it (`CODE: …`). */
|
|
197
|
+
const LEADING_CODE = new RegExp(`^(${[...CODES].join("|")}):`);
|
|
174
198
|
//#endregion
|
|
175
199
|
//#region src/keyed.ts
|
|
176
200
|
/**
|
|
@@ -237,6 +261,22 @@ var Keyed = class {
|
|
|
237
261
|
};
|
|
238
262
|
//#endregion
|
|
239
263
|
//#region src/server.ts
|
|
264
|
+
/**
|
|
265
|
+
* Whether a write's outcome is the FilesApi's alone: every chunk was handed to it (an emulated
|
|
266
|
+
* append, until its write-back starts, can still be cancelled).
|
|
267
|
+
*/
|
|
268
|
+
const committed = (s) => s.total !== void 0 && s.next >= s.total && (!s.emulated || s.committing === true);
|
|
269
|
+
/** `path` in normal form: one leading slash, no empty, "." or ".." segments, no trailing slash. */
|
|
270
|
+
function normalPath(path) {
|
|
271
|
+
const out = [];
|
|
272
|
+
for (const seg of String(path).split("/")) {
|
|
273
|
+
if (!seg || seg === ".") continue;
|
|
274
|
+
if (seg === "..") out.pop();
|
|
275
|
+
else out.push(seg);
|
|
276
|
+
}
|
|
277
|
+
return `/${out.join("/")}`;
|
|
278
|
+
}
|
|
279
|
+
const messageOf = (e) => String(e?.message ?? e);
|
|
240
280
|
const EMPTY = /* @__PURE__ */ new Uint8Array();
|
|
241
281
|
const STREAM_ID = /^[A-Za-z0-9_-]{16,128}$/;
|
|
242
282
|
function newStreamId() {
|
|
@@ -299,6 +339,40 @@ function newFilesRpcServer(options) {
|
|
|
299
339
|
* open it anew, and a closeWrite delivered again answers as the first one did.
|
|
300
340
|
*/
|
|
301
341
|
const ended = /* @__PURE__ */ new Map();
|
|
342
|
+
/** Answers of metadata calls, by method and requestId, oldest first. */
|
|
343
|
+
const requests = /* @__PURE__ */ new Map();
|
|
344
|
+
const maxRequests = Math.max(1, options.maxRequests ?? 1024);
|
|
345
|
+
/** Runs `call` once per requestId (within idleTimeout): a call delivered again gets its answer. */
|
|
346
|
+
const once = (method, opts, call) => {
|
|
347
|
+
const id = opts?.requestId;
|
|
348
|
+
if (id === void 0) return call();
|
|
349
|
+
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_-]"));
|
|
350
|
+
const key = `${method} ${id}`;
|
|
351
|
+
const kept = requests.get(key);
|
|
352
|
+
if (kept) return kept.answer;
|
|
353
|
+
const answer = call();
|
|
354
|
+
answer.catch(() => {});
|
|
355
|
+
const entry = {
|
|
356
|
+
at: now(),
|
|
357
|
+
settled: false,
|
|
358
|
+
answer
|
|
359
|
+
};
|
|
360
|
+
requests.set(key, entry);
|
|
361
|
+
const settle = () => {
|
|
362
|
+
entry.at = now();
|
|
363
|
+
entry.settled = true;
|
|
364
|
+
};
|
|
365
|
+
answer.then(settle, settle);
|
|
366
|
+
while (requests.size > maxRequests) {
|
|
367
|
+
let victim;
|
|
368
|
+
for (const [k, r] of requests) if (r.settled) {
|
|
369
|
+
victim = k;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
requests.delete(victim ?? requests.keys().next().value);
|
|
373
|
+
}
|
|
374
|
+
return answer;
|
|
375
|
+
};
|
|
302
376
|
const finish = (id, failure) => {
|
|
303
377
|
streams.delete(id);
|
|
304
378
|
ended.set(id, {
|
|
@@ -441,6 +515,52 @@ function newFilesRpcServer(options) {
|
|
|
441
515
|
s.wake = void 0;
|
|
442
516
|
}
|
|
443
517
|
}
|
|
518
|
+
/** Emulated appends in progress, by normal path: one at a time per file. */
|
|
519
|
+
const appending = /* @__PURE__ */ new Map();
|
|
520
|
+
/**
|
|
521
|
+
* Appends `content` to `path`: through the FilesApi's own `append`, or with read, write and
|
|
522
|
+
* remove only. The emulation never reads and writes one path at once (a FilesApi may empty a
|
|
523
|
+
* file when a write starts):
|
|
524
|
+
* 1. the file's content and the chunks go to a temporary sibling;
|
|
525
|
+
* 2. that sibling is written back over the file, then removed.
|
|
526
|
+
* A failure or an abort in step 1 leaves the file as it was and removes the sibling. A failure
|
|
527
|
+
* in step 2 keeps the sibling (the only complete copy) and names it in the error. Appends to
|
|
528
|
+
* one file through this server run one after the other; other writers are not excluded.
|
|
529
|
+
*/
|
|
530
|
+
const append = async (path, content, id, s) => {
|
|
531
|
+
const own = fs.append;
|
|
532
|
+
if (typeof own === "function") return own.call(fs, path, content);
|
|
533
|
+
s.emulated = true;
|
|
534
|
+
const file = normalPath(path);
|
|
535
|
+
const run = (appending.get(file) ?? Promise.resolve()).then(async () => {
|
|
536
|
+
if ((await fs.stats(file))?.kind === "directory") throw new FilesRpcError("INVALID", `cannot append to a directory: ${file}`);
|
|
537
|
+
const slash = file.lastIndexOf("/");
|
|
538
|
+
const temp = `${file.slice(0, slash + 1)}.${file.slice(slash + 1, slash + 65)}.append-${id.slice(0, 16)}`;
|
|
539
|
+
try {
|
|
540
|
+
await fs.write(temp, (async function* () {
|
|
541
|
+
yield* fs.read(file);
|
|
542
|
+
yield* content;
|
|
543
|
+
})());
|
|
544
|
+
if (s.failure !== void 0) throw s.failure;
|
|
545
|
+
} catch (e) {
|
|
546
|
+
await fs.remove(temp).catch(() => {});
|
|
547
|
+
throw e;
|
|
548
|
+
}
|
|
549
|
+
s.committing = true;
|
|
550
|
+
try {
|
|
551
|
+
await fs.write(file, fs.read(temp));
|
|
552
|
+
} catch (e) {
|
|
553
|
+
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 });
|
|
554
|
+
}
|
|
555
|
+
await fs.remove(temp).catch(() => {});
|
|
556
|
+
});
|
|
557
|
+
const settled = run.catch(() => {});
|
|
558
|
+
appending.set(file, settled);
|
|
559
|
+
settled.then(() => {
|
|
560
|
+
if (appending.get(file) === settled) appending.delete(file);
|
|
561
|
+
});
|
|
562
|
+
return run;
|
|
563
|
+
};
|
|
444
564
|
const failWrite = (s, failure) => {
|
|
445
565
|
s.failure ??= failure;
|
|
446
566
|
for (const r of s.room.splice(0)) r.reject(s.failure);
|
|
@@ -453,17 +573,17 @@ function newFilesRpcServer(options) {
|
|
|
453
573
|
async exists(path) {
|
|
454
574
|
return fs.exists(path);
|
|
455
575
|
},
|
|
456
|
-
async mkdir(path) {
|
|
457
|
-
await fs.mkdir(path);
|
|
576
|
+
async mkdir(path, opts) {
|
|
577
|
+
await once("mkdir", opts, () => fs.mkdir(path));
|
|
458
578
|
},
|
|
459
|
-
async remove(path) {
|
|
460
|
-
return fs.remove(path);
|
|
579
|
+
async remove(path, opts) {
|
|
580
|
+
return once("remove", opts, () => fs.remove(path));
|
|
461
581
|
},
|
|
462
|
-
async move(source, target) {
|
|
463
|
-
return fs.move(source, target);
|
|
582
|
+
async move(source, target, opts) {
|
|
583
|
+
return once("move", opts, () => fs.move(source, target));
|
|
464
584
|
},
|
|
465
|
-
async copy(source, target) {
|
|
466
|
-
return fs.copy(source, target);
|
|
585
|
+
async copy(source, target, opts) {
|
|
586
|
+
return once("copy", opts, () => fs.copy(source, target));
|
|
467
587
|
},
|
|
468
588
|
async list(path, opts = {}) {
|
|
469
589
|
const limit = Math.min(Math.max(1, Math.floor(Number(opts.limit) || maxPageSize)), maxPageSize);
|
|
@@ -556,7 +676,7 @@ function newFilesRpcServer(options) {
|
|
|
556
676
|
room: [],
|
|
557
677
|
done: Promise.resolve()
|
|
558
678
|
};
|
|
559
|
-
s.done = fs.write(path, ordered(s)).then(() => {
|
|
679
|
+
s.done = (opts.append ? append(path, ordered(s), id, s) : fs.write(path, ordered(s))).then(() => {
|
|
560
680
|
if (s.failure !== void 0) throw s.failure;
|
|
561
681
|
}, (e) => {
|
|
562
682
|
failWrite(s, e);
|
|
@@ -620,7 +740,7 @@ function newFilesRpcServer(options) {
|
|
|
620
740
|
const id = String(streamId);
|
|
621
741
|
const s = streams.get(id);
|
|
622
742
|
if (s?.kind !== "write") return;
|
|
623
|
-
if (s
|
|
743
|
+
if (committed(s)) return;
|
|
624
744
|
const error = new FilesRpcError("ABORTED", reason ? String(reason) : "the write was aborted");
|
|
625
745
|
finish(id, error);
|
|
626
746
|
failWrite(s, error);
|
|
@@ -639,6 +759,7 @@ function newFilesRpcServer(options) {
|
|
|
639
759
|
else failWrite(s, error);
|
|
640
760
|
}
|
|
641
761
|
for (const [id, e] of [...ended]) if (t - e.at > idleTimeout) ended.delete(id);
|
|
762
|
+
for (const [key, r] of [...requests]) if (r.settled && t - r.at > idleTimeout) requests.delete(key);
|
|
642
763
|
return dropped;
|
|
643
764
|
},
|
|
644
765
|
openStreams() {
|
|
@@ -648,6 +769,10 @@ function newFilesRpcServer(options) {
|
|
|
648
769
|
if (timer) clearInterval(timer);
|
|
649
770
|
const sources = [];
|
|
650
771
|
for (const [id, s] of [...streams]) {
|
|
772
|
+
if (s.kind === "write" && committed(s)) {
|
|
773
|
+
streams.delete(id);
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
651
776
|
const error = new FilesRpcError("ABORTED", "the server closed");
|
|
652
777
|
finish(id, error);
|
|
653
778
|
if (s.kind === "read") sources.push(closeSource(s));
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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,
|
|
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`. */
|
package/dist/server.d.ts.map
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
64
|
-
|
|
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
|
/**
|
package/dist/types.d.ts.map
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAE/E,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,qFAAqF;AACrF,MAAM,WAAW,cAAc;IAC7B;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD;;;OAGG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED;;;;;GAKG;AACH,MAAM,WAAW,kBAAmB,SAAQ,QAAQ;IAClD,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,CAAC,UAAU,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzE;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,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7D,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACjE,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACjF,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACjF,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;;;OAGG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACnF;;;;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"}
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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";
|
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
|