Skip to content

Add wasip1 support, enabling CLI, API, browser, sync/async - #64174

Draft
Jake Bailey (jakebailey) wants to merge 104 commits into
microsoft:mainfrom
jakebailey:wasip1
Draft

Jake Bailey (jakebailey) wants to merge 104 commits into
microsoft:mainfrom
jakebailey:wasip1

Conversation

@jakebailey

@jakebailey Jake Bailey (jakebailey) commented Sep 4, 2026 •

Copy link
Copy Markdown
Member

Fixes #63858
Fixes #63862
For #63813

This is still experimental, but it adds a wasip1/wasm build of tsc. The same tsc.wasm works two ways:

  • As a command. Run it under any WASI host to get the CLI, LSP server, and so on.
  • As a reactor. Instantiate it and hand it to the IPC-based JS API to get sync or async API access, including in the browser. This works similarly (though not identically) to David Sherret's ts-ast-viewer tsgo prototype.

The dual nature comes from some Wasm export and binaryen trickery that produces a single blob that can do both. In a perfect world we'd use wasip3 / the component model, but the main Go compiler can't do that yet.

This comes with a "no warranty" label for now: the wasip1 package README says it's experimental and that its APIs, behavior, and package layout may change without notice.

Packages

  • @typescript/typescript-wasip1-wasm is an artifact-only package: lib/tsc.wasm, the lib.*.d.ts files beside it (like every other platform package), and libFiles.json. Install it at the same exact version as the main package.
  • typescript/wasm in the main package owns the reactor host. It includes a minimal WASI shim, enough to make Go happy in browsers, and helpers (instantiateWasm / instantiateWasmSync) that produce a reactor instance for its WasmTransport. Custom transports can be plugged in through typescript/sync/transport and typescript/async/transport.
  • The API now has a single entry point for every platform.
    • Node modules are loaded lazily through process.getBuiltinModule, so typescript/sync and typescript/async load unbundled in a browser.
    • vscode-jsonrpc (CommonJS only) is loaded only when the API spawns or connects to a native server.
    • Spawning a native tsc from the API now needs Node.js 20.16+. Passing a transport works anywhere. engines stays at 16.20 for the CLI.
  • The tsc CLI falls back to Node's WASI when the wasip1 package is installed and there's no native build for the host. Perhaps overkill, but it makes "unsupported" platforms work.

VS Code for the Web

The extension now has a web entry point that runs tsc.wasm --lsp through ms-vscode.wasm-wasi-core. This needs non-blocking stdin pipes, from microsoft/vscode-wasm#298, which has now merged and requires wasm-wasi-core 1.0.4 or later.

  • Workspace folders are mounted into WASI. Non-file schemes are mapped to the same encoded paths the server uses for URIs.
  • The server is restarted when folders change, because WASI mounts are fixed.
  • The web entry honors js/ts.experimental.useTsgo and has the same onboarding and enable/disable commands as desktop.
  • The web VSIX is a separate build target.

Testing

  • test:api:wasm runs the sync and async API suites against the WASM reactor in Node.
  • test:api:browser-smoke uses Playwright to load the package in Chromium, both bundled and unbundled, and drives the API over the WASM transport.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

WASI LSP stdin busy-spins while idle, and process-backed API shutdown can hang during manually batched initialization.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds experimental WASI support for the TypeScript CLI and synchronous/asynchronous APIs across Node.js and browsers.

Changes:

  • Adds a dual CLI/reactor tsc.wasm build and minimal WASI host.
  • Introduces injectable API transports and browser-compatible clients.
  • Adds packaging, publishing, CI, and integration-test coverage.
