@polyengine/wasi 0.1.0-pre.g633468a
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +23 -0
- package/esm/cli.js +160 -0
- package/esm/cli_stdio.js +203 -0
- package/esm/clocks.js +84 -0
- package/esm/filesystem.js +83 -0
- package/esm/filesystem_node.js +463 -0
- package/esm/filesystem_web.js +315 -0
- package/esm/http.js +663 -0
- package/esm/internal/cli_shared.js +26 -0
- package/esm/internal/fs_provider.js +795 -0
- package/esm/internal/sockets_02.js +992 -0
- package/esm/internal/sockets_03.js +969 -0
- package/esm/internal/sockets_platform.js +530 -0
- package/esm/internal/sockets_shared.js +268 -0
- package/esm/io.js +643 -0
- package/esm/mod.js +80 -0
- package/esm/package.json +3 -0
- package/esm/random.js +96 -0
- package/esm/sockets.js +172 -0
- package/package.json +98 -0
- package/types/cli.d.ts +44 -0
- package/types/cli_stdio.d.ts +31 -0
- package/types/clocks.d.ts +8 -0
- package/types/filesystem.d.ts +30 -0
- package/types/filesystem_node.d.ts +16 -0
- package/types/filesystem_web.d.ts +61 -0
- package/types/http.d.ts +169 -0
- package/types/internal/cli_shared.d.ts +26 -0
- package/types/internal/fs_provider.d.ts +174 -0
- package/types/internal/sockets_02.d.ts +37 -0
- package/types/internal/sockets_03.d.ts +12 -0
- package/types/internal/sockets_platform.d.ts +112 -0
- package/types/internal/sockets_shared.d.ts +256 -0
- package/types/io.d.ts +177 -0
- package/types/mod.d.ts +31 -0
- package/types/random.d.ts +17 -0
- package/types/sockets.d.ts +22 -0
package/esm/http.js
ADDED
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
// `wasi:http@0.3` — the OUTBOUND half (`types` + `client`), served over
|
|
2
|
+
// `fetch`. À la carte (`@polyengine/wasi/http`): fetch is universal across
|
|
3
|
+
// the JS runtimes, but this fragment grants NETWORK EGRESS — unscoped,
|
|
4
|
+
// with no allowlist or address check (docs/security.md) — and the
|
|
5
|
+
// default `wasi()` merge carries only ambient, side-effect-benign
|
|
6
|
+
// capabilities (mod.ts "COMPOSITION") — portability is not the
|
|
7
|
+
// criterion, capability is.
|
|
8
|
+
//
|
|
9
|
+
// Interfaces served (WIT: wasi:http 0.3.1, released with the WASI 0.3
|
|
10
|
+
// consolidation — WebAssembly/WASI v0.3.1, proposals/http/wit; vendored
|
|
11
|
+
// copy for the fixture guest under examples/guests/http-fetch/wit):
|
|
12
|
+
//
|
|
13
|
+
// wasi:http/types@0.3 — resources fields, request, request-options,
|
|
14
|
+
// response (constructors and all accessors)
|
|
15
|
+
// wasi:http/client@0.3 — send: async func(request) -> result<response, error-code>
|
|
16
|
+
//
|
|
17
|
+
// `handler` (the middleware-chain interface, same shape as `client`) is
|
|
18
|
+
// deliberately NOT registered: serving a middleware's upstream from fetch
|
|
19
|
+
// would silently flatten a chain into the network. An embedder that means
|
|
20
|
+
// exactly that can register this fragment's `send` under its own handler
|
|
21
|
+
// key.
|
|
22
|
+
//
|
|
23
|
+
// VERSION KEYS: 0.3.x releases fold onto the `@0.3` compatibility track
|
|
24
|
+
// (contracts/embedder-api.md §"Version canonicalization"), so the
|
|
25
|
+
// default registration serves every released 0.3.x with one provider —
|
|
26
|
+
// the same flagship track-key pattern as the rest of this package. The
|
|
27
|
+
// pre-consolidation rc SNAPSHOTS (`0.3.0-rc-*`) are prereleases, which
|
|
28
|
+
// resolve exact-only: a guest pinned to one names it via
|
|
29
|
+
// `http({ version: "0.3.0-rc-..." })`, which re-keys the fragment at
|
|
30
|
+
// that exact id instead.
|
|
31
|
+
//
|
|
32
|
+
// Body/trailers plumbing is the same stream+future choreography the TCP
|
|
33
|
+
// provider proved: constructors return `[resource, transmission-future]`
|
|
34
|
+
// (the future is a Promise — amendment A12 lowers it as the future
|
|
35
|
+
// source), `consume-body` returns `[stream<u8>, trailers-future]`, and
|
|
36
|
+
// guest-abandoned streams are retired by the runtime's A13 machinery
|
|
37
|
+
// (`ReadableStream` sources are cancel()ed, which aborts the underlying
|
|
38
|
+
// fetch body).
|
|
39
|
+
//
|
|
40
|
+
// Recorded divergences (fetch exposes no lower transport):
|
|
41
|
+
//
|
|
42
|
+
// * Redirects are NOT followed (`redirect: "manual"`, matching
|
|
43
|
+
// wasmtime's plain-transport behavior); in BROWSERS a manual redirect
|
|
44
|
+
// is an opaque response (the platform hides the 3xx), a
|
|
45
|
+
// browser-only divergence.
|
|
46
|
+
// * Request trailers cannot be transmitted (fetch has no trailer
|
|
47
|
+
// channel): a trailers future resolving `some(trailers)` fails the
|
|
48
|
+
// transmission with `internal-error` rather than silently dropping
|
|
49
|
+
// data; resolving to an ERROR aborts the request, per the WIT.
|
|
50
|
+
// Response trailers always resolve `none` (fetch cannot read them).
|
|
51
|
+
// * Request bodies are BUFFERED before transmission (streaming request
|
|
52
|
+
// bodies via `duplex: "half"` are not universal); the transmission
|
|
53
|
+
// future settles only after the fetch, so the guest still observes
|
|
54
|
+
// truthful completion. Response bodies stream through unbuffered.
|
|
55
|
+
// * `set-connect-timeout` answers `not-supported` (fetch exposes no
|
|
56
|
+
// connect phase); first-byte and between-bytes timeouts are REAL,
|
|
57
|
+
// enforced with timers around the response-body reads.
|
|
58
|
+
// * Platform-managed headers (host, content-length, and the rest of
|
|
59
|
+
// fetch's forbidden list) are silently owned by the platform, not by
|
|
60
|
+
// the `fields` the guest set.
|
|
61
|
+
//
|
|
62
|
+
// Error model: `client.send` and the fallible fields/options methods
|
|
63
|
+
// throw branded `ComponentException`s whose payloads use the WIT case
|
|
64
|
+
// names VERBATIM (`DNS-timeout`, `TLS-protocol-error`, `internal-error` —
|
|
65
|
+
// A10: case names are data, kebab-case as written, including capitals).
|
|
66
|
+
// Fetch failures are TypeErrors with prose; a small sniff table maps the
|
|
67
|
+
// recognizable ones and everything else is `internal-error(message)`.
|
|
68
|
+
import { ComponentException } from "@polyengine/runtime/embedder";
|
|
69
|
+
/** The compatibility track the fragment registers on by default. */
|
|
70
|
+
export const HTTP_TRACK = "0.3";
|
|
71
|
+
const OK = { kind: "ok" };
|
|
72
|
+
function httpError(payload, detail) {
|
|
73
|
+
return new ComponentException(payload, `wasi:http: ${detail}`);
|
|
74
|
+
}
|
|
75
|
+
function headerError(kind, detail) {
|
|
76
|
+
return new ComponentException({ kind }, `wasi:http/types: ${detail}`);
|
|
77
|
+
}
|
|
78
|
+
/** Map a fetch failure onto `error-code` (sniff table + honest catch-all). */
|
|
79
|
+
export function mapFetchError(e) {
|
|
80
|
+
// Deno/undici wrap the transport detail in the `cause` chain; sniff the
|
|
81
|
+
// whole chain, report the top-level message.
|
|
82
|
+
let message = e instanceof Error ? e.message : String(e);
|
|
83
|
+
const parts = [];
|
|
84
|
+
for (let at = e; at instanceof Error; at = at.cause)
|
|
85
|
+
parts.push(at.message);
|
|
86
|
+
const m = parts.join(" | ").toLowerCase();
|
|
87
|
+
message = parts[0] ?? message;
|
|
88
|
+
if (m.includes("refused"))
|
|
89
|
+
return { kind: "connection-refused" };
|
|
90
|
+
if (m.includes("dns error") || m.includes("name not resolved") || m.includes("getaddrinfo")) {
|
|
91
|
+
return { kind: "DNS-error", value: { rcode: undefined, infoCode: undefined } };
|
|
92
|
+
}
|
|
93
|
+
if (m.includes("timed out") || m.includes("timeout"))
|
|
94
|
+
return { kind: "connection-timeout" };
|
|
95
|
+
if (m.includes("tls") || m.includes("certificate") || m.includes("ssl")) {
|
|
96
|
+
return { kind: "TLS-protocol-error" };
|
|
97
|
+
}
|
|
98
|
+
if (m.includes("reset"))
|
|
99
|
+
return { kind: "connection-terminated" };
|
|
100
|
+
return { kind: "internal-error", value: message };
|
|
101
|
+
}
|
|
102
|
+
/** Collect a body source to bytes (the buffered-request divergence). */
|
|
103
|
+
async function collectBody(source) {
|
|
104
|
+
const chunks = [];
|
|
105
|
+
let total = 0;
|
|
106
|
+
for await (const chunk of source) {
|
|
107
|
+
const bytes = chunk instanceof Uint8Array ? chunk : Uint8Array.from(chunk);
|
|
108
|
+
chunks.push(bytes);
|
|
109
|
+
total += bytes.length;
|
|
110
|
+
}
|
|
111
|
+
const out = new Uint8Array(total);
|
|
112
|
+
let at = 0;
|
|
113
|
+
for (const c of chunks) {
|
|
114
|
+
out.set(c, at);
|
|
115
|
+
at += c.length;
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
// --- field syntax ---------------------------------------------------------------
|
|
120
|
+
/** RFC 9110 token (field-name). */
|
|
121
|
+
const FIELD_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
122
|
+
function validFieldValue(v) {
|
|
123
|
+
// No NUL / CR / LF — the transport-splitting bytes.
|
|
124
|
+
return !v.some((b) => b === 0x00 || b === 0x0a || b === 0x0d);
|
|
125
|
+
}
|
|
126
|
+
const encoder = new TextEncoder();
|
|
127
|
+
const decoder = new TextDecoder();
|
|
128
|
+
/**
|
|
129
|
+
* `wasi:http` provider fragment (exact version keys — see the module
|
|
130
|
+
* header). The resource classes are built per fragment so the `onCall`
|
|
131
|
+
* observer is scoped to it.
|
|
132
|
+
*/
|
|
133
|
+
export function http(options = {}) {
|
|
134
|
+
const onCall = options.onCall ?? (() => { });
|
|
135
|
+
const v = options.version ?? HTTP_TRACK;
|
|
136
|
+
// --- fields -----------------------------------------------------------------
|
|
137
|
+
class Fields {
|
|
138
|
+
/** Entries in original casing and insertion order. */
|
|
139
|
+
entries = [];
|
|
140
|
+
mutable = true;
|
|
141
|
+
constructor() {
|
|
142
|
+
onCall("fields.constructor");
|
|
143
|
+
}
|
|
144
|
+
static fromList(entries) {
|
|
145
|
+
onCall("fields.from-list");
|
|
146
|
+
const f = internalFields([], true);
|
|
147
|
+
for (const [name, value] of entries) {
|
|
148
|
+
if (!FIELD_NAME.test(name)) {
|
|
149
|
+
throw headerError("invalid-syntax", `from-list: invalid field name ${JSON.stringify(name)}`);
|
|
150
|
+
}
|
|
151
|
+
if (!validFieldValue(value)) {
|
|
152
|
+
throw headerError("invalid-syntax", `from-list: invalid value for ${JSON.stringify(name)}`);
|
|
153
|
+
}
|
|
154
|
+
f.entries.push([name, value.slice()]);
|
|
155
|
+
}
|
|
156
|
+
return f;
|
|
157
|
+
}
|
|
158
|
+
get(name) {
|
|
159
|
+
onCall("fields.get");
|
|
160
|
+
const n = name.toLowerCase();
|
|
161
|
+
return this.entries.filter(([k]) => k.toLowerCase() === n).map(([, v]) => v.slice());
|
|
162
|
+
}
|
|
163
|
+
has(name) {
|
|
164
|
+
onCall("fields.has");
|
|
165
|
+
const n = name.toLowerCase();
|
|
166
|
+
return this.entries.some(([k]) => k.toLowerCase() === n);
|
|
167
|
+
}
|
|
168
|
+
set(name, value) {
|
|
169
|
+
onCall("fields.set");
|
|
170
|
+
requireMutableFields(this, "fields.set");
|
|
171
|
+
if (!FIELD_NAME.test(name)) {
|
|
172
|
+
throw headerError("invalid-syntax", `set: invalid field name ${JSON.stringify(name)}`);
|
|
173
|
+
}
|
|
174
|
+
for (const one of value) {
|
|
175
|
+
if (!validFieldValue(one)) {
|
|
176
|
+
throw headerError("invalid-syntax", `set: invalid value for ${JSON.stringify(name)}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const n = name.toLowerCase();
|
|
180
|
+
this.entries = this.entries.filter(([k]) => k.toLowerCase() !== n);
|
|
181
|
+
for (const one of value)
|
|
182
|
+
this.entries.push([name, one.slice()]);
|
|
183
|
+
}
|
|
184
|
+
delete(name) {
|
|
185
|
+
onCall("fields.delete");
|
|
186
|
+
requireMutableFields(this, "fields.delete");
|
|
187
|
+
const n = name.toLowerCase();
|
|
188
|
+
this.entries = this.entries.filter(([k]) => k.toLowerCase() !== n);
|
|
189
|
+
}
|
|
190
|
+
getAndDelete(name) {
|
|
191
|
+
onCall("fields.get-and-delete");
|
|
192
|
+
requireMutableFields(this, "fields.get-and-delete");
|
|
193
|
+
const n = name.toLowerCase();
|
|
194
|
+
const out = this.entries.filter(([k]) => k.toLowerCase() === n).map(([, v]) => v);
|
|
195
|
+
this.entries = this.entries.filter(([k]) => k.toLowerCase() !== n);
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
append(name, value) {
|
|
199
|
+
onCall("fields.append");
|
|
200
|
+
requireMutableFields(this, "fields.append");
|
|
201
|
+
if (!FIELD_NAME.test(name)) {
|
|
202
|
+
throw headerError("invalid-syntax", `append: invalid field name ${JSON.stringify(name)}`);
|
|
203
|
+
}
|
|
204
|
+
if (!validFieldValue(value)) {
|
|
205
|
+
throw headerError("invalid-syntax", `append: invalid value for ${JSON.stringify(name)}`);
|
|
206
|
+
}
|
|
207
|
+
this.entries.push([name, value.slice()]);
|
|
208
|
+
}
|
|
209
|
+
copyAll() {
|
|
210
|
+
onCall("fields.copy-all");
|
|
211
|
+
return this.entries.map(([k, v]) => [k, v.slice()]);
|
|
212
|
+
}
|
|
213
|
+
clone() {
|
|
214
|
+
onCall("fields.clone");
|
|
215
|
+
return internalFields(this.entries.map(([k, v]) => [k, v.slice()]), true);
|
|
216
|
+
}
|
|
217
|
+
[Symbol.dispose]() {
|
|
218
|
+
// Plain data; nothing to release.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** Mutability guard usable on Object.create-minted views (no #-brand). */
|
|
222
|
+
function requireMutableFields(f, what) {
|
|
223
|
+
if (!f.mutable) {
|
|
224
|
+
throw headerError("immutable", `${what}: these fields are immutable`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Mint a Fields without the WIT constructor's onCall. */
|
|
228
|
+
function internalFields(entries, mutable) {
|
|
229
|
+
const f = Object.create(Fields.prototype);
|
|
230
|
+
f.entries = entries;
|
|
231
|
+
f.mutable = mutable;
|
|
232
|
+
return f;
|
|
233
|
+
}
|
|
234
|
+
function fieldsFromFetchHeaders(h) {
|
|
235
|
+
const entries = [];
|
|
236
|
+
h.forEach((value, name) => entries.push([name, encoder.encode(value)]));
|
|
237
|
+
return internalFields(entries, false);
|
|
238
|
+
}
|
|
239
|
+
// --- request-options ----------------------------------------------------------
|
|
240
|
+
class RequestOptions {
|
|
241
|
+
connectTimeout;
|
|
242
|
+
firstByteTimeout;
|
|
243
|
+
betweenBytesTimeout;
|
|
244
|
+
mutable = true;
|
|
245
|
+
constructor() {
|
|
246
|
+
onCall("request-options.constructor");
|
|
247
|
+
}
|
|
248
|
+
getConnectTimeout() {
|
|
249
|
+
onCall("request-options.get-connect-timeout");
|
|
250
|
+
return this.connectTimeout;
|
|
251
|
+
}
|
|
252
|
+
setConnectTimeout(_duration) {
|
|
253
|
+
onCall("request-options.set-connect-timeout");
|
|
254
|
+
requireMutableOptions(this, "set-connect-timeout");
|
|
255
|
+
// fetch exposes no connect phase: storing the value would imply
|
|
256
|
+
// enforcement that cannot happen — the honest answer is refusal.
|
|
257
|
+
throw new ComponentException({ kind: "not-supported" }, "wasi:http/types: set-connect-timeout: fetch exposes no connect phase");
|
|
258
|
+
}
|
|
259
|
+
getFirstByteTimeout() {
|
|
260
|
+
onCall("request-options.get-first-byte-timeout");
|
|
261
|
+
return this.firstByteTimeout;
|
|
262
|
+
}
|
|
263
|
+
setFirstByteTimeout(duration) {
|
|
264
|
+
onCall("request-options.set-first-byte-timeout");
|
|
265
|
+
requireMutableOptions(this, "set-first-byte-timeout");
|
|
266
|
+
this.firstByteTimeout = duration;
|
|
267
|
+
}
|
|
268
|
+
getBetweenBytesTimeout() {
|
|
269
|
+
onCall("request-options.get-between-bytes-timeout");
|
|
270
|
+
return this.betweenBytesTimeout;
|
|
271
|
+
}
|
|
272
|
+
setBetweenBytesTimeout(duration) {
|
|
273
|
+
onCall("request-options.set-between-bytes-timeout");
|
|
274
|
+
requireMutableOptions(this, "set-between-bytes-timeout");
|
|
275
|
+
this.betweenBytesTimeout = duration;
|
|
276
|
+
}
|
|
277
|
+
clone() {
|
|
278
|
+
onCall("request-options.clone");
|
|
279
|
+
const c = Object.create(RequestOptions.prototype);
|
|
280
|
+
c.connectTimeout = this.connectTimeout;
|
|
281
|
+
c.firstByteTimeout = this.firstByteTimeout;
|
|
282
|
+
c.betweenBytesTimeout = this.betweenBytesTimeout;
|
|
283
|
+
c.mutable = true;
|
|
284
|
+
return c;
|
|
285
|
+
}
|
|
286
|
+
[Symbol.dispose]() {
|
|
287
|
+
// Plain data.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/** Mutability guard usable on clone-minted options (no #-brand). */
|
|
291
|
+
function requireMutableOptions(o, what) {
|
|
292
|
+
if (!o.mutable) {
|
|
293
|
+
throw new ComponentException({ kind: "immutable" }, `wasi:http/types: ${what}: this request-options is immutable`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// --- request --------------------------------------------------------------------
|
|
297
|
+
class Request {
|
|
298
|
+
method = { kind: "get" };
|
|
299
|
+
pathWithQuery;
|
|
300
|
+
scheme;
|
|
301
|
+
authority;
|
|
302
|
+
headers;
|
|
303
|
+
contents;
|
|
304
|
+
trailers;
|
|
305
|
+
options;
|
|
306
|
+
/** Settles the transmission future returned by `new`. */
|
|
307
|
+
settleTransmission;
|
|
308
|
+
consumed = false;
|
|
309
|
+
sent = false;
|
|
310
|
+
constructor() { }
|
|
311
|
+
// The WIT static is literally named `new` — legal as a JS static.
|
|
312
|
+
static "new"(headers, contents, trailers, options) {
|
|
313
|
+
onCall("request.new");
|
|
314
|
+
const r = new Request();
|
|
315
|
+
r.headers = headers;
|
|
316
|
+
headers.mutable = false; // ownership transferred; views are immutable
|
|
317
|
+
r.contents = contents;
|
|
318
|
+
r.trailers = trailers;
|
|
319
|
+
r.options = options;
|
|
320
|
+
if (options !== undefined)
|
|
321
|
+
options.mutable = false;
|
|
322
|
+
const transmission = new Promise((resolve) => {
|
|
323
|
+
r.settleTransmission = resolve;
|
|
324
|
+
});
|
|
325
|
+
return [r, transmission];
|
|
326
|
+
}
|
|
327
|
+
getMethod() {
|
|
328
|
+
onCall("request.get-method");
|
|
329
|
+
return this.method;
|
|
330
|
+
}
|
|
331
|
+
setMethod(method) {
|
|
332
|
+
onCall("request.set-method");
|
|
333
|
+
if (method.kind === "other" && !FIELD_NAME.test(method.value)) {
|
|
334
|
+
throw new ComponentException(null, "wasi:http/types: set-method: invalid method token");
|
|
335
|
+
}
|
|
336
|
+
this.method = method;
|
|
337
|
+
}
|
|
338
|
+
getPathWithQuery() {
|
|
339
|
+
onCall("request.get-path-with-query");
|
|
340
|
+
return this.pathWithQuery;
|
|
341
|
+
}
|
|
342
|
+
setPathWithQuery(pathWithQuery) {
|
|
343
|
+
onCall("request.set-path-with-query");
|
|
344
|
+
if (pathWithQuery !== undefined && /[ \t\r\n#]/.test(pathWithQuery)) {
|
|
345
|
+
throw new ComponentException(null, "wasi:http/types: set-path-with-query: invalid path");
|
|
346
|
+
}
|
|
347
|
+
this.pathWithQuery = pathWithQuery;
|
|
348
|
+
}
|
|
349
|
+
getScheme() {
|
|
350
|
+
onCall("request.get-scheme");
|
|
351
|
+
return this.scheme;
|
|
352
|
+
}
|
|
353
|
+
setScheme(scheme) {
|
|
354
|
+
onCall("request.set-scheme");
|
|
355
|
+
if (scheme?.kind === "other" && !/^[A-Za-z][A-Za-z0-9+.-]*$/.test(scheme.value)) {
|
|
356
|
+
throw new ComponentException(null, "wasi:http/types: set-scheme: invalid scheme");
|
|
357
|
+
}
|
|
358
|
+
this.scheme = scheme;
|
|
359
|
+
}
|
|
360
|
+
getAuthority() {
|
|
361
|
+
onCall("request.get-authority");
|
|
362
|
+
return this.authority;
|
|
363
|
+
}
|
|
364
|
+
setAuthority(authority) {
|
|
365
|
+
onCall("request.set-authority");
|
|
366
|
+
if (authority !== undefined && /[ \t\r\n/#?@]/.test(authority.replace(/@/, ""))) {
|
|
367
|
+
throw new ComponentException(null, "wasi:http/types: set-authority: invalid authority");
|
|
368
|
+
}
|
|
369
|
+
this.authority = authority;
|
|
370
|
+
}
|
|
371
|
+
getOptions() {
|
|
372
|
+
onCall("request.get-options");
|
|
373
|
+
return this.options;
|
|
374
|
+
}
|
|
375
|
+
getHeaders() {
|
|
376
|
+
onCall("request.get-headers");
|
|
377
|
+
return internalFields(this.headers.entries.map(([k, v]) => [k, v.slice()]), false);
|
|
378
|
+
}
|
|
379
|
+
static consumeBody(request, res) {
|
|
380
|
+
onCall("request.consume-body");
|
|
381
|
+
return consumeStoredBody(request, res);
|
|
382
|
+
}
|
|
383
|
+
[Symbol.dispose]() {
|
|
384
|
+
// A request dropped without transmission: the transmission future
|
|
385
|
+
// must still settle (a pending future the guest awaits would
|
|
386
|
+
// otherwise hang forever).
|
|
387
|
+
if (!this.sent && !this.consumed) {
|
|
388
|
+
this.settleTransmission({
|
|
389
|
+
kind: "err",
|
|
390
|
+
value: { kind: "internal-error", value: "request dropped without being sent" },
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// --- response -------------------------------------------------------------------
|
|
396
|
+
class Response {
|
|
397
|
+
statusCode = 200;
|
|
398
|
+
headers;
|
|
399
|
+
/** Guest-constructed responses carry sources; fetch responses carry the body. */
|
|
400
|
+
contents;
|
|
401
|
+
trailers;
|
|
402
|
+
fetchBody = null;
|
|
403
|
+
settleTransmission;
|
|
404
|
+
/** Timeouts inherited from the request's options (fetch responses). */
|
|
405
|
+
firstByteTimeout;
|
|
406
|
+
betweenBytesTimeout;
|
|
407
|
+
consumed = false;
|
|
408
|
+
constructor() { }
|
|
409
|
+
// The WIT static is literally named `new` — legal as a JS static.
|
|
410
|
+
static "new"(headers, contents, trailers) {
|
|
411
|
+
onCall("response.new");
|
|
412
|
+
const r = new Response();
|
|
413
|
+
r.headers = headers;
|
|
414
|
+
headers.mutable = false;
|
|
415
|
+
r.contents = contents;
|
|
416
|
+
r.trailers = trailers;
|
|
417
|
+
const transmission = new Promise((resolve) => {
|
|
418
|
+
r.settleTransmission = resolve;
|
|
419
|
+
});
|
|
420
|
+
return [r, transmission];
|
|
421
|
+
}
|
|
422
|
+
/** A response wrapping a live fetch result (internal). */
|
|
423
|
+
static fromFetch(resp, options) {
|
|
424
|
+
const r = new Response();
|
|
425
|
+
r.statusCode = resp.status;
|
|
426
|
+
r.headers = fieldsFromFetchHeaders(resp.headers);
|
|
427
|
+
r.fetchBody = resp.body;
|
|
428
|
+
r.firstByteTimeout = options?.firstByteTimeout;
|
|
429
|
+
r.betweenBytesTimeout = options?.betweenBytesTimeout;
|
|
430
|
+
return r;
|
|
431
|
+
}
|
|
432
|
+
getStatusCode() {
|
|
433
|
+
onCall("response.get-status-code");
|
|
434
|
+
return this.statusCode;
|
|
435
|
+
}
|
|
436
|
+
setStatusCode(statusCode) {
|
|
437
|
+
onCall("response.set-status-code");
|
|
438
|
+
if (!Number.isInteger(statusCode) || statusCode < 100 || statusCode > 999) {
|
|
439
|
+
throw new ComponentException(null, "wasi:http/types: set-status-code: invalid status code");
|
|
440
|
+
}
|
|
441
|
+
this.statusCode = statusCode;
|
|
442
|
+
}
|
|
443
|
+
getHeaders() {
|
|
444
|
+
onCall("response.get-headers");
|
|
445
|
+
return internalFields(this.headers.entries.map(([k, v]) => [k, v.slice()]), false);
|
|
446
|
+
}
|
|
447
|
+
static consumeBody(response, res) {
|
|
448
|
+
onCall("response.consume-body");
|
|
449
|
+
if (response.fetchBody !== null || (response.contents === undefined && response.trailers === undefined)) {
|
|
450
|
+
return consumeFetchBody(response, res);
|
|
451
|
+
}
|
|
452
|
+
return consumeStoredBody(response, res);
|
|
453
|
+
}
|
|
454
|
+
[Symbol.dispose]() {
|
|
455
|
+
if (this.fetchBody !== null && !this.consumed) {
|
|
456
|
+
this.fetchBody.cancel().catch(() => {
|
|
457
|
+
// The connection is being discarded; failures have no audience.
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if (this.settleTransmission !== undefined && !this.consumed) {
|
|
461
|
+
this.settleTransmission({
|
|
462
|
+
kind: "err",
|
|
463
|
+
value: { kind: "internal-error", value: "response dropped without being sent" },
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// --- body consumption (shared) ---------------------------------------------------
|
|
469
|
+
/** Consume a guest-constructed body: pass the stored sources through. */
|
|
470
|
+
function consumeStoredBody(holder, res) {
|
|
471
|
+
if (holder.consumed) {
|
|
472
|
+
return [
|
|
473
|
+
[],
|
|
474
|
+
Promise.resolve({
|
|
475
|
+
kind: "err",
|
|
476
|
+
value: { kind: "internal-error", value: "body already consumed" },
|
|
477
|
+
}),
|
|
478
|
+
];
|
|
479
|
+
}
|
|
480
|
+
holder.consumed = true;
|
|
481
|
+
const contents = holder.contents;
|
|
482
|
+
const trailers = holder.trailers ??
|
|
483
|
+
Promise.resolve({ kind: "ok", value: undefined });
|
|
484
|
+
// The consumer reports its outcome through `res`; that is what settles
|
|
485
|
+
// the producer-side transmission future.
|
|
486
|
+
const settle = holder.settleTransmission;
|
|
487
|
+
if (settle !== undefined) {
|
|
488
|
+
Promise.resolve(res).then((r) => settle(r), () => settle({ kind: "err", value: { kind: "internal-error", value: "consumer failed" } }));
|
|
489
|
+
}
|
|
490
|
+
const source = (async function* () {
|
|
491
|
+
if (contents === undefined)
|
|
492
|
+
return;
|
|
493
|
+
for await (const chunk of contents) {
|
|
494
|
+
yield chunk instanceof Uint8Array ? chunk : Uint8Array.from(chunk);
|
|
495
|
+
}
|
|
496
|
+
})();
|
|
497
|
+
return [source, Promise.resolve(trailers).then((t) => t)];
|
|
498
|
+
}
|
|
499
|
+
/** Consume a fetch response body, with the real byte timeouts. */
|
|
500
|
+
function consumeFetchBody(response, _res) {
|
|
501
|
+
if (response.consumed) {
|
|
502
|
+
return [
|
|
503
|
+
[],
|
|
504
|
+
Promise.resolve({
|
|
505
|
+
kind: "err",
|
|
506
|
+
value: { kind: "internal-error", value: "body already consumed" },
|
|
507
|
+
}),
|
|
508
|
+
];
|
|
509
|
+
}
|
|
510
|
+
response.consumed = true;
|
|
511
|
+
const body = response.fetchBody;
|
|
512
|
+
let settle;
|
|
513
|
+
const done = new Promise((resolve) => (settle = resolve));
|
|
514
|
+
const firstByteMs = toMs(response.firstByteTimeout);
|
|
515
|
+
const betweenBytesMs = toMs(response.betweenBytesTimeout);
|
|
516
|
+
const source = (async function* () {
|
|
517
|
+
if (body === null) {
|
|
518
|
+
settle({ kind: "ok", value: undefined }); // no trailers over fetch
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const reader = body.getReader();
|
|
522
|
+
let first = true;
|
|
523
|
+
try {
|
|
524
|
+
for (;;) {
|
|
525
|
+
const timeoutMs = first ? firstByteMs : betweenBytesMs;
|
|
526
|
+
let r;
|
|
527
|
+
try {
|
|
528
|
+
r = await readWithTimeout(reader, timeoutMs);
|
|
529
|
+
}
|
|
530
|
+
catch (e) {
|
|
531
|
+
settle({
|
|
532
|
+
kind: "err",
|
|
533
|
+
value: e === TIMED_OUT
|
|
534
|
+
? { kind: first ? "HTTP-response-timeout" : "connection-read-timeout" }
|
|
535
|
+
: mapFetchError(e),
|
|
536
|
+
});
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
first = false;
|
|
540
|
+
if (r.done) {
|
|
541
|
+
settle({ kind: "ok", value: undefined }); // clean end; no trailers over fetch
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (r.value.length > 0)
|
|
545
|
+
yield r.value;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
finally {
|
|
549
|
+
settle({ kind: "ok", value: undefined }); // reader dropped: canceller observes
|
|
550
|
+
reader.cancel().catch(() => {
|
|
551
|
+
// Discarding the rest of the body; failures have no audience.
|
|
552
|
+
});
|
|
553
|
+
reader.releaseLock();
|
|
554
|
+
}
|
|
555
|
+
})();
|
|
556
|
+
return [source, done];
|
|
557
|
+
}
|
|
558
|
+
const TIMED_OUT = Symbol("timed out");
|
|
559
|
+
function toMs(ns) {
|
|
560
|
+
return ns === undefined ? undefined : Number(ns / 1000000n);
|
|
561
|
+
}
|
|
562
|
+
function readWithTimeout(reader, timeoutMs) {
|
|
563
|
+
const read = reader.read();
|
|
564
|
+
if (timeoutMs === undefined)
|
|
565
|
+
return read;
|
|
566
|
+
return new Promise((resolve, reject) => {
|
|
567
|
+
const timer = setTimeout(() => reject(TIMED_OUT), timeoutMs);
|
|
568
|
+
read.then((r) => {
|
|
569
|
+
clearTimeout(timer);
|
|
570
|
+
resolve(r);
|
|
571
|
+
}, (e) => {
|
|
572
|
+
clearTimeout(timer);
|
|
573
|
+
reject(e);
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
// --- client.send over fetch --------------------------------------------------------
|
|
578
|
+
async function send(request) {
|
|
579
|
+
onCall("client.send");
|
|
580
|
+
if (request.sent || request.consumed) {
|
|
581
|
+
throw httpError({ kind: "internal-error", value: "request already sent or consumed" }, "client.send: request already sent or consumed");
|
|
582
|
+
}
|
|
583
|
+
request.sent = true;
|
|
584
|
+
// URL assembly. Scheme defaults to HTTPS ("the implementation may
|
|
585
|
+
// choose an appropriate default"); HTTP(S) requires an authority.
|
|
586
|
+
const scheme = request.scheme === undefined
|
|
587
|
+
? "https"
|
|
588
|
+
: request.scheme.kind === "other"
|
|
589
|
+
? request.scheme.value.toLowerCase()
|
|
590
|
+
: request.scheme.kind.toLowerCase();
|
|
591
|
+
if (scheme !== "http" && scheme !== "https") {
|
|
592
|
+
throw httpError({ kind: "internal-error", value: `fetch cannot carry scheme '${scheme}'` }, `client.send: unsupported scheme '${scheme}'`);
|
|
593
|
+
}
|
|
594
|
+
if (request.authority === undefined) {
|
|
595
|
+
throw httpError({ kind: "HTTP-request-URI-invalid" }, "client.send: no authority");
|
|
596
|
+
}
|
|
597
|
+
const path = request.pathWithQuery ?? "";
|
|
598
|
+
const url = `${scheme}://${request.authority}${path.startsWith("/") || path === "" ? path : "/" + path}`;
|
|
599
|
+
const method = request.method.kind === "other"
|
|
600
|
+
? request.method.value.toUpperCase()
|
|
601
|
+
: request.method.kind.toUpperCase();
|
|
602
|
+
const headers = new Headers();
|
|
603
|
+
for (const [name, value] of request.headers.entries) {
|
|
604
|
+
try {
|
|
605
|
+
headers.append(name, decoder.decode(value));
|
|
606
|
+
}
|
|
607
|
+
catch (e) {
|
|
608
|
+
throw httpError({ kind: "internal-error", value: `header '${name}' refused by the platform` }, `client.send: ${e}`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Buffered request body (module header divergence), then the trailers
|
|
612
|
+
// future decides whether the request may be transmitted at all.
|
|
613
|
+
let body;
|
|
614
|
+
if (request.contents !== undefined) {
|
|
615
|
+
body = await collectBody(request.contents);
|
|
616
|
+
}
|
|
617
|
+
const trailersResult = await request.trailers;
|
|
618
|
+
if (trailersResult.kind === "err") {
|
|
619
|
+
// Per the WIT: a trailers error closes the underlying connection —
|
|
620
|
+
// here, the request is never transmitted.
|
|
621
|
+
const err = { kind: "err", value: trailersResult.value };
|
|
622
|
+
request.settleTransmission(err);
|
|
623
|
+
throw httpError(trailersResult.value, "client.send: request trailers resolved to an error");
|
|
624
|
+
}
|
|
625
|
+
if (trailersResult.value !== undefined) {
|
|
626
|
+
const err = {
|
|
627
|
+
kind: "internal-error",
|
|
628
|
+
value: "fetch cannot transmit request trailers",
|
|
629
|
+
};
|
|
630
|
+
request.settleTransmission({ kind: "err", value: err });
|
|
631
|
+
throw httpError(err, "client.send: request trailers are not transmissible over fetch");
|
|
632
|
+
}
|
|
633
|
+
let resp;
|
|
634
|
+
try {
|
|
635
|
+
resp = await fetch(url, {
|
|
636
|
+
method,
|
|
637
|
+
headers,
|
|
638
|
+
body: body === undefined || body.length === 0 ? undefined : body,
|
|
639
|
+
redirect: "manual",
|
|
640
|
+
cache: "no-store",
|
|
641
|
+
credentials: "omit",
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
catch (e) {
|
|
645
|
+
const code = mapFetchError(e);
|
|
646
|
+
request.settleTransmission({ kind: "err", value: code });
|
|
647
|
+
throw httpError(code, `client.send: ${e instanceof Error ? e.message : String(e)}`);
|
|
648
|
+
}
|
|
649
|
+
request.settleTransmission(OK);
|
|
650
|
+
return Response.fromFetch(resp, request.options);
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
imports: {
|
|
654
|
+
[`wasi:http/types@${v}`]: { Fields, Request, RequestOptions, Response },
|
|
655
|
+
[`wasi:http/client@${v}`]: { send },
|
|
656
|
+
},
|
|
657
|
+
Fields: Fields,
|
|
658
|
+
Request: Request,
|
|
659
|
+
RequestOptions: RequestOptions,
|
|
660
|
+
Response: Response,
|
|
661
|
+
send: send,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// INTERNAL shared vocabulary of the `wasi:cli` impls (cli.ts capture,
|
|
2
|
+
// cli_stdio.ts host-stdio) — not a package export; the public home of
|
|
3
|
+
// these names is `@polyengine/wasi/cli`. Extracting them here is what keeps
|
|
4
|
+
// the two IMPLS independent of each other: an impl imports the
|
|
5
|
+
// vocabulary, never its sibling.
|
|
6
|
+
import { defineBrand, WASI_EXIT } from "@polyengine/protocol";
|
|
7
|
+
/** Raised by `exit()` when `throwOnExit` is set (contract: "option to throw a named ExitError"). */
|
|
8
|
+
export class ExitError extends Error {
|
|
9
|
+
ok;
|
|
10
|
+
code;
|
|
11
|
+
constructor(ok, code) {
|
|
12
|
+
super(`wasi:cli/exit#exit(${ok ? "success" : "failure"}${code === undefined ? "" : `, code ${code}`})`);
|
|
13
|
+
this.ok = ok;
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = "ExitError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// A9 brand: an exit unwind propagates out through the embedder and any host
|
|
19
|
+
// frames in between, so it must be recognizable across runtime copies
|
|
20
|
+
// (contracts/embedder-api.md §"Module identity", issue #83).
|
|
21
|
+
defineBrand(ExitError.prototype, WASI_EXIT);
|
|
22
|
+
/** `terminal-input`/`terminal-output` are opaque resources; never produced (no terminal). */
|
|
23
|
+
export class TerminalInput {
|
|
24
|
+
}
|
|
25
|
+
export class TerminalOutput {
|
|
26
|
+
}
|