Skip to content

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

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

Jake Bailey (jakebailey) wants to merge 149 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: tsc.wasm, the wasip1 package, and the typescript/wasm host are all marked experimental and 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.

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 runtime, browser-hosting, and publishing changes require end-to-end validation and final human review before approval.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Avoid byte-by-byte scans on long encoded strings

packages/​typescript/​src/​api/​node/​wtf8.ts:33

This adds a byte-by-byte JavaScript scan to every encoded string longer than 64 characters, including source text passed to WasmTransport.setFile and strings in the AST encoder. In a standalone check of 80 encodes of 1.1 MB of ASCII source, encoding plus this scan took 93–132 ms, versus 52–58 ms when using Uint8Array.indexOf to find candidate 0xEF bytes. Please jump between those candidates and inspect only their following bytes to preserve browser compatibility without this common-path encoding overhead.

Review effort: Balanced

Every encoded string longer than 64 characters, including whole source
files, is checked for U+FFFD. Walking the bytes in JavaScript made that
check several times slower than the Buffer search it replaced; jumping
between native indexOf hits keeps it browser compatible without the
cost.
The platform cannot gain toBase64, fromBase64, or Buffer while running,
so checking for them on every call only added work and repeated the
Buffer lookup in two places.

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-runtime interface, WASI scheduling, web-server lifecycle, and publication changes need end-to-end validation and final human review.

No open issues 🎉

Review effort: Balanced

Several tests repeated coverage that other tests already provide, mocked
the reactor protocol instead of running it, or checked internal state
and error message wording. Folding the close scenarios together and
dropping those checks keeps the regressions covered with less to
maintain.

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 execution, browser lifecycle, and publishing changes require successful runtime and packaging validation plus final human review.

No open issues 🎉

Review effort: Balanced

/** One final snapshot per client, written before its connection is closed. */
export class TimingDump {
private readonly instanceId = randomUUID();
private readonly instanceId = nodeProcess().getBuiltinModule("node:crypto").randomUUID();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Arguably this could be the crypto global but we're a bit wishy washy on versions here

npx tsgo --help
```

The JavaScript API requires Node.js 20.16 or later when it starts the compiler itself. In other environments, provide a transport.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We need to discuss which version to have here. 16? 20? engines match?

The WASI package has no dependency relationship with the main package,
so it never needed its own publish stage, artifact, and approval. It
belongs with the platform packages instead: if it fails to publish, the
main package should not ship pointing users at a tsc.wasm that does not
exist.
The small platform overrides were spread across four file pairs,
which made the WebAssembly surface of the command look larger than
it is. Keeping them in one pair puts every difference side by side.
These tests were skipped only because the reactor lists directories
in sorted order while the virtual test file system preserves insertion
order. The file list order is not what they are checking.

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-runtime interface, WASI scheduling, and web-server lifecycle require human review backed by cross-platform integration results.

No open issues 🎉

Review effort: Balanced

The synchronous protocol is a sequence of [type, method, payload]
tuples, and only the process transport needs them framed as
MessagePack. Splitting the two lets an in-process host exchange the
same tuples directly while reusing the protocol's request, call, and
response mapping.
An embedded server needs the same session, callback file system, and
connection handling as the process server, but over an in-memory file
system and without a pipe. Accepting both as options keeps a single
server setup for every host.
The async client already lets module resolution callbacks return
promises, but injected transports rejected them outright. A transport
that can wait for the client, as a separate server process does,
should be able to accept them; transports that cannot remain free to
reject them.
The reactor called back into JavaScript synchronously through a
tunneled fd_write, so callbacks could not return promises, could not
make nested requests, and only writeFile could be replaced. That made
the WebAssembly API a weaker contract than the same client talking to
a server process.

The reactor now runs the ordinary synchronous server on a goroutine
and exposes its messages through send and receive exports. The
transport drives it with a stack of requests and callbacks, just as a
client reads from a server pipe, so every callback the process server
supports works the same way, including asynchronous and re-entrant
ones. Receiving only runs Go until its next message and reports when
the server is waiting instead, which keeps the single-threaded runtime
from deadlocking.
The WebAssembly transport now supports every file system and module
resolution callback, so the test harness passes callbacks through
instead of mirroring files into the reactor, and only the tests that
need the real disk remain skipped.
The remaining skips hid real gaps. The roundtrip test reads fixtures
that a server process finds on its own disk, but the reactor's base
file system is in memory, and the AST benchmark spawned a native
server, so it never exercised WebAssembly at all.

The test harness now sends whatever a server process would read or
write on its disk to the disk through callbacks, leaving the embedded
libraries to the reactor, and the benchmark uses the same API
selection as the other tests.

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

Coordinated WASM startup, reentrant transport, and web extension lifecycle changes require human review backed by cross-runtime validation.

1 open issue

Review effort: Balanced

Comment thread packages/typescript/test/sync/wasm.test.ts Outdated
The web VSIX build rewrote package.json against a hardcoded command
allowlist, which had to be kept in sync with the extension by hand.
VS Code already exposes isWeb as a context key, so the manifest can
hide the commands the web extension never registers on its own.
A hung server would otherwise outlive a timed-out test, leaving the
child process, its pipes and the LSP polling loop running and keeping
the test runner from exiting.

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-runtime interface, Go scheduler interactions, and browser language-server lifecycle need human review backed by successful end-to-end validation.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

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