diff --git a/packages/preview2-shim/lib/browser/clocks.js b/packages/preview2-shim/lib/browser/clocks.js index f0bdb687e..038f4a97b 100644 --- a/packages/preview2-shim/lib/browser/clocks.js +++ b/packages/preview2-shim/lib/browser/clocks.js @@ -1,3 +1,6 @@ +import { poll } from './io.js'; +const { Pollable } = poll; + export const monotonicClock = { resolution() { // usually we dont get sub-millisecond accuracy in the browser @@ -10,15 +13,16 @@ export const monotonicClock = { }, subscribeInstant(instant) { instant = BigInt(instant); - const now = this.now(); + const now = monotonicClock.now(); if (instant <= now) { - return this.subscribeDuration(0); + return new Pollable(new Promise(resolve => setTimeout(resolve, 0))); } - return this.subscribeDuration(instant - now); + return monotonicClock.subscribeDuration(instant - now); }, - subscribeDuration(_duration) { - _duration = BigInt(_duration); - console.log(`[monotonic-clock] subscribe`); + subscribeDuration(duration) { + duration = BigInt(duration); + const ms = duration <= 0n ? 0 : Number(duration / 1_000_000n); + return new Pollable(new Promise(resolve => setTimeout(resolve, ms))); }, }; diff --git a/packages/preview2-shim/lib/browser/http.js b/packages/preview2-shim/lib/browser/http.js index b8a646b30..04f85df1f 100644 --- a/packages/preview2-shim/lib/browser/http.js +++ b/packages/preview2-shim/lib/browser/http.js @@ -1,155 +1,692 @@ -/** - * @param {import("../../types/interfaces/wasi-http-types").Request} req - * @returns {string} - */ -export function send(req) { - console.log(`[http] Send (browser) ${req.uri}`); - try { - const xhr = new XMLHttpRequest(); - xhr.open(req.method.toString(), req.uri, false); - const requestHeaders = new Headers(req.headers); - for (let [name, value] of requestHeaders.entries()) { - if (name !== 'user-agent' && name !== 'host') { - xhr.setRequestHeader(name, value); +import { streams, poll } from './io.js'; + +const { InputStream, OutputStream } = streams; +const { Pollable } = poll; + +const symbolDispose = Symbol.dispose || Symbol.for('dispose'); +const utf8Decoder = new TextDecoder(); +const forbiddenHeaders = new Set(['connection', 'keep-alive', 'host']); +const DEFAULT_HTTP_TIMEOUT_NS = 600_000_000_000n; + +// RFC 9110 compliant header validation +const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const FIELD_VALUE_RE = /^[\t\x20-\x7E\x80-\xFF]*$/; + +function validateHeaderName(name) { + if (!TOKEN_RE.test(name)) { + throw { tag: 'invalid-syntax' }; + } +} + +function validateHeaderValue(value) { + const str = typeof value === 'string' ? value : utf8Decoder.decode(value); + if (!FIELD_VALUE_RE.test(str)) { + throw { tag: 'invalid-syntax' }; + } +} + +class Fields { + #immutable = false; + /** @type {[string, Uint8Array][]} */ #entries = []; + /** @type {Map} */ #table = new Map(); + + static fromList(entries) { + const fields = new Fields(); + for (const [key, value] of entries) { + fields.append(key, value); + } + return fields; + } + + get(name) { + const tableEntries = this.#table.get(name.toLowerCase()); + if (!tableEntries) { + return []; + } + return tableEntries.map(([, v]) => v); + } + + set(name, values) { + if (this.#immutable) { + throw { tag: 'immutable' }; + } + validateHeaderName(name); + for (const value of values) { + validateHeaderValue(value); + } + const lowercased = name.toLowerCase(); + if (forbiddenHeaders.has(lowercased)) { + throw { tag: 'forbidden' }; + } + const tableEntries = this.#table.get(lowercased); + if (tableEntries) { + this.#entries = this.#entries.filter( + (entry) => !tableEntries.includes(entry) + ); + tableEntries.splice(0, tableEntries.length); + } else { + this.#table.set(lowercased, []); + } + const newTableEntries = this.#table.get(lowercased); + for (const value of values) { + const entry = [name, value]; + this.#entries.push(entry); + newTableEntries.push(entry); + } + } + + has(name) { + return this.#table.has(name.toLowerCase()); + } + + delete(name) { + if (this.#immutable) { + throw { tag: 'immutable' }; + } + const lowercased = name.toLowerCase(); + const tableEntries = this.#table.get(lowercased); + if (tableEntries) { + this.#entries = this.#entries.filter( + (entry) => !tableEntries.includes(entry) + ); + this.#table.delete(lowercased); + } + } + + append(name, value) { + if (this.#immutable) { + throw { tag: 'immutable' }; + } + validateHeaderName(name); + validateHeaderValue(value); + const lowercased = name.toLowerCase(); + if (forbiddenHeaders.has(lowercased)) { + throw { tag: 'forbidden' }; + } + const entry = [name, value]; + this.#entries.push(entry); + const tableEntries = this.#table.get(lowercased); + if (tableEntries) { + tableEntries.push(entry); + } else { + this.#table.set(lowercased, [entry]); + } + } + + entries() { + return this.#entries; + } + + clone() { + return fieldsFromEntriesChecked(this.#entries); + } + + static _lock(fields) { + fields.#immutable = true; + return fields; + } + + static _fromEntriesChecked(entries) { + const fields = new Fields(); + fields.#entries = entries; + for (const entry of entries) { + const lowercase = entry[0].toLowerCase(); + const existing = fields.#table.get(lowercase); + if (existing) { + existing.push(entry); + } else { + fields.#table.set(lowercase, [entry]); } } - xhr.send(req.body && req.body.length > 0 ? req.body : null); - const body = xhr.response - ? new TextEncoder().encode(xhr.response) - : undefined; - const headers = []; - xhr.getAllResponseHeaders() - .trim() - .split(/[\r\n]+/) - .forEach((line) => { - var parts = line.split(': '); - var key = parts.shift(); - var value = parts.join(': '); - headers.push([key, value]); - }); + return fields; + } +} +const fieldsLock = Fields._lock; +delete Fields._lock; +const fieldsFromEntriesChecked = Fields._fromEntriesChecked; +delete Fields._fromEntriesChecked; + +class RequestOptions { + #connectTimeout = DEFAULT_HTTP_TIMEOUT_NS; + #firstByteTimeout = DEFAULT_HTTP_TIMEOUT_NS; + #betweenBytesTimeout = DEFAULT_HTTP_TIMEOUT_NS; + connectTimeout() { + return this.#connectTimeout; + } + setConnectTimeout(duration) { + if (duration < 0n) { + throw new Error('duration must not be negative'); + } + this.#connectTimeout = duration; + } + firstByteTimeout() { + return this.#firstByteTimeout; + } + setFirstByteTimeout(duration) { + if (duration < 0n) { + throw new Error('duration must not be negative'); + } + this.#firstByteTimeout = duration; + } + betweenBytesTimeout() { + return this.#betweenBytesTimeout; + } + setBetweenBytesTimeout(duration) { + if (duration < 0n) { + throw new Error('duration must not be negative'); + } + this.#betweenBytesTimeout = duration; + } +} + +class OutgoingBody { + #outputStream = null; + #chunks = []; + #finished = false; + + write() { + const outputStream = this.#outputStream; + if (outputStream === null) { + throw undefined; + } + this.#outputStream = null; + return outputStream; + } + + static finish(body, trailers) { + if (trailers) { + throw { tag: 'internal-error', val: 'trailers unsupported' }; + } + if (body.#finished) { + throw { tag: 'internal-error', val: 'body already finished' }; + } + body.#finished = true; + } + + static _bodyData(outgoingBody) { + if (outgoingBody.#chunks.length === 0) { + return null; + } + let totalLen = 0; + for (const chunk of outgoingBody.#chunks) { + totalLen += chunk.byteLength; + } + const result = new Uint8Array(totalLen); + let offset = 0; + for (const chunk of outgoingBody.#chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; + } + + static _create() { + const outgoingBody = new OutgoingBody(); + const chunks = outgoingBody.#chunks; + outgoingBody.#outputStream = new OutputStream({ + write(buf) { + chunks.push(new Uint8Array(buf)); + }, + flush() {}, + blockingFlush() {}, + subscribe() { + return new Pollable(); + }, + }); + return outgoingBody; + } + + [symbolDispose]() {} +} +const outgoingBodyCreate = OutgoingBody._create; +delete OutgoingBody._create; +const outgoingBodyData = OutgoingBody._bodyData; +delete OutgoingBody._bodyData; + +class OutgoingRequest { + /** @type {{ tag: string, val?: string }} */ #method = { tag: 'get' }; + /** @type {{ tag: string, val?: string } | undefined} */ #scheme = undefined; + /** @type {string | undefined} */ #pathWithQuery = undefined; + /** @type {string | undefined} */ #authority = undefined; + /** @type {Fields} */ #headers; + /** @type {OutgoingBody} */ #body; + #bodyRequested = false; + + constructor(headers) { + fieldsLock(headers); + this.#headers = headers; + this.#body = outgoingBodyCreate(); + } + + body() { + if (this.#bodyRequested) { + throw new Error('Body already requested'); + } + this.#bodyRequested = true; + return this.#body; + } + + method() { + return this.#method; + } + + setMethod(method) { + if (method.tag === 'other' && !method.val.match(/^[a-zA-Z-]+$/)) { + throw undefined; + } + this.#method = method; + } + + pathWithQuery() { + return this.#pathWithQuery; + } + + setPathWithQuery(pathWithQuery) { + if ( + pathWithQuery && + !pathWithQuery.match(/^[a-zA-Z0-9.\-_~!$&'()*+,;=:@%?/]+$/) + ) { + throw undefined; + } + this.#pathWithQuery = pathWithQuery; + } + + scheme() { + return this.#scheme; + } + + setScheme(scheme) { + if (scheme?.tag === 'other' && !scheme.val.match(/^[a-zA-Z]+$/)) { + throw undefined; + } + this.#scheme = scheme; + } + + authority() { + return this.#authority; + } + + setAuthority(authority) { + if (authority) { + const [host, port, ...extra] = authority.split(':'); + const portNum = Number(port); + if ( + extra.length || + (port !== undefined && + (portNum.toString() !== port || portNum > 65535)) || + !host.match(/^[a-zA-Z0-9-.]+$/) + ) { + throw undefined; + } + } + this.#authority = authority; + } + + headers() { + return this.#headers; + } + + [symbolDispose]() {} + + static _handle(request, options) { + const scheme = schemeString(request.#scheme); + const method = request.#method.val || request.#method.tag; + + if (!request.#pathWithQuery) { + throw { tag: 'HTTP-request-URI-invalid' }; + } + + const url = `${scheme}//${request.#authority || ''}${request.#pathWithQuery}`; + + const headers = new Headers(); + for (const [key, value] of request.#headers.entries()) { + const lowerKey = key.toLowerCase(); + if (!forbiddenHeaders.has(lowerKey)) { + headers.set(key, utf8Decoder.decode(value)); + } + } + + const bodyData = outgoingBodyData(request.#body); + + let timeoutMs = Number(DEFAULT_HTTP_TIMEOUT_NS / 1_000_000n); + if (options) { + const ct = options.connectTimeout?.() ?? DEFAULT_HTTP_TIMEOUT_NS; + const fbt = options.firstByteTimeout?.() ?? DEFAULT_HTTP_TIMEOUT_NS; + const minTimeout = ct < fbt ? ct : fbt; + timeoutMs = Number(minTimeout / 1_000_000n); + } + + return futureIncomingResponseCreate( + url, method.toUpperCase(), headers, bodyData, timeoutMs + ); + } +} +const outgoingRequestHandle = OutgoingRequest._handle; +delete OutgoingRequest._handle; + +class IncomingBody { + #finished = false; + #stream = undefined; + + stream() { + if (!this.#stream) { + throw undefined; + } + const stream = this.#stream; + this.#stream = null; + return stream; + } + + static finish(incomingBody) { + if (incomingBody.#finished) { + throw new Error('incoming body already finished'); + } + incomingBody.#finished = true; + return futureTrailersCreate(); + } + + [symbolDispose]() {} + + static _create(fetchResponse) { + const incomingBody = new IncomingBody(); + let buffer = null; + let bufferOffset = 0; + let done = false; + let reader = null; + let readPromise = null; + + function ensureReader() { + if (!reader && fetchResponse.body) { + reader = fetchResponse.body.getReader(); + } + } + + function startRead() { + if (readPromise || done) { return; } + ensureReader(); + if (!reader) { + done = true; + return; + } + readPromise = reader.read().then( + (result) => { + readPromise = null; + if (result.done) { + done = true; + } else { + buffer = result.value; + bufferOffset = 0; + } + }, + () => { + readPromise = null; + done = true; + } + ); + } + + incomingBody.#stream = new InputStream({ + read(len) { + if (done && (buffer === null || bufferOffset >= buffer.byteLength)) { + throw { tag: 'closed' }; + } + if (buffer !== null && bufferOffset < buffer.byteLength) { + const available = buffer.byteLength - bufferOffset; + const toRead = Math.min(Number(len), available); + const slice = buffer.slice(bufferOffset, bufferOffset + toRead); + bufferOffset += toRead; + if (bufferOffset >= buffer.byteLength) { + buffer = null; + bufferOffset = 0; + if (!done) { startRead(); } + } + return slice; + } + throw { tag: 'would-block' }; + }, + blockingRead(len) { + if (done && (buffer === null || bufferOffset >= buffer.byteLength)) { + throw { tag: 'closed' }; + } + if (buffer !== null && bufferOffset < buffer.byteLength) { + const available = buffer.byteLength - bufferOffset; + const toRead = Math.min(Number(len), available); + const slice = buffer.slice(bufferOffset, bufferOffset + toRead); + bufferOffset += toRead; + if (bufferOffset >= buffer.byteLength) { + buffer = null; + bufferOffset = 0; + if (!done) { startRead(); } + } + return slice; + } + startRead(); + const waitFor = readPromise || Promise.resolve(); + return waitFor.then(() => { + if (done && (buffer === null || bufferOffset >= buffer.byteLength)) { + throw { tag: 'closed' }; + } + if (buffer !== null && bufferOffset < buffer.byteLength) { + const available = buffer.byteLength - bufferOffset; + const toRead = Math.min(Number(len), available); + const slice = buffer.slice(bufferOffset, bufferOffset + toRead); + bufferOffset += toRead; + if (bufferOffset >= buffer.byteLength) { + buffer = null; + bufferOffset = 0; + if (!done) { startRead(); } + } + return slice; + } + throw { tag: 'closed' }; + }); + }, + subscribe() { + if (done || (buffer !== null && bufferOffset < buffer.byteLength)) { + return new Pollable(); + } + startRead(); + if (readPromise) { + return new Pollable(readPromise); + } + return new Pollable(); + }, + }); + + startRead(); + return incomingBody; + } +} +const incomingBodyCreate = IncomingBody._create; +delete IncomingBody._create; + +class IncomingResponse { + /** @type {Fields} */ #headers = undefined; + #status = 0; + /** @type {IncomingBody} */ #body; + + status() { + return this.#status; + } + + headers() { + return this.#headers; + } + + consume() { + if (this.#body === undefined) { + throw undefined; + } + const body = this.#body; + this.#body = undefined; + return body; + } + + [symbolDispose]() {} + + static _create(fetchResponse) { + const res = new IncomingResponse(); + res.#status = fetchResponse.status; + + const headerEntries = []; + const encoder = new TextEncoder(); + fetchResponse.headers.forEach((value, key) => { + headerEntries.push([key, encoder.encode(value)]); + }); + res.#headers = fieldsLock(fieldsFromEntriesChecked(headerEntries)); + res.#body = incomingBodyCreate(fetchResponse); + return res; + } +} +const incomingResponseCreate = IncomingResponse._create; +delete IncomingResponse._create; + +class FutureTrailers { + #requested = false; + subscribe() { + return new Pollable(); + } + get() { + if (this.#requested) { + return { tag: 'err' }; + } + this.#requested = true; return { - status: xhr.status, + tag: 'ok', + val: { + tag: 'ok', + val: undefined, + }, + }; + } + static _create() { + return new FutureTrailers(); + } +} +const futureTrailersCreate = FutureTrailers._create; +delete FutureTrailers._create; + +function mapFetchError(err) { + if (err.name === 'AbortError') { + return { tag: 'connection-timeout' }; + } + if (err.name === 'TypeError') { + return { tag: 'connection-refused' }; + } + return { tag: 'internal-error', val: err.message }; +} + +class FutureIncomingResponse { + #result = undefined; + #promise = null; + + subscribe() { + return new Pollable(this.#promise); + } + + get() { + if (this.#result === undefined) { + return undefined; + } + const result = this.#result; + this.#result = { tag: 'err' }; + return result; + } + + [symbolDispose]() { + this.#promise = null; + } + + static _create(url, method, headers, bodyData, timeoutMs) { + const future = new FutureIncomingResponse(); + + const controller = new AbortController(); + let timer; + if (timeoutMs < Infinity) { + timer = setTimeout(() => controller.abort(), timeoutMs); + } + + const init = { + method, headers, - body, + signal: controller.signal, }; - } catch (err) { - throw new Error(err.message); + if (bodyData && method !== 'GET' && method !== 'HEAD') { + init.body = bodyData; + } + + future.#promise = fetch(url, init).then( + (response) => { + if (timer) { clearTimeout(timer); } + future.#result = { + tag: 'ok', + val: { + tag: 'ok', + val: incomingResponseCreate(response), + }, + }; + }, + (err) => { + if (timer) { clearTimeout(timer); } + future.#result = { + tag: 'ok', + val: { + tag: 'err', + val: mapFetchError(err), + }, + }; + } + ); + + return future; } } +const futureIncomingResponseCreate = FutureIncomingResponse._create; +delete FutureIncomingResponse._create; -export const incomingHandler = { - handle() {}, -}; +function schemeString(scheme) { + if (!scheme) { + return 'https:'; + } + switch (scheme.tag) { + case 'HTTP': + return 'http:'; + case 'HTTPS': + return 'https:'; + case 'other': + return scheme.val.toLowerCase() + ':'; + } +} + +function httpErrorCode(err) { + if (err.payload) { + return err.payload; + } + return { + tag: 'internal-error', + val: err.message, + }; +} export const outgoingHandler = { + handle: outgoingRequestHandle, +}; + +export const incomingHandler = { handle() {}, }; export const types = { - dropFields(_fields) { - console.log('[types] Drop fields'); - }, - newFields(_entries) { - console.log('[types] New fields'); - }, - fieldsGet(_fields, _name) { - console.log('[types] Fields get'); - }, - fieldsSet(_fields, _name, _value) { - console.log('[types] Fields set'); - }, - fieldsDelete(_fields, _name) { - console.log('[types] Fields delete'); - }, - fieldsAppend(_fields, _name, _value) { - console.log('[types] Fields append'); - }, - fieldsEntries(_fields) { - console.log('[types] Fields entries'); - }, - fieldsClone(_fields) { - console.log('[types] Fields clone'); - }, - finishIncomingStream(s) { - console.log(`[types] Finish incoming stream ${s}`); - }, - finishOutgoingStream(s, _trailers) { - console.log(`[types] Finish outgoing stream ${s}`); - }, - dropIncomingRequest(_req) { - console.log('[types] Drop incoming request'); - }, - dropOutgoingRequest(_req) { - console.log('[types] Drop outgoing request'); - }, - incomingRequestMethod(_req) { - console.log('[types] Incoming request method'); - }, - incomingRequestPathWithQuery(_req) { - console.log('[types] Incoming request path with query'); - }, - incomingRequestScheme(_req) { - console.log('[types] Incoming request scheme'); - }, - incomingRequestAuthority(_req) { - console.log('[types] Incoming request authority'); - }, - incomingRequestHeaders(_req) { - console.log('[types] Incoming request headers'); - }, - incomingRequestConsume(_req) { - console.log('[types] Incoming request consume'); - }, - newOutgoingRequest(_method, _pathWithQuery, _scheme, _authority, _headers) { - console.log('[types] New outgoing request'); - }, - outgoingRequestWrite(_req) { - console.log('[types] Outgoing request write'); - }, - dropResponseOutparam(_res) { - console.log('[types] Drop response outparam'); - }, - setResponseOutparam(_response) { - console.log('[types] Drop fields'); - }, - dropIncomingResponse(_res) { - console.log('[types] Drop incoming response'); - }, - dropOutgoingResponse(_res) { - console.log('[types] Drop outgoing response'); - }, - incomingResponseStatus(_res) { - console.log('[types] Incoming response status'); - }, - incomingResponseHeaders(_res) { - console.log('[types] Incoming response headers'); - }, - incomingResponseConsume(_res) { - console.log('[types] Incoming response consume'); - }, - newOutgoingResponse(_statusCode, _headers) { - console.log('[types] New outgoing response'); - }, - outgoingResponseWrite(_res) { - console.log('[types] Outgoing response write'); - }, - dropFutureIncomingResponse(_f) { - console.log('[types] Drop future incoming response'); - }, - futureIncomingResponseGet(_f) { - console.log('[types] Future incoming response get'); - }, - listenToFutureIncomingResponse(_f) { - console.log('[types] Listen to future incoming response'); - }, - Fields: class Fields {}, - FutureIncomingResponse: new class FutureIncomingResponse {}, - IncomingBody: new class IncomingBody {}, - IncomingRequest: new class IncomingRequest {}, - IncomingResponse: new class IncomingResponse {}, - OutgoingBody: new class OutgoingBody {}, - OutgoingRequest: new class OutgoingRequest {}, - OutgoingResponse: new class OutgoingResponse {}, - RequestOptions: new class RequestOptions {}, - ResponseOutparam: new class ResponseOutparam {}, + Fields, + FutureIncomingResponse, + FutureTrailers, + IncomingBody, + IncomingRequest: class IncomingRequest {}, + IncomingResponse, + OutgoingBody, + OutgoingRequest, + OutgoingResponse: class OutgoingResponse {}, + ResponseOutparam: class ResponseOutparam {}, + RequestOptions, + httpErrorCode, }; diff --git a/packages/preview2-shim/lib/browser/io.js b/packages/preview2-shim/lib/browser/io.js index 3b4d22c42..52747b008 100644 --- a/packages/preview2-shim/lib/browser/io.js +++ b/packages/preview2-shim/lib/browser/io.js @@ -77,7 +77,9 @@ class InputStream { return BigInt(bytes.byteLength); } subscribe() { - console.log(`[streams] Subscribe to input stream ${this.id}`); + if (this.handler.subscribe) { + return this.handler.subscribe(); + } return new Pollable(); } [symbolDispose]() { @@ -168,7 +170,9 @@ class OutputStream { console.log(`[streams] Forward ${this.id}`); } subscribe() { - console.log(`[streams] Subscribe to output stream ${this.id}`); + if (this.handler.subscribe) { + return this.handler.subscribe(); + } return new Pollable(); } [symbolDispose]() {} @@ -178,19 +182,75 @@ export const error = { Error: IoError }; export const streams = { InputStream, OutputStream }; -class Pollable {} +class Pollable { + #ready = false; + #promise = null; + + constructor(promise) { + if (!promise) { + this.#ready = true; + } else { + this.#promise = promise.then( + () => { this.#ready = true; }, + () => { this.#ready = true; } + ); + } + } + + ready() { + return this.#ready; + } + + block() { + if (this.#ready) { + return Promise.resolve(); + } + return this.#promise; + } -function pollList(_list) { - // TODO + [symbolDispose]() { + this.#promise = null; + } +} + +function pollList(list) { + if (list.length === 0) { + throw new Error('poll list must not be empty'); + } + if (list.length > 0xFFFFFFFF) { + throw new Error('poll list length exceeds u32 index range'); + } + const ready = []; + for (let i = 0; i < list.length; i++) { + if (list[i].ready()) { + ready.push(i); + } + } + if (ready.length > 0) { + return new Uint32Array(ready); + } + // None ready synchronously. Wait for the first to resolve via Promise.race, + // then sweep for any others that became ready concurrently. + return Promise.race( + list.map((p, i) => p.block().then(() => { + const result = [i]; + for (let j = 0; j < list.length; j++) { + if (j !== i && list[j].ready()) { + result.push(j); + } + } + return new Uint32Array(result); + })) + ); } -function pollOne(_poll) { - // TODO +function pollOne(poll) { + return poll.block(); } export const poll = { Pollable, pollList, pollOne, - poll: pollOne, + poll: pollList, }; diff --git a/packages/preview2-shim/test/browser.js b/packages/preview2-shim/test/browser.js index d06b2d92b..7c31a53c6 100644 --- a/packages/preview2-shim/test/browser.js +++ b/packages/preview2-shim/test/browser.js @@ -8,6 +8,1032 @@ import { transpile } from "@bytecodealliance/jco"; import { getTmpDir, FIXTURES_WIT_DIR, startTestServer, runBasicHarnessPageTest } from "./common.js"; suite("browser", () => { + test("native-fetch", async () => { + const outDir = await getTmpDir(); + + const { baseURL, browser, cleanup } = await startTestServer({ + transpiledOutputDir: outDir, + }); + + const page = await browser.newPage(); + await page.goto(`${baseURL}/index.html`); + + const result = await page.evaluate(async () => { + const res = await fetch('/api/test-echo'); + return { + status: res.status, + text: await res.clone().text(), + json: await res.clone().json(), + }; + }); + + assert.strictEqual(result.status, 200); + assert.strictEqual(typeof result.text, 'string'); + assert.ok(result.text.includes('hello from test server')); + assert.strictEqual(result.json.message, 'hello from test server'); + + await page.close(); + await cleanup(); + }); + + test("http-fetch", async () => { + const outDir = await getTmpDir(); + + // Start the server first to get the port (outDir exists but is empty; + // files are served dynamically so we can write them after) + const { port, baseURL, browser, cleanup } = await startTestServer({ + transpiledOutputDir: outDir, + }); + + // Build a component that makes an HTTP request using WASI HTTP + const { component } = await componentize( +` +import { Fields } from "wasi:http/types@0.2.8"; +import { handle } from "wasi:http/outgoing-handler@0.2.8"; +import { OutgoingRequest, OutgoingBody, IncomingBody } from "wasi:http/types@0.2.8"; + +export const test = { + run() { + const headers = Fields.fromList([]); + const req = new OutgoingRequest(headers); + req.setMethod({ tag: "get" }); + req.setScheme({ tag: "HTTP" }); + req.setAuthority("localhost:${port}"); + req.setPathWithQuery("/api/test-echo"); + + const outBody = req.body(); + OutgoingBody.finish(outBody, undefined); + + const future = handle(req, undefined); + + const pollable = future.subscribe(); + pollable.block(); + + const result = future.get(); + if (!result) { throw "ERROR: no result from future"; } + if (result.tag === "err") { throw "ERROR: future error: " + JSON.stringify(result); } + if (result.val.tag === "err") { throw "ERROR: HTTP error: " + JSON.stringify(result.val.val); } + + const response = result.val.val; + const status = response.status(); + if (status !== 200) { throw "ERROR: expected 200, got " + status; } + + const incomingBody = response.consume(); + const stream = incomingBody.stream(); + + let bodyBytes = new Uint8Array(0); + try { + while (true) { + const pollable = stream.subscribe(); + pollable.block(); + const chunk = stream.read(65536n); + const merged = new Uint8Array(bodyBytes.length + chunk.length); + merged.set(bodyBytes); + merged.set(chunk, bodyBytes.length); + bodyBytes = merged; + } + } catch (e) { + const tag = e.tag || (e.payload && e.payload.tag); + if (tag !== "closed") { throw "ERROR: stream error: " + JSON.stringify(e); } + } + + const bodyText = new TextDecoder().decode(bodyBytes); + return bodyText; + } +} +`, + { + sourceName: 'component', + witPath: FIXTURES_WIT_DIR, + worldName: 'browser-http-fetch', + }); + + const { files } = await transpile(component, { + async: true, + name: 'component', + optimize: false, + asyncMode: 'jspi', + asyncImports: [ + 'wasi:io/poll#[method]pollable.block', + 'wasi:io/poll#poll', + 'wasi:io/streams#[method]input-stream.blocking-read', + ], + asyncExports: [ + 'tests:p2-shim/test#run', + ], + wasiShim: true, + outDir, + }); + for (const [outPath, source] of Object.entries(files)) { + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, source); + } + + const { statusJSON } = await runBasicHarnessPageTest({ + browser, + url: `${baseURL}/index.html#transpiled:component.js`, + }); + + assert.ok(statusJSON.msg.includes('hello from test server')); + + await cleanup(); + }, 120_000); + + test("http-fetch-with-options", async () => { + const outDir = await getTmpDir(); + + const { port, baseURL, browser, cleanup } = await startTestServer({ + transpiledOutputDir: outDir, + }); + + const { component } = await componentize( +` +import { Fields, RequestOptions } from "wasi:http/types@0.2.8"; +import { handle } from "wasi:http/outgoing-handler@0.2.8"; +import { OutgoingRequest, OutgoingBody, IncomingBody } from "wasi:http/types@0.2.8"; + +export const test = { + run() { + const headers = Fields.fromList([]); + const req = new OutgoingRequest(headers); + req.setMethod({ tag: "get" }); + req.setScheme({ tag: "HTTP" }); + req.setAuthority("localhost:${port}"); + req.setPathWithQuery("/api/test-echo"); + + const outBody = req.body(); + OutgoingBody.finish(outBody, undefined); + + const options = new RequestOptions(); + options.setConnectTimeout(30000000000n); + options.setFirstByteTimeout(30000000000n); + options.setBetweenBytesTimeout(30000000000n); + + const future = handle(req, options); + + const pollable = future.subscribe(); + pollable.block(); + + const result = future.get(); + if (!result) { throw "ERROR: no result from future"; } + if (result.tag === "err") { throw "ERROR: future error: " + JSON.stringify(result); } + if (result.val.tag === "err") { throw "ERROR: HTTP error: " + JSON.stringify(result.val.val); } + + const response = result.val.val; + const status = response.status(); + if (status !== 200) { throw "ERROR: expected 200, got " + status; } + + const incomingBody = response.consume(); + const stream = incomingBody.stream(); + + let bodyBytes = new Uint8Array(0); + try { + while (true) { + const pollable = stream.subscribe(); + pollable.block(); + const chunk = stream.read(65536n); + const merged = new Uint8Array(bodyBytes.length + chunk.length); + merged.set(bodyBytes); + merged.set(chunk, bodyBytes.length); + bodyBytes = merged; + } + } catch (e) { + const tag = e.tag || (e.payload && e.payload.tag); + if (tag !== "closed") { throw "ERROR: stream error: " + JSON.stringify(e); } + } + + const bodyText = new TextDecoder().decode(bodyBytes); + return bodyText; + } +} +`, + { + sourceName: 'component', + witPath: FIXTURES_WIT_DIR, + worldName: 'browser-http-fetch', + }); + + const { files } = await transpile(component, { + async: true, + name: 'component', + optimize: false, + asyncMode: 'jspi', + asyncImports: [ + 'wasi:io/poll#[method]pollable.block', + 'wasi:io/poll#poll', + 'wasi:io/streams#[method]input-stream.blocking-read', + ], + asyncExports: [ + 'tests:p2-shim/test#run', + ], + wasiShim: true, + outDir, + }); + for (const [outPath, source] of Object.entries(files)) { + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, source); + } + + const { statusJSON } = await runBasicHarnessPageTest({ + browser, + url: `${baseURL}/index.html#transpiled:component.js`, + }); + + assert.ok(statusJSON.msg.includes('hello from test server')); + + await cleanup(); + }, 120_000); + + test("http-poll-fetch", async () => { + const outDir = await getTmpDir(); + + const { port, baseURL, browser, cleanup } = await startTestServer({ + transpiledOutputDir: outDir, + }); + + // This component mimics the wstd reactor pattern: + // poll with subscribe-duration(0) in a loop until future.get() returns + const { component } = await componentize( +` +import { Fields } from "wasi:http/types@0.2.8"; +import { handle } from "wasi:http/outgoing-handler@0.2.8"; +import { OutgoingRequest, OutgoingBody, IncomingBody } from "wasi:http/types@0.2.8"; +import { subscribeDuration } from "wasi:clocks/monotonic-clock@0.2.8"; +import { poll } from "wasi:io/poll@0.2.8"; + +export const test = { + run() { + const headers = Fields.fromList([]); + const req = new OutgoingRequest(headers); + req.setMethod({ tag: "get" }); + req.setScheme({ tag: "HTTP" }); + req.setAuthority("localhost:${port}"); + req.setPathWithQuery("/api/test-echo"); + + const outBody = req.body(); + OutgoingBody.finish(outBody, undefined); + + const future = handle(req, undefined); + + // Poll loop: subscribe-duration(0) + future.subscribe, then check get() + let result; + for (let i = 0; i < 1000; i++) { + const timerPollable = subscribeDuration(0n); + const futurePollable = future.subscribe(); + poll([timerPollable, futurePollable]); + + result = future.get(); + if (result) break; + } + + if (!result) { throw "ERROR: no result from future after poll loop"; } + if (result.tag === "err") { throw "ERROR: future error: " + JSON.stringify(result); } + if (result.val.tag === "err") { throw "ERROR: HTTP error: " + JSON.stringify(result.val.val); } + + const response = result.val.val; + const status = response.status(); + if (status !== 200) { throw "ERROR: expected 200, got " + status; } + + const incomingBody = response.consume(); + const stream = incomingBody.stream(); + + let bodyBytes = new Uint8Array(0); + try { + while (true) { + const pollable = stream.subscribe(); + pollable.block(); + const chunk = stream.read(65536n); + const merged = new Uint8Array(bodyBytes.length + chunk.length); + merged.set(bodyBytes); + merged.set(chunk, bodyBytes.length); + bodyBytes = merged; + } + } catch (e) { + const tag = e.tag || (e.payload && e.payload.tag); + if (tag !== "closed") { throw "ERROR: stream error: " + JSON.stringify(e); } + } + + const bodyText = new TextDecoder().decode(bodyBytes); + if (!bodyText.includes("hello from test server")) { + throw "ERROR: body missing expected content, got: " + bodyText; + } + return bodyText; + } +} +`, + { + sourceName: 'component', + witPath: FIXTURES_WIT_DIR, + worldName: 'browser-http-poll-fetch', + }); + + const { files } = await transpile(component, { + async: true, + name: 'component', + optimize: false, + asyncMode: 'jspi', + asyncImports: [ + 'wasi:io/poll#[method]pollable.block', + 'wasi:io/poll#poll', + 'wasi:io/streams#[method]input-stream.blocking-read', + 'wasi:clocks/monotonic-clock#subscribe-duration', + ], + asyncExports: [ + 'tests:p2-shim/test#run', + ], + wasiShim: true, + outDir, + }); + for (const [outPath, source] of Object.entries(files)) { + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, source); + } + + const { statusJSON } = await runBasicHarnessPageTest({ + browser, + url: `${baseURL}/index.html#transpiled:component.js`, + }); + + assert.ok(statusJSON.msg.includes('hello from test server')); + + await cleanup(); + }, 120_000); + + test("http-blocking-read", async () => { + const outDir = await getTmpDir(); + + const { port, baseURL, browser, cleanup } = await startTestServer({ + transpiledOutputDir: outDir, + }); + + // This component uses blocking-read instead of subscribe+block+read + // to read the response body — matching how QuickJS and other sync + // runtimes consume streams via JSPI + const { component } = await componentize( +` +import { Fields } from "wasi:http/types@0.2.8"; +import { handle } from "wasi:http/outgoing-handler@0.2.8"; +import { OutgoingRequest, OutgoingBody, IncomingBody } from "wasi:http/types@0.2.8"; + +export const test = { + run() { + const headers = Fields.fromList([]); + const req = new OutgoingRequest(headers); + req.setMethod({ tag: "get" }); + req.setScheme({ tag: "HTTP" }); + req.setAuthority("localhost:${port}"); + req.setPathWithQuery("/api/test-echo"); + + const outBody = req.body(); + OutgoingBody.finish(outBody, undefined); + + const future = handle(req, undefined); + + const pollable = future.subscribe(); + pollable.block(); + + const result = future.get(); + if (!result) { throw "ERROR: no result from future"; } + if (result.tag === "err") { throw "ERROR: future error: " + JSON.stringify(result); } + if (result.val.tag === "err") { throw "ERROR: HTTP error: " + JSON.stringify(result.val.val); } + + const response = result.val.val; + const status = response.status(); + if (status !== 200) { throw "ERROR: expected 200, got " + status; } + + const incomingBody = response.consume(); + const stream = incomingBody.stream(); + + let bodyBytes = new Uint8Array(0); + try { + while (true) { + const chunk = stream.blockingRead(65536n); + const merged = new Uint8Array(bodyBytes.length + chunk.length); + merged.set(bodyBytes); + merged.set(chunk, bodyBytes.length); + bodyBytes = merged; + } + } catch (e) { + const tag = e.tag || (e.payload && e.payload.tag); + if (tag !== "closed") { throw "ERROR: stream error: " + JSON.stringify(e); } + } + + const bodyText = new TextDecoder().decode(bodyBytes); + if (!bodyText.includes("hello from test server")) { + throw "ERROR: body missing expected content, got: [" + bodyText + "]"; + } + return bodyText; + } +} +`, + { + sourceName: 'component', + witPath: FIXTURES_WIT_DIR, + worldName: 'browser-http-fetch', + }); + + const { files } = await transpile(component, { + async: true, + name: 'component', + optimize: false, + asyncMode: 'jspi', + asyncImports: [ + 'wasi:io/poll#[method]pollable.block', + 'wasi:io/poll#poll', + 'wasi:io/streams#[method]input-stream.blocking-read', + ], + asyncExports: [ + 'tests:p2-shim/test#run', + ], + wasiShim: true, + outDir, + }); + for (const [outPath, source] of Object.entries(files)) { + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, source); + } + + const { statusJSON } = await runBasicHarnessPageTest({ + browser, + url: `${baseURL}/index.html#transpiled:component.js`, + }); + + assert.ok(statusJSON.msg.includes('hello from test server')); + + await cleanup(); + }, 120_000); + + // Ported from wasmtime p2_sleep.rs + test("clocks-sleep", async () => { + const outDir = await getTmpDir(); + + const { baseURL, browser, cleanup } = await startTestServer({ + transpiledOutputDir: outDir, + }); + + const { component } = await componentize( +` +import { now, subscribeDuration, subscribeInstant } from "wasi:clocks/monotonic-clock@0.2.8"; + +export const test = { + run() { + // sleep 10ms via subscribe-instant + const dur = 10_000_000n; + const p1 = subscribeInstant(now() + dur); + p1.block(); + + // sleep 10ms via subscribe-duration + const p2 = subscribeDuration(dur); + p2.block(); + + // subscribe-duration(0) should resolve without hanging + const p3 = subscribeDuration(0n); + p3.block(); + + // subscribe-instant in the past should resolve without hanging + const p4 = subscribeInstant(now() - 1n); + p4.block(); + + return "clocks-sleep: all passed"; + } +} +`, + { + sourceName: 'component', + witPath: FIXTURES_WIT_DIR, + worldName: 'browser-clocks-poll', + }); + + const { files } = await transpile(component, { + async: true, + name: 'component', + optimize: false, + asyncMode: 'jspi', + asyncImports: [ + 'wasi:io/poll#[method]pollable.block', + 'wasi:clocks/monotonic-clock#subscribe-duration', + 'wasi:clocks/monotonic-clock#subscribe-instant', + ], + asyncExports: [ + 'tests:p2-shim/test#run', + ], + wasiShim: true, + outDir, + }); + for (const [outPath, source] of Object.entries(files)) { + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, source); + } + + const { statusJSON } = await runBasicHarnessPageTest({ + browser, + url: `${baseURL}/index.html#transpiled:component.js`, + }); + + assert.ok(statusJSON.msg.includes('all passed')); + + await cleanup(); + }); + + // Ported from wasmtime p2_pollable_correct.rs + test("pollable-correct", async () => { + const outDir = await getTmpDir(); + + const { baseURL, browser, cleanup } = await startTestServer({ + transpiledOutputDir: outDir, + }); + + const { component } = await componentize( +` +import { subscribeDuration } from "wasi:clocks/monotonic-clock@0.2.8"; +import { poll } from "wasi:io/poll@0.2.8"; + +export const test = { + run() { + const p1 = subscribeDuration(0n); + const p2 = subscribeDuration(0n); + + // Same pollable passed multiple times + distinct pollables + const ready = poll([p1, p2, p1, p2]); + + if (ready.length === 0) { + throw "ERROR: poll returned empty array"; + } + + // All should be ready since duration is 0 + for (const idx of ready) { + if (idx > 3) { + throw "ERROR: poll returned out-of-bounds index: " + idx; + } + } + + // Verify poll with a single pollable + const p3 = subscribeDuration(1_000_000n); + const ready2 = poll([p3]); + if (ready2.length === 0) { + throw "ERROR: poll with single pollable returned empty"; + } + + return "pollable-correct: all passed"; + } +} +`, + { + sourceName: 'component', + witPath: FIXTURES_WIT_DIR, + worldName: 'browser-clocks-poll', + }); + + const { files } = await transpile(component, { + async: true, + name: 'component', + optimize: false, + asyncMode: 'jspi', + asyncImports: [ + 'wasi:io/poll#[method]pollable.block', + 'wasi:io/poll#poll', + 'wasi:clocks/monotonic-clock#subscribe-duration', + ], + asyncExports: [ + 'tests:p2-shim/test#run', + ], + wasiShim: true, + outDir, + }); + for (const [outPath, source] of Object.entries(files)) { + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, source); + } + + const { statusJSON } = await runBasicHarnessPageTest({ + browser, + url: `${baseURL}/index.html#transpiled:component.js`, + }); + + assert.ok(statusJSON.msg.includes('all passed')); + + await cleanup(); + }); + + // Ported from wasmtime p2_stream_pollable_correct.rs + // Tests that pollables can be reused across multiple block() calls + test("pollable-reuse", async () => { + const outDir = await getTmpDir(); + + const { baseURL, browser, cleanup } = await startTestServer({ + transpiledOutputDir: outDir, + }); + + const { component } = await componentize( +` +import { subscribeDuration } from "wasi:clocks/monotonic-clock@0.2.8"; + +export const test = { + run() { + const p = subscribeDuration(1_000_000n); + + // Pollable should be usable many times over its lifetime + for (let i = 0; i < 5; i++) { + p.block(); + if (!p.ready()) { + throw "ERROR: after block(), ready() should be true (iteration " + i + ")"; + } + } + + return "pollable-reuse: all passed"; + } +} +`, + { + sourceName: 'component', + witPath: FIXTURES_WIT_DIR, + worldName: 'browser-clocks-poll', + }); + + const { files } = await transpile(component, { + async: true, + name: 'component', + optimize: false, + asyncMode: 'jspi', + asyncImports: [ + 'wasi:io/poll#[method]pollable.block', + 'wasi:clocks/monotonic-clock#subscribe-duration', + ], + asyncExports: [ + 'tests:p2-shim/test#run', + ], + wasiShim: true, + outDir, + }); + for (const [outPath, source] of Object.entries(files)) { + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, source); + } + + const { statusJSON } = await runBasicHarnessPageTest({ + browser, + url: `${baseURL}/index.html#transpiled:component.js`, + }); + + assert.ok(statusJSON.msg.includes('all passed')); + + await cleanup(); + }); + + // Ported from wasmtime p2_http_outbound_request_{get,post,put}.rs + test("http-methods", async () => { + const outDir = await getTmpDir(); + + const { port, baseURL, browser, cleanup } = await startTestServer({ + transpiledOutputDir: outDir, + }); + + const { component } = await componentize( +` +import { Fields, OutgoingRequest, OutgoingBody } from "wasi:http/types@0.2.8"; +import { handle } from "wasi:http/outgoing-handler@0.2.8"; + +function doRequest(method, path, bodyData) { + const enc = new TextEncoder(); + const headers = Fields.fromList([ + ["User-agent", enc.encode("WASI-HTTP/0.0.1")], + ["Content-type", enc.encode("application/json")], + ]); + const req = new OutgoingRequest(headers); + req.setMethod(method); + req.setScheme({ tag: "HTTP" }); + req.setAuthority("localhost:${port}"); + req.setPathWithQuery(path); + + const outBody = req.body(); + if (bodyData) { + const outStream = outBody.write(); + outStream.blockingWriteAndFlush(bodyData); + } + OutgoingBody.finish(outBody, undefined); + + const future = handle(req, undefined); + const pollable = future.subscribe(); + pollable.block(); + + const result = future.get(); + if (!result) throw "no result from future"; + if (result.tag === "err") throw "future error: " + JSON.stringify(result); + if (result.val.tag === "err") throw "HTTP error: " + JSON.stringify(result.val.val); + + const response = result.val.val; + const status = response.status(); + + const respHeaders = response.headers(); + const dec = new TextDecoder(); + const getHeader = (name) => { + const vals = respHeaders.get(name); + return vals.length > 0 ? dec.decode(vals[0]) : null; + }; + + const incomingBody = response.consume(); + const stream = incomingBody.stream(); + let bodyBytes = new Uint8Array(0); + try { + while (true) { + const p = stream.subscribe(); + p.block(); + const chunk = stream.read(65536n); + const merged = new Uint8Array(bodyBytes.length + chunk.length); + merged.set(bodyBytes); + merged.set(chunk, bodyBytes.length); + bodyBytes = merged; + } + } catch (e) { + const tag = e.tag || (e.payload && e.payload.tag); + if (tag !== "closed") throw "stream error: " + JSON.stringify(e); + } + + return { status, getHeader, body: bodyBytes }; +} + +export const test = { + run() { + const dec = new TextDecoder(); + + // GET with query string (p2_http_outbound_request_get) + { + const res = doRequest({ tag: "get" }, "/get?some=arg&goes=here"); + if (res.status !== 200) throw "GET: expected 200, got " + res.status; + if (res.getHeader("x-wasmtime-test-method") !== "GET") + throw "GET: wrong method header: " + res.getHeader("x-wasmtime-test-method"); + if (res.getHeader("x-wasmtime-test-uri") !== "/get?some=arg&goes=here") + throw "GET: wrong uri header: " + res.getHeader("x-wasmtime-test-uri"); + if (res.body.length !== 0) + throw "GET: expected empty body, got " + res.body.length + " bytes"; + } + + // POST with JSON body (p2_http_outbound_request_post) + { + const postData = new TextEncoder().encode('{"foo": "bar"}'); + const res = doRequest({ tag: "post" }, "/post", postData); + if (res.status !== 200) throw "POST: expected 200, got " + res.status; + if (res.getHeader("x-wasmtime-test-method") !== "POST") + throw "POST: wrong method header"; + if (res.getHeader("x-wasmtime-test-uri") !== "/post") + throw "POST: wrong uri header"; + const body = dec.decode(res.body); + if (body !== '{"foo": "bar"}') + throw "POST: expected echoed body, got: " + body; + } + + // PUT with empty body (p2_http_outbound_request_put) + { + const res = doRequest({ tag: "put" }, "/put", new Uint8Array(0)); + if (res.status !== 200) throw "PUT: expected 200, got " + res.status; + if (res.getHeader("x-wasmtime-test-method") !== "PUT") + throw "PUT: wrong method header"; + if (res.getHeader("x-wasmtime-test-uri") !== "/put") + throw "PUT: wrong uri header"; + if (res.body.length !== 0) + throw "PUT: expected empty body, got " + res.body.length + " bytes"; + } + + return "http-methods: all passed"; + } +} +`, + { + sourceName: 'component', + witPath: FIXTURES_WIT_DIR, + worldName: 'browser-http-fetch', + }); + + const { files } = await transpile(component, { + async: true, + name: 'component', + optimize: false, + asyncMode: 'jspi', + asyncImports: [ + 'wasi:io/poll#[method]pollable.block', + 'wasi:io/poll#poll', + 'wasi:io/streams#[method]input-stream.blocking-read', + ], + asyncExports: [ + 'tests:p2-shim/test#run', + ], + wasiShim: true, + outDir, + }); + for (const [outPath, source] of Object.entries(files)) { + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, source); + } + + const { statusJSON } = await runBasicHarnessPageTest({ + browser, + url: `${baseURL}/index.html#transpiled:component.js`, + }); + + assert.ok(statusJSON.msg.includes('all passed')); + + await cleanup(); + }, 120_000); + + // Ported from wasmtime p2_http_outbound_request_{invalid_header,response_build, + // unknown_method,invalid_port,missing_path_and_query}.rs + test("http-validation", async () => { + const outDir = await getTmpDir(); + + const { baseURL, browser, cleanup } = await startTestServer({ + transpiledOutputDir: outDir, + }); + + const { component } = await componentize( +` +import { Fields, OutgoingRequest, OutgoingBody } from "wasi:http/types@0.2.8"; +import { handle } from "wasi:http/outgoing-handler@0.2.8"; + +function expectThrow(fn, expectedTag, label) { + try { + fn(); + throw "NOTHROW:" + label + ": should have thrown"; + } catch (e) { + if (typeof e === "string" && e.startsWith("NOTHROW:")) throw e; + const tag = e.tag || (e.payload && e.payload.tag); + if (tag !== expectedTag) { + throw label + ": expected " + expectedTag + ", got " + JSON.stringify(e); + } + } +} + +function expectNoThrow(fn, label) { + try { + fn(); + } catch (e) { + throw label + ": unexpected throw: " + JSON.stringify(e); + } +} + +export const test = { + run() { + // --- Header validation (p2_http_outbound_request_invalid_header) --- + { + const hdrs = Fields.fromList([]); + + // Bad header name + expectThrow( + () => hdrs.append("malformed header name", new TextEncoder().encode("ok value")), + "invalid-syntax", "bad header name" + ); + + // Good header + expectNoThrow( + () => hdrs.append("ok-header-name", new TextEncoder().encode("ok value")), + "good header" + ); + + // Bad header value (newline) + expectThrow( + () => hdrs.append("ok-header-name", new TextEncoder().encode("bad\\nvalue")), + "invalid-syntax", "bad header value" + ); + + // Forbidden headers + expectThrow( + () => hdrs.append("Connection", new TextEncoder().encode("keep-alive")), + "forbidden", "Connection header" + ); + expectThrow( + () => hdrs.append("Keep-Alive", new TextEncoder().encode("stuff")), + "forbidden", "Keep-Alive header" + ); + expectThrow( + () => hdrs.append("Host", new TextEncoder().encode("example.com")), + "forbidden", "Host header" + ); + + // fromList with bad header name + expectThrow( + () => Fields.fromList([["bad header", new TextEncoder().encode("value")]]), + "invalid-syntax", "fromList bad name" + ); + + // fromList with bad header value + expectThrow( + () => Fields.fromList([["ok-name", new TextEncoder().encode("bad\\nvalue")]]), + "invalid-syntax", "fromList bad value" + ); + + // Immutable headers: headers attached to a request become immutable + const req = new OutgoingRequest(hdrs); + const immutableHdrs = req.headers(); + expectThrow( + () => immutableHdrs.set("Content-Length", [new TextEncoder().encode("10")]), + "immutable", "immutable set" + ); + expectThrow( + () => immutableHdrs.append("Content-Length", new TextEncoder().encode("10")), + "immutable", "immutable append" + ); + expectThrow( + () => immutableHdrs.delete("Content-Length"), + "immutable", "immutable delete" + ); + } + + // --- Request setter validation (p2_http_outbound_request_response_build) --- + { + const req = new OutgoingRequest(Fields.fromList([])); + + // Invalid method (contains space) + expectThrow( + () => req.setMethod({ tag: "other", val: "invalid method" }), + undefined, "invalid method" + ); + + // Invalid path (contains newline) + expectThrow( + () => req.setPathWithQuery("/bad\\npath"), + undefined, "invalid path" + ); + } + + // --- Unknown method (p2_http_outbound_request_unknown_method) --- + { + const hdrs = Fields.fromList([]); + const req = new OutgoingRequest(hdrs); + expectThrow( + () => req.setMethod({ tag: "other", val: "bad\\nmethod" }), + undefined, "unknown method with newline" + ); + } + + // --- Invalid port (p2_http_outbound_request_invalid_port) --- + { + const hdrs = Fields.fromList([]); + const req = new OutgoingRequest(hdrs); + expectThrow( + () => req.setAuthority("localhost:99999"), + undefined, "invalid port" + ); + } + + // --- Missing path and query (p2_http_outbound_request_missing_path_and_query) --- + { + const hdrs = Fields.fromList([]); + const req = new OutgoingRequest(hdrs); + req.setMethod({ tag: "get" }); + req.setScheme({ tag: "HTTPS" }); + req.setAuthority("example.com"); + // Do NOT set path + const outBody = req.body(); + OutgoingBody.finish(outBody, undefined); + let threw = false; + try { + handle(req, undefined); + } catch (e) { + threw = true; + } + if (!threw) throw "missing path: handle() should have thrown"; + } + + return "http-validation: all passed"; + } +} +`, + { + sourceName: 'component', + witPath: FIXTURES_WIT_DIR, + worldName: 'browser-http-fetch', + }); + + const { files } = await transpile(component, { + async: true, + name: 'component', + optimize: false, + asyncMode: 'jspi', + asyncImports: [ + 'wasi:io/poll#[method]pollable.block', + 'wasi:io/poll#poll', + 'wasi:io/streams#[method]input-stream.blocking-read', + ], + asyncExports: [ + 'tests:p2-shim/test#run', + ], + wasiShim: true, + outDir, + }); + for (const [outPath, source] of Object.entries(files)) { + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, source); + } + + const { statusJSON } = await runBasicHarnessPageTest({ + browser, + url: `${baseURL}/index.html#transpiled:component.js`, + }); + + assert.ok(statusJSON.msg.includes('all passed')); + + await cleanup(); + }, 120_000); + test("fs-open", async () => { const outDir = await getTmpDir(); diff --git a/packages/preview2-shim/test/common.js b/packages/preview2-shim/test/common.js index d0f46e2d2..312561be0 100644 --- a/packages/preview2-shim/test/common.js +++ b/packages/preview2-shim/test/common.js @@ -88,6 +88,45 @@ export async function startTestServer(args) { const htmlDirURL = pathToFileURL(htmlDir + '/'); const newServer = createHTTPServer(async (req, res) => { + // Handle CORS preflight for all endpoints + if (req.method === 'OPTIONS') { + res.writeHead(204, { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', + 'access-control-allow-headers': req.headers['access-control-request-headers'] || '*', + 'access-control-expose-headers': '*', + 'access-control-max-age': '86400', + }); + res.end(); + return; + } + + if (req.url === '/api/test-echo') { + res.writeHead(200, { + 'content-type': 'application/json', + 'x-test-header': 'test-value', + 'access-control-allow-origin': '*', + }); + res.end(JSON.stringify({ message: 'hello from test server' })); + return; + } + + // Wasmtime-compatible echo endpoint: echoes method, URI, and body + if (req.url.startsWith('/echo/') || ['/get', '/post', '/put'].includes(req.url.split('?')[0])) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const body = Buffer.concat(chunks); + res.writeHead(200, { + 'content-type': 'application/octet-stream', + 'x-wasmtime-test-method': req.method, + 'x-wasmtime-test-uri': req.url, + 'access-control-allow-origin': '*', + 'access-control-expose-headers': 'x-wasmtime-test-method, x-wasmtime-test-uri', + }); + res.end(body); + return; + } + let fileURL; try { if (req.url.startsWith('/transpiled/')) { diff --git a/packages/preview2-shim/test/fixtures/browser/basic-harness/index.html b/packages/preview2-shim/test/fixtures/browser/basic-harness/index.html index 29c62f990..c490e927f 100644 --- a/packages/preview2-shim/test/fixtures/browser/basic-harness/index.html +++ b/packages/preview2-shim/test/fixtures/browser/basic-harness/index.html @@ -97,7 +97,7 @@ // Run the exported function (which will call the async host import above), get response text // (Jco will encode results as the type if nothing is thrown) - const responseText = fn(); + const responseText = await fn(); updateOutput({ status: 'success', msg: responseText }); } catch (e) { diff --git a/packages/preview2-shim/test/fixtures/wit/deps/wasi-cli-0.2.8/package.wit b/packages/preview2-shim/test/fixtures/wit/deps/wasi-cli-0.2.8/package.wit new file mode 100644 index 000000000..8e71725b4 --- /dev/null +++ b/packages/preview2-shim/test/fixtures/wit/deps/wasi-cli-0.2.8/package.wit @@ -0,0 +1,20 @@ +package wasi:cli@0.2.8; + +interface stdout { + use wasi:io/streams@0.2.8.{output-stream}; + + get-stdout: func() -> output-stream; +} + +interface stderr { + use wasi:io/streams@0.2.8.{output-stream}; + + get-stderr: func() -> output-stream; +} + +interface stdin { + use wasi:io/streams@0.2.8.{input-stream}; + + get-stdin: func() -> input-stream; +} + diff --git a/packages/preview2-shim/test/fixtures/wit/deps/wasi-clocks-0.2.8/package.wit b/packages/preview2-shim/test/fixtures/wit/deps/wasi-clocks-0.2.8/package.wit index 31b876c0c..918bbea89 100644 --- a/packages/preview2-shim/test/fixtures/wit/deps/wasi-clocks-0.2.8/package.wit +++ b/packages/preview2-shim/test/fixtures/wit/deps/wasi-clocks-0.2.8/package.wit @@ -1,13 +1,162 @@ package wasi:clocks@0.2.8; +/// WASI Monotonic Clock is a clock API intended to let users measure elapsed +/// time. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +/// +/// A monotonic clock is a clock which has an unspecified initial value, and +/// successive reads of the clock will produce non-decreasing values. +@since(version = 0.2.0) +interface monotonic-clock { + @since(version = 0.2.0) + use wasi:io/poll@0.2.8.{pollable}; + + /// An instant in time, in nanoseconds. An instant is relative to an + /// unspecified initial value, and can only be compared to instances from + /// the same monotonic-clock. + @since(version = 0.2.0) + type instant = u64; + + /// A duration of time, in nanoseconds. + @since(version = 0.2.0) + type duration = u64; + + /// Read the current value of the clock. + /// + /// The clock is monotonic, therefore calling this function repeatedly will + /// produce a sequence of non-decreasing values. + /// + /// For completeness, this function traps if it's not possible to represent + /// the value of the clock in an `instant`. Consequently, implementations + /// should ensure that the starting time is low enough to avoid the + /// possibility of overflow in practice. + @since(version = 0.2.0) + now: func() -> instant; + + /// Query the resolution of the clock. Returns the duration of time + /// corresponding to a clock tick. + @since(version = 0.2.0) + resolution: func() -> duration; + + /// Create a `pollable` which will resolve once the specified instant + /// has occurred. + @since(version = 0.2.0) + subscribe-instant: func(when: instant) -> pollable; + + /// Create a `pollable` that will resolve after the specified duration has + /// elapsed from the time this function is invoked. + @since(version = 0.2.0) + subscribe-duration: func(when: duration) -> pollable; +} + +/// WASI Wall Clock is a clock API intended to let users query the current +/// time. The name "wall" makes an analogy to a "clock on the wall", which +/// is not necessarily monotonic as it may be reset. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +/// +/// A wall clock is a clock which measures the date and time according to +/// some external reference. +/// +/// External references may be reset, so this clock is not necessarily +/// monotonic, making it unsuitable for measuring elapsed time. +/// +/// It is intended for reporting the current date and time for humans. +@since(version = 0.2.0) interface wall-clock { + /// A time and date in seconds plus nanoseconds. + @since(version = 0.2.0) record datetime { seconds: u64, nanoseconds: u32, } + /// Read the current value of the clock. + /// + /// This clock is not monotonic, therefore calling this function repeatedly + /// will not necessarily produce a sequence of non-decreasing values. + /// + /// The returned timestamps represent the number of seconds since + /// 1970-01-01T00:00:00Z, also known as [POSIX's Seconds Since the Epoch], + /// also known as [Unix Time]. + /// + /// The nanoseconds field of the output is always less than 1000000000. + /// + /// [POSIX's Seconds Since the Epoch]: https://pubs.opengroup.org/onlinepubs/9699919799/xrat/V4_xbd_chap04.html#tag_21_04_16 + /// [Unix Time]: https://en.wikipedia.org/wiki/Unix_time + @since(version = 0.2.0) now: func() -> datetime; + /// Query the resolution of the clock. + /// + /// The nanoseconds field of the output is always less than 1000000000. + @since(version = 0.2.0) resolution: func() -> datetime; } +@unstable(feature = clocks-timezone) +interface timezone { + @unstable(feature = clocks-timezone) + use wall-clock.{datetime}; + + /// Information useful for displaying the timezone of a specific `datetime`. + /// + /// This information may vary within a single `timezone` to reflect daylight + /// saving time adjustments. + @unstable(feature = clocks-timezone) + record timezone-display { + /// The number of seconds difference between UTC time and the local + /// time of the timezone. + /// + /// The returned value will always be less than 86400 which is the + /// number of seconds in a day (24*60*60). + /// + /// In implementations that do not expose an actual time zone, this + /// should return 0. + utc-offset: s32, + /// The abbreviated name of the timezone to display to a user. The name + /// `UTC` indicates Coordinated Universal Time. Otherwise, this should + /// reference local standards for the name of the time zone. + /// + /// In implementations that do not expose an actual time zone, this + /// should be the string `UTC`. + /// + /// In time zones that do not have an applicable name, a formatted + /// representation of the UTC offset may be returned, such as `-04:00`. + name: string, + /// Whether daylight saving time is active. + /// + /// In implementations that do not expose an actual time zone, this + /// should return false. + in-daylight-saving-time: bool, + } + + /// Return information needed to display the given `datetime`. This includes + /// the UTC offset, the time zone name, and a flag indicating whether + /// daylight saving time is active. + /// + /// If the timezone cannot be determined for the given `datetime`, return a + /// `timezone-display` for `UTC` with a `utc-offset` of 0 and no daylight + /// saving time. + @unstable(feature = clocks-timezone) + display: func(when: datetime) -> timezone-display; + + /// The same as `display`, but only return the UTC offset. + @unstable(feature = clocks-timezone) + utc-offset: func(when: datetime) -> s32; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/poll@0.2.8; + @since(version = 0.2.0) + import monotonic-clock; + @since(version = 0.2.0) + import wall-clock; + @unstable(feature = clocks-timezone) + import timezone; +} diff --git a/packages/preview2-shim/test/fixtures/wit/deps/wasi-http-0.2.8/package.wit b/packages/preview2-shim/test/fixtures/wit/deps/wasi-http-0.2.8/package.wit new file mode 100644 index 000000000..a644ead84 --- /dev/null +++ b/packages/preview2-shim/test/fixtures/wit/deps/wasi-http-0.2.8/package.wit @@ -0,0 +1,733 @@ +package wasi:http@0.2.8; + +/// This interface defines all of the types and methods for implementing +/// HTTP Requests and Responses, both incoming and outgoing, as well as +/// their headers, trailers, and bodies. +@since(version = 0.2.0) +interface types { + @since(version = 0.2.0) + use wasi:clocks/monotonic-clock@0.2.8.{duration}; + @since(version = 0.2.0) + use wasi:io/streams@0.2.8.{input-stream, output-stream}; + @since(version = 0.2.0) + use wasi:io/error@0.2.8.{error as io-error}; + @since(version = 0.2.0) + use wasi:io/poll@0.2.8.{pollable}; + + /// This type corresponds to HTTP standard Methods. + @since(version = 0.2.0) + variant method { + get, + head, + post, + put, + delete, + connect, + options, + trace, + patch, + other(string), + } + + /// This type corresponds to HTTP standard Related Schemes. + @since(version = 0.2.0) + variant scheme { + HTTP, + HTTPS, + other(string), + } + + /// Defines the case payload type for `DNS-error` above: + @since(version = 0.2.0) + record DNS-error-payload { + rcode: option, + info-code: option, + } + + /// Defines the case payload type for `TLS-alert-received` above: + @since(version = 0.2.0) + record TLS-alert-received-payload { + alert-id: option, + alert-message: option, + } + + /// Defines the case payload type for `HTTP-response-{header,trailer}-size` above: + @since(version = 0.2.0) + record field-size-payload { + field-name: option, + field-size: option, + } + + /// These cases are inspired by the IANA HTTP Proxy Error Types: + /// + @since(version = 0.2.0) + variant error-code { + DNS-timeout, + DNS-error(DNS-error-payload), + destination-not-found, + destination-unavailable, + destination-IP-prohibited, + destination-IP-unroutable, + connection-refused, + connection-terminated, + connection-timeout, + connection-read-timeout, + connection-write-timeout, + connection-limit-reached, + TLS-protocol-error, + TLS-certificate-error, + TLS-alert-received(TLS-alert-received-payload), + HTTP-request-denied, + HTTP-request-length-required, + HTTP-request-body-size(option), + HTTP-request-method-invalid, + HTTP-request-URI-invalid, + HTTP-request-URI-too-long, + HTTP-request-header-section-size(option), + HTTP-request-header-size(option), + HTTP-request-trailer-section-size(option), + HTTP-request-trailer-size(field-size-payload), + HTTP-response-incomplete, + HTTP-response-header-section-size(option), + HTTP-response-header-size(field-size-payload), + HTTP-response-body-size(option), + HTTP-response-trailer-section-size(option), + HTTP-response-trailer-size(field-size-payload), + HTTP-response-transfer-coding(option), + HTTP-response-content-coding(option), + HTTP-response-timeout, + HTTP-upgrade-failed, + HTTP-protocol-error, + loop-detected, + configuration-error, + /// This is a catch-all error for anything that doesn't fit cleanly into a + /// more specific case. It also includes an optional string for an + /// unstructured description of the error. Users should not depend on the + /// string for diagnosing errors, as it's not required to be consistent + /// between implementations. + internal-error(option), + } + + /// This type enumerates the different kinds of errors that may occur when + /// setting or appending to a `fields` resource. + @since(version = 0.2.0) + variant header-error { + /// This error indicates that a `field-name` or `field-value` was + /// syntactically invalid when used with an operation that sets headers in a + /// `fields`. + invalid-syntax, + /// This error indicates that a forbidden `field-name` was used when trying + /// to set a header in a `fields`. + forbidden, + /// This error indicates that the operation on the `fields` was not + /// permitted because the fields are immutable. + immutable, + } + + /// Field keys are always strings. + /// + /// Field keys should always be treated as case insensitive by the `fields` + /// resource for the purposes of equality checking. + /// + /// # Deprecation + /// + /// This type has been deprecated in favor of the `field-name` type. + @since(version = 0.2.0) + @deprecated(version = 0.2.2) + type field-key = string; + + /// Field names are always strings. + /// + /// Field names should always be treated as case insensitive by the `fields` + /// resource for the purposes of equality checking. + @since(version = 0.2.1) + type field-name = field-key; + + /// Field values should always be ASCII strings. However, in + /// reality, HTTP implementations often have to interpret malformed values, + /// so they are provided as a list of bytes. + @since(version = 0.2.0) + type field-value = list; + + /// This following block defines the `fields` resource which corresponds to + /// HTTP standard Fields. Fields are a common representation used for both + /// Headers and Trailers. + /// + /// A `fields` may be mutable or immutable. A `fields` created using the + /// constructor, `from-list`, or `clone` will be mutable, but a `fields` + /// resource given by other means (including, but not limited to, + /// `incoming-request.headers`, `outgoing-request.headers`) might be + /// immutable. In an immutable fields, the `set`, `append`, and `delete` + /// operations will fail with `header-error.immutable`. + @since(version = 0.2.0) + resource fields { + /// Construct an empty HTTP Fields. + /// + /// The resulting `fields` is mutable. + @since(version = 0.2.0) + constructor(); + /// Construct an HTTP Fields. + /// + /// The resulting `fields` is mutable. + /// + /// The list represents each name-value pair in the Fields. Names + /// which have multiple values are represented by multiple entries in this + /// list with the same name. + /// + /// The tuple is a pair of the field name, represented as a string, and + /// Value, represented as a list of bytes. + /// + /// An error result will be returned if any `field-name` or `field-value` is + /// syntactically invalid, or if a field is forbidden. + @since(version = 0.2.0) + from-list: static func(entries: list>) -> result; + /// Get all of the values corresponding to a name. If the name is not present + /// in this `fields` or is syntactically invalid, an empty list is returned. + /// However, if the name is present but empty, this is represented by a list + /// with one or more empty field-values present. + @since(version = 0.2.0) + get: func(name: field-name) -> list; + /// Returns `true` when the name is present in this `fields`. If the name is + /// syntactically invalid, `false` is returned. + @since(version = 0.2.0) + has: func(name: field-name) -> bool; + /// Set all of the values for a name. Clears any existing values for that + /// name, if they have been set. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` or any of + /// the `field-value`s are syntactically invalid. + @since(version = 0.2.0) + set: func(name: field-name, value: list) -> result<_, header-error>; + /// Delete all values for a name. Does nothing if no values for the name + /// exist. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` is + /// syntactically invalid. + @since(version = 0.2.0) + delete: func(name: field-name) -> result<_, header-error>; + /// Append a value for a name. Does not change or delete any existing + /// values for that name. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` or + /// `field-value` are syntactically invalid. + @since(version = 0.2.0) + append: func(name: field-name, value: field-value) -> result<_, header-error>; + /// Retrieve the full set of names and values in the Fields. Like the + /// constructor, the list represents each name-value pair. + /// + /// The outer list represents each name-value pair in the Fields. Names + /// which have multiple values are represented by multiple entries in this + /// list with the same name. + /// + /// The names and values are always returned in the original casing and in + /// the order in which they will be serialized for transport. + @since(version = 0.2.0) + entries: func() -> list>; + /// Make a deep copy of the Fields. Equivalent in behavior to calling the + /// `fields` constructor on the return value of `entries`. The resulting + /// `fields` is mutable. + @since(version = 0.2.0) + clone: func() -> fields; + } + + /// Headers is an alias for Fields. + @since(version = 0.2.0) + type headers = fields; + + /// Trailers is an alias for Fields. + @since(version = 0.2.0) + type trailers = fields; + + /// Represents an incoming HTTP Request. + @since(version = 0.2.0) + resource incoming-request { + /// Returns the method of the incoming request. + @since(version = 0.2.0) + method: func() -> method; + /// Returns the path with query parameters from the request, as a string. + @since(version = 0.2.0) + path-with-query: func() -> option; + /// Returns the protocol scheme from the request. + @since(version = 0.2.0) + scheme: func() -> option; + /// Returns the authority of the Request's target URI, if present. + @since(version = 0.2.0) + authority: func() -> option; + /// Get the `headers` associated with the request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// The `headers` returned are a child resource: it must be dropped before + /// the parent `incoming-request` is dropped. Dropping this + /// `incoming-request` before all children are dropped will trap. + @since(version = 0.2.0) + headers: func() -> headers; + /// Gives the `incoming-body` associated with this request. Will only + /// return success at most once, and subsequent calls will return error. + @since(version = 0.2.0) + consume: func() -> result; + } + + /// Represents an outgoing HTTP Request. + @since(version = 0.2.0) + resource outgoing-request { + /// Construct a new `outgoing-request` with a default `method` of `GET`, and + /// `none` values for `path-with-query`, `scheme`, and `authority`. + /// + /// * `headers` is the HTTP Headers for the Request. + /// + /// It is possible to construct, or manipulate with the accessor functions + /// below, an `outgoing-request` with an invalid combination of `scheme` + /// and `authority`, or `headers` which are not permitted to be sent. + /// It is the obligation of the `outgoing-handler.handle` implementation + /// to reject invalid constructions of `outgoing-request`. + @since(version = 0.2.0) + constructor(headers: headers); + /// Returns the resource corresponding to the outgoing Body for this + /// Request. + /// + /// Returns success on the first call: the `outgoing-body` resource for + /// this `outgoing-request` can be retrieved at most once. Subsequent + /// calls will return error. + @since(version = 0.2.0) + body: func() -> result; + /// Get the Method for the Request. + @since(version = 0.2.0) + method: func() -> method; + /// Set the Method for the Request. Fails if the string present in a + /// `method.other` argument is not a syntactically valid method. + @since(version = 0.2.0) + set-method: func(method: method) -> result; + /// Get the combination of the HTTP Path and Query for the Request. + /// When `none`, this represents an empty Path and empty Query. + @since(version = 0.2.0) + path-with-query: func() -> option; + /// Set the combination of the HTTP Path and Query for the Request. + /// When `none`, this represents an empty Path and empty Query. Fails is the + /// string given is not a syntactically valid path and query uri component. + @since(version = 0.2.0) + set-path-with-query: func(path-with-query: option) -> result; + /// Get the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. + @since(version = 0.2.0) + scheme: func() -> option; + /// Set the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. Fails if the + /// string given is not a syntactically valid uri scheme. + @since(version = 0.2.0) + set-scheme: func(scheme: option) -> result; + /// Get the authority of the Request's target URI. A value of `none` may be used + /// with Related Schemes which do not require an authority. The HTTP and + /// HTTPS schemes always require an authority. + @since(version = 0.2.0) + authority: func() -> option; + /// Set the authority of the Request's target URI. A value of `none` may be used + /// with Related Schemes which do not require an authority. The HTTP and + /// HTTPS schemes always require an authority. Fails if the string given is + /// not a syntactically valid URI authority. + @since(version = 0.2.0) + set-authority: func(authority: option) -> result; + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `outgoing-request` is dropped, or its ownership is transferred to + /// another component by e.g. `outgoing-handler.handle`. + @since(version = 0.2.0) + headers: func() -> headers; + } + + /// Parameters for making an HTTP Request. Each of these parameters is + /// currently an optional timeout applicable to the transport layer of the + /// HTTP protocol. + /// + /// These timeouts are separate from any the user may use to bound a + /// blocking call to `wasi:io/poll.poll`. + @since(version = 0.2.0) + resource request-options { + /// Construct a default `request-options` value. + @since(version = 0.2.0) + constructor(); + /// The timeout for the initial connect to the HTTP Server. + @since(version = 0.2.0) + connect-timeout: func() -> option; + /// Set the timeout for the initial connect to the HTTP Server. An error + /// return value indicates that this timeout is not supported. + @since(version = 0.2.0) + set-connect-timeout: func(duration: option) -> result; + /// The timeout for receiving the first byte of the Response body. + @since(version = 0.2.0) + first-byte-timeout: func() -> option; + /// Set the timeout for receiving the first byte of the Response body. An + /// error return value indicates that this timeout is not supported. + @since(version = 0.2.0) + set-first-byte-timeout: func(duration: option) -> result; + /// The timeout for receiving subsequent chunks of bytes in the Response + /// body stream. + @since(version = 0.2.0) + between-bytes-timeout: func() -> option; + /// Set the timeout for receiving subsequent chunks of bytes in the Response + /// body stream. An error return value indicates that this timeout is not + /// supported. + @since(version = 0.2.0) + set-between-bytes-timeout: func(duration: option) -> result; + } + + /// Represents the ability to send an HTTP Response. + /// + /// This resource is used by the `wasi:http/incoming-handler` interface to + /// allow a Response to be sent corresponding to the Request provided as the + /// other argument to `incoming-handler.handle`. + @since(version = 0.2.0) + resource response-outparam { + /// Send an HTTP 1xx response. + /// + /// Unlike `response-outparam.set`, this does not consume the + /// `response-outparam`, allowing the guest to send an arbitrary number of + /// informational responses before sending the final response using + /// `response-outparam.set`. + /// + /// This will return an `HTTP-protocol-error` if `status` is not in the + /// range [100-199], or an `internal-error` if the implementation does not + /// support informational responses. + @unstable(feature = informational-outbound-responses) + send-informational: func(status: u16, headers: headers) -> result<_, error-code>; + /// Set the value of the `response-outparam` to either send a response, + /// or indicate an error. + /// + /// This method consumes the `response-outparam` to ensure that it is + /// called at most once. If it is never called, the implementation + /// will respond with an error. + /// + /// The user may provide an `error` to `response` to allow the + /// implementation determine how to respond with an HTTP error response. + @since(version = 0.2.0) + set: static func(param: response-outparam, response: result); + } + + /// This type corresponds to the HTTP standard Status Code. + @since(version = 0.2.0) + type status-code = u16; + + /// Represents an incoming HTTP Response. + @since(version = 0.2.0) + resource incoming-response { + /// Returns the status code from the incoming response. + @since(version = 0.2.0) + status: func() -> status-code; + /// Returns the headers from the incoming response. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `incoming-response` is dropped. + @since(version = 0.2.0) + headers: func() -> headers; + /// Returns the incoming body. May be called at most once. Returns error + /// if called additional times. + @since(version = 0.2.0) + consume: func() -> result; + } + + /// Represents an incoming HTTP Request or Response's Body. + /// + /// A body has both its contents - a stream of bytes - and a (possibly + /// empty) set of trailers, indicating that the full contents of the + /// body have been received. This resource represents the contents as + /// an `input-stream` and the delivery of trailers as a `future-trailers`, + /// and ensures that the user of this interface may only be consuming either + /// the body contents or waiting on trailers at any given time. + @since(version = 0.2.0) + resource incoming-body { + /// Returns the contents of the body, as a stream of bytes. + /// + /// Returns success on first call: the stream representing the contents + /// can be retrieved at most once. Subsequent calls will return error. + /// + /// The returned `input-stream` resource is a child: it must be dropped + /// before the parent `incoming-body` is dropped, or consumed by + /// `incoming-body.finish`. + /// + /// This invariant ensures that the implementation can determine whether + /// the user is consuming the contents of the body, waiting on the + /// `future-trailers` to be ready, or neither. This allows for network + /// backpressure is to be applied when the user is consuming the body, + /// and for that backpressure to not inhibit delivery of the trailers if + /// the user does not read the entire body. + @since(version = 0.2.0) + %stream: func() -> result; + /// Takes ownership of `incoming-body`, and returns a `future-trailers`. + /// This function will trap if the `input-stream` child is still alive. + @since(version = 0.2.0) + finish: static func(this: incoming-body) -> future-trailers; + } + + /// Represents a future which may eventually return trailers, or an error. + /// + /// In the case that the incoming HTTP Request or Response did not have any + /// trailers, this future will resolve to the empty set of trailers once the + /// complete Request or Response body has been received. + @since(version = 0.2.0) + resource future-trailers { + /// Returns a pollable which becomes ready when either the trailers have + /// been received, or an error has occurred. When this pollable is ready, + /// the `get` method will return `some`. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Returns the contents of the trailers, or an error which occurred, + /// once the future is ready. + /// + /// The outer `option` represents future readiness. Users can wait on this + /// `option` to become `some` using the `subscribe` method. + /// + /// The outer `result` is used to retrieve the trailers or error at most + /// once. It will be success on the first call in which the outer option + /// is `some`, and error on subsequent calls. + /// + /// The inner `result` represents that either the HTTP Request or Response + /// body, as well as any trailers, were received successfully, or that an + /// error occurred receiving them. The optional `trailers` indicates whether + /// or not trailers were present in the body. + /// + /// When some `trailers` are returned by this method, the `trailers` + /// resource is immutable, and a child. Use of the `set`, `append`, or + /// `delete` methods will return an error, and the resource must be + /// dropped before the parent `future-trailers` is dropped. + @since(version = 0.2.0) + get: func() -> option, error-code>>>; + } + + /// Represents an outgoing HTTP Response. + @since(version = 0.2.0) + resource outgoing-response { + /// Construct an `outgoing-response`, with a default `status-code` of `200`. + /// If a different `status-code` is needed, it must be set via the + /// `set-status-code` method. + /// + /// * `headers` is the HTTP Headers for the Response. + @since(version = 0.2.0) + constructor(headers: headers); + /// Get the HTTP Status Code for the Response. + @since(version = 0.2.0) + status-code: func() -> status-code; + /// Set the HTTP Status Code for the Response. Fails if the status-code + /// given is not a valid http status code. + @since(version = 0.2.0) + set-status-code: func(status-code: status-code) -> result; + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `outgoing-request` is dropped, or its ownership is transferred to + /// another component by e.g. `outgoing-handler.handle`. + @since(version = 0.2.0) + headers: func() -> headers; + /// Returns the resource corresponding to the outgoing Body for this Response. + /// + /// Returns success on the first call: the `outgoing-body` resource for + /// this `outgoing-response` can be retrieved at most once. Subsequent + /// calls will return error. + @since(version = 0.2.0) + body: func() -> result; + } + + /// Represents an outgoing HTTP Request or Response's Body. + /// + /// A body has both its contents - a stream of bytes - and a (possibly + /// empty) set of trailers, inducating the full contents of the body + /// have been sent. This resource represents the contents as an + /// `output-stream` child resource, and the completion of the body (with + /// optional trailers) with a static function that consumes the + /// `outgoing-body` resource, and ensures that the user of this interface + /// may not write to the body contents after the body has been finished. + /// + /// If the user code drops this resource, as opposed to calling the static + /// method `finish`, the implementation should treat the body as incomplete, + /// and that an error has occurred. The implementation should propagate this + /// error to the HTTP protocol by whatever means it has available, + /// including: corrupting the body on the wire, aborting the associated + /// Request, or sending a late status code for the Response. + @since(version = 0.2.0) + resource outgoing-body { + /// Returns a stream for writing the body contents. + /// + /// The returned `output-stream` is a child resource: it must be dropped + /// before the parent `outgoing-body` resource is dropped (or finished), + /// otherwise the `outgoing-body` drop or `finish` will trap. + /// + /// Returns success on the first call: the `output-stream` resource for + /// this `outgoing-body` may be retrieved at most once. Subsequent calls + /// will return error. + @since(version = 0.2.0) + write: func() -> result; + /// Finalize an outgoing body, optionally providing trailers. This must be + /// called to signal that the response is complete. If the `outgoing-body` + /// is dropped without calling `outgoing-body.finalize`, the implementation + /// should treat the body as corrupted. + /// + /// Fails if the body's `outgoing-request` or `outgoing-response` was + /// constructed with a Content-Length header, and the contents written + /// to the body (via `write`) does not match the value given in the + /// Content-Length. + @since(version = 0.2.0) + finish: static func(this: outgoing-body, trailers: option) -> result<_, error-code>; + } + + /// Represents a future which may eventually return an incoming HTTP + /// Response, or an error. + /// + /// This resource is returned by the `wasi:http/outgoing-handler` interface to + /// provide the HTTP Response corresponding to the sent Request. + @since(version = 0.2.0) + resource future-incoming-response { + /// Returns a pollable which becomes ready when either the Response has + /// been received, or an error has occurred. When this pollable is ready, + /// the `get` method will return `some`. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Returns the incoming HTTP Response, or an error, once one is ready. + /// + /// The outer `option` represents future readiness. Users can wait on this + /// `option` to become `some` using the `subscribe` method. + /// + /// The outer `result` is used to retrieve the response or error at most + /// once. It will be success on the first call in which the outer option + /// is `some`, and error on subsequent calls. + /// + /// The inner `result` represents that either the incoming HTTP Response + /// status and headers have received successfully, or that an error + /// occurred. Errors may also occur while consuming the response body, + /// but those will be reported by the `incoming-body` and its + /// `output-stream` child. + @since(version = 0.2.0) + get: func() -> option>>; + } + + /// Attempts to extract a http-related `error` from the wasi:io `error` + /// provided. + /// + /// Stream operations which return + /// `wasi:io/stream/stream-error::last-operation-failed` have a payload of + /// type `wasi:io/error/error` with more information about the operation + /// that failed. This payload can be passed through to this function to see + /// if there's http-related information about the error to return. + /// + /// Note that this function is fallible because not all io-errors are + /// http-related errors. + @since(version = 0.2.0) + http-error-code: func(err: borrow) -> option; +} + +/// This interface defines a handler of incoming HTTP Requests. It should +/// be exported by components which can respond to HTTP Requests. +@since(version = 0.2.0) +interface incoming-handler { + @since(version = 0.2.0) + use types.{incoming-request, response-outparam}; + + /// This function is invoked with an incoming HTTP Request, and a resource + /// `response-outparam` which provides the capability to reply with an HTTP + /// Response. The response is sent by calling the `response-outparam.set` + /// method, which allows execution to continue after the response has been + /// sent. This enables both streaming to the response body, and performing other + /// work. + /// + /// The implementor of this function must write a response to the + /// `response-outparam` before returning, or else the caller will respond + /// with an error on its behalf. + @since(version = 0.2.0) + handle: func(request: incoming-request, response-out: response-outparam); +} + +/// This interface defines a handler of outgoing HTTP Requests. It should be +/// imported by components which wish to make HTTP Requests. +@since(version = 0.2.0) +interface outgoing-handler { + @since(version = 0.2.0) + use types.{outgoing-request, request-options, future-incoming-response, error-code}; + + /// This function is invoked with an outgoing HTTP Request, and it returns + /// a resource `future-incoming-response` which represents an HTTP Response + /// which may arrive in the future. + /// + /// The `options` argument accepts optional parameters for the HTTP + /// protocol's transport layer. + /// + /// This function may return an error if the `outgoing-request` is invalid + /// or not allowed to be made. Otherwise, protocol errors are reported + /// through the `future-incoming-response`. + @since(version = 0.2.0) + handle: func(request: outgoing-request, options: option) -> result; +} + +/// The `wasi:http/imports` world imports all the APIs for HTTP proxies. +/// It is intended to be `include`d in other worlds. +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/poll@0.2.8; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.8; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.8; + @since(version = 0.2.0) + import wasi:random/random@0.2.8; + @since(version = 0.2.0) + import wasi:io/error@0.2.8; + @since(version = 0.2.0) + import wasi:io/streams@0.2.8; + @since(version = 0.2.0) + import wasi:cli/stdout@0.2.8; + @since(version = 0.2.0) + import wasi:cli/stderr@0.2.8; + @since(version = 0.2.0) + import wasi:cli/stdin@0.2.8; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import outgoing-handler; +} +/// The `wasi:http/proxy` world captures a widely-implementable intersection of +/// hosts that includes HTTP forward and reverse proxies. Components targeting +/// this world may concurrently stream in and out any number of incoming and +/// outgoing HTTP requests. +@since(version = 0.2.0) +world proxy { + @since(version = 0.2.0) + import wasi:io/poll@0.2.8; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.8; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.8; + @since(version = 0.2.0) + import wasi:random/random@0.2.8; + @since(version = 0.2.0) + import wasi:io/error@0.2.8; + @since(version = 0.2.0) + import wasi:io/streams@0.2.8; + @since(version = 0.2.0) + import wasi:cli/stdout@0.2.8; + @since(version = 0.2.0) + import wasi:cli/stderr@0.2.8; + @since(version = 0.2.0) + import wasi:cli/stdin@0.2.8; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import outgoing-handler; + + @since(version = 0.2.0) + export incoming-handler; +} diff --git a/packages/preview2-shim/test/fixtures/wit/deps/wasi-io-0.2.8/package.wit b/packages/preview2-shim/test/fixtures/wit/deps/wasi-io-0.2.8/package.wit index 22e2b43ad..81f5ce66a 100644 --- a/packages/preview2-shim/test/fixtures/wit/deps/wasi-io-0.2.8/package.wit +++ b/packages/preview2-shim/test/fixtures/wit/deps/wasi-io-0.2.8/package.wit @@ -1,48 +1,299 @@ package wasi:io@0.2.8; +@since(version = 0.2.0) interface error { + /// A resource which represents some error information. + /// + /// The only method provided by this resource is `to-debug-string`, + /// which provides some human-readable information about the error. + /// + /// In the `wasi:io` package, this resource is returned through the + /// `wasi:io/streams/stream-error` type. + /// + /// To provide more specific error information, other interfaces may + /// offer functions to "downcast" this error into more specific types. For example, + /// errors returned from streams derived from filesystem types can be described using + /// the filesystem's own error-code type. This is done using the function + /// `wasi:filesystem/types/filesystem-error-code`, which takes a `borrow` + /// parameter and returns an `option`. + /// + /// The set of functions which can "downcast" an `error` into a more + /// concrete type is open. + @since(version = 0.2.0) resource error { + /// Returns a string that is suitable to assist humans in debugging + /// this error. + /// + /// WARNING: The returned string should not be consumed mechanically! + /// It may change across platforms, hosts, or other implementation + /// details. Parsing this string is a major platform-compatibility + /// hazard. + @since(version = 0.2.0) to-debug-string: func() -> string; } } +/// A poll API intended to let users wait for I/O events on multiple handles +/// at once. +@since(version = 0.2.0) interface poll { + /// `pollable` represents a single I/O event which may be ready, or not. + @since(version = 0.2.0) resource pollable { + /// Return the readiness of a pollable. This function never blocks. + /// + /// Returns `true` when the pollable is ready, and `false` otherwise. + @since(version = 0.2.0) ready: func() -> bool; + /// `block` returns immediately if the pollable is ready, and otherwise + /// blocks until ready. + /// + /// This function is equivalent to calling `poll.poll` on a list + /// containing only this pollable. + @since(version = 0.2.0) block: func(); } + /// Poll for completion on a set of pollables. + /// + /// This function takes a list of pollables, which identify I/O sources of + /// interest, and waits until one or more of the events is ready for I/O. + /// + /// The result `list` contains one or more indices of handles in the + /// argument list that is ready for I/O. + /// + /// This function traps if either: + /// - the list is empty, or: + /// - the list contains more elements than can be indexed with a `u32` value. + /// + /// A timeout can be implemented by adding a pollable from the + /// wasi-clocks API to the list. + /// + /// This function does not return a `result`; polling in itself does not + /// do any I/O so it doesn't fail. If any of the I/O sources identified by + /// the pollables has an error, it is indicated by marking the source as + /// being ready for I/O. + @since(version = 0.2.0) poll: func(in: list>) -> list; } +/// WASI I/O is an I/O abstraction API which is currently focused on providing +/// stream types. +/// +/// In the future, the component model is expected to add built-in stream types; +/// when it does, they are expected to subsume this API. +@since(version = 0.2.0) interface streams { + @since(version = 0.2.0) use error.{error}; + @since(version = 0.2.0) use poll.{pollable}; + /// An error for input-stream and output-stream operations. + @since(version = 0.2.0) variant stream-error { + /// The last operation (a write or flush) failed before completion. + /// + /// More information is available in the `error` payload. + /// + /// After this, the stream will be closed. All future operations return + /// `stream-error::closed`. last-operation-failed(error), + /// The stream is closed: no more input will be accepted by the + /// stream. A closed output-stream will return this error on all + /// future operations. closed, } + /// An input bytestream. + /// + /// `input-stream`s are *non-blocking* to the extent practical on underlying + /// platforms. I/O operations always return promptly; if fewer bytes are + /// promptly available than requested, they return the number of bytes promptly + /// available, which could even be zero. To wait for data to be available, + /// use the `subscribe` function to obtain a `pollable` which can be polled + /// for using `wasi:io/poll`. + @since(version = 0.2.0) resource input-stream { + /// Perform a non-blocking read from the stream. + /// + /// When the source of a `read` is binary data, the bytes from the source + /// are returned verbatim. When the source of a `read` is known to the + /// implementation to be text, bytes containing the UTF-8 encoding of the + /// text are returned. + /// + /// This function returns a list of bytes containing the read data, + /// when successful. The returned list will contain up to `len` bytes; + /// it may return fewer than requested, but not more. The list is + /// empty when no bytes are available for reading at this time. The + /// pollable given by `subscribe` will be ready when more bytes are + /// available. + /// + /// This function fails with a `stream-error` when the operation + /// encounters an error, giving `last-operation-failed`, or when the + /// stream is closed, giving `closed`. + /// + /// When the caller gives a `len` of 0, it represents a request to + /// read 0 bytes. If the stream is still open, this call should + /// succeed and return an empty list, or otherwise fail with `closed`. + /// + /// The `len` parameter is a `u64`, which could represent a list of u8 which + /// is not possible to allocate in wasm32, or not desirable to allocate as + /// as a return value by the callee. The callee may return a list of bytes + /// less than `len` in size while more bytes are available for reading. + @since(version = 0.2.0) read: func(len: u64) -> result, stream-error>; + /// Read bytes from a stream, after blocking until at least one byte can + /// be read. Except for blocking, behavior is identical to `read`. + @since(version = 0.2.0) blocking-read: func(len: u64) -> result, stream-error>; + /// Skip bytes from a stream. Returns number of bytes skipped. + /// + /// Behaves identical to `read`, except instead of returning a list + /// of bytes, returns the number of bytes consumed from the stream. + @since(version = 0.2.0) skip: func(len: u64) -> result; + /// Skip bytes from a stream, after blocking until at least one byte + /// can be skipped. Except for blocking behavior, identical to `skip`. + @since(version = 0.2.0) blocking-skip: func(len: u64) -> result; + /// Create a `pollable` which will resolve once either the specified stream + /// has bytes available to read or the other end of the stream has been + /// closed. + /// The created `pollable` is a child resource of the `input-stream`. + /// Implementations may trap if the `input-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) subscribe: func() -> pollable; } + /// An output bytestream. + /// + /// `output-stream`s are *non-blocking* to the extent practical on + /// underlying platforms. Except where specified otherwise, I/O operations also + /// always return promptly, after the number of bytes that can be written + /// promptly, which could even be zero. To wait for the stream to be ready to + /// accept data, the `subscribe` function to obtain a `pollable` which can be + /// polled for using `wasi:io/poll`. + /// + /// Dropping an `output-stream` while there's still an active write in + /// progress may result in the data being lost. Before dropping the stream, + /// be sure to fully flush your writes. + @since(version = 0.2.0) resource output-stream { + /// Check readiness for writing. This function never blocks. + /// + /// Returns the number of bytes permitted for the next call to `write`, + /// or an error. Calling `write` with more bytes than this function has + /// permitted will trap. + /// + /// When this function returns 0 bytes, the `subscribe` pollable will + /// become ready when this function will report at least 1 byte, or an + /// error. + @since(version = 0.2.0) check-write: func() -> result; + /// Perform a write. This function never blocks. + /// + /// When the destination of a `write` is binary data, the bytes from + /// `contents` are written verbatim. When the destination of a `write` is + /// known to the implementation to be text, the bytes of `contents` are + /// transcoded from UTF-8 into the encoding of the destination and then + /// written. + /// + /// Precondition: check-write gave permit of Ok(n) and contents has a + /// length of less than or equal to n. Otherwise, this function will trap. + /// + /// returns Err(closed) without writing if the stream has closed since + /// the last call to check-write provided a permit. + @since(version = 0.2.0) write: func(contents: list) -> result<_, stream-error>; + /// Perform a write of up to 4096 bytes, and then flush the stream. Block + /// until all of these operations are complete, or an error occurs. + /// + /// Returns success when all of the contents written are successfully + /// flushed to output. If an error occurs at any point before all + /// contents are successfully flushed, that error is returned as soon as + /// possible. If writing and flushing the complete contents causes the + /// stream to become closed, this call should return success, and + /// subsequent calls to check-write or other interfaces should return + /// stream-error::closed. + @since(version = 0.2.0) blocking-write-and-flush: func(contents: list) -> result<_, stream-error>; + /// Request to flush buffered output. This function never blocks. + /// + /// This tells the output-stream that the caller intends any buffered + /// output to be flushed. the output which is expected to be flushed + /// is all that has been passed to `write` prior to this call. + /// + /// Upon calling this function, the `output-stream` will not accept any + /// writes (`check-write` will return `ok(0)`) until the flush has + /// completed. The `subscribe` pollable will become ready when the + /// flush has completed and the stream can accept more writes. + @since(version = 0.2.0) flush: func() -> result<_, stream-error>; + /// Request to flush buffered output, and block until flush completes + /// and stream is ready for writing again. + @since(version = 0.2.0) blocking-flush: func() -> result<_, stream-error>; + /// Create a `pollable` which will resolve once the output-stream + /// is ready for more writing, or an error has occurred. When this + /// pollable is ready, `check-write` will return `ok(n)` with n>0, or an + /// error. + /// + /// If the stream is closed, this pollable is always ready immediately. + /// + /// The created `pollable` is a child resource of the `output-stream`. + /// Implementations may trap if the `output-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) subscribe: func() -> pollable; + /// Write zeroes to a stream. + /// + /// This should be used precisely like `write` with the exact same + /// preconditions (must use check-write first), but instead of + /// passing a list of bytes, you simply pass the number of zero-bytes + /// that should be written. + @since(version = 0.2.0) write-zeroes: func(len: u64) -> result<_, stream-error>; + /// Perform a write of up to 4096 zeroes, and then flush the stream. + /// Block until all of these operations are complete, or an error + /// occurs. + /// + /// Functionality is equivelant to `blocking-write-and-flush` with + /// contents given as a list of len containing only zeroes. + @since(version = 0.2.0) blocking-write-zeroes-and-flush: func(len: u64) -> result<_, stream-error>; + /// Read from one stream and write to another. + /// + /// The behavior of splice is equivalent to: + /// 1. calling `check-write` on the `output-stream` + /// 2. calling `read` on the `input-stream` with the smaller of the + /// `check-write` permitted length and the `len` provided to `splice` + /// 3. calling `write` on the `output-stream` with that read data. + /// + /// Any error reported by the call to `check-write`, `read`, or + /// `write` ends the splice and reports that error. + /// + /// This function returns the number of bytes transferred; it may be less + /// than `len`. + @since(version = 0.2.0) splice: func(src: borrow, len: u64) -> result; + /// Read from one stream and write to another, with blocking. + /// + /// This is similar to `splice`, except that it blocks until the + /// `output-stream` is ready for writing, and the `input-stream` + /// is ready for reading, before performing the `splice`. + @since(version = 0.2.0) blocking-splice: func(src: borrow, len: u64) -> result; } } +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import error; + @since(version = 0.2.0) + import poll; + @since(version = 0.2.0) + import streams; +} diff --git a/packages/preview2-shim/test/fixtures/wit/deps/wasi-random-0.2.8/package.wit b/packages/preview2-shim/test/fixtures/wit/deps/wasi-random-0.2.8/package.wit new file mode 100644 index 000000000..342c0dcef --- /dev/null +++ b/packages/preview2-shim/test/fixtures/wit/deps/wasi-random-0.2.8/package.wit @@ -0,0 +1,8 @@ +package wasi:random@0.2.8; + +interface random { + get-random-bytes: func(len: u64) -> list; + + get-random-u64: func() -> u64; +} + diff --git a/packages/preview2-shim/test/fixtures/wit/tests.wit b/packages/preview2-shim/test/fixtures/wit/tests.wit index 239239f89..d8e97e81f 100644 --- a/packages/preview2-shim/test/fixtures/wit/tests.wit +++ b/packages/preview2-shim/test/fixtures/wit/tests.wit @@ -10,3 +10,31 @@ world browser-fs-write { export test; } + +world browser-http-fetch { + import wasi:io/poll@0.2.8; + import wasi:io/streams@0.2.8; + import wasi:io/error@0.2.8; + import wasi:http/types@0.2.8; + import wasi:http/outgoing-handler@0.2.8; + + export test; +} + +world browser-clocks-poll { + import wasi:io/poll@0.2.8; + import wasi:clocks/monotonic-clock@0.2.8; + + export test; +} + +world browser-http-poll-fetch { + import wasi:io/poll@0.2.8; + import wasi:io/streams@0.2.8; + import wasi:io/error@0.2.8; + import wasi:clocks/monotonic-clock@0.2.8; + import wasi:http/types@0.2.8; + import wasi:http/outgoing-handler@0.2.8; + + export test; +} diff --git a/packages/preview2-shim/test/test.js b/packages/preview2-shim/test/test.js index 3223f17f7..76c8ba482 100644 --- a/packages/preview2-shim/test/test.js +++ b/packages/preview2-shim/test/test.js @@ -882,6 +882,37 @@ suite("Sandboxing", () => { }, /not-permitted/, "Should not allow traversing outside preopen"); })); }); +suite("Browser shim guards", () => { + test("pollList throws on empty list", async () => { + const { poll } = await import("../lib/browser/io.js"); + assert.throws(() => poll.poll([]), /empty/); + }); + + test("pollList throws on list exceeding u32 range", async () => { + const { poll } = await import("../lib/browser/io.js"); + const fakeList = { length: 0x100000000 }; + assert.throws(() => poll.poll(fakeList), /u32/); + }); + + test("RequestOptions rejects negative connect timeout", async () => { + const { types } = await import("../lib/browser/http.js"); + const opts = new types.RequestOptions(); + assert.throws(() => opts.setConnectTimeout(-1n), /negative/); + }); + + test("RequestOptions rejects negative first-byte timeout", async () => { + const { types } = await import("../lib/browser/http.js"); + const opts = new types.RequestOptions(); + assert.throws(() => opts.setFirstByteTimeout(-1n), /negative/); + }); + + test("RequestOptions rejects negative between-bytes timeout", async () => { + const { types } = await import("../lib/browser/http.js"); + const opts = new types.RequestOptions(); + assert.throws(() => opts.setBetweenBytesTimeout(-1n), /negative/); + }); +}); + function testWithGCWrap(asyncTestFn) { return async () => { await asyncTestFn();