feat(example-app): SharedWorker demo with main-thread fallback

- Add src/worker.ts as the webpack worker entry (imports @webnet/tsconnect-worker/worker)
- IpnProvider: add useWorker/workerAvailable state; when useWorker is on,
  useBuildIpnWorker() creates the client and its embedded store is passed to
  IpnStoreProvider; when off, existing useBuildIpn() path runs unchanged
- Debug: add "use SharedWorker" checkbox (checked by default when available,
  disabled when not); state dropdown shows indexeddb/memory in worker mode
  and localStorage/sessionStorage/disable in main-thread mode; taildrop
  option label reflects opfs vs memory depending on mode
- useBuildIpnWorker: add optional workerOptions param instead of hardcoding
  { type: "module" } — webpack bundles the worker as a classic script, so
  the caller passes no options; module-mode callers opt in explicitly
- Add @webnet/tsconnect-react, @webnet/tsconnect-redux, @webnet/tsconnect-worker
  to example-app package.json dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 11:35:07 +00:00
co-authored by Claude
parent c65dd00f71
commit 720a0c9d59
5 changed files with 107 additions and 23 deletions
+4 -1
View File
@@ -11,7 +11,10 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-redux": "^9.2.0",
"@webnet/tsconnect": "*"
"@webnet/tsconnect": "*",
"@webnet/tsconnect-react": "*",
"@webnet/tsconnect-redux": "*",
"@webnet/tsconnect-worker": "*"
},
"devDependencies": {
"@babel/core": "^7.27.1",
+41 -13
View File
@@ -79,7 +79,7 @@ function BaseDebug() {
<br />
ipn:{" "}
{ipn ? (
"present"
prepare.useWorker ? "present (SharedWorker)" : "present (main thread)"
) : prepare.willBuild ? (
"waiting..."
) : (
@@ -88,25 +88,53 @@ function BaseDebug() {
<br />
caCerts: {caCerts ? fmtSize(caCerts.length) : "loading..."}
<br />
state:{" "}
<select
disabled={!!ipn}
value={prepare.state}
onChange={(e) => prepare.setState(e.target.value as typeof prepare.state)}
>
<option>disable</option>
<option>localStorage</option>
<option>sessionStorage</option>
</select>
<label>
<input
type="checkbox"
disabled={!!ipn || !prepare.workerAvailable}
checked={prepare.useWorker && prepare.workerAvailable}
onChange={(e) => prepare.setUseWorker(e.target.checked)}
/>{" "}
use SharedWorker{!prepare.workerAvailable ? " (unavailable)" : ""}
</label>
<br />
taildrop:
{prepare.useWorker ? (
<>
state:{" "}
<select
disabled={!!ipn}
value={prepare.state === "disable" ? "memory" : "indexeddb"}
onChange={(e) =>
prepare.setState(e.target.value === "memory" ? "disable" : "localStorage")
}
>
<option value="indexeddb">indexeddb</option>
<option value="memory">memory</option>
</select>
</>
) : (
<>
state:{" "}
<select
disabled={!!ipn}
value={prepare.state}
onChange={(e) => prepare.setState(e.target.value as typeof prepare.state)}
>
<option>disable</option>
<option>localStorage</option>
<option>sessionStorage</option>
</select>
</>
)}
<br />
taildrop:{" "}
<select
disabled={!!ipn}
value={prepare.taildrop}
onChange={(e) => prepare.setTaildrop(e.target.value as typeof prepare.taildrop)}
>
<option>disable</option>
<option>memory</option>
<option value="memory">{prepare.useWorker ? "opfs" : "memory"}</option>
</select>
<br />
<label>
@@ -1,5 +1,6 @@
import { useBuildIpn } from "@webnet/tsconnect-react"
import { useBuildIpn, useBuildIpnWorker } from "@webnet/tsconnect-react"
import { buildIpnStore, getIpnState, type IpnStore } from "@webnet/tsconnect-redux"
import type { WorkerConfig } from "@webnet/tsconnect-worker"
import {
ContextType,
createContext,
@@ -12,9 +13,13 @@ import {
} from "react"
import { useIpnBuilder } from "./IpnBuilderContext"
import { IpnContext, IpnStoreProvider } from "./tsconnect"
import { InMemoryFileOps, IPN, WebStorageState } from "@webnet/tsconnect"
import { InMemoryFileOps, WebStorageState } from "@webnet/tsconnect"
import type { IpnClient } from "@webnet/tsconnect"
import { useIpnSelector } from "../hooks/tsconnect"
import { useClient } from "../hooks/useClient"
import wasmUrl from "@webnet/tsconnect/main.wasm"
const workerScriptUrl = new URL("../worker.ts", import.meta.url)
export const IpnPrepareContext = createContext<{
// pre-built parts
@@ -38,6 +43,11 @@ export const IpnPrepareContext = createContext<{
// automatically login to tailscale
autoLogin: boolean
setAutoLogin: (autoLogin: boolean) => void
// worker vs main-thread mode
workerAvailable: boolean
useWorker: boolean
setUseWorker: (useWorker: boolean) => void
} | null>(null)
export function IpnProvider({
@@ -57,6 +67,8 @@ export function IpnProvider({
defer?: boolean
autoLogin?: boolean
}) {
const workerAvailable = typeof SharedWorker !== "undefined"
const storeRef = useRef<IpnStore | null>(null)
if (!storeRef.current) {
storeRef.current = buildIpnStore()
@@ -67,6 +79,7 @@ export function IpnProvider({
const [willBuild, setWillBuild] = useState(!defer)
const build = useCallback(() => setWillBuild(true), [setWillBuild])
const [useWorker, setUseWorker] = useState(workerAvailable)
const [state, setState] = useState(defaultState)
const [taildrop, setTaildrop] = useState(defaultTaildrop)
@@ -77,6 +90,27 @@ export function IpnProvider({
const [autoLogin, setAutoLogin] = useState(defaultAutoLogin)
const client = useClient()
// ── Worker mode ──────────────────────────────────────────────────────────────
const workerConfig = useMemo<WorkerConfig>(
() => ({
wasmUrl: wasmUrl as string,
hostname: hostname ?? undefined,
controlURL: controlURL ?? undefined,
authKey: authKey ?? undefined,
stateStorage: state === "disable" ? "memory" : "indexeddb",
fileOps: taildrop !== "disable",
}),
[hostname, controlURL, authKey, state, taildrop],
)
const workerClient = useBuildIpnWorker(
useWorker && willBuild ? workerScriptUrl : null,
workerConfig,
runParams,
)
// ── Main-thread mode ─────────────────────────────────────────────────────────
const builderParams = useMemo(() => {
const builderParams = { ...ipnBuilderParams }
@@ -96,7 +130,17 @@ export function IpnProvider({
return builderParams
}, [ipnBuilderParams, state, taildrop, hostname, controlURL, authKey, client])
const ipn = useBuildIpn(store, client && willBuild ? ipnBuilder : null, builderParams, runParams)
const mainIpn = useBuildIpn(
store,
!useWorker && client && willBuild ? ipnBuilder : null,
builderParams,
runParams,
)
// ── Active client ─────────────────────────────────────────────────────────────
const ipn = useWorker ? workerClient : mainIpn
const activeStore = useWorker && workerClient ? workerClient.store : store
if (typeof window !== "undefined") Object.assign(window, { ipn })
const prepare = useMemo<NonNullable<ContextType<typeof IpnPrepareContext>>>(
@@ -118,6 +162,10 @@ export function IpnProvider({
autoLogin,
setAutoLogin,
workerAvailable,
useWorker,
setUseWorker,
}),
[
state,
@@ -134,11 +182,14 @@ export function IpnProvider({
build,
autoLogin,
setAutoLogin,
workerAvailable,
useWorker,
setUseWorker,
],
)
return (
<IpnStoreProvider store={store}>
<IpnStoreProvider store={activeStore}>
{ipn && autoLogin && <AutoLogin ipn={ipn} />}
<IpnContext value={ipn}>
<IpnPrepareContext value={prepare}>{children}</IpnPrepareContext>
@@ -147,7 +198,7 @@ export function IpnProvider({
)
}
function AutoLogin({ ipn }: { ipn: IPN }) {
function AutoLogin({ ipn }: { ipn: IpnClient }) {
const state = useIpnSelector(getIpnState)
useEffect(() => {
if (state === "NeedsLogin") ipn.login()
+1
View File
@@ -0,0 +1 @@
import "@webnet/tsconnect-worker/worker"
+5 -4
View File
@@ -39,12 +39,13 @@ export function useBuildIpnWorker(
worker: string | URL | null,
config: WorkerConfig | null = null,
runParams: Parameters<IPN["run"]>[0] | null = null,
workerOptions?: WorkerOptions,
): IpnWorkerClient | null {
const clientRef = useRef<IpnWorkerClient | null>(null)
const [client, setClient] = useState<IpnWorkerClient | null>(null)
const connect = useEffectEvent(
(worker: string | URL, config: WorkerConfig) => {
const sw = new SharedWorker(worker, { type: "module" })
(worker: string | URL, config: WorkerConfig, workerOptions: WorkerOptions | undefined) => {
const sw = new SharedWorker(worker, workerOptions)
let cleanedUp = false
let activeClient: IpnWorkerClient | null = null
IpnWorkerClient.connect(sw.port, config).then((c) => {
@@ -60,8 +61,8 @@ export function useBuildIpnWorker(
},
)
useEffect(() => {
if (worker && config && !clientRef.current) return connect(worker, config)
}, [worker, config])
if (worker && config && !clientRef.current) return connect(worker, config, workerOptions)
}, [worker, config, workerOptions])
return client
}