diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index bc5ed3e80..1778e597e 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -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 {