refactor(tsconnect): move the fork's own TS onto the init callback

build-pkg runs tsc and dts-bundle-generator over src/, so the demo app
and the package entry point have to follow the runtime off the global
factory or the wasm build stops working.

Both now start the runtime through a shared startIPN helper, which
installs the callback, passes its name in through go.env, and races
readiness against the runtime exiting so a startup crash rejects instead
of hanging. The panic handler is wired to that exit rather than being
attached to a floating go.run() promise.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 01:01:41 +00:00
co-authored by Claude
parent c98a03dfa5
commit 7ca658b028
4 changed files with 61 additions and 20 deletions
+16 -13
View File
@@ -5,6 +5,7 @@ import "../wasm_exec"
import wasmUrl from "./main.wasm"
import { sessionStateStorage } from "../lib/js-state-store"
import { renderApp } from "./app"
import { startIPN } from "../lib/start-ipn"
async function main() {
const app = await renderApp()
@@ -13,23 +14,25 @@ async function main() {
fetch(`./dist/${wasmUrl}`),
go.importObject
)
// The Go process should never exit, if it does then it's an unhandled panic.
go.run(wasmInstance.instance).then(() =>
app.handleGoPanic("Unexpected shutdown")
)
const params = new URLSearchParams(window.location.search)
const authKey = params.get("authkey") ?? undefined
const ipn = newIPN({
// Persist IPN state in sessionStorage in development, so that we don't need
// to re-authorize every time we reload the page.
stateStorage: DEBUG ? sessionStateStorage : undefined,
// authKey allows for an auth key to be
// specified as a url param which automatically
// authorizes the client for use.
authKey: DEBUG ? authKey : undefined,
})
// The Go process should never exit, if it does then it's an unhandled panic.
const ipn = await startIPN(
go,
wasmInstance.instance,
{
// Persist IPN state in sessionStorage in development, so that we don't
// need to re-authorize every time we reload the page.
stateStorage: DEBUG ? sessionStateStorage : undefined,
// authKey allows for an auth key to be
// specified as a url param which automatically
// authorizes the client for use.
authKey: DEBUG ? authKey : undefined,
},
(reason) => app.handleGoPanic(reason)
)
app.runWithIPN(ipn)
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
/**
* Starts a Go runtime and returns the single IPN it owns.
*
* The runtime does not publish its bridge on a global. It reads the name of a
* callback from its environment and invokes it once the bridge is ready, so
* this resolves on an explicit signal rather than on the Go scheduler having
* run far enough. The name is generated per runtime, so several runtimes can
* start in one page without racing each other.
*/
export async function startIPN(
go: Go,
instance: WebAssembly.Instance,
config: IPNConfig,
onExit: (reason: string) => void
): Promise<IPN> {
const name = `__tsconnectInit_${Math.random().toString(36).slice(2)}`
const globals = globalThis as Record<string, unknown>
const ready = new Promise<(config: IPNConfig) => Promise<IPN>>(
(resolve) => {
globals[name] = (newIPN: (config: IPNConfig) => Promise<IPN>) => {
delete globals[name]
resolve(newIPN)
}
}
)
go.env[INIT_CALLBACK_ENV] = name
const exited = go.run(instance).then(() => {
delete globals[name]
onExit("Unexpected shutdown")
throw new Error("Go runtime exited before the IPN was ready")
})
const newIPN = await Promise.race([ready, exited])
return newIPN(config)
}
/** Must match initCallbackEnv in wasm_js.go. */
const INIT_CALLBACK_ENV = "TSCONNECT_INIT_CALLBACK"
+2 -5
View File
@@ -7,6 +7,7 @@
/// <reference path="../types/wasm_js.d.ts" />
import "../wasm_exec"
import { startIPN } from "../lib/start-ipn"
import wasmURL from "./main.wasm"
/**
@@ -30,11 +31,7 @@ export async function createIPN(config: IPNPackageConfig): Promise<IPN> {
go.importObject
)
// The Go process should never exit, if it does then it's an unhandled panic.
go.run(wasmInstance.instance).then(() =>
config.panicHandler("Unexpected shutdown")
)
return newIPN(config)
return startIPN(go, wasmInstance.instance, config, config.panicHandler)
}
export { runSSHSession } from "../lib/ssh"
-2
View File
@@ -7,8 +7,6 @@
*/
declare global {
function newIPN(config: IPNConfig): IPN
interface IPN {
run(callbacks: IPNCallbacks): void
login(): void