File summaries
File Description
tsc/internal/ipc/timing.go Exposes server timing collection.
tsc/internal/api/wasmreactor/reactor.go Implements the in-process API reactor.
tsc/internal/api/wasmreactor/reactor_test.go Tests reactor requests and files.
tsc/cmd/tsc/wasmapi_wasip1.go Defines the WebAssembly reactor ABI.
tsc/cmd/tsc/sys.go Uses platform-aware working-directory lookup.
tsc/cmd/tsc/notifycontext.go Provides native signal handling.
tsc/cmd/tsc/notifycontext_wasip1.go Disables unsupported WASI signals.
tsc/cmd/tsc/main.go Uses platform-specific notification contexts.
tsc/cmd/tsc/lsp.go Makes LSP dependencies WASI-compatible.
tsc/cmd/tsc/lsp_stdin.go Defines native LSP stdin.
tsc/cmd/tsc/lsp_stdin_wasip1.go Adds WASI stdin handling.
tsc/cmd/tsc/lsp_process.go Defines native process integrations.
tsc/cmd/tsc/lsp_process_wasip1.go Disables unavailable WASI process features.
tsc/cmd/tsc/currentdirectory.go Defines native cwd lookup.
tsc/cmd/tsc/currentdirectory_wasip1.go Derives WASI cwd from PWD.
tsc/cmd/tsc/api.go Uses portable cwd and signal helpers.
tools/pipelines/typescript-publish.yml Publishes the WASI npm artifact.
packages/typescript/test/version.test.ts Tests CLI WASI fallback.
packages/typescript/test/sync/wasm.test.ts Tests CLI and API WebAssembly execution.
packages/typescript/test/sync/transport.test.ts Tests synchronous injected transports.
packages/typescript/test/encoder.test.ts Tests portable base64 encoding.
packages/typescript/test/browser/wasm.test.ts Tests WebAssembly APIs in Chromium.
packages/typescript/test/browser/syncAPI.ts Adapts sync API browser tests.
packages/typescript/test/browser/suite.test.ts Runs API suites in Chromium.
packages/typescript/test/browser/processShim.ts Provides browser process/timer shims.
packages/typescript/test/browser/harness.ts Implements the browser test harness.
packages/typescript/test/browser/bundle.test.ts Verifies browser-safe bundles.
packages/typescript/test/browser/asyncAPI.ts Adapts async API browser tests.
packages/typescript/test/browser/apiWrapper.ts Manages browser reactors and filesystem mirroring.
packages/typescript/test/async/transport.test.ts Tests asynchronous transport behavior.
packages/typescript/src/api/sync/transportClient.ts Implements sync protocol-over-transport.
packages/typescript/src/api/sync/transport.ts Defines the sync transport contract.
packages/typescript/src/api/sync/client.ts Supports injected sync transports.
packages/typescript/src/api/sync/browserClient.ts Adds the browser sync client.
packages/typescript/src/api/sync/api.ts Routes generated sync API through browser-aware clients.
packages/typescript/src/api/options.ts Adds transport-based API options.
packages/typescript/src/api/node/wtf8.ts Removes the Node buffer dependency.
packages/typescript/src/api/node/encoder.ts Adds portable base64 encoding.
packages/typescript/src/api/async/transportClient.ts Implements async protocol batching and lifecycle.
packages/typescript/src/api/async/transport.ts Defines the async transport contract.
packages/typescript/src/api/async/client.ts Supports injected async transports.
packages/typescript/src/api/async/browserClient.ts Adds the browser async client.
packages/typescript/src/api/async/api.ts Selects browser-aware clients and adjusts closing.
packages/typescript/package.json Exports transports and browser clients.
packages/typescript/lib/tsc.js Falls back to WASI when native binaries are unavailable.
packages/typescript/lib/getExePath.js Resolves the WASI package artifact.
packages/typescript/lib/getExePath.d.ts Declares WASI path resolution.
packages/typescript-wasip1-wasm/tsconfig.json Configures the WASI helper package.
packages/typescript-wasip1-wasm/src/wasmURL.ts Exposes the packaged module URL.
packages/typescript-wasip1-wasm/src/wasmURL.source.ts Exposes the source-build module URL.
packages/typescript-wasip1-wasm/src/wasi.ts Implements the browser WASI host.
packages/typescript-wasip1-wasm/src/index.ts Implements and exports WasmTransport.
packages/typescript-wasip1-wasm/README.md Documents CLI and API usage.
packages/typescript-wasip1-wasm/package.json Defines the WASI npm package.
package.json Adds Binaryen tooling.
package-lock.json Locks new packages and workspace metadata.
Herebyfile.mjs Builds, patches, tests, and packages WebAssembly.
.github/workflows/ci.yml Runs Chromium API tests in CI.
Review details
  • Files reviewed: 56/58 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread tsc/cmd/tsc/lsp_stdin_wasip1.go Outdated
Comment thread packages/typescript/src/api/async/api.ts
@NullVoxPopuli-ai-agent

tried this out in a browser worker (Chrome), running tsc --lsp --stdio from the wasip1 build, with @bjorn3/browser_wasi_shim as the host (in-memory fs, stdin backed by a SharedArrayBuffer ring, stdout parsed for LSP frames).

what works:

  • initialize answers in ~100ms
  • textDocument/diagnostic for a .ts file comes back correct, in under a second

