Skip to content

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

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

Jake Bailey (jakebailey) wants to merge 26 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 a bit experimental, but this creates a wasip1 tsc.wasm build. This build works as a CLI, so one can run it anywhere and get the CLI, LSP server, etc. But, one can also instantiate the module and give it to the new TypeScript IPC-based API (including in the browser!) and get sync/async API access. This works similarly (though not the same) to David Sherret's ts-ast-viewer tsgo prototype. The dual-nature of this is enabled by some Wasm export + binaryen trickery to make one blob that can do both things.

In a perfect world, we'd be able to do wasip3 or something, component model, yadda yadda, but the main Go compiler cannot do that yet.

I think for now, this works alright. I think I'd also want to slap a "no warranty" label on this for now until we can get Wasm a bit more settled.

Some interesting tidbits:

  • This all is validated in playwright. Since we use node:test, I had copilot write some shims for those APIs to make it work. Smarter would be to use vitest in browser mode, I suspect. But this works and runs all but 20 tests succesfully.
  • Since this is wasip1, the host needs to provide that. For the browser, someone has to provide it; I used copilot to put together the absolute minimum wasi shim to make Go happy and that's included.
  • If the wasip1 package is installed and the native host OS does not have a build, the tsc CLI will fall back to using Node's wasi support, allowing "unsupported" platforms to work. Perhaps this is overkill.

TODO:

  • Figure out what to do about bundled files (lib.d.ts, localizations)
  • ???

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

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

The web runtime dependency cannot yet provide nonblocking stdin, and Windows response files and nightly fallback contain unresolved failures.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 High severity · 1 Medium severity

Open (3)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Translate absolute Windows response-file paths after @​ prefix

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

On Windows, absolute response-file arguments are not translated because path.isAbsolute("@C:\\args.rsp") is false. The compiler then strips @ and tries to open the unmapped host path, while this launcher has only preopened /mnt/..., so WASI fallback invocations using an absolute response file fail before parsing any options. Translate the path after the @ prefix as well.

Comment on lines +128 to +129
// `nonBlocking` is pending in https://github.com/microsoft/vscode-wasm/pull/298.
const stdin = { kind: "pipeIn" as const, nonBlocking: true };
Comment thread packages/vscode-typescript/src/extension.web.ts Outdated

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

🔵 Needs a closer look

WASM process closure is not propagated to the language client, skipped browser tests are executed, and callback allocation is excessively costly.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (2)
Previously missed (3)

In code that hasn't changed since last review

Medium severity Test skip options are discarded by the shim

packages/​typescript/​test/​browser/​harness.ts:70

TestOptions.skip is accepted by the shim but discarded here, so any imported node:test case using { skip: true } or a skip reason is executed and counted as passed/failed instead of skipped. Preserve the option on the registration and have the runner add it to results.skipped; the same propagation is needed for skipped describe blocks.

Medium severity Process exit does not trigger the reader close event

packages/​vscode-typescript/​src/​extension.web.ts:35

The reader never raises onClose when the WASM process exits. MessageReader.onClose is defined as the end-of-transport signal, but this implementation only subscribes to data and process.run() merely logs completion below. A server crash or normal exit therefore leaves the language client believing the connection is still open, which can prevent shutdown/restart handling. Wire process/stream completion to fireClose() (and ensure it fires once).

Medium severity Host callbacks allocate oversized buffers on every invocation

tsc/​cmd/​tsc/​wasmapi_wasip1.go:259

Every host callback allocates and zeroes a buffer larger than 16 MiB, even though module-resolution callbacks can run once per import. On WASM's constrained linear heap this creates substantial allocation/GC pressure and can make callback-heavy projects impractical. Reuse a non-reentrant scratch buffer (reentrancy is already rejected) or use a sized/retry protocol instead of allocating the fixed maximum for each call.

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

Clean validation misses the WASI build, the web extension depends on unavailable nonblocking stdin support, and callback promise detection is incomplete.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 2 Medium severity

Open (3)

Comment thread Herebyfile.mjs Outdated
Comment thread packages/typescript/src/api/async/transportClient.ts Outdated

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

WASI API stdin, Windows response-file paths, and callback-time transport closure have unresolved correctness issues.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 3 High severity

Open (3)
Resolved since last review (2)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Translate paths inside Windows response files

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

On Windows this translates the response-file name after @, but not the arguments subsequently read from that file. An .rsp containing an absolute C:\... input (or a nested absolute @...) reaches wasip1 unchanged while the drive is only mounted at /mnt/c, so the transparent fallback cannot resolve those paths. Response-file contents need equivalent host-to-guest path translation.

Medium severity Allow undefined in optional input API options

packages/​typescript/​test/​browser/​apiWrapper.ts:19

These input options proxy the public API options, which explicitly permit undefined, but this narrower shape rejects values such as { fs: undefined } under exactOptionalPropertyTypes and drives the conditional-spread workarounds below. Keep input-position optional properties | undefined so the wrapper preserves the API's accepted inputs.

Comment thread packages/typescript-wasip1-wasm/src/index.ts
Comment thread tsc/cmd/tsc/lsp_stdin_wasip1.go

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

The web LSP depends on an unavailable nonblocking-stdin implementation and can stall with the pinned WASI dependency.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 1 Low severity

Open (2)
Resolved since last review (2)

for the compiler, `--lsp --stdio`, or `--api`:

```sh
wasmtime run --dir=.::/ node_modules/@typescript/typescript-wasip1-wasm/dist/tsc.wasm --version

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