@bytecodealliance/preview3-shim 0.1.0-rc.0 → 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.
@@ -52,6 +52,9 @@ export class FutureReader {
52
52
  if (!promise || typeof promise.then !== "function") {
53
53
  throw new Error("Provided future must be a Promise");
54
54
  }
55
+ if (promise instanceof Promise) {
56
+ promise.catch(() => {});
57
+ }
55
58
  this.#promise = promise;
56
59
  }
57
60
 
@@ -136,7 +136,7 @@ const responseFromParts = (parts) => {
136
136
  });
137
137
  });
138
138
 
139
- const future = new FutureReader(promise.then(_trailerResultFromEntries));
139
+ const future = new TrailerFutureReader(promise.then(_trailerResultFromEntries), trailers);
140
140
  const contents = new StreamReader(body);
141
141
  const fields = _fieldsFromEntriesChecked(headers);
142
142
 
@@ -144,3 +144,18 @@ const responseFromParts = (parts) => {
144
144
  res.setStatusCode(statusCode);
145
145
  return res;
146
146
  };
147
+
148
+ class TrailerFutureReader extends FutureReader {
149
+ #trailers;
150
+
151
+ constructor(promise, trailers) {
152
+ super(promise);
153
+ this.#trailers = trailers;
154
+ }
155
+
156
+ close() {
157
+ this.#trailers?.close();
158
+ this.#trailers = null;
159
+ super.close();
160
+ }
161
+ }
@@ -2,7 +2,12 @@ import { HttpError } from "./error.js";
2
2
  import { _fieldsLock, Fields } from "./fields.js";
3
3
 
4
4
  import { FutureReader, future } from "../future.js";
5
- import { StreamReader, readableByteStreamFromReader } from "../stream.js";
5
+ import {
6
+ DEFAULT_BYTE_STREAM_CHUNK_SIZE,
7
+ StreamReader,
8
+ _byteChunk,
9
+ readableByteStreamFromReader,
10
+ } from "../stream.js";
6
11
 
7
12
  const symbolDispose = Symbol.dispose || Symbol.for("dispose");
8
13
 
