@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.
- package/lib/nodejs/filesystem/descriptor.js +108 -12
- package/lib/nodejs/future.js +3 -0
- package/lib/nodejs/http/client.js +16 -1
- package/lib/nodejs/http/fields.js +22 -8
- package/lib/nodejs/http/request.js +110 -8
- package/lib/nodejs/http/response.js +20 -5
- package/lib/nodejs/sockets/tcp.js +27 -2
- package/lib/nodejs/stream.js +18 -4
- package/lib/nodejs/workers/http-worker.js +33 -3
- package/lib/nodejs/workers/resource-worker.js +47 -3
- package/lib/nodejs/workers/tcp-worker.js +104 -17
- package/package.json +2 -2
|
@@ -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 (
|
|
587
|
+
if (mode.read && mode.write) {
|
|
563
588
|
fsFlags |= fs.constants.O_RDWR;
|
|
564
|
-
} else if (
|
|
589
|
+
} else if (mode.write) {
|
|
565
590
|
fsFlags |= fs.constants.O_WRONLY;
|
|
566
|
-
} else if (
|
|
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
|
|
614
|
-
const isDir =
|
|
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
|
-
|
|
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.#
|
|
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,
|
package/lib/nodejs/future.js
CHANGED
|
@@ -136,7 +136,7 @@ const responseFromParts = (parts) => {
|
|
|
136
136
|
});
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
-
const future = new
|
|
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
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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 =
|
|
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 = /^[
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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 [
|
|
350
|
+
return [
|
|
351
|
+
new StreamReader(transform.readable, { preventCancel: false }),
|
|
352
|
+
new FutureReader(promise),
|
|
353
|
+
];
|
|
329
354
|
}
|
|
330
355
|
|
|
331
356
|
/**
|
package/lib/nodejs/stream.js
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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, {
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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.
|
|
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": "^
|
|
46
|
+
"vitest": "^4.1.0"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
49
|
"compile:check": "tsc --noEmit types/index.d.ts",
|