two things I hit on the way, in case they're useful for the README:

  • stdin has to return EAGAIN when it's empty. a blocking fd_read parks every goroutine, so the writer never gets to finish the first response and the server looks dead. (your wasiStdin loop already expects this, it just took me a bit to see why)
  • in a browser that means the page needs COOP/COEP so stdin can be a SharedArrayBuffer. fine for us, but worth a line in the docs

what doesn't work: content mappers. getLSPSpawn() is nil on wasip1 and the reactor has no spawner, so a .gts file gets:

TS100025 The content mapper 'ember-content-mapper' failed to transform this file.

which is the one thing we need for templates 😅

here's a patch that closes that gap with only standard WASI imports -- same idea as hostWriteFileFD, but as a path instead of a reserved fd:

  • the module opens /.typescript/host-process
  • writes one JSON line: { "command": [...], "dir": "..." }
  • then speaks the normal mapper protocol over that descriptor, polling reads like stdin does
  • if the host doesn't provide the path, the open fails and you get today's behavior
--- a/tsc/cmd/tsc/lsp_process_wasip1.go
+++ b/tsc/cmd/tsc/lsp_process_wasip1.go
@@ -5,6 +5,11 @@ package main
 import (
 	"errors"
+	"fmt"
 	"io"
+	"runtime"
+	"syscall"
+
+	"github.com/microsoft/TypeScript/tsc/internal/json"
 )

 func getNpmInstall() func(cwd string, args []string) ([]byte, error) {
@@ -13,6 +18,71 @@ func getNpmInstall() func(cwd string, args []string) ([]byte, error) {
 	}
 }

+// hostProcessPath is a device the WASI host may provide so that content mappers can run.
+// A wasm module cannot start processes, so the host runs the mapper itself: the module opens
+// this path, writes one JSON line naming the command and directory, and then speaks the
+// mapper's stdio protocol over the descriptor. Hosts that do not provide the path get the
+// usual "content mappers are unsupported" behavior, because opening it fails.
+const hostProcessPath = "/.typescript/host-process"
+
 func getLSPSpawn() func(command []string, dir string, stderr io.Writer) (io.ReadWriteCloser, error) {
-	return nil
+	return spawnHostProcess
+}
+
+type hostProcessHeader struct {
+	Command []string `json:"command"`
+	Dir     string   `json:"dir"`
+}
+
+func spawnHostProcess(command []string, dir string, stderr io.Writer) (io.ReadWriteCloser, error) {
+	fd, err := syscall.Open(hostProcessPath, syscall.O_RDWR, 0)
+	if err != nil {
+		return nil, fmt.Errorf("the host does not provide %s: %w", hostProcessPath, err)
+	}
+	header, err := json.Marshal(hostProcessHeader{Command: command, Dir: dir})
+	if err != nil {
+		_ = syscall.Close(fd)
+		return nil, err
+	}
+	p := &hostProcess{fd: fd}
+	if _, err := p.Write(append(header, '\n')); err != nil {
+		_ = p.Close()
+		return nil, err
+	}
+	return p, nil
+}
+
+// hostProcess adapts the device descriptor to an io.ReadWriteCloser. Reads poll, like the LSP's
+// stdin does, because a blocking host call would park every goroutine in the module.
+type hostProcess struct {
+	fd int
+}
+
+func (p *hostProcess) Read(b []byte) (int, error) {
+	for {
+		n, err := syscall.Read(p.fd, b)
+		if n == 0 && err == nil {
+			return 0, io.EOF
+		}
+		if err != syscall.EAGAIN {
+			return n, err
+		}
+		runtime.Gosched()
+	}
+}
+
+func (p *hostProcess) Write(b []byte) (int, error) {
+	written := 0
+	for written < len(b) {
+		n, err := syscall.Write(p.fd, b[written:])
+		if err != nil {
+			return written, err
+		}
+		written += n
+	}
+	return written, nil
+}
+
+func (p *hostProcess) Close() error {
+	return syscall.Close(p.fd)
 }

on the host side that's a device whose fd_write parses the header line, starts the mapper (for us: ember-content-mapper's request handlers, bundled into the same worker), and answers each request synchronously -- it has to be sync, the module holds the thread. responses queue up for fd_read.

with that, the .gts file gets its diagnostics, mapped back to template positions:

4:4   TS2322 Type 'number' is not assignable to type 'string'.
8:33  TS2339 Property 'nope' does not exist on type 'Counter'.

commit: NullVoxPopuli-ai-agent@304e189

