Skip to content

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

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

Jake Bailey (jakebailey) wants to merge 132 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.

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.

This comment was marked as resolved.

@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.

This comment was marked as resolved.

Dynamic URI paths and, without drive mapping, drive-rooted paths are
relative in WASI and resolve against the working directory, where the
browser host mounts workspaces. Realpath made them absolute, so a
dependency under ^/~ts-uri~/memfs/... came back as /^/~ts-uri~/...,
which the compiler treats as a different, POSIX file. Navigation into
dependencies then produced file: URIs instead of the workspace's own.
Each export that touches the session repeated the same re-entrancy,
session, length, and panic handling, which made it easy for one of them
to drift. Also drop an unused pointer helper.
Both host exchanges decode the error region the same way but clamped
its length differently, which invited the two to diverge.

This comment was marked as resolved.

The response wrapper carried a single field, and the only caller of
Close already clears its reference first, so neither earned its
indirection.
Stdin is its only caller, so a separate file and descriptor parameter
only spread one loop across two places.
The sync and async transport options differ only in their transport, so
the shared fields and the type guard no longer need to be duplicated.
The options are always provided by the constructor, so treating them as
optional only added unreachable checks.
An injected transport is already connected when it is handed to the
client, so tracking a separate connected state only duplicated the
closed checks.
Closing a freshly created session only fails if the module itself has
trapped, which is not worth a dedicated aggregate error.
The callback and writeFile channels wrote errors back to the module in
the same way, and stdout and stderr can be decoded chunk by chunk without
first copying them into a single buffer.
Open files and file change notifications mirror a file into the reactor
in exactly the same way.
Hosts without Uint8Array.prototype.toBase64 or Buffer still have btoa,
so a hand-written base64 encoder is unnecessary.
The skipped test no longer exists.
The package input can be copied in one step, a few package checks were
already implied by stronger ones, and both VSIX targets need the same
package inputs.
Running check:herebyfile from validate is unrelated to WASI support and
can be proposed separately.

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

The cross-platform runtime, web language server, and publishing changes require integration validation and final human review.

1 open issue
1 previously missed issue

In code that hasn't changed since last review

Medium severity Preserve root slash in workspace URI path handling

packages/​vscode-typescript/​src/​webMountPoint.ts:82

A workspace rooted at memfs:/ round-trips through its mount point as memfs:. toWorkspaceUri then forwards root stat and readDirectory calls with an empty path instead of /, breaking providers that distinguish those paths. Preserve / when the decoded path contains one empty segment, while keeping a genuinely absent path empty. Add a memfs:/ round-trip test.

Review effort: Balanced

The test fails on Windows with only an exit code, which does not say
what the compiler rejected.
Node's WASI implementation cannot read directories on Windows, so the
default include pattern found no inputs there. Module resolution still
realpaths the dependency, which is what the test covers.
Node's WASI implementation is experimental, needs recent Node versions,
and cannot list directories on Windows, so it is not a dependable way
to run the compiler. Native binaries cover the supported platforms; a
fallback can come back later with a host we control.
Mapping drive letters to /mnt/<letter> preopens existed only for the
Node CLI fallback on Windows, which is gone. The remaining hosts run
with drive-rooted and dynamic paths resolved against the working
directory.

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-platform execution, browser lifecycle, and publishing changes require human integration review alongside resolution of the retained findings.

2 open issues
1 previously missed issue

In code that hasn't changed since last review

Medium severity Avoid extra copy when decoding generator responses

packages/​typescript/​src/​api/​node/​encoder.ts:412

On Node.js runtimes without Uint8Array.fromBase64, this copies the decoded Buffer into a second full-size allocation. Generator-based syntax-tree responses use this helper, so large responses incur an extra copy and temporarily need twice the decoded storage. Return a Uint8Array view over the Buffer's backing storage to preserve the previous zero-copy behavior.

Review effort: Balanced

}

const encoder = new TextEncoder();
const decoder = new TextDecoder();

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