@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/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
+ }