@@ -221,7 +226,7 @@ export class Request {
221
226
  if (contents != null && !(contents instanceof StreamReader)) {
222
227
  try {
223
228
  dispose = contents[symbolDispose]?.bind(contents);
224
- const inner = readableByteStreamFromReader(contents, { name: "contents" });
229
+ const inner = readableContentsStream(contents, headers);
225
230
  contents = new StreamReader(inner);
226
231
  } catch (err) {
227
232
  throw new HttpError("invalid-argument", err.message);
@@ -231,7 +236,6 @@ export class Request {
231
236
  // Generated P3 futures are lazy thenables so we want to observe them now so early error paths don't hang.
232
237
  if (!(trailers instanceof FutureReader)) {
233
238
  const promise = Promise.resolve(trailers);
234
- void promise.catch(() => {});
235
239
  trailers = new FutureReader(promise);
236
240
  }
237
241
 
@@ -477,6 +481,93 @@ export class Request {
477
481
  }
478
482
  }
479
483
 
484
+ function readableContentsStream(reader, headers) {
485
+ const expectedLength = contentLength(headers.copyAll());
486
+ if (expectedLength === null) {
487
+ return readableByteStreamFromReader(reader, { name: "contents" });
488
+ }
489
+
490
+ const source =
491
+ typeof reader?.read === "function"
492
+ ? reader
493
+ : new StreamReader(readableByteStreamFromReader(reader, { name: "contents" }));
494
+ return readableByteStreamFromReader(contentLengthReader(source, expectedLength), {
495
+ name: "contents",
496
+ });
497
+ }
498
+
499
+ function contentLengthReader(reader, expectedLength) {
500
+ let sent = 0n;
501
+ return {
502
+ async read() {
503
+ const result = await reader.read(readOpts(expectedLength - sent));
504
+ if (isIteratorResult(result) && result.rejectedLength !== undefined) {
505
+ throw requestBodySizeError(sent + BigInt(result.rejectedLength));
506
+ }
507
+ if (isIteratorResult(result) && result.done) {
508
+ if (sent < expectedLength) {
509
+ throw requestBodySizeError(sent);
510
+ }
511
+ return result;
512
+ }
513
+
514
+ const value = isIteratorResult(result) ? result.value : result;
515
+ const chunk = _byteChunk(value);
516
+ sent += BigInt(chunk.byteLength);
517
+ if (sent > expectedLength) {
518
+ throw requestBodySizeError(sent);
519
+ }
520
+ return { value: chunk, done: false };
521
+ },
522
+ cancel(reason) {
523
+ if (typeof reader.cancel === "function") {
524
+ return reader.cancel(reason);
525
+ }
526
+ if (typeof reader.close === "function") {
527
+ return reader.close();
528
+ }
529
+ return reader[symbolDispose]?.();
530
+ },
531
+ };
532
+ }
533
+
534
+ function readOpts(remaining) {
535
+ if (remaining <= 0n) {
536
+ return { count: 0, rejectLength: 0 };
537
+ }
538
+
539
+ const count = Number(
540
+ remaining <= BigInt(DEFAULT_BYTE_STREAM_CHUNK_SIZE)
541
+ ? remaining
542
+ : BigInt(DEFAULT_BYTE_STREAM_CHUNK_SIZE),
543
+ );
544
+ const opts = { count };
545
+ if (remaining <= BigInt(Number.MAX_SAFE_INTEGER)) {
546
+ opts.rejectLength = Number(remaining);
547
+ }
548
+ return opts;
549
+ }
550
+
551
+ function requestBodySizeError(bytes) {
552
+ return { tag: "HTTP-request-body-size", val: bytes };
553
+ }
554
+
555
+ function isIteratorResult(value) {
556
+ return value != null && typeof value === "object" && typeof value.done === "boolean";
557
+ }
558
+
559
+ const decoder = new TextDecoder();
560
+
561
+ const contentLength = (entries) => {
562
+ const entry = entries.findLast(([name]) => name.toLowerCase() === "content-length");
563
+ if (!entry) {
564
+ return null;
565
+ }
566
+
567
+ const value = decoder.decode(entry[1]);
568
+ return /^\d+$/.test(value) ? BigInt(value) : null;
569
+ };
570
+
480
571
  const UrlPart = {
481
572
  PATH_WITH_QUERY: "pathWithQuery",
482
573
  SCHEME: "scheme",
@@ -71,7 +71,6 @@ export class Response {
71
71
  // Generated P3 futures are lazy thenables so we want to observe them now so early error paths don't hang.
72
72
  if (!(trailers instanceof FutureReader)) {
73
73
  const promise = Promise.resolve(trailers);
74
- void promise.catch(() => {});
75
74
  trailers = new FutureReader(promise);
76
75
  }
77
76
 
@@ -90,7 +89,7 @@ export class Response {
90
89
  [symbolDispose]() {
91
90
  if (this.#contents && !this.#bodyOpen && !this.#bodyEnded) {
92
91
  this.#contentsDispose?.();
93
- this.#contents.close();
92
+ this.#closeBody();
94
93
  }
95
94
  this.#contents = null;
96
95
  this.#contentsDispose = null;
@@ -194,9 +193,12 @@ export class Response {
194
193
 
195
194
  const closedFn = reader.close.bind(reader);
196
195
  reader.close = () => {
197
- closedFn();
198
- response.#bodyEnded = true;
199
- response.#bodyOpen = false;
196
+ response.#closeBody(closedFn);
197
+ };
198
+
199
+ const cancelFn = reader.cancel.bind(reader);
200
+ reader.cancel = async (...args) => {
201
+ await response.#closeBody(() => cancelFn(...args));
200
202
  };
201
203
 
202
204
  return [response.#contents, response.#trailersFuture];
@@ -209,4 +211,17 @@ export class Response {
209
211
  this.#responseFuture = null;
210
212
  }
211
213
  }
214
+
215
+ #closeBody(close = this.#contents?.close.bind(this.#contents)) {
216
+ const closeTrailers = !this.#bodyEnded;
217
+ try {
218
+ return close?.();
219
+ } finally {
220
+ if (closeTrailers) {
221
+ this.#trailersFuture.close();
222
+ }
223
+ this.#bodyEnded = true;
224
+ this.#bodyOpen = false;
225
+ }
226
+ }
212
227
  }
@@ -13,6 +13,7 @@ import {
13
13
  } from "./address.js";
14
14
 
15
15
  let WORKER = null;
16
+ const symbolDispose = Symbol.dispose || Symbol.for("dispose");
16
17
  function worker() {
17
18
  return (WORKER ??= new ResourceWorker(new URL("../workers/tcp-worker.js", import.meta.url)));
18
19
  }
@@ -61,6 +62,8 @@ export class TcpSocket {
61
62
  #socketId = null;
62
63
  #family = null;
63
64
  #state = "unbound";
65
+ #sendStarted = false;
66
+ #receiveStarted = false;
64
67
  #options = {
65
68
  // defaults per https://nodejs.org/docs/latest/api/net.html#socketsetkeepaliveenable-initialdelay
66
69
  keepAliveEnabled: false,
@@ -270,6 +273,10 @@ export class TcpSocket {
270
273
  if (this.#state !== STATE.CONNECTED) {
271
274
  throw new SocketError("invalid-state");
272
275
  }
276
+ if (this.#sendStarted) {
277
+ data?.[symbolDispose]?.();
278
+ throw new SocketError("invalid-state");
279
+ }
273
280
  let stream;
274
281
  try {
275
282
  stream = readableByteStreamFromReader(data, { name: "tcp send data" });
@@ -279,6 +286,7 @@ export class TcpSocket {
279
286
  }
280
287
  throw error;
281
288
  }
289
+ this.#sendStarted = true;
282
290
 
283
291
  try {
284
292
  // Transfer the stream to the worker
@@ -310,6 +318,16 @@ export class TcpSocket {
310
318
  if (this.#state !== STATE.CONNECTED) {
311
319
  throw new SocketError("invalid-state");
312
320
  }
321
+ if (this.#receiveStarted) {
322
+ const stream = new ReadableStream({
323
+ start(controller) {
324
+ controller.close();
325
+ },
326
+ });
327
+ const promise = Promise.reject(new SocketError("invalid-state"));
328
+ return [new StreamReader(stream, { preventCancel: false }), new FutureReader(promise)];
329
+ }
330
+ this.#receiveStarted = true;
313
331
 
314
332
  const transform = new TransformStream();
315
333
  const promise = worker()
@@ -325,7 +343,10 @@ export class TcpSocket {
325
343
  throw SocketError.from(err);
326
344
  });
327
345
 
328
- return [new StreamReader(transform.readable), new FutureReader(promise)];
346
+ return [
347
+ new StreamReader(transform.readable, { preventCancel: false }),
348
+ new FutureReader(promise),
349
+ ];
329
350
  }
330
351
 
331
352
  /**
@@ -1,4 +1,5 @@
1
1
  export const DEFAULT_BYTE_STREAM_CHUNK_SIZE = 64 * 1024;
2
+ const symbolDispose = Symbol.dispose || Symbol.for("dispose");
2
3
 
3
4
  let BYTE_STREAM_ENCODER = null;
4
5
  function encoder() {
@@ -61,7 +62,7 @@ export function readableByteStreamFromReader(reader, opts = {}) {
61
62
  controller.close();
62
63
  return;
63
64
  }
64
- controller.enqueue(byteChunk(value));
65
+ controller.enqueue(_byteChunk(value));
65
66
  },
66
67
  cancel(reason) {
67
68
  return source.cancel?.(reason);
@@ -85,6 +86,15 @@ function byteStreamSource(reader, opts) {
85
86
  }
86
87
  return { value: result, done: result === null };
87
88
  },
89
+ cancel(reason) {
90
+ if (typeof reader.cancel === "function") {
91
+ return reader.cancel(reason);
92
+ }
93
+ if (typeof reader.close === "function") {
94
+ return reader.close();
95
+ }
96
+ return reader[symbolDispose]?.();
97
+ },
88
98
  };
89
99
  }
90
100
 
@@ -107,7 +117,7 @@ function isIteratorResult(value) {
107
117
  return value != null && typeof value === "object" && typeof value.done === "boolean";
108
118
  }
109
119
 
110
- function byteChunk(value) {
120
+ export function _byteChunk(value) {
111
121
  if (value instanceof Uint8Array) {
112
122
  return value;
113
123
  }
@@ -196,7 +206,7 @@ export class StreamReader {
196
206
  * @param {AsyncIterable|Iterable} source - An async or sync iterable to consume e.g. ReadableStream, async generator, array.
197
207
  * @throws {Error} If the provided source does not implement `[Symbol.asyncIterator]` or `[Symbol.iterator]`.
198
208
  */
199
- constructor(source) {
209
+ constructor(source, opts = {}) {
200
210
  if (
201
211
  !source ||
202
212
  (typeof source[Symbol.asyncIterator] !== "function" &&
@@ -210,7 +220,7 @@ export class StreamReader {
210
220
  // For ReadableStream, use values() with preventCancel so the underlying
211
221
  // stream is not cancelled when the iterator is released.
212
222
  if (source instanceof ReadableStream) {
213
- this.#iterator = source.values({ preventCancel: true });
223
+ this.#iterator = source.values({ preventCancel: opts.preventCancel ?? true });
214
224
  } else if (typeof source[Symbol.asyncIterator] === "function") {
215
225
  this.#iterator = source[Symbol.asyncIterator]();
216
226
  } else {
@@ -298,6 +308,10 @@ export class StreamReader {
298
308
  this.#iterator = null;
299
309
  }
300
310
 
311
+ [symbolDispose]() {
312
+ this.close();
313
+ }
314
+
301
315
  /**
302
316
  * Consumes the reader and returns the underlying async iterator.
303
317
  *
@@ -222,7 +222,10 @@ async function doHandleRequest({ url, method, headers, trailers, body, timeouts
222
222
  req.once("close", onClose);
223
223
  });
224
224
 
225
- const upload = body ? sendRequestBody(req, body, trailers, () => resStarted) : endRequest(req);
225
+ const expectedLength = contentLength(headers);
226
+ const upload = body
227
+ ? sendRequestBody(req, body, trailers, () => resStarted, expectedLength)
228
+ : endRequest(req);
226
229
 
227
230
  upload.then(
228
231
  () => transmit.ok(),
@@ -287,10 +290,12 @@ async function handleHttpServerClose({ serverId }) {
287
290
  return serverId;
288
291
  }
289
292
 
290
- async function sendRequestBody(req, body, trailers, resStarted) {
293
+ async function sendRequestBody(req, body, trailers, resStarted, expectedLength) {
291
294
  try {
292
295
  req.flushHeaders();
293
- await pipeline(Readable.fromWeb(body), req, { end: false });
296
+ await pipeline(validateRequestBody(Readable.fromWeb(body), expectedLength), req, {
297
+ end: false,
298
+ });
294
299
  const fields = await recvTrailers(trailers);
295
300
  if (fields) {
296
301
  req.addTrailers(toObject(fields));
@@ -304,6 +309,21 @@ async function sendRequestBody(req, body, trailers, resStarted) {
304
309
  }
305
310
  }
306
311
 
312
+ async function* validateRequestBody(body, expectedLength) {
313
+ let bytes = 0n;
314
+ for await (const chunk of body) {
315
+ bytes += BigInt(chunk.byteLength);
316
+ if (expectedLength !== null && bytes > expectedLength) {
317
+ throw new HttpError("HTTP-request-body-size", undefined, bytes);
318
+ }
319
+ yield chunk;
320
+ }
321
+
322
+ if (expectedLength !== null && bytes < expectedLength) {
323
+ throw new HttpError("HTTP-request-body-size", undefined, bytes);
324
+ }
325
+ }
326
+
307
327
  function endRequest(req) {
308
328
  return new Promise((resolve, reject) => {
309
329
  let settled = false;
@@ -374,6 +394,16 @@ const msecs = (time) => {
374
394
 
375
395
  const decoder = new TextDecoder();
376
396
 
397
+ const contentLength = (entries) => {
398
+ const entry = entries.findLast(([name]) => name.toLowerCase() === "content-length");
399
+ if (!entry) {
400
+ return null;
401
+ }
402
+
403
+ const value = decoder.decode(entry[1]);
404
+ return /^\d+$/.test(value) ? BigInt(value) : null;
405
+ };
406
+
377
407
  const toObject = (entries) => {
378
408
  return Object.fromEntries(entries.map(([key, val]) => [key, decoder.decode(val)]));
379
409
  };
@@ -46,7 +46,7 @@ export class ResourceWorker {
46
46
  port1.close();
47
47
 
48
48
  if (error) {
49
- reject(error);
49
+ reject(deserializeError(error));
50
50
  return;
51
51
  }
52
52
  resolve(result);
@@ -77,7 +77,7 @@ export class ResourceWorker {
77
77
  const { result, error } = message;
78
78
 
79
79
  if (error) {
80
- throw error;
80
+ throw deserializeError(error);
81
81
  }
82
82
  return result;
83
83
  }
@@ -118,7 +118,7 @@ export function Router() {
118
118
  result = outcome;
119
119
  }
120
120
  } catch (err) {
121
- error = err;
121
+ error = serializeError(err);
122
122
  }
123
123
 
124
124
  _reply.postMessage({ result, error }, transfer);
@@ -144,6 +144,50 @@ export function Router() {
144
144
  };
145
145
  }
146
146
 
147
+ function serializeError(err) {
148
+ if (!(err instanceof Error)) {
149
+ return err;
150
+ }
151
+
152
+ const serialized = {
153
+ __resourceWorkerError: true,
154
+ name: err.name,
155
+ message: err.message,
156
+ stack: err.stack,
157
+ };
158
+
159
+ for (const key of Reflect.ownKeys(err)) {
160
+ serialized[key] = err[key];
161
+ }
162
+
163
+ if (err.cause !== undefined) {
164
+ serialized.cause = serializeError(err.cause);
165
+ }
166
+
167
+ return serialized;
168
+ }
169
+
170
+ function deserializeError(err) {
171
+ if (!err || typeof err !== "object" || err.__resourceWorkerError !== true) {
172
+ return err;
173
+ }
174
+
175
+ const error = new Error(err.message);
176
+ error.name = err.name;
177
+ if (err.stack) {
178
+ error.stack = err.stack;
179
+ }
180
+
181
+ for (const key of Reflect.ownKeys(err)) {
182
+ if (key === "__resourceWorkerError" || key === "name" || key === "message" || key === "stack") {
183
+ continue;
184
+ }
185
+ error[key] = key === "cause" ? deserializeError(err[key]) : err[key];
186
+ }
187
+
188
+ return error;
189
+ }
190
+
147
191
  function notify(condvar) {
148
192
  const lock = new Int32Array(condvar);
149
193
  Atomics.store(lock, 0, 1);
@@ -1,5 +1,5 @@
1
1
  import { Socket, Server } from "node:net";
2
- import { Readable, Writable } from "stream";
2
+ import { Readable } from "stream";
3
3
  import { pipeline } from "stream/promises";
4
4
  import { once } from "node:events";
5
5
 
@@ -48,6 +48,8 @@ function handleTcpCreate({ family }) {
48
48
  server: null,
49
49
  backlog: 128,
50
50
  localAddress: null,
51
+ disposed: false,
52
+ activeStreams: 0,
51
53
  });
52
54
 
53
55
  return { socketId };
@@ -143,6 +145,7 @@ async function handleTcpListen({ socketId, stream }) {
143
145
  }
144
146
 
145
147
  server.on("connection", (conn) => {
148
+ conn.allowHalfOpen = true;
146
149
  const id = NEXT_SOCKET_ID++;
147
150
  sockets.set(id, {
148
151
  handle: conn._handle,
@@ -151,6 +154,8 @@ async function handleTcpListen({ socketId, stream }) {
151
154
  tcp: conn,
152
155
  server: null,
153
156
  localAddress: makeIpAddress(family, conn.localAddress, conn.localPort),
157
+ disposed: false,
158
+ activeStreams: 0,
154
159
  });
155
160
  writer.write({ family, socketId: id });
156
161
  });
@@ -162,19 +167,111 @@ async function handleTcpListen({ socketId, stream }) {
162
167
 
163
168
  async function handleTcpSend({ socketId, stream }) {
164
169
  const socket = sockets.get(socketId);
170
+ socket.activeStreams++;
171
+
165
172
  const { tcp } = socket;
166
173
  const readable = Readable.fromWeb(stream);
167
174
 
168
- // TODO(tandr): Should we handle FIN packet?
169
- await pipeline(readable, tcp);
175
+ try {
176
+ await pipeline(readable, tcp);
177
+ } finally {
178
+ socket.activeStreams--;
179
+ cleanupDisposedSocket(socketId, socket);
180
+ }
170
181
  }
171
182
 
172
183
  async function handleTcpReceive({ socketId, stream }) {
173
184
  const socket = sockets.get(socketId);
185
+ const writer = stream.getWriter();
186
+ socket.activeStreams++;
187
+
174
188
  const { tcp } = socket;
175
189
 
176
- const writable = Writable.fromWeb(stream);
177
- await pipeline(tcp, writable);
190
+ try {
191
+ await new Promise((resolve, reject) => {
192
+ let settled = false;
193
+ let pending = Promise.resolve();
194
+
195
+ const cleanup = () => {
196
+ tcp.off("data", onData);
197
+ tcp.off("end", onEnd);
198
+ tcp.off("error", onError);
199
+ };
200
+ const settle = (err) => {
201
+ if (settled) {
202
+ return;
203
+ }
204
+ settled = true;
205
+ cleanup();
206
+ if (err) {
207
+ reject(err);
208
+ } else {
209
+ resolve();
210
+ }
211
+ };
212
+ const onData = (chunk) => {
213
+ tcp.pause();
214
+ pending = pending.then(async () => {
215
+ try {
216
+ await writer.write(chunk);
217
+ } catch {
218
+ settle();
219
+ return;
220
+ } finally {
221
+ // Always undo the pause, even if the guest dropped or cancelled the
222
+ // receive stream. Otherwise the send path can remain backpressured
223
+ // forever.
224
+ tcp.resume();
225
+ }
226
+ }, settle);
227
+ };
228
+ const onEnd = () => {
229
+ pending = pending.then(async () => {
230
+ try {
231
+ await writer.close();
232
+ } catch {
233
+ // The guest can drop the receive stream before remote EOF.
234
+ }
235
+ settle();
236
+ }, settle);
237
+ };
238
+ const onError = (err) => {
239
+ pending = pending.finally(() => settle(err));
240
+ };
241
+
242
+ writer.closed.then(
243
+ () => settle(),
244
+ () => settle(),
245
+ );
246
+ tcp.on("data", onData);
247
+ tcp.once("end", onEnd);
248
+ tcp.once("error", onError);
249
+ tcp.resume();
250
+ });
251
+ } finally {
252
+ writer.releaseLock();
253
+ socket.activeStreams--;
254
+ cleanupDisposedSocket(socketId, socket);
255
+ }
256
+ }
257
+
258
+ function cleanupDisposedSocket(socketId, socket) {
259
+ if (!socket.disposed || socket.activeStreams > 0) {
260
+ return;
261
+ }
262
+
263
+ if (socket.server) {
264
+ socket.server.close();
265
+ }
266
+
267
+ if (socket.tcp) {
268
+ socket.tcp.destroy();
269
+ }
270
+ if (socket.handle) {
271
+ socket.handle.close();
272
+ }
273
+
274
+ sockets.delete(socketId);
178
275
  }
179
276
 
180
277
  async function handleGetLocalAddress({ socketId }) {
@@ -241,18 +338,8 @@ function handleTcpDispose({ socketId }) {
241
338
  return;
242
339
  }
243
340
 
244
- if (socket.server) {
245
- socket.server.close();
246
- }
247
-
248
- if (socket.tcp) {
249
- socket.tcp.destroy();
250
- }
251
- if (socket.handle) {
252
- socket.handle.close();
253
- }
254
-
255
- sockets.delete(socketId);
341
+ socket.disposed = true;
342
+ cleanupDisposedSocket(socketId, socket);
256
343
  }
257
344
 
258
345
  let _recvBufferSize, _sendBufferSize;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytecodealliance/preview3-shim",
3
- "version": "0.1.0-rc.0",
3
+ "version": "0.1.0",
4
4
  "description": "WASI Preview3 shim for JS environments",
5
5
  "homepage": "https://github.com/bytecodealliance/jco/blob/main/packages/preview3-shim/#readme",
6
6
  "bugs": {