Skip to content

Implement sync API without NAPI#2716

Merged
jakebailey merged 17 commits intomainfrom
jabaile/sync-api-no-libsyncrpc
Feb 18, 2026
Merged

Implement sync API without NAPI#2716
jakebailey merged 17 commits intomainfrom
jabaile/sync-api-no-libsyncrpc

Conversation

@jakebailey
Copy link
Copy Markdown
Member

This is something I've wanted to try for a while, but was always unsuccessful at. This time, it finally works.

For our sync API, we use a napi-rs module to do what Node largely can't; synchronous communication to a child process. This is good and all, but complicates things because in addition to publishing a multi-platform package with Go, we also have to do that with Rust. That and, we all need to have rust installed, the package changes integrity every install, etc.

I've long wanted to try using Node's fs.writeSync / fs.readSync on raw file descriptors to do the same thing, in pure JS. Most of my attempts failed, or were seemingly slow.

However, this attempt did not seem to fail (at least on my machine). A summary copied from a temporarily-committed markdown file:

Benchmark libsyncrpc Sync JS (new) Async JS Sync JS vs libsyncrpc Async JS vs libsyncrpc
Spawn 308 µs 8.6 ms 2.6 µs 28× slower 118× faster
Load project 64 µs 58 µs 110 ms 0.9× (faster) 1719× slower
Transfer debug.ts 1.6 ms 1.3 ms 29.7 ms 0.8× (faster) 18.6× slower
Transfer program.ts 5.8 ms 4.7 ms 100.8 ms 0.8× (faster) 17.4× slower
Transfer checker.ts 74.7 ms 58.2 ms 1240 ms 0.8× (faster) 16.6× slower
Materialize program.ts 2.7 ms 2.6 ms 2.5 ms 1.0× (~equal) 0.9× (~equal)
Materialize checker.ts 73.1 ms 72.2 ms 73.7 ms 1.0× (~equal) 1.0× (~equal)
getSymbolAtPosition (1) 13.2 µs 15.5 µs 43.6 µs 1.2× slower 3.3× slower
getSymbolAtPosition (10153, batched) 87.4 ms 84.9 ms 87.5 ms 1.0× (~equal) 1.0× (~equal)
getSymbolAtLocation (10153) 406 ms 442 ms 798 ms 1.1× slower 2.0× slower
getSymbolAtLocation (10153, batched) 282 ms 267 ms 278 ms 0.9× (faster) 1.0× (~equal)
TS baseline (load project) 886 ms 898 ms 910 ms
TS baseline (getSymbol 10153) 25.2 ms 25.2 ms 26.9 ms

The pure JS version is effectively the same as going through the native code, except for startup time.

The async code looks mega fast due the fact that it can async start a process and then you can get a response later, but it's not like it's doing less work, so I don't know if that is actually a downside. But clearly, Node has a huge overhead for spawning a process that a NAPI extension does not need.

@jakebailey
Copy link
Copy Markdown
Member Author

Seemingly this does not work on Windows, which hangs. I didn't exactly test there, so, fair.

Comment thread _packages/api/src/syncChannel.ts Outdated
Comment on lines +190 to +191
stdout._handle.setBlocking?.(true);
stdin._handle.setBlocking?.(true);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may look sketchy, but this is what tsc has done all along, in fact

@jakebailey jakebailey force-pushed the jabaile/sync-api-no-libsyncrpc branch from 24c623b to 826838f Compare February 12, 2026 18:41
@jakebailey jakebailey force-pushed the jabaile/sync-api-no-libsyncrpc branch from 826838f to 204ce2a Compare February 12, 2026 18:41
@jakebailey jakebailey marked this pull request as ready for review February 12, 2026 18:54
Copilot AI review requested due to automatic review settings February 12, 2026 18:54
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR removes the Rust/N-API-based @typescript/libsyncrpc dependency for the synchronous JS API by implementing a pure-JS synchronous RPC channel (using fs.readSync/fs.writeSync on raw fds) and adding a pipe/socket transport option on the Go API server (used on Windows).

Changes:

  • Replace @typescript/libsyncrpc with a pure JS SyncRpcChannel implementation and wire it into the API client.
  • Add --pipe support to the tsgo --api server so clients can connect over a named pipe (Windows) / Unix domain socket instead of stdio.
  • Remove Rust requirements from docs, devcontainer, and CI workflows; adjust benchmark iteration settings to reduce runtime.

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
package-lock.json Removes @typescript/libsyncrpc from the lockfile.
internal/api/server.go Adds PipePath option and chooses pipe vs stdio transport at runtime.
cmd/tsgo/api.go Adds --pipe flag and configures server options accordingly.
_packages/api/src/syncChannel.ts New pure-JS synchronous RPC implementation replacing the native addon.
_packages/api/src/client.ts Switches sync client to use the new local SyncRpcChannel.
_packages/api/package.json Drops dependency on @typescript/libsyncrpc.
_packages/api/test/api.sync.bench.ts Reduces benchmark iterations/warmup to shorten runtime.
_packages/api/test/api.async.bench.ts Same benchmark iteration/warmup reduction for async benches.
CONTRIBUTING.md Removes Rust requirement from contributor setup docs.
.github/workflows/create-cache.yml Removes Rust toolchain setup step.
.github/workflows/copilot-setup-steps.yml Removes Rust toolchain setup step.
.github/workflows/ci.yml Removes Rust toolchain setup steps from CI jobs.
.devcontainer/devcontainer.json Removes Rust devcontainer feature.

Comment thread internal/api/server.go
Comment on lines +60 to +63
} else {
t := NewStdioTransport(s.options.In, s.options.Out)
defer t.Close()
transport = t
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When PipePath is empty, this branch constructs a StdioTransport even if options.In/options.Out are nil. That will lead to a nil Reader/Writer and a later panic or confusing I/O error. Consider validating at startup that either PipePath is set OR both In and Out are non-nil, and return a clear error (or panic) if the configuration is invalid.

Copilot uses AI. Check for mistakes.
Comment thread internal/api/server.go
Comment on lines +53 to +56
if s.options.PipePath != "" {
t, err := NewPipeTransport(s.options.PipePath)
if err != nil {
return fmt.Errorf("failed to create pipe transport: %w", err)
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security/data-loss risk: on Unix, NewPipeTransport ultimately calls newPipeListener which unconditionally os.Remove()s the provided path before listening. Now that PipePath is user-controllable (via --pipe), passing an arbitrary path could delete a regular file. Consider only removing an existing file if it is a Unix socket (or refusing to overwrite non-socket paths), and ideally clean up the socket file on shutdown as well.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not a security boundary; if you can run tsgo with weird args, you can run rm with bad args

Comment on lines +134 to +137
const pipePath = `\\\\.\\pipe\\tsgo-sync-${process.pid}-${Date.now()}`;
this.child = spawn(exe, [...args, "--pipe", pipePath], {
stdio: ["ignore", "ignore", "inherit"],
});
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spawn() can emit an 'error' event (e.g. ENOENT when the executable path is wrong). With no listener attached, Node will treat it as an unhandled 'error' event and crash the process. Consider adding a one-time 'error' handler that captures the spawn failure and surfaces it as a regular thrown error to the caller (and/or closes the channel).

Copilot uses AI. Check for mistakes.
Comment on lines +146 to +150
catch {
if (this.child.exitCode !== null) {
throw new Error(
`Child process exited with code ${this.child.exitCode} before pipe was ready`,
);
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Windows pipe connect loop retries openSync for up to ~5s, but the bare catch {} swallows all errors (including permission/path-format errors) and will eventually report a generic timeout. Capturing the exception and only retrying on the expected transient errors (and surfacing others immediately) would make failures much easier to diagnose.

Copilot uses AI. Check for mistakes.
@jakebailey jakebailey added this pull request to the merge queue Feb 18, 2026
Merged via the queue into main with commit 940ae9f Feb 18, 2026
20 checks passed
@jakebailey jakebailey deleted the jabaile/sync-api-no-libsyncrpc branch February 18, 2026 02:41
Copilot AI pushed a commit that referenced this pull request Feb 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants