@minip2p/web 0.0.0 → 0.10.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/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/io.d.ts +107 -0
- package/dist/io.d.ts.map +1 -0
- package/dist/io.js +480 -0
- package/dist/io.js.map +1 -0
- package/dist/manual.d.ts +10 -0
- package/dist/manual.d.ts.map +1 -0
- package/dist/manual.js +10 -0
- package/dist/manual.js.map +1 -0
- package/dist/sdk.d.ts +35 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +191 -0
- package/dist/sdk.js.map +1 -0
- package/dist/wasm.d.ts +18 -0
- package/dist/wasm.d.ts.map +1 -0
- package/dist/wasm.js +37 -0
- package/dist/wasm.js.map +1 -0
- package/package.json +75 -4
- package/src/index.ts +19 -0
- package/src/io.ts +601 -0
- package/src/manual.ts +17 -0
- package/src/sdk.ts +237 -0
- package/src/wasm.ts +48 -0
- package/wasm/minip2p_web.d.ts +241 -0
- package/wasm/minip2p_web.js +1100 -0
- package/wasm/minip2p_web_bg.wasm +0 -0
- package/wasm/minip2p_web_bg.wasm.d.ts +48 -0
package/src/io.ts
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
/* oxlint-disable func-style, max-classes-per-file, no-empty-function, no-use-before-define, promise/prefer-await-to-callbacks, promise/prefer-await-to-then, promise/prefer-catch -- Promise callbacks become queued events here by design: nothing awaits, so the wasm side stays caller-driven, and a rejection the end of a stream or session already reports is dropped on purpose. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `JsIo`: the `WebTransportIo` seam of `minip2p-webtransport` over the
|
|
5
|
+
* browser's `WebTransport` API. Rust owns the protocol and calls these
|
|
6
|
+
* methods synchronously; every outcome the browser learns later (a session
|
|
7
|
+
* coming up, a read, a write resolving, a reset) is queued as an
|
|
8
|
+
* {@link IoEvent} and handed back by {@link JsIo.pollEvent}, and `wake`
|
|
9
|
+
* asks the host to pump.
|
|
10
|
+
*
|
|
11
|
+
* The rules Rust relies on:
|
|
12
|
+
*
|
|
13
|
+
* - `write` copies what it accepts before returning, so a queued browser
|
|
14
|
+
* write never views wasm memory, and caps each stream's in-flight bytes;
|
|
15
|
+
* each accepted write reports exactly one `writeCompleted` with its own
|
|
16
|
+
* length, and a write the browser rejects reports nothing.
|
|
17
|
+
* - Reads are pulled: `grantRead` is additive, at most one read is
|
|
18
|
+
* outstanding per stream, and `data` never exceeds what was granted.
|
|
19
|
+
* - Handles are never reused, and events for a reset stream or an ended
|
|
20
|
+
* session are dropped. A stream whose read side ended and whose write
|
|
21
|
+
* side finished (every accepted write reported first) or was stopped is
|
|
22
|
+
* retired, so finished streams hold nothing.
|
|
23
|
+
* - Stream error codes are WebTransport application codes cut to `u8`
|
|
24
|
+
* (minip2p only sends 0); a missing code is 0.
|
|
25
|
+
* - Only `connect`, `openStream` and `write` may throw (Rust catches those
|
|
26
|
+
* as `IoError`s). An exception escaping any other method would unwind
|
|
27
|
+
* through Rust and leave the `WebEndpoint` borrowed for good, so they
|
|
28
|
+
* swallow everything.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** In-flight write bytes each stream may hold in the browser. */
|
|
32
|
+
export const MAX_IN_FLIGHT_BYTES = 64 * 1024;
|
|
33
|
+
/** The largest single read requested from the browser. */
|
|
34
|
+
const MAX_READ_BYTES = 64 * 1024;
|
|
35
|
+
|
|
36
|
+
/** One outcome for Rust, the shape `crates/web/src/io.rs` decodes. */
|
|
37
|
+
export type IoEvent =
|
|
38
|
+
| { readonly kind: "sessionReady"; readonly session: number }
|
|
39
|
+
| {
|
|
40
|
+
readonly kind: "sessionClosed";
|
|
41
|
+
readonly session: number;
|
|
42
|
+
readonly code: number;
|
|
43
|
+
readonly reason: string;
|
|
44
|
+
}
|
|
45
|
+
| {
|
|
46
|
+
readonly kind: "sessionFailed";
|
|
47
|
+
readonly session: number;
|
|
48
|
+
readonly reason: string;
|
|
49
|
+
}
|
|
50
|
+
| { readonly kind: "streamOpened"; readonly stream: number }
|
|
51
|
+
| {
|
|
52
|
+
readonly kind: "incomingStream";
|
|
53
|
+
readonly session: number;
|
|
54
|
+
readonly stream: number;
|
|
55
|
+
}
|
|
56
|
+
| {
|
|
57
|
+
readonly kind: "data";
|
|
58
|
+
readonly stream: number;
|
|
59
|
+
readonly bytes: Uint8Array;
|
|
60
|
+
}
|
|
61
|
+
| { readonly kind: "fin"; readonly stream: number }
|
|
62
|
+
| { readonly kind: "reset"; readonly stream: number; readonly code: number }
|
|
63
|
+
| {
|
|
64
|
+
readonly kind: "stopSending";
|
|
65
|
+
readonly stream: number;
|
|
66
|
+
readonly code: number;
|
|
67
|
+
}
|
|
68
|
+
| {
|
|
69
|
+
readonly kind: "writeCompleted";
|
|
70
|
+
readonly stream: number;
|
|
71
|
+
readonly bytes: number;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The part of a browser `WebTransport` that `JsIo` uses. */
|
|
75
|
+
export type WebTransportSession = Pick<
|
|
76
|
+
WebTransport,
|
|
77
|
+
| "close"
|
|
78
|
+
| "closed"
|
|
79
|
+
| "createBidirectionalStream"
|
|
80
|
+
| "incomingBidirectionalStreams"
|
|
81
|
+
| "ready"
|
|
82
|
+
>;
|
|
83
|
+
|
|
84
|
+
/** Opens one session; the browser's `new WebTransport(url, options)`. */
|
|
85
|
+
export type OpenSession = (
|
|
86
|
+
url: string,
|
|
87
|
+
options: WebTransportOptions
|
|
88
|
+
) => WebTransportSession;
|
|
89
|
+
|
|
90
|
+
interface Session {
|
|
91
|
+
readonly transport: WebTransportSession;
|
|
92
|
+
readonly streams: Set<number>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A stream's read side: BYOB when the browser offers it. */
|
|
96
|
+
interface ChunkReader {
|
|
97
|
+
readonly closed: Promise<void>;
|
|
98
|
+
read: (max: number) => Promise<ReadableStreamReadResult<Uint8Array>>;
|
|
99
|
+
cancel: (reason?: unknown) => Promise<void>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
class Stream {
|
|
103
|
+
writer: WritableStreamDefaultWriter<Uint8Array> | undefined;
|
|
104
|
+
reader: ChunkReader | undefined;
|
|
105
|
+
inFlight = 0;
|
|
106
|
+
granted = 0;
|
|
107
|
+
reading = false;
|
|
108
|
+
/** Fin, reset or a local stop: no more `data`. */
|
|
109
|
+
readEnded = false;
|
|
110
|
+
/** Finished after its last write, or stopped by the peer. */
|
|
111
|
+
writeEnded = false;
|
|
112
|
+
/** Reset locally or its session ended: no more events at all. */
|
|
113
|
+
dead = false;
|
|
114
|
+
/** A `stop` that arrived before the stream finished opening. */
|
|
115
|
+
pendingStop: number | undefined;
|
|
116
|
+
readonly session: number;
|
|
117
|
+
|
|
118
|
+
constructor(session: number) {
|
|
119
|
+
this.session = session;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const defaultOpenSession = (): OpenSession | undefined =>
|
|
124
|
+
typeof WebTransport === "function"
|
|
125
|
+
? (url, options) => new WebTransport(url, options)
|
|
126
|
+
: undefined;
|
|
127
|
+
|
|
128
|
+
/** The browser `WebTransportIo` behind one `WebEndpoint`. */
|
|
129
|
+
export class JsIo {
|
|
130
|
+
readonly #wake: () => void;
|
|
131
|
+
readonly #openSession: OpenSession | undefined;
|
|
132
|
+
readonly #events: IoEvent[] = [];
|
|
133
|
+
readonly #sessions = new Map<number, Session>();
|
|
134
|
+
readonly #streams = new Map<number, Stream>();
|
|
135
|
+
#nextSession = 1;
|
|
136
|
+
#nextStream = 1;
|
|
137
|
+
#disposed = false;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `wake` runs whenever an event is queued; `openSession` defaults to the
|
|
141
|
+
* global `WebTransport`.
|
|
142
|
+
*/
|
|
143
|
+
constructor(wake: () => void, openSession = defaultOpenSession()) {
|
|
144
|
+
this.#wake = wake;
|
|
145
|
+
this.#openSession = openSession;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Starts a session trusting exactly the certificates `certhashes` name. */
|
|
149
|
+
connect(url: string, certhashes: Uint8Array<ArrayBuffer>[]): number {
|
|
150
|
+
if (this.#disposed) {
|
|
151
|
+
throw new Error("this endpoint's WebTransport I/O was disposed");
|
|
152
|
+
}
|
|
153
|
+
if (this.#openSession === undefined) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
"this browser has no WebTransport; minip2p needs a WebTransport-capable browser"
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const transport = this.#openSession(url, {
|
|
159
|
+
serverCertificateHashes: certhashes.map((value) => ({
|
|
160
|
+
algorithm: "sha-256",
|
|
161
|
+
value,
|
|
162
|
+
})),
|
|
163
|
+
});
|
|
164
|
+
const id = this.#nextSession;
|
|
165
|
+
this.#nextSession += 1;
|
|
166
|
+
const session: Session = { streams: new Set(), transport };
|
|
167
|
+
this.#sessions.set(id, session);
|
|
168
|
+
transport.ready.then(
|
|
169
|
+
() => {
|
|
170
|
+
if (this.#sessions.get(id) === session) {
|
|
171
|
+
this.#push({ kind: "sessionReady", session: id });
|
|
172
|
+
void this.#acceptIncoming(id, session);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
// `closed` rejects too and reports it.
|
|
176
|
+
() => {}
|
|
177
|
+
);
|
|
178
|
+
transport.closed.then(
|
|
179
|
+
(info) => {
|
|
180
|
+
this.#endSession({
|
|
181
|
+
code: info.closeCode ?? 0,
|
|
182
|
+
kind: "sessionClosed",
|
|
183
|
+
reason: info.reason ?? "",
|
|
184
|
+
session: id,
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
// Never came up, or lost (the network going away): not an orderly close.
|
|
188
|
+
(error: unknown) => {
|
|
189
|
+
this.#endSession({
|
|
190
|
+
kind: "sessionFailed",
|
|
191
|
+
reason: errorMessage(error),
|
|
192
|
+
session: id,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
);
|
|
196
|
+
return id;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Opens a bidirectional stream; `streamOpened` follows. */
|
|
200
|
+
openStream(sessionId: number): number {
|
|
201
|
+
const session = this.#sessions.get(sessionId);
|
|
202
|
+
if (session === undefined) {
|
|
203
|
+
throw new Error(`session ${sessionId} is closed`);
|
|
204
|
+
}
|
|
205
|
+
const id = this.#addStream(sessionId, session);
|
|
206
|
+
const stream = this.#streams.get(id);
|
|
207
|
+
session.transport.createBidirectionalStream().then(
|
|
208
|
+
(bidi) => {
|
|
209
|
+
if (stream === undefined || stream.dead) {
|
|
210
|
+
abandon(bidi);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.#attach(id, stream, bidi);
|
|
214
|
+
this.#push({ kind: "streamOpened", stream: id });
|
|
215
|
+
},
|
|
216
|
+
() => {
|
|
217
|
+
if (stream !== undefined && !stream.dead) {
|
|
218
|
+
this.#forget(id, stream);
|
|
219
|
+
this.#push({ code: 0, kind: "reset", stream: id });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
);
|
|
223
|
+
return id;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Copies and queues a prefix of `data`; a short write is backpressure. */
|
|
227
|
+
write(streamId: number, data: Uint8Array): number {
|
|
228
|
+
const stream = this.#streams.get(streamId);
|
|
229
|
+
const writer = stream?.writer;
|
|
230
|
+
if (stream === undefined || writer === undefined) {
|
|
231
|
+
throw new Error(`stream ${streamId} is not writable`);
|
|
232
|
+
}
|
|
233
|
+
const accepted = Math.min(
|
|
234
|
+
MAX_IN_FLIGHT_BYTES - stream.inFlight,
|
|
235
|
+
data.length
|
|
236
|
+
);
|
|
237
|
+
if (accepted <= 0) {
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
// `data` views wasm memory, which may move: copy before returning.
|
|
241
|
+
const copy = data.slice(0, accepted);
|
|
242
|
+
stream.inFlight += accepted;
|
|
243
|
+
writer.write(copy).then(
|
|
244
|
+
() => {
|
|
245
|
+
if (!stream.dead) {
|
|
246
|
+
stream.inFlight -= accepted;
|
|
247
|
+
this.#push({
|
|
248
|
+
bytes: accepted,
|
|
249
|
+
kind: "writeCompleted",
|
|
250
|
+
stream: streamId,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
// A rejected write reports nothing: the stream's end releases it.
|
|
255
|
+
() => {}
|
|
256
|
+
);
|
|
257
|
+
return accepted;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Finishes the write side after every accepted write. */
|
|
261
|
+
closeWrite(streamId: number): void {
|
|
262
|
+
quietly(() => {
|
|
263
|
+
const stream = this.#streams.get(streamId);
|
|
264
|
+
// Resolves once every accepted write has resolved, and been reported.
|
|
265
|
+
stream?.writer?.close().then(
|
|
266
|
+
() => {
|
|
267
|
+
stream.writeEnded = true;
|
|
268
|
+
this.#retireIfDone(streamId, stream);
|
|
269
|
+
},
|
|
270
|
+
// Stopped or reset: `writer.closed` or the reset covers it.
|
|
271
|
+
() => {}
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Resets the stream both ways (`abort()` and `cancel()`). */
|
|
277
|
+
reset(streamId: number, code: number): void {
|
|
278
|
+
quietly(() => {
|
|
279
|
+
const stream = this.#streams.get(streamId);
|
|
280
|
+
if (stream === undefined) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
this.#forget(streamId, stream);
|
|
284
|
+
stream.writer?.abort(streamError(code)).catch(() => {});
|
|
285
|
+
stream.reader?.cancel(streamError(code)).catch(() => {});
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Asks the peer to stop sending (`cancel()` only). */
|
|
290
|
+
stop(streamId: number, code: number): void {
|
|
291
|
+
quietly(() => {
|
|
292
|
+
const stream = this.#streams.get(streamId);
|
|
293
|
+
if (stream === undefined) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
stream.readEnded = true;
|
|
297
|
+
if (stream.reader === undefined) {
|
|
298
|
+
stream.pendingStop = code;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
stream.reader.cancel(streamError(code)).catch(() => {});
|
|
302
|
+
this.#retireIfDone(streamId, stream);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Lets the stream deliver up to `bytes` more. */
|
|
307
|
+
grantRead(streamId: number, bytes: number): void {
|
|
308
|
+
quietly(() => {
|
|
309
|
+
const stream = this.#streams.get(streamId);
|
|
310
|
+
if (stream === undefined) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
stream.granted += bytes;
|
|
314
|
+
this.#read(streamId, stream);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Closes the session with a close capsule, as `WebTransport.close()` does. */
|
|
319
|
+
close(sessionId: number, code: number, reason: string): void {
|
|
320
|
+
quietly(() => {
|
|
321
|
+
const session = this.#sessions.get(sessionId);
|
|
322
|
+
if (session === undefined) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// Rust forgets the session's streams with it.
|
|
326
|
+
for (const id of session.streams) {
|
|
327
|
+
const stream = this.#streams.get(id);
|
|
328
|
+
if (stream !== undefined) {
|
|
329
|
+
this.#forget(id, stream);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// Throws once already closed, when `closed` reports the end anyway.
|
|
333
|
+
session.transport.close({ closeCode: code, reason });
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The next queued event, if any. */
|
|
338
|
+
pollEvent(): IoEvent | undefined {
|
|
339
|
+
return this.#events.shift();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Closes every session and forgets everything, for an owner that can no
|
|
344
|
+
* longer drive Rust (a trapped wasm module): no later event or wake, and
|
|
345
|
+
* `connect` throws. Idempotent; called from TypeScript, not Rust.
|
|
346
|
+
*/
|
|
347
|
+
dispose(): void {
|
|
348
|
+
this.#disposed = true;
|
|
349
|
+
this.#events.length = 0;
|
|
350
|
+
// `close` retires each session's streams, silencing their callbacks.
|
|
351
|
+
for (const id of this.#sessions.keys()) {
|
|
352
|
+
this.close(id, 0, "");
|
|
353
|
+
}
|
|
354
|
+
this.#sessions.clear();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
#push(event: IoEvent): void {
|
|
358
|
+
if (this.#disposed) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
this.#events.push(event);
|
|
362
|
+
this.#wake();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#addStream(sessionId: number, session: Session): number {
|
|
366
|
+
const id = this.#nextStream;
|
|
367
|
+
this.#nextStream += 1;
|
|
368
|
+
this.#streams.set(id, new Stream(sessionId));
|
|
369
|
+
session.streams.add(id);
|
|
370
|
+
return id;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Forgets a stream once both of its sides are done. */
|
|
374
|
+
#retireIfDone(id: number, stream: Stream): void {
|
|
375
|
+
if (!stream.dead && stream.readEnded && stream.writeEnded) {
|
|
376
|
+
this.#forget(id, stream);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#forget(id: number, stream: Stream): void {
|
|
381
|
+
stream.dead = true;
|
|
382
|
+
this.#streams.delete(id);
|
|
383
|
+
this.#sessions.get(stream.session)?.streams.delete(id);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
#attach(
|
|
387
|
+
id: number,
|
|
388
|
+
stream: Stream,
|
|
389
|
+
bidi: WebTransportBidirectionalStream
|
|
390
|
+
): void {
|
|
391
|
+
stream.writer = bidi.writable.getWriter();
|
|
392
|
+
stream.reader = chunkReader(bidi.readable);
|
|
393
|
+
// The peer's STOP_SENDING errors the write side.
|
|
394
|
+
stream.writer.closed.catch((error: unknown) => {
|
|
395
|
+
const code = peerStreamCode(error);
|
|
396
|
+
if (!stream.dead && code !== undefined) {
|
|
397
|
+
stream.writeEnded = true;
|
|
398
|
+
this.#push({ code, kind: "stopSending", stream: id });
|
|
399
|
+
this.#retireIfDone(id, stream);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
// The peer's RESET_STREAM errors the read side, read pending or not.
|
|
403
|
+
stream.reader.closed.catch((error: unknown) => {
|
|
404
|
+
this.#readFailed(id, stream, error);
|
|
405
|
+
});
|
|
406
|
+
if (stream.pendingStop !== undefined) {
|
|
407
|
+
stream.reader.cancel(streamError(stream.pendingStop)).catch(() => {});
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
this.#read(id, stream);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
#read(id: number, stream: Stream): void {
|
|
414
|
+
const { reader } = stream;
|
|
415
|
+
if (
|
|
416
|
+
reader === undefined ||
|
|
417
|
+
stream.reading ||
|
|
418
|
+
stream.readEnded ||
|
|
419
|
+
stream.dead ||
|
|
420
|
+
stream.granted <= 0
|
|
421
|
+
) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
stream.reading = true;
|
|
425
|
+
reader.read(Math.min(stream.granted, MAX_READ_BYTES)).then(
|
|
426
|
+
({ done, value }) => {
|
|
427
|
+
stream.reading = false;
|
|
428
|
+
if (stream.dead || stream.readEnded) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (done) {
|
|
432
|
+
stream.readEnded = true;
|
|
433
|
+
this.#push({ kind: "fin", stream: id });
|
|
434
|
+
this.#retireIfDone(id, stream);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (value.byteLength > 0) {
|
|
438
|
+
stream.granted -= value.byteLength;
|
|
439
|
+
this.#push({ bytes: value, kind: "data", stream: id });
|
|
440
|
+
}
|
|
441
|
+
this.#read(id, stream);
|
|
442
|
+
},
|
|
443
|
+
(error: unknown) => {
|
|
444
|
+
stream.reading = false;
|
|
445
|
+
this.#readFailed(id, stream, error);
|
|
446
|
+
}
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* A read side errored: a peer reset, its session going away (which
|
|
452
|
+
* `closed` reports too), or an error with no WebTransport code (Firefox
|
|
453
|
+
* errors the read side so when the peer stops our sending as it closes).
|
|
454
|
+
* Each ends the read side as a reset, so nothing waits on it.
|
|
455
|
+
*/
|
|
456
|
+
#readFailed(id: number, stream: Stream, error: unknown): void {
|
|
457
|
+
if (stream.dead || stream.readEnded) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
stream.readEnded = true;
|
|
461
|
+
this.#push({ code: peerStreamCode(error) ?? 0, kind: "reset", stream: id });
|
|
462
|
+
this.#retireIfDone(id, stream);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async #acceptIncoming(id: number, session: Session): Promise<void> {
|
|
466
|
+
const reader = session.transport.incomingBidirectionalStreams.getReader();
|
|
467
|
+
try {
|
|
468
|
+
for (;;) {
|
|
469
|
+
// oxlint-disable-next-line no-await-in-loop -- One stream at a time, in arrival order.
|
|
470
|
+
const { done, value } = await reader.read();
|
|
471
|
+
if (done || this.#sessions.get(id) !== session) {
|
|
472
|
+
if (!done) {
|
|
473
|
+
abandon(value);
|
|
474
|
+
}
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const stream = this.#addStream(id, session);
|
|
478
|
+
const state = this.#streams.get(stream);
|
|
479
|
+
if (state !== undefined) {
|
|
480
|
+
this.#attach(stream, state, value);
|
|
481
|
+
this.#push({ kind: "incomingStream", session: id, stream });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
} catch {
|
|
485
|
+
// The session ended; `closed` reports it.
|
|
486
|
+
} finally {
|
|
487
|
+
reader.releaseLock();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#endSession(
|
|
492
|
+
end: Extract<IoEvent, { kind: "sessionClosed" | "sessionFailed" }>
|
|
493
|
+
): void {
|
|
494
|
+
const id = end.session;
|
|
495
|
+
const session = this.#sessions.get(id);
|
|
496
|
+
if (session === undefined) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
for (const streamId of session.streams) {
|
|
500
|
+
const stream = this.#streams.get(streamId);
|
|
501
|
+
if (stream !== undefined) {
|
|
502
|
+
this.#forget(streamId, stream);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
this.#sessions.delete(id);
|
|
506
|
+
this.#push(end);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function chunkReader(readable: ReadableStream<Uint8Array>): ChunkReader {
|
|
511
|
+
try {
|
|
512
|
+
const byob = readable.getReader({ mode: "byob" });
|
|
513
|
+
return {
|
|
514
|
+
cancel: (reason) => byob.cancel(reason),
|
|
515
|
+
closed: byob.closed,
|
|
516
|
+
read: (max) => byob.read(new Uint8Array(max)),
|
|
517
|
+
};
|
|
518
|
+
} catch {
|
|
519
|
+
// Not a byte stream: split default chunks to the grant instead.
|
|
520
|
+
}
|
|
521
|
+
const reader = readable.getReader();
|
|
522
|
+
let rest: Uint8Array | undefined;
|
|
523
|
+
return {
|
|
524
|
+
cancel: (reason) => reader.cancel(reason),
|
|
525
|
+
closed: reader.closed,
|
|
526
|
+
read: async (max) => {
|
|
527
|
+
if (rest === undefined) {
|
|
528
|
+
const result = await reader.read();
|
|
529
|
+
if (result.done) {
|
|
530
|
+
return result;
|
|
531
|
+
}
|
|
532
|
+
rest = result.value;
|
|
533
|
+
}
|
|
534
|
+
const value = rest.subarray(0, max);
|
|
535
|
+
rest = rest.byteLength > max ? rest.subarray(max) : undefined;
|
|
536
|
+
return { done: false, value };
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The reason a reset or stop passes, carrying its code: none for 0 (what
|
|
543
|
+
* the browser sends by default), else a `WebTransportError`, in the spec's
|
|
544
|
+
* `(message, options)` form or the `(init)` form Chrome implements.
|
|
545
|
+
*/
|
|
546
|
+
function streamError(code: number): unknown {
|
|
547
|
+
if (code === 0 || typeof WebTransportError !== "function") {
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
return new WebTransportError("minip2p stream error", {
|
|
552
|
+
streamErrorCode: code,
|
|
553
|
+
});
|
|
554
|
+
} catch {
|
|
555
|
+
// Chrome's constructor takes one init dictionary.
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
const init: unknown = {
|
|
559
|
+
message: "minip2p stream error",
|
|
560
|
+
streamErrorCode: code,
|
|
561
|
+
};
|
|
562
|
+
return Reflect.construct(WebTransportError, [init]) as unknown;
|
|
563
|
+
} catch {
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Runs a `JsIo` call Rust makes without catching, swallowing any throw. */
|
|
569
|
+
function quietly(operation: () => void): void {
|
|
570
|
+
try {
|
|
571
|
+
operation();
|
|
572
|
+
} catch {
|
|
573
|
+
// See the module rules: nothing may unwind into Rust from here.
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* The WebTransport code of an error the peer caused on one stream (a
|
|
579
|
+
* `WebTransportError` with `source: "stream"`), cut to `u8`; `undefined`
|
|
580
|
+
* for anything else, such as the session ending.
|
|
581
|
+
*/
|
|
582
|
+
function peerStreamCode(error: unknown): number | undefined {
|
|
583
|
+
if (
|
|
584
|
+
error === null ||
|
|
585
|
+
typeof error !== "object" ||
|
|
586
|
+
Reflect.get(error, "source") !== "stream"
|
|
587
|
+
) {
|
|
588
|
+
return undefined;
|
|
589
|
+
}
|
|
590
|
+
const code: unknown = Reflect.get(error, "streamErrorCode");
|
|
591
|
+
return typeof code === "number" ? Math.min(Math.max(code, 0), 0xff) : 0;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function abandon(bidi: WebTransportBidirectionalStream): void {
|
|
595
|
+
bidi.writable.abort().catch(() => {});
|
|
596
|
+
bidi.readable.cancel().catch(() => {});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function errorMessage(error: unknown): string {
|
|
600
|
+
return error instanceof Error ? error.message : String(error);
|
|
601
|
+
}
|
package/src/manual.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@minip2p/web/manual`: the same API as `@minip2p/web`, without loading
|
|
3
|
+
* the wasm module at import. For hosts that serve the `.wasm` themselves (a
|
|
4
|
+
* CDN, a strict CSP): `await initMinip2p({ wasm })` before
|
|
5
|
+
* `Minip2p.create`, which throws until it has resolved.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export * from "@minip2p/core";
|
|
9
|
+
export {
|
|
10
|
+
Minip2p,
|
|
11
|
+
circuitAddress,
|
|
12
|
+
generateSecretKey,
|
|
13
|
+
initMinip2p,
|
|
14
|
+
peerIdFromSecretKey,
|
|
15
|
+
type InitMinip2pOptions,
|
|
16
|
+
} from "./sdk.js";
|
|
17
|
+
export { type WasmSource } from "./wasm.js";
|