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 WASI host refuses to mount a folder over an existing mount, so a
multi-root workspace containing both a folder and one of its
descendants failed to start the language server at all. The outer
mount already exposes the nested folder's files.
VS Code serializes file URIs with a lowercase drive letter and
authority, and the server derives file names from that text. Mounting
at the URI's unserialized path left documents in a folder like
C:/Project outside the case-sensitive WASI mount, so the server could
not read unopened files there.
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.
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.
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.
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.
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.
This branch has not been deployed
No deployments
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.