@bytecodealliance/preview3-shim 0.1.0-rc.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.
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs/promises";
2
+ import nodePath from "node:path";
2
3
  import process from "node:process";
3
4
 
4
5
  import { StreamReader, readableByteStreamFromReader } from "../stream.js";
@@ -29,8 +30,10 @@ class Descriptor {
29
30
  #finalizer;
30
31
  /** Host filesystem path for preopened directories */
31
32
  #hostPreopen;
33
+ /** Whether this descriptor refers to a directory. */
34
+ #isDirectory = false;
32
35
 
33
- static _create(handle, mode, fullPath) {
36
+ static _create(handle, mode, fullPath, isDirectory = false) {
34
37
  const {
35
38
  read = false,
36
39
  write = false,
@@ -52,6 +55,7 @@ class Descriptor {
52
55
  const desc = new Descriptor();
53
56
  desc.#handle = handle;
54
57
  desc.#fullPath = fullPath;
58
+ desc.#isDirectory = isDirectory;
55
59
  desc.#mode = merged;
56
60
  desc.#finalizer = registerDispose(desc, null, handle, (handle) => handle.close());
57
61
 
@@ -60,6 +64,15 @@ class Descriptor {
60
64
 
61
65
  static _createPreopen(hostPreopen) {
62
66
  const desc = new Descriptor();
67
+ desc.#isDirectory = true;
68
+ desc.#mode = {
69
+ read: true,
70
+ write: false,
71
+ fileIntegritySync: false,
72
+ dataIntegritySync: false,
73
+ requestedWriteSync: false,
74
+ mutateDirectory: true,
75
+ };
63
76
 
64
77
  if (hostPreopen.endsWith("/")) {
65
78
  desc.#hostPreopen = hostPreopen.slice(0, -1) || "/";
@@ -363,6 +376,7 @@ class Descriptor {
363
376
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
364
377
  */
365
378
  async createDirectoryAt(path) {
379
+ await this.#ensureSandboxedPath(path);
366
380
  const full = this.#getFullPath(path, false);
367
381
  try {
368
382
  await fs.mkdir(full);
@@ -426,6 +440,7 @@ class Descriptor {
426
440
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
427
441
  */
428
442
  async statAt(flags, path) {
443
+ await this.#ensureSandboxedPath(path);
429
444
  const full = this.#getFullPath(path, flags.symlinkFollow);
430
445
  try {
431
446
  const statFn = flags.symlinkFollow ? fs.stat : fs.lstat;
@@ -464,6 +479,7 @@ class Descriptor {
464
479
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
465
480
  */
466
481
  async setTimesAt(flags, path, atimeDesc, mtimeDesc) {
482
+ await this.#ensureSandboxedPath(path);
467
483
  const { atime, mtime } = await this.#computeTimestamps(atimeDesc, mtimeDesc, path);
468
484
 
469
485
  if (!flags.symlinkFollow && !fs.lutimes) {
@@ -501,6 +517,8 @@ class Descriptor {
501
517
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
502
518
  */
503
519
  async linkAt(oldFlags, oldPath, newDesc, newPath) {
520
+ await this.#ensureSandboxedPath(oldPath);
521
+ await newDesc.#ensureSandboxedPath(newPath);
504
522
  const src = this.#getFullPath(oldPath, oldFlags.symlinkFollow);
505
523
  const dst = newDesc.#getFullPath(newPath, false);
506
524
 
@@ -542,9 +560,16 @@ class Descriptor {
542
560
  throw new FSError("access");
543
561
  }
544
562
 
563
+ await this.#ensureSandboxedPath(path);
545
564
  const fullPath = this.#getFullPath(path, pf.symlinkFollow);
546
565
  const target = stripTrailingSlash(fullPath);
547
566
 
567
+ const mode = {
568
+ ...df,
569
+ read: df.read || !df.write,
570
+ write: df.write || of.create || of.truncate,
571
+ };
572
+
548
573
  const makeFsFlags = () => {
549
574
  let fsFlags = 0;
550
575
  if (of.create) {
@@ -559,11 +584,11 @@ class Descriptor {
559
584
  if (of.truncate) {
560
585
  fsFlags |= fs.constants.O_TRUNC;
561
586
  }
562
- if (df.read && df.write) {
587
+ if (mode.read && mode.write) {
563
588
  fsFlags |= fs.constants.O_RDWR;
564
- } else if (df.write) {
589
+ } else if (mode.write) {
565
590
  fsFlags |= fs.constants.O_WRONLY;
566
- } else if (df.read) {
591
+ } else if (mode.read) {
567
592
  fsFlags |= fs.constants.O_RDONLY;
568
593
  }
569
594
  if (df.fileIntegritySync) {
@@ -610,8 +635,9 @@ class Descriptor {
610
635
 
611
636
  try {
612
637
  const handle = await fs.open(target, fsFlags);
613
- const desc = descriptorCreate(handle, df, fullPath);
614
- const isDir = (await desc.getType()).tag === "directory";
638
+ const stats = await handle.stat();
639
+ const isDir = stats.isDirectory();
640
+ const desc = descriptorCreate(handle, mode, fullPath, isDir);
615
641
 
616
642
  if (fullPath.endsWith("/") && !isDir) {
617
643
  desc[symbolDispose]();
@@ -639,6 +665,7 @@ class Descriptor {
639
665
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
640
666
  */
641
667
  async readlinkAt(path) {
668
+ await this.#ensureSandboxedPath(path);
642
669
  const full = this.#getFullPath(path, false);
643
670
  try {
644
671
  return await fs.readlink(full);
@@ -660,6 +687,10 @@ class Descriptor {
660
687
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
661
688
  */
662
689
  async removeDirectoryAt(path) {
690
+ if (path === ".") {
691
+ throw new FSError("invalid");
692
+ }
693
+ await this.#ensureSandboxedPath(path);
663
694
  const full = this.#getFullPath(path, false);
664
695
  try {
665
696
  await fs.rmdir(full);
@@ -686,6 +717,8 @@ class Descriptor {
686
717
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
687
718
  */
688
719
  async renameAt(oldPath, newDesc, newPath) {
720
+ await this.#ensureSandboxedPath(oldPath);
721
+ await newDesc.#ensureSandboxedPath(newPath);
689
722
  const src = this.#getFullPath(oldPath, false);
690
723
  const dst = newDesc.#getFullPath(newPath, false);
691
724
  try {
@@ -715,6 +748,7 @@ class Descriptor {
715
748
  if (target.startsWith("/")) {
716
749
  throw new FSError("not-permitted");
717
750
  }
751
+ await this.#ensureSandboxedPath(path);
718
752
  const full = this.#getFullPath(path, false);
719
753
 
720
754
  try {
@@ -750,6 +784,7 @@ class Descriptor {
750
784
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
751
785
  */
752
786
  async unlinkFileAt(path) {
787
+ await this.#ensureSandboxedPath(path);
753
788
  const full = this.#getFullPath(path, false);
754
789
  if (full.endsWith("/")) {
755
790
  const isDir = (await fs.stat(full)).isDirectory();
@@ -787,7 +822,8 @@ class Descriptor {
787
822
  * @returns {Promise<boolean>}
788
823
  */
789
824
  async isSameObject(other) {
790
- return other === this;
825
+ const [left, right] = await Promise.all([this.#statForIdentity(), other.#statForIdentity()]);
826
+ return left.dev === right.dev && left.ino === right.ino;
791
827
  }
792
828
 
793
829
  /**
@@ -802,11 +838,8 @@ class Descriptor {
802
838
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
803
839
  */
804
840
  async metadataHash() {
805
- if (this.#hostPreopen) {
806
- return { upper: 0n, lower: BigInt(this._id) };
807
- }
808
841
  try {
809
- const s = await this.#handle.stat();
842
+ const s = await this.#statForIdentity();
810
843
  return { upper: s.mtimeNs, lower: s.ino };
811
844
  } catch (e) {
812
845
  throw FSError.from(e);
@@ -827,10 +860,11 @@ class Descriptor {
827
860
  * @throws {FSError} `payload.tag` contains mapped WASI error code.
828
861
  */
829
862
  async metadataHashAt(flags, path) {
863
+ await this.#ensureSandboxedPath(path);
830
864
  const full = this.#getFullPath(path, false);
831
865
  try {
832
866
  const statFn = flags.symlinkFollow ? fs.stat : fs.lstat;
833
- const s = await statFn(full);
867
+ const s = await statFn(full, { bigint: true });
834
868
  return { upper: s.mtimeNs, lower: s.ino };
835
869
  } catch (e) {
836
870
  throw FSError.from(e);
@@ -896,6 +930,10 @@ class Descriptor {
896
930
  #getFullPath(subpath, _followSymlinks) {
897
931
  subpath = subpath.replaceAll("\\", "/").replace(/\/\/+/g, "/");
898
932
 
933
+ if (subpath === "") {
934
+ throw new FSError("no-entry");
935
+ }
936
+
899
937
  if (subpath.startsWith("/")) {
900
938
  throw new FSError("not-permitted");
901
939
  }
@@ -924,6 +962,59 @@ class Descriptor {
924
962
  return `${baseNormalized}/${segments.join("/")}`;
925
963
  }
926
964
 
965
+ async #statForIdentity() {
966
+ if (this.#hostPreopen) {
967
+ return fs.stat(this.#hostPreopen, { bigint: true });
968
+ }
969
+ this.#ensureHandle();
970
+ return this.#handle.stat({ bigint: true });
971
+ }
972
+
973
+ // WASI paths are always relative to a directory descriptor. During path
974
+ // resolution, both `..` and symlinks must not escape that descriptor's base.
975
+ async #ensureSandboxedPath(subpath) {
976
+ if (!this.#isDirectory) {
977
+ throw new FSError("not-directory");
978
+ }
979
+
980
+ const base = this.#hostPreopen ?? this.#fullPath;
981
+ const baseResolved = nodePath.resolve(base);
982
+ const segments = subpath.replaceAll("\\", "/").replace(/\/\/+/g, "/").split("/");
983
+ let current = baseResolved;
984
+
985
+ for (const seg of segments) {
986
+ if (seg === "" || seg === ".") {
987
+ continue;
988
+ }
989
+ if (seg === "..") {
990
+ current = nodePath.dirname(current);
991
+ if (!isWithinPath(baseResolved, current)) {
992
+ throw new FSError("not-permitted");
993
+ }
994
+ continue;
995
+ }
996
+
997
+ current = nodePath.join(current, seg);
998
+ let stat;
999
+ try {
1000
+ stat = await fs.lstat(current);
1001
+ } catch (err) {
1002
+ if (err.code === "ENOENT") {
1003
+ return;
1004
+ }
1005
+ throw FSError.from(err);
1006
+ }
1007
+
1008
+ if (stat.isSymbolicLink()) {
1009
+ const target = await fs.readlink(current);
1010
+ current = nodePath.resolve(nodePath.dirname(current), target);
1011
+ if (!isWithinPath(baseResolved, current)) {
1012
+ throw new FSError("not-permitted");
1013
+ }
1014
+ }
1015
+ }
1016
+ }
1017
+
927
1018
  #ensureHandle() {
928
1019
  if (!this.#handle) {
929
1020
  throw new FSError("bad-descriptor");
@@ -964,6 +1055,11 @@ function stripTrailingSlash(path) {
964
1055
  return path.replace(/\/+$/, "");
965
1056
  }
966
1057
 
1058
+ function isWithinPath(base, candidate) {
1059
+ const relative = nodePath.relative(base, candidate);
1060
+ return relative === "" || (!relative.startsWith("..") && !nodePath.isAbsolute(relative));
1061
+ }
1062
+
967
1063
  const preopenEntries = [];
968
1064
  export const preopens = {
969
1065
  Descriptor,
@@ -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
+ }
@@ -115,16 +115,19 @@ export class Fields {
115
115
  let bucket = this.#table.get(lowercased);
116
116
  if (bucket) {
117
117
  this.#entries = this.#entries.filter((e) => !bucket.includes(e));
118
- bucket.splice(0, bucket.length);
119
- } else {
120
- bucket = [];
121
- this.#table.set(lowercased, bucket);
118
+ this.#table.delete(lowercased);
119
+ }
120
+
121
+ if (values.length === 0) {
122
+ return;
122
123
  }
123
124
 
125
+ bucket = [];
126
+ this.#table.set(lowercased, bucket);
124
127
  for (const value of values) {
125
128
  const entry = [name, value];
126
129
  this.#entries.push(entry);
127
- this.#table.get(lowercased).push(entry);
130
+ bucket.push(entry);
128
131
  }
129
132
  }
130
133
 
@@ -140,6 +143,7 @@ export class Fields {
140
143
  */
141
144
  delete(name) {
142
145
  this.#ensureMutable();
146
+ this.#validateName(name);
143
147
  const lowercased = name.toLowerCase();
144
148
  const tableEntries = this.#table.get(lowercased);
145
149
 
@@ -162,6 +166,7 @@ export class Fields {
162
166
  */
163
167
  getAndDelete(name) {
164
168
  this.#ensureMutable();
169
+ this.#validateName(name);
165
170
 
166
171
  const values = this.get(name);
167
172
  this.delete(name);
@@ -188,10 +193,11 @@ export class Fields {
188
193
  this.#validateValue(name, value);
189
194
 
190
195
  const lowercased = name.toLowerCase();
191
- const entry = [name, value];
192
- this.#entries.push(entry);
193
196
 
194
197
  const tableEntries = this.#table.get(lowercased);
198
+ const entryName = tableEntries?.[0]?.[0] ?? name;
199
+ const entry = [entryName, value];
200
+ this.#entries.push(entry);
195
201
  if (tableEntries) {
196
202
  tableEntries.push(entry);
197
203
  } else {
@@ -274,7 +280,7 @@ export class Fields {
274
280
 
275
281
  #validateValue(name, value) {
276
282
  try {
277
- validateHeaderValue(name, new TextDecoder().decode(value));
283
+ validateHeaderValue(name, bytesToHeaderValueString(value));
278
284
  } catch {
279
285
  throw new HttpError("invalid-syntax", `Invalid header value for ${name}`);
280
286
  }
@@ -287,6 +293,14 @@ export class Fields {
287
293
  }
288
294
  }
289
295
 
296
+ function bytesToHeaderValueString(value) {
297
+ let result = "";
298
+ for (let i = 0; i < value.length; i += 0x8000) {
299
+ result += String.fromCharCode(...value.subarray(i, i + 0x8000));
300
+ }
301
+ return result;
302
+ }
303
+
290
304
  export function _fieldsLock(fields) {
291
305
  return Fields._lock(fields);
292
306
  }
@@ -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
 
@@ -321,7 +325,7 @@ export class Request {
321
325
  */
322
326
  setPathWithQuery(pathWithQuery) {
323
327
  validateUrlPart(pathWithQuery, UrlPart.PATH_WITH_QUERY);
324
- this.#pathWithQuery = pathWithQuery ?? undefined;
328
+ this.#pathWithQuery = pathWithQuery === "" ? "/" : (pathWithQuery ?? undefined);
325
329
  }
326
330
 
327
331
  /**
@@ -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",
@@ -484,7 +575,7 @@ const UrlPart = {
484
575
  };
485
576
 
486
577
  function normalizeMethod(method) {
487
- const VALUE_TOKEN_RE = /^[a-zA-Z-]+$/;
578
+ const VALUE_TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
488
579
 
489
580
  if (typeof method === "string") {
490
581
  if (!VALUE_TOKEN_RE.test(method)) {
@@ -506,7 +597,10 @@ function normalizeMethod(method) {
506
597
  }
507
598
 
508
599
  if (method.tag === "other" && typeof method.val === "string" && VALUE_TOKEN_RE.test(method.val)) {
509
- return { tag: "other", val: method.val.toLowerCase() };
600
+ const standard = method.val.toLowerCase();
601
+ return SUPPORTED_METHODS.includes(standard) && method.val === method.val.toUpperCase()
602
+ ? { tag: standard }
603
+ : { tag: "other", val: method.val };
510
604
  }
511
605
 
512
606
  throw new HttpError("invalid-syntax");
@@ -523,7 +617,7 @@ function normalizeScheme(scheme) {
523
617
  if (uppercase === "HTTP" || uppercase === "HTTPS") {
524
618
  return { tag: uppercase };
525
619
  }
526
- return { tag: "other", val: scheme.toLowerCase() };
620
+ return { tag: "other", val: scheme };
527
621
  }
528
622
 
529
623
  if (typeof scheme !== "object" || typeof scheme.tag !== "string") {
@@ -536,7 +630,11 @@ function normalizeScheme(scheme) {
536
630
 
537
631
  if (scheme.tag === "other" && typeof scheme.val === "string") {
538
632
  validateUrlPart(scheme.val, UrlPart.SCHEME);
539
- return { tag: "other", val: scheme.val.toLowerCase() };
633
+ const uppercase = scheme.val.toUpperCase();
634
+ if (uppercase === "HTTP" || uppercase === "HTTPS") {
635
+ return { tag: uppercase };
636
+ }
637
+ return { tag: "other", val: scheme.val };
540
638
  }
541
639
 
542
640
  throw new HttpError("invalid-syntax");
@@ -569,6 +667,10 @@ function validateUrlPart(value, part) {
569
667
  throw new HttpError("invalid-syntax", `Invalid ${part}: ${value}`);
570
668
  }
571
669
 
670
+ if (part === UrlPart.PATH_WITH_QUERY && /[ <>`]/.test(value)) {
671
+ throw new HttpError("invalid-syntax", `Invalid ${part}: ${value}`);
672
+ }
673
+
572
674
  try {
573
675
  switch (part) {
574
676
  case UrlPart.PATH_WITH_QUERY:
@@ -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
  }
@@ -22,6 +23,16 @@ function token() {
22
23
  return (TCP_CREATE_TOKEN ??= Symbol("TcpCreateToken"));
23
24
  }
24
25
 
26
+ function invalidRecv() {
27
+ const stream = new ReadableStream({
28
+ start(controller) {
29
+ controller.close();
30
+ },
31
+ });
32
+ const promise = Promise.reject(new SocketError("invalid-state"));
33
+ return [new StreamReader(stream, { preventCancel: false }), new FutureReader(promise)];
34
+ }
35
+
25
36
  const STATE = {
26
37
  UNBOUND: "unbound",
27
38
  BOUND: "bound",
@@ -61,6 +72,8 @@ export class TcpSocket {
61
72
  #socketId = null;
62
73
  #family = null;
63
74
  #state = "unbound";
75
+ #sendStarted = false;
76
+ #receiveStarted = false;
64
77
  #options = {
65
78
  // defaults per https://nodejs.org/docs/latest/api/net.html#socketsetkeepaliveenable-initialdelay
66
79
  keepAliveEnabled: false,
@@ -270,6 +283,10 @@ export class TcpSocket {
270
283
  if (this.#state !== STATE.CONNECTED) {
271
284
  throw new SocketError("invalid-state");
272
285
  }
286
+ if (this.#sendStarted) {
287
+ data?.[symbolDispose]?.();
288
+ throw new SocketError("invalid-state");
289
+ }
273
290
  let stream;
274
291
  try {
275
292
  stream = readableByteStreamFromReader(data, { name: "tcp send data" });
@@ -279,6 +296,7 @@ export class TcpSocket {
279
296
  }
280
297
  throw error;
281
298
  }
299
+ this.#sendStarted = true;
282
300
 
283
301
  try {
284
302
  // Transfer the stream to the worker
@@ -308,8 +326,12 @@ export class TcpSocket {
308
326
  */
309
327
  receive() {
310
328
  if (this.#state !== STATE.CONNECTED) {
311
- throw new SocketError("invalid-state");
329
+ return invalidRecv();
330
+ }
331
+ if (this.#receiveStarted) {
332
+ return invalidRecv();
312
333
  }
334
+ this.#receiveStarted = true;
313
335
 
314
336
  const transform = new TransformStream();
315
337
  const promise = worker()
@@ -325,7 +347,10 @@ export class TcpSocket {
325
347
  throw SocketError.from(err);
326
348
  });
327
349
 
328
- return [new StreamReader(transform.readable), new FutureReader(promise)];
350
+ return [
351
+ new StreamReader(transform.readable, { preventCancel: false }),
352
+ new FutureReader(promise),
353
+ ];
329
354
  }
330
355
 
331
356
  /**
@@ -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.1",
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": {
@@ -43,7 +43,7 @@
43
43
  "devDependencies": {
44
44
  "globals": "^16.3.0",
45
45
  "vite": "^7.1.5",
46
- "vitest": "^3.2.4"
46
+ "vitest": "^4.1.0"
47
47
  },
48
48
  "scripts": {
49
49
  "compile:check": "tsc --noEmit types/index.d.ts",