feat(tsconnect/wasm): hand the bridge to JS through an init callback

The runtime published its bridge by setting globalThis.newIPN and the
loader read it back immediately after go.run(). That works only because
main() happens to reach the Set call before it blocks, so any package
init that waits on a channel or makes an async JS call would leave the
loader reading a global that is not there yet.

Take the name of a JS callback from go.env instead, and invoke it once
the bridge is built. Readiness is now the call itself, the runtime writes
nothing to the shared global scope, and two runtimes in one realm cannot
collide on a name.

The callback receives a factory that may be used once. shutdown() exits
the whole Go runtime, so a second IPN here would be torn down by the
first one's shutdown; an atomic guard rejects it rather than handing back
an instance that dies unpredictably.

The factory returns a promise, so failures building the engine, netstack,
or LocalBackend reject instead of calling log.Fatal and taking the
runtime down with no explanation for the caller.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 00:57:59 +00:00
co-authored by Claude
parent 7e9868f50e
commit 24ee15e524
+41 -12
View File
@@ -24,9 +24,11 @@ import (
"net/http"
"net/http/httptest"
"net/netip"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall/js"
"time"
@@ -64,21 +66,48 @@ import (
// ControlURL defines the URL to be used for connection to Control.
var ControlURL = ipn.DefaultControlURL
// initCallbackEnv names the JS global holding the callback that this runtime
// hands its bridge to. The loader generates a name, installs the callback under
// it, and passes the name in through go.env before starting the runtime.
//
// Publishing the bridge through a caller-supplied callback rather than a fixed
// global means the loader never has to guess when the Go scheduler has run far
// enough to expose it, and two runtimes in one JS realm cannot collide on the
// name.
const initCallbackEnv = "TSCONNECT_INIT_CALLBACK"
func main() {
name := os.Getenv(initCallbackEnv)
if name == "" {
log.Fatalf("%s is not set; this module must be loaded by @webnet/tsconnect", initCallbackEnv)
}
callback := js.Global().Get(name)
if callback.Type() != js.TypeFunction {
log.Fatalf("globalThis[%q] is not a function", name)
}
shutdownCh := make(chan struct{})
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Fatal("Usage: newIPN(config)")
return nil
}
return newIPN(args[0], shutdownCh)
var claimed atomic.Bool
callback.Invoke(js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
if len(args) != 1 {
return nil, errors.New("newIPN takes exactly one argument")
}
// One IPN per runtime: shutdown exits the shared Go runtime, so a
// second IPN here would be torn down by the first one's shutdown.
if !claimed.CompareAndSwap(false, true) {
return nil, errors.New("this WASM runtime already has an IPN; start another runtime instead")
}
return newIPN(args[0], shutdownCh)
})
}))
// Block until shutdown() is called on the IPN, then let main return so the
// Go runtime (and all its goroutines) can be collected by the JS engine.
<-shutdownCh
}
func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
func newIPN(jsConfig js.Value, shutdownCh chan struct{}) (map[string]any, error) {
netns.SetEnabled(false)
var store ipn.StateStore
@@ -133,13 +162,13 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
EventBus: sys.Bus.Get(),
})
if err != nil {
log.Fatal(err)
return nil, fmt.Errorf("wgengine.NewUserspaceEngine: %w", err)
}
sys.Set(eng)
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
if err != nil {
log.Fatalf("netstack.Create: %v", err)
return nil, fmt.Errorf("netstack.Create: %w", err)
}
sys.Set(ns)
ns.ProcessLocalIPs = true
@@ -179,10 +208,10 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
srv := ipnserver.New(logf, logid, sys.Bus.Get(), sys.NetMon.Get())
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral)
if err != nil {
log.Fatalf("ipnlocal.NewLocalBackend: %v", err)
return nil, fmt.Errorf("ipnlocal.NewLocalBackend: %w", err)
}
if err := ns.Start(lb); err != nil {
log.Fatalf("failed to start netstack: %v", err)
return nil, fmt.Errorf("starting netstack: %w", err)
}
wireTaildropFileOps(lb, jsConfig.Get("fileOps"))
srv.SetLocalBackend(lb)
@@ -377,7 +406,7 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
}),
}
wireDriveJS(jsIPN, driveFS, m)
return m
return m, nil
}
type jsIPN struct {