@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/LICENSE +21 -0
- package/README.md +264 -2
- package/dist/cjs/index.cjs +671 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/esm/index.js +666 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/keyed.d.ts +21 -0
- package/dist/keyed.d.ts.map +1 -0
- package/dist/server.d.ts +49 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/types.d.ts +80 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +54 -3
- package/src/client.ts +200 -0
- package/src/errors.ts +25 -0
- package/src/index.ts +18 -0
- package/src/keyed.ts +57 -0
- package/src/server.ts +591 -0
- package/src/types.ts +89 -0
|
@@ -0,0 +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,CAyC9E;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,CA2Bf;IAEK,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
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { FilesRpcErrorCode } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* An error of the protocol itself. The code also leads the message (`UNKNOWN_STREAM: …`), since
|
|
4
|
+
* a structured clone keeps an error's name and message but not its other properties:
|
|
5
|
+
* `errorCode(e)` reads it back on the other side of a transport.
|
|
6
|
+
*/
|
|
7
|
+
export declare class FilesRpcError extends Error {
|
|
8
|
+
readonly code: FilesRpcErrorCode;
|
|
9
|
+
constructor(code: FilesRpcErrorCode, message: string);
|
|
10
|
+
}
|
|
11
|
+
/** The FilesRpcError code of an error, also after it crossed a transport; undefined otherwise. */
|
|
12
|
+
export declare function errorCode(e: unknown): FilesRpcErrorCode | undefined;
|
|
13
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +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,CAOnE"}
|
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
//#region src/client.ts
|
|
2
|
+
const toBytes$1 = (v) => v instanceof Uint8Array ? v : ArrayBuffer.isView(v) ? new Uint8Array(v.buffer, v.byteOffset, v.byteLength) : new Uint8Array(v);
|
|
3
|
+
const newStreamId$1 = () => crypto.randomUUID().replace(/-/g, "");
|
|
4
|
+
/** Cuts any sequence of byte arrays into chunks of exactly `size` bytes (the last may be shorter). */
|
|
5
|
+
async function* rechunk(content, size) {
|
|
6
|
+
let carry;
|
|
7
|
+
let carried = 0;
|
|
8
|
+
for await (const piece of content) {
|
|
9
|
+
let part = toBytes$1(piece);
|
|
10
|
+
while (part.length) {
|
|
11
|
+
if (!carry && part.length >= size) {
|
|
12
|
+
yield part.slice(0, size);
|
|
13
|
+
part = part.subarray(size);
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
carry ??= new Uint8Array(size);
|
|
17
|
+
const n = Math.min(size - carried, part.length);
|
|
18
|
+
carry.set(part.subarray(0, n), carried);
|
|
19
|
+
carried += n;
|
|
20
|
+
part = part.subarray(n);
|
|
21
|
+
if (carried === size) {
|
|
22
|
+
yield carry;
|
|
23
|
+
carry = void 0;
|
|
24
|
+
carried = 0;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (carry && carried) yield carry.slice(0, carried);
|
|
29
|
+
}
|
|
30
|
+
/** A FilesApi whose every call goes through a FilesRpc; content moves in keyed chunks. */
|
|
31
|
+
var FilesRpcClient = class {
|
|
32
|
+
#rpc;
|
|
33
|
+
#chunkSize;
|
|
34
|
+
#readAhead;
|
|
35
|
+
#writeAhead;
|
|
36
|
+
#pageSize;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
this.#rpc = options.rpc;
|
|
39
|
+
this.#chunkSize = Math.max(1, options.chunkSize ?? 262144);
|
|
40
|
+
this.#readAhead = Math.max(1, options.readAhead ?? 4);
|
|
41
|
+
this.#writeAhead = Math.max(1, options.writeAhead ?? 4);
|
|
42
|
+
this.#pageSize = Math.max(1, options.pageSize ?? 256);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Asks for chunks 0, 1, 2, … keeping `readAhead` requests in flight, and yields them in key
|
|
46
|
+
* order whatever order the answers come in.
|
|
47
|
+
*/
|
|
48
|
+
async *read(path, options = {}) {
|
|
49
|
+
const { start, length, signal } = options;
|
|
50
|
+
if (length === 0) return;
|
|
51
|
+
signal?.throwIfAborted();
|
|
52
|
+
const { streamId } = await this.#rpc.openRead(path, {
|
|
53
|
+
start,
|
|
54
|
+
length,
|
|
55
|
+
streamId: newStreamId$1()
|
|
56
|
+
});
|
|
57
|
+
const pending = /* @__PURE__ */ new Map();
|
|
58
|
+
const aborted = signal ? new Promise((_, reject) => {
|
|
59
|
+
const fail = () => reject(signal.reason ?? /* @__PURE__ */ new Error("aborted"));
|
|
60
|
+
if (signal.aborted) fail();
|
|
61
|
+
else signal.addEventListener("abort", fail, { once: true });
|
|
62
|
+
}) : void 0;
|
|
63
|
+
aborted?.catch(() => {});
|
|
64
|
+
let asked = 0;
|
|
65
|
+
let finished = false;
|
|
66
|
+
try {
|
|
67
|
+
for (let next = 0;; next++) {
|
|
68
|
+
while (!finished && asked < next + this.#readAhead) {
|
|
69
|
+
const request = this.#rpc.readChunk(streamId, asked);
|
|
70
|
+
request.catch(() => {});
|
|
71
|
+
pending.set(asked++, request);
|
|
72
|
+
}
|
|
73
|
+
const answer = pending.get(next);
|
|
74
|
+
pending.delete(next);
|
|
75
|
+
const { data, done } = await (aborted ? Promise.race([answer, aborted]) : answer);
|
|
76
|
+
signal?.throwIfAborted();
|
|
77
|
+
if (done) finished = true;
|
|
78
|
+
const bytes = toBytes$1(data);
|
|
79
|
+
if (bytes.length) yield bytes;
|
|
80
|
+
if (done) return;
|
|
81
|
+
signal?.throwIfAborted();
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
await this.#rpc.closeRead(streamId).catch(() => {});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Sends chunks of `chunkSize` bytes keyed 0, 1, 2, … with up to `writeAhead` unacknowledged,
|
|
89
|
+
* then `closeWrite` with their count; any failure aborts the write.
|
|
90
|
+
*/
|
|
91
|
+
async write(path, content) {
|
|
92
|
+
const { streamId } = await this.#rpc.openWrite(path, { streamId: newStreamId$1() });
|
|
93
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
94
|
+
let failure;
|
|
95
|
+
let count = 0;
|
|
96
|
+
try {
|
|
97
|
+
for await (const chunk of rechunk(content, this.#chunkSize)) {
|
|
98
|
+
if (failure) throw failure.error;
|
|
99
|
+
const sent = this.#rpc.writeChunk(streamId, count++, chunk).then(() => {
|
|
100
|
+
inFlight.delete(sent);
|
|
101
|
+
}, (error) => {
|
|
102
|
+
inFlight.delete(sent);
|
|
103
|
+
failure ??= { error };
|
|
104
|
+
});
|
|
105
|
+
inFlight.add(sent);
|
|
106
|
+
while (inFlight.size >= this.#writeAhead) await Promise.race(inFlight);
|
|
107
|
+
}
|
|
108
|
+
await Promise.all(inFlight);
|
|
109
|
+
if (failure) throw failure.error;
|
|
110
|
+
await this.#rpc.closeWrite(streamId, count);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
await this.#rpc.abortWrite(streamId, String(e?.message ?? e)).catch(() => {});
|
|
113
|
+
throw e;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async mkdir(path) {
|
|
117
|
+
await this.#rpc.mkdir(path);
|
|
118
|
+
}
|
|
119
|
+
/** Pages of `pageSize`, each asked after the last path of the one before. */
|
|
120
|
+
async *list(path, options = {}) {
|
|
121
|
+
let after = options.after;
|
|
122
|
+
for (;;) {
|
|
123
|
+
const page = await this.#rpc.list(path, {
|
|
124
|
+
recursive: options.recursive,
|
|
125
|
+
after,
|
|
126
|
+
limit: this.#pageSize
|
|
127
|
+
});
|
|
128
|
+
yield* page.entries;
|
|
129
|
+
if (!page.more || page.entries.length === 0) return;
|
|
130
|
+
after = page.entries[page.entries.length - 1]?.path;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
stats(path) {
|
|
134
|
+
return this.#rpc.stats(path);
|
|
135
|
+
}
|
|
136
|
+
exists(path) {
|
|
137
|
+
return this.#rpc.exists(path);
|
|
138
|
+
}
|
|
139
|
+
remove(path) {
|
|
140
|
+
return this.#rpc.remove(path);
|
|
141
|
+
}
|
|
142
|
+
move(source, target) {
|
|
143
|
+
return this.#rpc.move(source, target);
|
|
144
|
+
}
|
|
145
|
+
copy(source, target) {
|
|
146
|
+
return this.#rpc.copy(source, target);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
/** A FilesApi over a FilesRpc. */
|
|
150
|
+
function newFilesRpcClient(options) {
|
|
151
|
+
return new FilesRpcClient(options);
|
|
152
|
+
}
|
|
153
|
+
//#endregion
|
|
154
|
+
//#region src/errors.ts
|
|
155
|
+
/**
|
|
156
|
+
* An error of the protocol itself. The code also leads the message (`UNKNOWN_STREAM: …`), since
|
|
157
|
+
* a structured clone keeps an error's name and message but not its other properties:
|
|
158
|
+
* `errorCode(e)` reads it back on the other side of a transport.
|
|
159
|
+
*/
|
|
160
|
+
var FilesRpcError = class extends Error {
|
|
161
|
+
code;
|
|
162
|
+
constructor(code, message) {
|
|
163
|
+
super(`${code}: ${message}`);
|
|
164
|
+
this.name = "FilesRpcError";
|
|
165
|
+
this.code = code;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
/** The FilesRpcError code of an error, also after it crossed a transport; undefined otherwise. */
|
|
169
|
+
function errorCode(e) {
|
|
170
|
+
const code = e?.code;
|
|
171
|
+
if (typeof code === "string") return code;
|
|
172
|
+
return /^(UNKNOWN_STREAM|CHUNK_RELEASED|TOO_MANY_STREAMS|ABORTED|INVALID):/.exec(String(e?.message ?? ""))?.[1];
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/keyed.ts
|
|
176
|
+
/**
|
|
177
|
+
* Values kept by key, and waiters for keys that have no value yet: what both ends of a stream
|
|
178
|
+
* use to keep chunks by chunkId and wait for the ones still on their way.
|
|
179
|
+
*/
|
|
180
|
+
var Keyed = class {
|
|
181
|
+
#values = /* @__PURE__ */ new Map();
|
|
182
|
+
#waiters = /* @__PURE__ */ new Map();
|
|
183
|
+
#failure;
|
|
184
|
+
get size() {
|
|
185
|
+
return this.#values.size;
|
|
186
|
+
}
|
|
187
|
+
has(key) {
|
|
188
|
+
return this.#values.has(key);
|
|
189
|
+
}
|
|
190
|
+
get(key) {
|
|
191
|
+
return this.#values.get(key);
|
|
192
|
+
}
|
|
193
|
+
keys() {
|
|
194
|
+
return this.#values.keys();
|
|
195
|
+
}
|
|
196
|
+
/** Keeps `value` under `key` and wakes whoever waits for it. */
|
|
197
|
+
put(key, value) {
|
|
198
|
+
this.#values.set(key, value);
|
|
199
|
+
const waiting = this.#waiters.get(key);
|
|
200
|
+
if (!waiting) return;
|
|
201
|
+
this.#waiters.delete(key);
|
|
202
|
+
for (const w of waiting) w.resolve(value);
|
|
203
|
+
}
|
|
204
|
+
delete(key) {
|
|
205
|
+
this.#values.delete(key);
|
|
206
|
+
}
|
|
207
|
+
/** The value of `key`: now if kept, else when it is put (or the whole set fails). */
|
|
208
|
+
wait(key) {
|
|
209
|
+
if (this.#values.has(key)) return Promise.resolve(this.#values.get(key));
|
|
210
|
+
if (this.#failure) return Promise.reject(this.#failure.error);
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
const list = this.#waiters.get(key);
|
|
213
|
+
if (list) list.push({
|
|
214
|
+
resolve,
|
|
215
|
+
reject
|
|
216
|
+
});
|
|
217
|
+
else this.#waiters.set(key, [{
|
|
218
|
+
resolve,
|
|
219
|
+
reject
|
|
220
|
+
}]);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/** Answers every waiter for a key at or above `from` with `value` (the end of a stream). */
|
|
224
|
+
settleFrom(from, value) {
|
|
225
|
+
for (const [key, waiting] of [...this.#waiters]) {
|
|
226
|
+
if (key < from) continue;
|
|
227
|
+
this.#waiters.delete(key);
|
|
228
|
+
for (const w of waiting) w.resolve(value(key));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** Fails every waiter, now and later. */
|
|
232
|
+
fail(error) {
|
|
233
|
+
this.#failure ??= { error };
|
|
234
|
+
for (const waiting of this.#waiters.values()) for (const w of waiting) w.reject(error);
|
|
235
|
+
this.#waiters.clear();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
//#endregion
|
|
239
|
+
//#region src/server.ts
|
|
240
|
+
const EMPTY = /* @__PURE__ */ new Uint8Array();
|
|
241
|
+
const STREAM_ID = /^[A-Za-z0-9_-]{16,128}$/;
|
|
242
|
+
function newStreamId() {
|
|
243
|
+
return crypto.randomUUID().replace(/-/g, "");
|
|
244
|
+
}
|
|
245
|
+
function toBytes(data) {
|
|
246
|
+
if (data instanceof Uint8Array) return data;
|
|
247
|
+
if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
248
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
249
|
+
throw new FilesRpcError("INVALID", "a chunk is a Uint8Array or an ArrayBuffer");
|
|
250
|
+
}
|
|
251
|
+
function chunkIdOf(v) {
|
|
252
|
+
const n = Number(v);
|
|
253
|
+
if (!Number.isSafeInteger(n) || n < 0) throw new FilesRpcError("INVALID", `a chunk id is an integer from 0 (got ${String(v)})`);
|
|
254
|
+
return n;
|
|
255
|
+
}
|
|
256
|
+
/** Orders paths by Unicode code point, as `FilesApi.list()` does (webrun-files' comparePaths). */
|
|
257
|
+
function comparePaths(a, b) {
|
|
258
|
+
const x = a[Symbol.iterator]();
|
|
259
|
+
const y = b[Symbol.iterator]();
|
|
260
|
+
for (;;) {
|
|
261
|
+
const p = x.next();
|
|
262
|
+
const q = y.next();
|
|
263
|
+
if (p.done || q.done) return p.done && q.done ? 0 : p.done ? -1 : 1;
|
|
264
|
+
const d = p.value.codePointAt(0) - q.value.codePointAt(0);
|
|
265
|
+
if (d !== 0) return d;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** Joins the first `n` buffered bytes into one chunk. */
|
|
269
|
+
function take(s, n) {
|
|
270
|
+
const out = new Uint8Array(n);
|
|
271
|
+
let at = 0;
|
|
272
|
+
while (at < n) {
|
|
273
|
+
const head = s.buffer[0];
|
|
274
|
+
const part = head.subarray(0, n - at);
|
|
275
|
+
out.set(part, at);
|
|
276
|
+
at += part.length;
|
|
277
|
+
if (part.length === head.length) s.buffer.shift();
|
|
278
|
+
else s.buffer[0] = head.subarray(part.length);
|
|
279
|
+
}
|
|
280
|
+
s.buffered -= n;
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
/** Serves a FilesApi as the FilesRpc methods. */
|
|
284
|
+
function newFilesRpcServer(options) {
|
|
285
|
+
const fs = options.fs;
|
|
286
|
+
const chunkSize = Math.max(1, options.chunkSize ?? 262144);
|
|
287
|
+
const readAhead = Math.max(1, options.readAhead ?? 4);
|
|
288
|
+
const retain = Math.max(1, options.retain ?? 16);
|
|
289
|
+
const window = Math.max(1, options.window ?? 64);
|
|
290
|
+
const writeWindow = Math.max(1, options.writeWindow ?? 16);
|
|
291
|
+
const maxChunkSize = Math.max(1, options.maxChunkSize ?? 4194304);
|
|
292
|
+
const idleTimeout = options.idleTimeout ?? 12e4;
|
|
293
|
+
const maxStreams = options.maxStreams ?? 256;
|
|
294
|
+
const maxPageSize = Math.max(1, options.maxPageSize ?? 256);
|
|
295
|
+
const now = options.now ?? Date.now;
|
|
296
|
+
const streams = /* @__PURE__ */ new Map();
|
|
297
|
+
/**
|
|
298
|
+
* Streams that ended recently, by id: an open delivered again after its stream ended must not
|
|
299
|
+
* open it anew, and a closeWrite delivered again answers as the first one did.
|
|
300
|
+
*/
|
|
301
|
+
const ended = /* @__PURE__ */ new Map();
|
|
302
|
+
const finish = (id, failure) => {
|
|
303
|
+
streams.delete(id);
|
|
304
|
+
ended.set(id, {
|
|
305
|
+
at: now(),
|
|
306
|
+
failure
|
|
307
|
+
});
|
|
308
|
+
};
|
|
309
|
+
const stream = (id, kind) => {
|
|
310
|
+
const s = streams.get(String(id));
|
|
311
|
+
if (!s || s.kind !== kind) throw new FilesRpcError("UNKNOWN_STREAM", `no open ${kind} stream '${String(id)}'`);
|
|
312
|
+
s.touched = now();
|
|
313
|
+
return s;
|
|
314
|
+
};
|
|
315
|
+
/** Runs `work` as a call in progress on `s`: the stream is not idle meanwhile. */
|
|
316
|
+
const busy = async (s, work) => {
|
|
317
|
+
s.busy++;
|
|
318
|
+
try {
|
|
319
|
+
return await work();
|
|
320
|
+
} finally {
|
|
321
|
+
s.busy--;
|
|
322
|
+
s.touched = now();
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
/** An id for a new stream, or the stream an open delivered twice already opened. */
|
|
326
|
+
const claim = (opts, kind) => {
|
|
327
|
+
const wanted = opts?.streamId;
|
|
328
|
+
if (wanted !== void 0) {
|
|
329
|
+
if (typeof wanted !== "string" || !STREAM_ID.test(wanted)) throw new FilesRpcError("INVALID", "a stream id is 16 to 128 characters of [A-Za-z0-9_-]");
|
|
330
|
+
const existing = streams.get(wanted);
|
|
331
|
+
if (existing) {
|
|
332
|
+
if (existing.kind !== kind) throw new FilesRpcError("INVALID", `stream '${wanted}' is a ${existing.kind} stream`);
|
|
333
|
+
return {
|
|
334
|
+
id: wanted,
|
|
335
|
+
existing
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
if (ended.has(wanted)) throw new FilesRpcError("INVALID", `stream '${wanted}' has ended`);
|
|
339
|
+
}
|
|
340
|
+
server.sweep();
|
|
341
|
+
if (streams.size >= maxStreams) throw new FilesRpcError("TOO_MANY_STREAMS", `at most ${maxStreams} open streams`);
|
|
342
|
+
return { id: wanted ?? newStreamId() };
|
|
343
|
+
};
|
|
344
|
+
/** Pulls the source until `readAhead` chunks from the highest asked exist, or it ends. */
|
|
345
|
+
const wanted = (s) => !s.ended && !s.closed && s.error === void 0 && s.produced < s.asked + readAhead;
|
|
346
|
+
const pump = (s) => {
|
|
347
|
+
if (s.pumping) return s.pumping;
|
|
348
|
+
const run = (async () => {
|
|
349
|
+
await void 0;
|
|
350
|
+
while (wanted(s)) {
|
|
351
|
+
if (s.buffered > chunkSize) {
|
|
352
|
+
const id = s.produced++;
|
|
353
|
+
s.chunks.put(id, {
|
|
354
|
+
data: take(s, chunkSize),
|
|
355
|
+
done: false
|
|
356
|
+
});
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
let next;
|
|
360
|
+
try {
|
|
361
|
+
next = await s.source.next();
|
|
362
|
+
} catch (e) {
|
|
363
|
+
while (s.buffered > 0) {
|
|
364
|
+
const id = s.produced++;
|
|
365
|
+
s.chunks.put(id, {
|
|
366
|
+
data: take(s, Math.min(chunkSize, s.buffered)),
|
|
367
|
+
done: false
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
s.error = e;
|
|
371
|
+
s.chunks.fail(e);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (next.done) {
|
|
375
|
+
while (s.buffered > chunkSize) {
|
|
376
|
+
const id = s.produced++;
|
|
377
|
+
s.chunks.put(id, {
|
|
378
|
+
data: take(s, chunkSize),
|
|
379
|
+
done: false
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
const id = s.produced++;
|
|
383
|
+
s.chunks.put(id, {
|
|
384
|
+
data: s.buffered ? take(s, s.buffered) : EMPTY,
|
|
385
|
+
done: true
|
|
386
|
+
});
|
|
387
|
+
s.ended = true;
|
|
388
|
+
s.chunks.settleFrom(s.produced, () => ({
|
|
389
|
+
data: EMPTY,
|
|
390
|
+
done: true
|
|
391
|
+
}));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
let part;
|
|
395
|
+
try {
|
|
396
|
+
part = toBytes(next.value);
|
|
397
|
+
} catch {
|
|
398
|
+
s.error = new FilesRpcError("INVALID", "the source yielded something that is not bytes");
|
|
399
|
+
s.chunks.fail(s.error);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (part.length) {
|
|
403
|
+
s.buffer.push(part);
|
|
404
|
+
s.buffered += part.length;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
})();
|
|
408
|
+
s.pumping = run;
|
|
409
|
+
run.finally(() => {
|
|
410
|
+
s.pumping = void 0;
|
|
411
|
+
if (wanted(s)) pump(s);
|
|
412
|
+
});
|
|
413
|
+
return run;
|
|
414
|
+
};
|
|
415
|
+
const closeSource = async (s) => {
|
|
416
|
+
s.closed = true;
|
|
417
|
+
s.chunks.fail(new FilesRpcError("UNKNOWN_STREAM", "the stream was closed"));
|
|
418
|
+
await s.pumping?.catch(() => {});
|
|
419
|
+
await Promise.resolve(s.source.return?.()).catch(() => {});
|
|
420
|
+
};
|
|
421
|
+
/** The chunks of a write stream in order, as they arrive: what the FilesApi's write consumes. */
|
|
422
|
+
async function* ordered(s) {
|
|
423
|
+
for (;;) {
|
|
424
|
+
s.touched = now();
|
|
425
|
+
if (s.failure !== void 0) throw s.failure;
|
|
426
|
+
const chunk = s.chunks.get(s.next);
|
|
427
|
+
if (chunk) {
|
|
428
|
+
s.chunks.delete(s.next);
|
|
429
|
+
s.next++;
|
|
430
|
+
for (const r of s.room.filter((r) => r.chunkId < s.next + writeWindow)) {
|
|
431
|
+
s.room.splice(s.room.indexOf(r), 1);
|
|
432
|
+
r.resolve();
|
|
433
|
+
}
|
|
434
|
+
yield chunk;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (s.total !== void 0 && s.next >= s.total) return;
|
|
438
|
+
await new Promise((resolve) => {
|
|
439
|
+
s.wake = resolve;
|
|
440
|
+
});
|
|
441
|
+
s.wake = void 0;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const failWrite = (s, failure) => {
|
|
445
|
+
s.failure ??= failure;
|
|
446
|
+
for (const r of s.room.splice(0)) r.reject(s.failure);
|
|
447
|
+
s.wake?.();
|
|
448
|
+
};
|
|
449
|
+
const server = {
|
|
450
|
+
async stats(path) {
|
|
451
|
+
return fs.stats(path);
|
|
452
|
+
},
|
|
453
|
+
async exists(path) {
|
|
454
|
+
return fs.exists(path);
|
|
455
|
+
},
|
|
456
|
+
async mkdir(path) {
|
|
457
|
+
await fs.mkdir(path);
|
|
458
|
+
},
|
|
459
|
+
async remove(path) {
|
|
460
|
+
return fs.remove(path);
|
|
461
|
+
},
|
|
462
|
+
async move(source, target) {
|
|
463
|
+
return fs.move(source, target);
|
|
464
|
+
},
|
|
465
|
+
async copy(source, target) {
|
|
466
|
+
return fs.copy(source, target);
|
|
467
|
+
},
|
|
468
|
+
async list(path, opts = {}) {
|
|
469
|
+
const limit = Math.min(Math.max(1, Math.floor(Number(opts.limit) || maxPageSize)), maxPageSize);
|
|
470
|
+
const entries = [];
|
|
471
|
+
let more = false;
|
|
472
|
+
const after = opts.after;
|
|
473
|
+
for await (const entry of fs.list(path, {
|
|
474
|
+
recursive: !!opts.recursive,
|
|
475
|
+
after
|
|
476
|
+
})) {
|
|
477
|
+
if (after !== void 0 && comparePaths(entry.path, after) <= 0) continue;
|
|
478
|
+
if (entries.length === limit) {
|
|
479
|
+
more = true;
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
entries.push({ ...entry });
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
entries,
|
|
486
|
+
more
|
|
487
|
+
};
|
|
488
|
+
},
|
|
489
|
+
async openRead(path, opts = {}) {
|
|
490
|
+
const { id, existing } = claim(opts, "read");
|
|
491
|
+
if (existing) return { streamId: id };
|
|
492
|
+
const length = opts.length;
|
|
493
|
+
const source = length === 0 ? (async function* () {})() : fs.read(path, {
|
|
494
|
+
start: opts.start,
|
|
495
|
+
length
|
|
496
|
+
})[Symbol.asyncIterator]();
|
|
497
|
+
streams.set(id, {
|
|
498
|
+
kind: "read",
|
|
499
|
+
touched: now(),
|
|
500
|
+
busy: 0,
|
|
501
|
+
source,
|
|
502
|
+
chunks: new Keyed(),
|
|
503
|
+
buffer: [],
|
|
504
|
+
buffered: 0,
|
|
505
|
+
produced: 0,
|
|
506
|
+
low: 0,
|
|
507
|
+
delivered: /* @__PURE__ */ new Set(),
|
|
508
|
+
asked: 0,
|
|
509
|
+
released: 0,
|
|
510
|
+
ended: false,
|
|
511
|
+
closed: false
|
|
512
|
+
});
|
|
513
|
+
return { streamId: id };
|
|
514
|
+
},
|
|
515
|
+
async readChunk(streamId, chunkIdArg) {
|
|
516
|
+
const s = stream(streamId, "read");
|
|
517
|
+
const chunkId = chunkIdOf(chunkIdArg);
|
|
518
|
+
if (chunkId >= s.low + window) throw new FilesRpcError("INVALID", `chunk ${chunkId} is more than ${window} chunks past the lowest undelivered (${s.low})`);
|
|
519
|
+
if (chunkId < s.released && chunkId < s.produced && !s.chunks.has(chunkId)) throw new FilesRpcError("CHUNK_RELEASED", `chunk ${chunkId} of '${streamId}' was released (the reader moved past it)`);
|
|
520
|
+
if (chunkId > s.asked) {
|
|
521
|
+
s.asked = chunkId;
|
|
522
|
+
const keepFrom = s.asked - retain + 1;
|
|
523
|
+
for (; s.released < keepFrom; s.released++) if (s.released < s.low || s.delivered.has(s.released)) s.chunks.delete(s.released);
|
|
524
|
+
}
|
|
525
|
+
if (s.ended && chunkId >= s.produced) return {
|
|
526
|
+
data: EMPTY,
|
|
527
|
+
done: true
|
|
528
|
+
};
|
|
529
|
+
return busy(s, async () => {
|
|
530
|
+
const answer = s.chunks.wait(chunkId);
|
|
531
|
+
pump(s);
|
|
532
|
+
const result = await answer;
|
|
533
|
+
if (chunkId === s.low) {
|
|
534
|
+
s.low++;
|
|
535
|
+
while (s.delivered.delete(s.low)) s.low++;
|
|
536
|
+
} else if (chunkId > s.low) s.delivered.add(chunkId);
|
|
537
|
+
if (chunkId < s.released) s.chunks.delete(chunkId);
|
|
538
|
+
return result;
|
|
539
|
+
});
|
|
540
|
+
},
|
|
541
|
+
async closeRead(streamId) {
|
|
542
|
+
const s = streams.get(String(streamId));
|
|
543
|
+
if (s?.kind !== "read") return;
|
|
544
|
+
finish(String(streamId));
|
|
545
|
+
await closeSource(s);
|
|
546
|
+
},
|
|
547
|
+
async openWrite(path, opts = {}) {
|
|
548
|
+
const { id, existing } = claim(opts, "write");
|
|
549
|
+
if (existing) return { streamId: id };
|
|
550
|
+
const s = {
|
|
551
|
+
kind: "write",
|
|
552
|
+
touched: now(),
|
|
553
|
+
busy: 0,
|
|
554
|
+
chunks: /* @__PURE__ */ new Map(),
|
|
555
|
+
next: 0,
|
|
556
|
+
room: [],
|
|
557
|
+
done: Promise.resolve()
|
|
558
|
+
};
|
|
559
|
+
s.done = fs.write(path, ordered(s)).then(() => {
|
|
560
|
+
if (s.failure !== void 0) throw s.failure;
|
|
561
|
+
}, (e) => {
|
|
562
|
+
failWrite(s, e);
|
|
563
|
+
throw s.failure;
|
|
564
|
+
});
|
|
565
|
+
s.done.catch(() => {});
|
|
566
|
+
streams.set(id, s);
|
|
567
|
+
return { streamId: id };
|
|
568
|
+
},
|
|
569
|
+
async writeChunk(streamId, chunkIdArg, data) {
|
|
570
|
+
const s = stream(streamId, "write");
|
|
571
|
+
const chunkId = chunkIdOf(chunkIdArg);
|
|
572
|
+
const bytes = toBytes(data);
|
|
573
|
+
if (bytes.length > maxChunkSize) throw new FilesRpcError("INVALID", `a chunk is at most ${maxChunkSize} bytes`);
|
|
574
|
+
if (s.failure !== void 0) throw s.failure;
|
|
575
|
+
if (s.total !== void 0 && chunkId >= s.total) throw new FilesRpcError("INVALID", `chunk ${chunkId} is past the stream's ${s.total} chunks`);
|
|
576
|
+
if (chunkId < s.next || s.chunks.has(chunkId)) return;
|
|
577
|
+
if (chunkId >= s.next + 2 * writeWindow) throw new FilesRpcError("INVALID", `chunk ${chunkId} is more than ${2 * writeWindow} chunks past the next to write (${s.next})`);
|
|
578
|
+
if (chunkId >= s.next + writeWindow) {
|
|
579
|
+
await busy(s, () => new Promise((resolve, reject) => s.room.push({
|
|
580
|
+
chunkId,
|
|
581
|
+
resolve,
|
|
582
|
+
reject
|
|
583
|
+
})));
|
|
584
|
+
if (chunkId < s.next || s.chunks.has(chunkId)) return;
|
|
585
|
+
}
|
|
586
|
+
s.chunks.set(chunkId, bytes.slice());
|
|
587
|
+
if (chunkId === s.next) s.wake?.();
|
|
588
|
+
},
|
|
589
|
+
async closeWrite(streamId, chunksArg) {
|
|
590
|
+
const id = String(streamId);
|
|
591
|
+
const done = ended.get(id);
|
|
592
|
+
if (done && !streams.has(id)) {
|
|
593
|
+
if (done.failure !== void 0) throw done.failure;
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const s = stream(id, "write");
|
|
597
|
+
const total = chunkIdOf(chunksArg);
|
|
598
|
+
const highest = Math.max(s.next - 1, ...s.chunks.keys());
|
|
599
|
+
if (highest >= total) {
|
|
600
|
+
const error = new FilesRpcError("INVALID", `the stream has chunk ${highest}, so more than ${total} chunks`);
|
|
601
|
+
failWrite(s, error);
|
|
602
|
+
finish(id, error);
|
|
603
|
+
throw error;
|
|
604
|
+
}
|
|
605
|
+
s.total = total;
|
|
606
|
+
for (const r of s.room.filter((r) => r.chunkId >= total)) {
|
|
607
|
+
s.room.splice(s.room.indexOf(r), 1);
|
|
608
|
+
r.reject(new FilesRpcError("INVALID", `chunk ${r.chunkId} is past the stream's ${total} chunks`));
|
|
609
|
+
}
|
|
610
|
+
s.wake?.();
|
|
611
|
+
try {
|
|
612
|
+
await busy(s, () => s.done);
|
|
613
|
+
if (streams.get(id) === s) finish(id);
|
|
614
|
+
} catch (e) {
|
|
615
|
+
if (streams.get(id) === s) finish(id, e);
|
|
616
|
+
throw e;
|
|
617
|
+
}
|
|
618
|
+
},
|
|
619
|
+
async abortWrite(streamId, reason) {
|
|
620
|
+
const id = String(streamId);
|
|
621
|
+
const s = streams.get(id);
|
|
622
|
+
if (s?.kind !== "write") return;
|
|
623
|
+
if (s.total !== void 0 && s.next >= s.total) return;
|
|
624
|
+
const error = new FilesRpcError("ABORTED", reason ? String(reason) : "the write was aborted");
|
|
625
|
+
finish(id, error);
|
|
626
|
+
failWrite(s, error);
|
|
627
|
+
},
|
|
628
|
+
sweep() {
|
|
629
|
+
const t = now();
|
|
630
|
+
let dropped = 0;
|
|
631
|
+
for (const [id, s] of [...streams]) {
|
|
632
|
+
if (t - s.touched <= idleTimeout) continue;
|
|
633
|
+
if (s.kind === "read" && s.busy > 0) continue;
|
|
634
|
+
if (s.kind === "write" && (s.chunks.has(s.next) || s.total !== void 0 && s.next >= s.total)) continue;
|
|
635
|
+
dropped++;
|
|
636
|
+
const error = new FilesRpcError("ABORTED", "the stream was idle too long");
|
|
637
|
+
finish(id, error);
|
|
638
|
+
if (s.kind === "read") closeSource(s);
|
|
639
|
+
else failWrite(s, error);
|
|
640
|
+
}
|
|
641
|
+
for (const [id, e] of [...ended]) if (t - e.at > idleTimeout) ended.delete(id);
|
|
642
|
+
return dropped;
|
|
643
|
+
},
|
|
644
|
+
openStreams() {
|
|
645
|
+
return streams.size;
|
|
646
|
+
},
|
|
647
|
+
async close() {
|
|
648
|
+
if (timer) clearInterval(timer);
|
|
649
|
+
const sources = [];
|
|
650
|
+
for (const [id, s] of [...streams]) {
|
|
651
|
+
const error = new FilesRpcError("ABORTED", "the server closed");
|
|
652
|
+
finish(id, error);
|
|
653
|
+
if (s.kind === "read") sources.push(closeSource(s));
|
|
654
|
+
else failWrite(s, error);
|
|
655
|
+
}
|
|
656
|
+
await Promise.race([Promise.all(sources), new Promise((r) => {
|
|
657
|
+
setTimeout(r, 1e3).unref?.();
|
|
658
|
+
})]);
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
const timer = options.sweepInterval && options.sweepInterval > 0 ? setInterval(() => server.sweep(), options.sweepInterval) : void 0;
|
|
662
|
+
timer?.unref?.();
|
|
663
|
+
return server;
|
|
664
|
+
}
|
|
665
|
+
//#endregion
|
|
666
|
+
export { FilesRpcClient, FilesRpcError, errorCode, newFilesRpcClient, newFilesRpcServer };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* webrun-files-rpc: a FilesApi over any RPC. The server serves a FilesApi as plain calls; the
|
|
3
|
+
* client is a FilesApi over them. Content is streamed as chunks keyed by (streamId, chunkId):
|
|
4
|
+
* both sides keep chunks by their key and wait for those still on their way, so calls may arrive
|
|
5
|
+
* out of order, concurrently, or twice.
|
|
6
|
+
*/
|
|
7
|
+
export { FilesRpcClient, type FilesRpcClientOptions, newFilesRpcClient } from "./client.js";
|
|
8
|
+
export { errorCode, FilesRpcError } from "./errors.js";
|
|
9
|
+
export { type FilesRpcServer, type FilesRpcServerOptions, newFilesRpcServer } from "./server.js";
|
|
10
|
+
export type { FilesRpc, FilesRpcErrorCode, ListPage, ListPageOptions, OpenOptions, OpenReadOptions, ReadResult, } from "./types.js";
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +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,GACX,MAAM,YAAY,CAAC"}
|