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 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)
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.
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.
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.
Process exit does not trigger the reader close event
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).
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.
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.
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.
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.
wasmtime run --dir=.::/ node_modules/@typescript/typescript-wasip1-wasm/dist/tsc.wasm --version
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 a bit experimental, but this creates a wasip1
tsc.wasmbuild. 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 +binaryentrickery 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:
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.TODO: