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": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"@webnet/tsconnect": "*" "@webnet/tsconnect": "*",
"@webnet/tsconnect-react": "*",
"@webnet/tsconnect-redux": "*",
"@webnet/tsconnect-worker": "*"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.27.1", "@babel/core": "^7.27.1",
+41 -13
View File
@@ -79,7 +79,7 @@ function BaseDebug() {
<br /> <br />
ipn:{" "} ipn:{" "}
{ipn ? ( {ipn ? (
"present" prepare.useWorker ? "present (SharedWorker)" : "present (main thread)"
) : prepare.willBuild ? ( ) : prepare.willBuild ? (
"waiting..." "waiting..."
) : ( ) : (
@@ -88,25 +88,53 @@ function BaseDebug() {
<br /> <br />
caCerts: {caCerts ? fmtSize(caCerts.length) : "loading..."} caCerts: {caCerts ? fmtSize(caCerts.length) : "loading..."}
<br /> <br />
state:{" "} <label>
<select <input
disabled={!!ipn} type="checkbox"
value={prepare.state} disabled={!!ipn || !prepare.workerAvailable}
onChange={(e) => prepare.setState(e.target.value as typeof prepare.state)} checked={prepare.useWorker && prepare.workerAvailable}
> onChange={(e) => prepare.setUseWorker(e.target.checked)}
<option>disable</option> />{" "}
<option>localStorage</option> use SharedWorker{!prepare.workerAvailable ? " (unavailable)" : ""}
<option>sessionStorage</option> </label>
</select>
<br /> <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 <select
disabled={!!ipn} disabled={!!ipn}
value={prepare.taildrop} value={prepare.taildrop}
onChange={(e) => prepare.setTaildrop(e.target.value as typeof prepare.taildrop)} onChange={(e) => prepare.setTaildrop(e.target.value as typeof prepare.taildrop)}
> >
<option>disable</option> <option>disable</option>
<option>memory</option> <option value="memory">{prepare.useWorker ? "opfs" : "memory"}</option>
</select> </select>
<br /> <br />
<label> <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 { buildIpnStore, getIpnState, type IpnStore } from "@webnet/tsconnect-redux"
import type { WorkerConfig } from "@webnet/tsconnect-worker"
import { import {
ContextType, ContextType,
createContext, createContext,
@@ -12,9 +13,13 @@ import {
} from "react" } from "react"
import { useIpnBuilder } from "./IpnBuilderContext" import { useIpnBuilder } from "./IpnBuilderContext"
import { IpnContext, IpnStoreProvider } from "./tsconnect" 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 { useIpnSelector } from "../hooks/tsconnect"
import { useClient } from "../hooks/useClient" 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<{ export const IpnPrepareContext = createContext<{
// pre-built parts // pre-built parts
@@ -38,6 +43,11 @@ export const IpnPrepareContext = createContext<{
// automatically login to tailscale // automatically login to tailscale
autoLogin: boolean autoLogin: boolean
setAutoLogin: (autoLogin: boolean) => void setAutoLogin: (autoLogin: boolean) => void
// worker vs main-thread mode
workerAvailable: boolean
useWorker: boolean
setUseWorker: (useWorker: boolean) => void
} | null>(null) } | null>(null)
export function IpnProvider({ export function IpnProvider({
@@ -57,6 +67,8 @@ export function IpnProvider({
defer?: boolean defer?: boolean
autoLogin?: boolean autoLogin?: boolean
}) { }) {
const workerAvailable = typeof SharedWorker !== "undefined"
const storeRef = useRef<IpnStore | null>(null) const storeRef = useRef<IpnStore | null>(null)
if (!storeRef.current) { if (!storeRef.current) {
storeRef.current = buildIpnStore() storeRef.current = buildIpnStore()
@@ -67,6 +79,7 @@ export function IpnProvider({
const [willBuild, setWillBuild] = useState(!defer) const [willBuild, setWillBuild] = useState(!defer)
const build = useCallback(() => setWillBuild(true), [setWillBuild]) const build = useCallback(() => setWillBuild(true), [setWillBuild])
const [useWorker, setUseWorker] = useState(workerAvailable)
const [state, setState] = useState(defaultState) const [state, setState] = useState(defaultState)
const [taildrop, setTaildrop] = useState(defaultTaildrop) const [taildrop, setTaildrop] = useState(defaultTaildrop)
@@ -77,6 +90,27 @@ export function IpnProvider({
const [autoLogin, setAutoLogin] = useState(defaultAutoLogin) const [autoLogin, setAutoLogin] = useState(defaultAutoLogin)
const client = useClient() 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 = useMemo(() => {
const builderParams = { ...ipnBuilderParams } const builderParams = { ...ipnBuilderParams }
@@ -96,7 +130,17 @@ export function IpnProvider({
return builderParams return builderParams
}, [ipnBuilderParams, state, taildrop, hostname, controlURL, authKey, client]) }, [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 }) if (typeof window !== "undefined") Object.assign(window, { ipn })
const prepare = useMemo<NonNullable<ContextType<typeof IpnPrepareContext>>>( const prepare = useMemo<NonNullable<ContextType<typeof IpnPrepareContext>>>(
@@ -118,6 +162,10 @@ export function IpnProvider({
autoLogin, autoLogin,
setAutoLogin, setAutoLogin,
workerAvailable,
useWorker,
setUseWorker,
}), }),
[ [
state, state,
@@ -134,11 +182,14 @@ export function IpnProvider({
build, build,
autoLogin, autoLogin,
setAutoLogin, setAutoLogin,
workerAvailable,
useWorker,
setUseWorker,
], ],
) )
return ( return (
<IpnStoreProvider store={store}> <IpnStoreProvider store={activeStore}>
{ipn && autoLogin && <AutoLogin ipn={ipn} />} {ipn && autoLogin && <AutoLogin ipn={ipn} />}
<IpnContext value={ipn}> <IpnContext value={ipn}>
<IpnPrepareContext value={prepare}>{children}</IpnPrepareContext> <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) const state = useIpnSelector(getIpnState)
useEffect(() => { useEffect(() => {
if (state === "NeedsLogin") ipn.login() 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, worker: string | URL | null,
config: WorkerConfig | null = null, config: WorkerConfig | null = null,
runParams: Parameters<IPN["run"]>[0] | null = null, runParams: Parameters<IPN["run"]>[0] | null = null,
workerOptions?: WorkerOptions,
): IpnWorkerClient | null { ): IpnWorkerClient | null {
const clientRef = useRef<IpnWorkerClient | null>(null) const clientRef = useRef<IpnWorkerClient | null>(null)
const [client, setClient] = useState<IpnWorkerClient | null>(null) const [client, setClient] = useState<IpnWorkerClient | null>(null)
const connect = useEffectEvent( const connect = useEffectEvent(
(worker: string | URL, config: WorkerConfig) => { (worker: string | URL, config: WorkerConfig, workerOptions: WorkerOptions | undefined) => {
const sw = new SharedWorker(worker, { type: "module" }) const sw = new SharedWorker(worker, workerOptions)
let cleanedUp = false let cleanedUp = false
let activeClient: IpnWorkerClient | null = null let activeClient: IpnWorkerClient | null = null
IpnWorkerClient.connect(sw.port, config).then((c) => { IpnWorkerClient.connect(sw.port, config).then((c) => {
@@ -60,8 +61,8 @@ export function useBuildIpnWorker(
}, },
) )
useEffect(() => { useEffect(() => {
if (worker && config && !clientRef.current) return connect(worker, config) if (worker && config && !clientRef.current) return connect(worker, config, workerOptions)
}, [worker, config]) }, [worker, config, workerOptions])
return client return client
} }