the consumer this is for is the REPL at https://limber.glimdown.com -- prototype here: NullVoxPopuli/limber#2255. that one currently runs a GOOS=js build (a globalThis hook for spawn instead of a device) because it exists today, but the host side is the same, so swapping to the wasip1 package once spawn is possible is a contained change.

happy to open a PR against your branch if the shape works for you.

(posted from my agent account; the testing and the patch were done with AI assistance)

@NullVoxPopuli

Copy link
Copy Markdown

apologies if this is way off base. I literally don't know golang :(

@jakebailey

Copy link
Copy Markdown
Member Author

Thanks for the feedback; it's alright to not know Go, just trying it in a real project (agent or not) is good!

@jakebailey

Copy link
Copy Markdown
Member Author

When I started this work, content mappers didn't exist, so I'm not surprised that it's not factored in yet. I don't know what "execing" looks like, nor how an existing tsconfig would have any hope of working out of the box without node.

@NullVoxPopuli

Copy link
Copy Markdown

one thing that's suprised me so far is how the globalThis.fs interface is expected to function. I don't know if there are any standards around this, but it "would be cool", if fs access (or rather, the way of accessing my virtual FS) could be options passed somehow. idk. maybe a global mock fs is the best way -- I only have my own REPL to pull experience from, and I try to not have giant deps / polyfills when possible (tho, I understand the ts wasm file is much bigger than all my other assets combined lol <3)

@NullVoxPopuli

NullVoxPopuli commented Sep 16, 2026 •

Copy link
Copy Markdown

well, it works~ish
image

image

Add an injectable synchronous transport and a browser-conditioned client for
the TypeScript API. Provide a Go WebAssembly reactor backed by an in-memory
filesystem and exercise it through the compiler and checker APIs.

Publish the reactor and its transport separately as @typescript/api-wasm,
with release-versioned builds and ordered npm publishing. Keep the main
TypeScript package free of the WebAssembly binary and verify its browser
module graph with an esbuild regression test.
Reuse the existing async and sync API suites in Playwright with narrow browser shims and explicit host-only exclusions.

Add WASI scheduler polling and callback-backed output writes so WasmTransport preserves native filesystem behavior.
Do not clear filesystem callbacks configured directly on a transport when the API is constructed without its own filesystem.

Assert that the browser harness accounts for all 669 API tests and cover WASM emit callbacks through the public transport setup.
Keep an active transport's filesystem callback intact when a second transport fails to create a session on the same reactor instance.
Close a newly created reactor session when filesystem callback attachment fails so the instance remains reusable.
Build the compiler, language server, and in-process API from one WASI module. Add the Node fallback, platform-specific runtime behavior, and release packaging for the unified artifact.
Return io.EOF when WASI stdin closes instead of triggering repeated
empty reads. Cover the command lifecycle in the unified module test.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Critical callback contract and reentrancy failures, plus unresolved lifecycle, compatibility, path translation, polling, and allocation issues, block approval.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 3 High severity · 5 Medium severity

Open (8)

Comment thread packages/typescript-wasip1-wasm/src/index.ts Outdated
Comment thread packages/typescript/src/api/async/transportClient.ts Outdated
Comment thread packages/typescript-wasip1-wasm/src/index.ts Outdated
Comment thread packages/typescript-wasip1-wasm/src/wasi.ts Outdated
Comment thread packages/typescript/lib/tsc.js Outdated
Comment thread packages/typescript/src/api/options.ts Outdated
The API client is meant to work outside Node when given a transport,
but its option handling, filesystem helpers, timing dumps and sync
channel imported Node modules at load time, so importing any of them
outside Node failed before a transport could be used. Load Node modules
through process.getBuiltinModule only when spawning or connecting.

getExePath now receives the Node modules it uses instead of importing
them, so the CLI can keep passing static imports on older Node versions
while the API passes modules obtained at runtime.
vscode-jsonrpc's Node entry point requires Node modules when it loads,
so even a lazy import of it makes bundlers targeting browsers fail.
Use the library's environment-neutral core and provide the small Node
runtime layer and stream adapters the async client needs.
With Node modules loaded only when spawning or connecting, the Node
client works anywhere a transport is supplied, so the browser-specific
client variants and the package imports that chose between them are no
longer needed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Native API runtime compatibility and web extension enablement handling remain unresolved.

2 open issues
1 resolved since last review

Review effort: Balanced

