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: 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.
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 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
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.
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.
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.
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.
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.
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.
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.
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:
tsc.wasm, the wasip1 package, and thetypescript/wasmhost are all marked experimental and 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.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.