You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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'.
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)
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.
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)
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.
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.
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.
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.
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.
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.
Cancel and clean up child processes in stdin tests
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.
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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #63858
Fixes #63862
For #63813
This is still experimental, but it adds a
wasip1/wasmbuild oftsc. The sametsc.wasmworks two ways:The dual nature comes from some Wasm export and
binaryentrickery 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-wasmis an artifact-only package:lib/tsc.wasm, thelib.*.d.tsfiles beside it (like every other platform package), andlibFiles.json. Install it at the same exact version as the main package.typescript/wasmin 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 itsWasmTransport. Custom transports can be plugged in throughtypescript/sync/transportandtypescript/async/transport.process.getBuiltinModule, sotypescript/syncandtypescript/asyncload unbundled in a browser.tscfrom the API now needs Node.js 20.16+. Passing a transport works anywhere.enginesstays at 16.20 for the CLI.tscCLI 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 --lspthroughms-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.fileschemes are mapped to the same encoded paths the server uses for URIs.js/ts.experimental.useTsgoand has the same onboarding and enable/disable commands as desktop.Testing
test:api:wasmruns the sync and async API suites against the WASM reactor in Node.test:api:browser-smokeuses Playwright to load the package in Chromium, both bundled and unbundled, and drives the API over the WASM transport.