Comment on lines +10 to +11
if (typeof process?.getBuiltinModule !== "function") {
throw new Error("Starting or connecting to a TypeScript API server requires Node.js 20.16 or later; in other environments, provide a transport.");
Comment thread packages/vscode-typescript/src/extension.web.ts Outdated
vscode-jsonrpc ships only CommonJS, which browsers cannot import as
modules, so loading the async API directly from its built files failed
even when a transport was injected. Load the adapter only when the
client spawns or connects to a server, which only happens in Node.

The browser tests bundled the API, which hid this; also load the built
entries directly from a server rooted at the package.
The server escapes a leading segment only when it is exactly a drive
letter and colon, so authorities such as x:8080 were escaped on the
client but not on the server, and the workspace mount pointed somewhere
the server never reads.
Closing the API more than once, or combining close() with await using,
would close the injected transport again. Transports are not required
to tolerate that, and a second close could tear down a session another
caller is still awaiting.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The documented reactor initialization call is incompatible with the module’s exports.

3 open issues

Review effort: Balanced

Comment thread packages/typescript/src/wasm/transport.ts Outdated
The web reader copied everything buffered so far on every stdout chunk,
so a large response arriving in small pieces cost time quadratic in its
size. Large responses such as full-file semantic tokens or workspace
symbols are common enough for this to stall the editor.
The web extension needs the same enablement logic as the desktop
extension, but util imports Node modules that cannot be bundled for the
browser. Keeping the setting helpers in their own module lets both
entry points share them.
The web extension started its language server unconditionally, so it
ran alongside the built-in TypeScript support even when TypeScript 7
was disabled, and the enable and disable commands were missing. It now
onboards, starts, stops and restarts the same way as the desktop
extension.
The published declarations must not depend on Node types, since API
consumers outside Node may not have them, and the release build rejects
external imports in .d.ts files.
The package still installs on older Node.js for the compiler CLI, but
the API's default transport relies on process.getBuiltinModule, which
only exists from Node.js 20.16.
The API exports no longer use an unstable/ prefix, so the WASM host and
transport exports added here should not reintroduce it.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Cross-runtime integration and unresolved correctness issues require fixes and final human validation.

3 open issues
2 previously missed issues

In code that hasn't changed since last review

Medium severity Preopen the current Windows drive at / for WASI paths

packages/​typescript/​lib/​tsc.js:86

On Windows, a current-drive-rooted path such as /src/input.ts remains unchanged by toOSPath, but this branch only preopens /mnt/<letter>. Inputs, response files, and output directories using a single leading slash therefore become inaccessible in the WASI fallback. Also preopen the current drive at / so these paths retain their Windows meaning.

Medium severity Cancel and clean up child processes in stdin tests

packages/​typescript/​test/​sync/​wasm.test.ts:171

Both stdin tests spawn children without cancellation or cleanup. If LSP initialization or API shutdown hangs, the 10-second test timeout leaves the child and its pipes open; the LSP polling loop also keeps scheduling timers. This can keep the test runner alive after the test times out. In both tests, use the test context to terminate and reap the child on cancellation/cleanup, and make the LSP polling loop stop on cancellation.

1 resolved since last review

Review effort: Balanced

Comment thread tsc/cmd/tsc/wasmapi_wasip1.go
The documented wasi.initialize(instance) call cannot work: the module
exports _start for the command line and typescript_initialize for the
reactor, and Node's WASI rejects modules shaped that way.
Host callback responses were returned as views into a shared buffer,
and a large result is fetched in a second exchange through the host's
single pending slot. Concurrent goroutines in one request, such as file
loading and module resolution, could overwrite a response before its
caller decoded it, or take another call's pending result.
The server encodes a URI authority as a final path segment, keeping its
extension outside the escape. The web mount escaped it whole, so
workspaces whose authority needs escaping were mounted at a path the
server never reads.
Disposing the extension only terminated the process that already
existed. A start or restart still waiting for the WASI runtime would
then create a server after disposal and leave it running.
The web extension cannot load session.ts or the Node language client,
but output focus, import source actions, code lens locations, and
source definition only need a running language server.
The web VSIX kept every desktop command contribution but registered
only enable and disable, so the rest failed once the server started.
Register the commands that work in the browser, and keep the commands
that need a local executable or Node out of the web manifest.
After a crash the language client restarts itself, and it refuses to
stop while that start is in progress or after it fails. Restarting
then gave up before starting a new server, leaving the extension
without its commands or a way to recover short of reloading.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Author: Team For Milestone Bug PRs that fix a bug with a specific milestone

Projects

None yet

4 participants