diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 82ec2c019..4bd27b426 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -14,6 +14,8 @@ - [Detecting traps](./advanced/detecting-traps.md) - [Preview 3 streams and backpressure](./advanced/preview3-streams-and-backpressure.md) - [Preview 3 host value disposal](./advanced/preview3-host-value-disposal.md) + - [Preview 2 shim helpers]() + - [CLI helpers](./advanced/preview2-shim-helpers/cli.md) - [JavaScript ecosystem interoperability]() - [`jco-std`](./interop/jco-std.md) - [Node.js built-in compatibility](./interop/nodejs-builtins.md) diff --git a/docs/src/advanced/preview2-shim-helpers/cli.md b/docs/src/advanced/preview2-shim-helpers/cli.md new file mode 100644 index 000000000..4802788a5 --- /dev/null +++ b/docs/src/advanced/preview2-shim-helpers/cli.md @@ -0,0 +1,72 @@ +# Preview 2 shim CLI helpers + +[`@bytecodealliance/preview2-shim`][npm-p2-shim] ships host-side helper functions next to its WASI +implementations. Helpers build only on the shim's public surface and are exported from `helpers/` +subpaths, separate from the underscore-prefixed functions (`_setStdin`, `_setPreopens`, ...) that +exist to configure the shim itself. + +Helpers that only exist for one build are exported under that build's prefix. The CLI helper on +this page is browser-only, because it builds on the browser `wasi:io` pollables: + +```js +import { bufferedInputStream } from "@bytecodealliance/preview2-shim/browser/helpers/cli"; +``` + +[npm-p2-shim]: https://www.npmjs.com/package/@bytecodealliance/preview2-shim + +## `bufferedInputStream(input)` + +Builds an `InputStreamHandler` that serves a fixed buffer to the guest and then reports the stream +as closed. It replaces the bounded handler that hosts otherwise have to hand-roll whenever a +guest's stdin is known up front, such as piping a document into a CLI component. + +`input` is a `Uint8Array`, or a string that is encoded as UTF-8. Each `blockingRead(len)` returns +up to `len` bytes from what remains of the buffer. Once the buffer is exhausted, further reads raise +the WASI `closed` stream error, which the guest observes as end of input. `subscribe()` returns an +always-ready pollable, so a guest that polls stdin before reading never blocks. + +### Feeding an isolated CLI + +`createCli` builds CLI interfaces that do not touch the shim's global streams: + +```js +import { cli } from "@bytecodealliance/preview2-shim"; +import { bufferedInputStream } from "@bytecodealliance/preview2-shim/browser/helpers/cli"; + +const isolated = cli.createCli({ + stdin: bufferedInputStream("first line\nsecond line\n"), +}); +``` + +### Feeding a `WASIShim` + +`WASIShim` accepts the same handler through its `stdin` option and builds an isolated CLI for the +instance: + +```js +import { WASIShim } from "@bytecodealliance/preview2-shim/instantiation"; +import { bufferedInputStream } from "@bytecodealliance/preview2-shim/browser/helpers/cli"; + +const shim = new WASIShim({ + stdin: bufferedInputStream("input for the guest\n"), +}); +const component = await instantiate(undefined, shim.getImportObject()); +``` + +See [Manual Wasm instantiation with WASI overrides](../manual-wasm-instantiation-with-wasi-overrides.md) +for how `instantiate` and `WASIShim` fit together. + +### Replacing the global stdin + +The compatibility setter installs the handler on the stream shared by every component that does +not configure its own CLI: + +```js +import { cli } from "@bytecodealliance/preview2-shim"; +import { bufferedInputStream } from "@bytecodealliance/preview2-shim/browser/helpers/cli"; + +cli._setStdin(bufferedInputStream(new Uint8Array([1, 2, 3]))); +``` + +Prefer `createCli` or `WASIShim` when more than one component runs in the same page, since the +global stream is consumed once and then stays closed. diff --git a/packages/preview2-shim/README.md b/packages/preview2-shim/README.md index 39c27ebad..97da2c048 100644 --- a/packages/preview2-shim/README.md +++ b/packages/preview2-shim/README.md @@ -73,6 +73,13 @@ http._setRequestStreaming(true); The setting affects subsequent requests made through the browser HTTP shim. Call `http._setRequestStreaming(false)` to restore portable completion buffering. +Host-side helper functions that build on the public shim surface are exported from `helpers/` +subpaths, separate from the underscore-prefixed configuration setters. The browser-only +`bufferedInputStream` helper (`@bytecodealliance/preview2-shim/browser/helpers/cli`) feeds a guest a +fixed stdin buffer. See [Preview 2 shim CLI helpers][book-cli-helpers] in the Jco book. + +[book-cli-helpers]: https://bytecodealliance.github.io/jco/advanced/preview2-shim-helpers/cli.html + Browser applications select storage explicitly. The bundled file-data adapter is ephemeral and must be opted into: diff --git a/packages/preview2-shim/package.json b/packages/preview2-shim/package.json index 18c57ca2a..70c51c1c3 100644 --- a/packages/preview2-shim/package.json +++ b/packages/preview2-shim/package.json @@ -32,20 +32,24 @@ "type": "module", "exports": { ".": { - "types": "./dist/browser/index.d.ts", "node": { "types": "./dist/nodejs/index.d.ts", "default": "./dist/nodejs/index.js" }, - "default": "./dist/browser/index.js" + "default": { + "types": "./dist/browser/index.d.ts", + "default": "./dist/browser/index.js" + } }, "./*": { - "types": "./dist/browser/*.d.ts", "node": { "types": "./dist/nodejs/*.d.ts", "default": "./dist/nodejs/*.js" }, - "default": "./dist/browser/*.js" + "default": { + "types": "./dist/browser/*.d.ts", + "default": "./dist/browser/*.js" + } }, "./instantiation": { "types": "./dist/common/instantiation.d.ts", @@ -55,6 +59,10 @@ "./interfaces/*": { "types": "./types/interfaces/*.d.ts" }, + "./browser/helpers/cli": { + "types": "./dist/browser/helpers/cli.d.ts", + "default": "./dist/browser/helpers/cli.js" + }, "./io-worker": { "types": "./dist/nodejs/io-worker.d.ts", "node": "./dist/nodejs/io-worker.js" diff --git a/packages/preview2-shim/src/browser/helpers/cli.ts b/packages/preview2-shim/src/browser/helpers/cli.ts new file mode 100644 index 000000000..0e7303ea4 --- /dev/null +++ b/packages/preview2-shim/src/browser/helpers/cli.ts @@ -0,0 +1,27 @@ +import { pollableCreate, type InputStreamHandler } from "../io.js"; + +/** + * Build an `InputStreamHandler` that serves a fixed buffer, then reports `closed`. + * + * Pass the result to `createCli({ stdin })` or `_setStdin` to feed a guest a fixed stdin. + */ +export function bufferedInputStream(input: Uint8Array | string): InputStreamHandler { + const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input; + let offset = 0; + + return { + blockingRead(len: bigint): Uint8Array { + if (offset >= bytes.length) { + throw { tag: "closed" }; + } + const remaining = bytes.length - offset; + const toRead = len < BigInt(remaining) ? Number(len) : remaining; + const chunk = bytes.slice(offset, offset + toRead); + offset += toRead; + return chunk; + }, + subscribe() { + return pollableCreate(); + }, + }; +} diff --git a/packages/preview2-shim/test/browser/helpers/cli.ts b/packages/preview2-shim/test/browser/helpers/cli.ts new file mode 100644 index 000000000..e7e0cbaad --- /dev/null +++ b/packages/preview2-shim/test/browser/helpers/cli.ts @@ -0,0 +1,42 @@ +import { suite, test, assert } from "vitest"; + +suite("Browser CLI helpers", () => { + test("bufferedInputStream serves bytes across reads then reports closed", async () => { + const { bufferedInputStream } = await import("../../../src/browser/helpers/cli.js"); + const handler = bufferedInputStream(new Uint8Array([1, 2, 3, 4, 5])); + + assert.deepStrictEqual(handler.blockingRead(3n), new Uint8Array([1, 2, 3])); + assert.deepStrictEqual(handler.blockingRead(10n), new Uint8Array([4, 5])); + let caught: unknown; + try { + handler.blockingRead(1n); + } catch (err) { + caught = err; + } + assert.deepStrictEqual(caught, { tag: "closed" }); + }); + + test("bufferedInputStream accepts a string and encodes it as UTF-8", async () => { + const { bufferedInputStream } = await import("../../../src/browser/helpers/cli.js"); + const handler = bufferedInputStream("hi"); + + assert.deepStrictEqual(handler.blockingRead(10n), new TextEncoder().encode("hi")); + assert.throws(() => handler.blockingRead(1n)); + }); + + test("bufferedInputStream reports closed immediately for empty input", async () => { + const { bufferedInputStream } = await import("../../../src/browser/helpers/cli.js"); + const handler = bufferedInputStream(new Uint8Array(0)); + + assert.throws(() => handler.blockingRead(1n)); + }); + + test("bufferedInputStream wires into createCli's stdin", async () => { + const { createCli } = await import("../../../src/browser/cli.js"); + const { bufferedInputStream } = await import("../../../src/browser/helpers/cli.js"); + const cli = createCli({ stdin: bufferedInputStream("hello") }); + const stdin = cli.stdin.getStdin(); + + assert.deepStrictEqual(stdin.blockingRead(5n), new TextEncoder().encode("hello")); + }); +});