Merge branch 'main' into fix/tailshare-css-module
CI / install (pull_request) Successful in 4m54s
CI / typetest (pull_request) Successful in 1m27s
CI / node-tests (pull_request) Successful in 1m40s
CI / format (pull_request) Successful in 1m36s
CI / typecheck (pull_request) Successful in 1m42s
CI / lint (pull_request) Successful in 1m36s
CI / browser-tests (pull_request) Successful in 2m58s

This commit is contained in:
2026-07-26 02:35:49 +02:00
16 changed files with 384 additions and 281 deletions
+2
View File
@@ -2,6 +2,7 @@
This file records all work in this repository that was assisted or authored by an AI agent. It is append-only; merge conflicts are resolved by keeping all lines (see `.gitattributes`). This file records all work in this repository that was assisted or authored by an AI agent. It is append-only; merge conflicts are resolved by keeping all lines (see `.gitattributes`).
- **`@webnet/tsconnect-worker` — deterministic async test cleanup**: `gpt-5.6-sol` fixed a flaky Go/WASM test teardown by awaiting the runtime shutdown started by `disconnect()`, replaced nearby fixed-delay MessagePort waits with direct call assertions and an ordered delivery barrier, and converted TTL tests to mocked timers so they wait for every relevant expiration rather than whichever equal-duration timer happens to fire first.
- **`@webnet/sftp` — stable keyless server identity**: GPT-5.6 Luna fixed `SFTPServer` to lazily generate and cache one ephemeral host key per server instance, and added a regression test covering sequential connections and host-key fingerprints. - **`@webnet/sftp` — stable keyless server identity**: GPT-5.6 Luna fixed `SFTPServer` to lazily generate and cache one ephemeral host key per server instance, and added a regression test covering sequential connections and host-key fingerprints.
- **`@webnet/vfs` — canonical path helpers**: Codex (GPT-5.6 Luna) added public POSIX VFS path utilities for normalization, resolution, parents, basenames, and joining. The helpers clamp traversal at the VFS root, preserve backslashes and Unicode names, and are covered by focused edge-case tests. MemoryVFS, FsaVFS, FTP, and SFTP now reuse the shared implementation; NodeVFS retains its separate traversal-rejection guard. - **`@webnet/vfs` — canonical path helpers**: Codex (GPT-5.6 Luna) added public POSIX VFS path utilities for normalization, resolution, parents, basenames, and joining. The helpers clamp traversal at the VFS root, preserve backslashes and Unicode names, and are covered by focused edge-case tests. MemoryVFS, FsaVFS, FTP, and SFTP now reuse the shared implementation; NodeVFS retains its separate traversal-rejection guard.
@@ -118,3 +119,4 @@ The test suite for `packages/http` was mostly generated by Claude Code, which al
- **`@webnet/state-transfer` — review follow-up**: Codex (GPT-5.6 Luna) restored the original object-only type-guard semantics, excluded typetests from the production build, added package coverage metadata, and validated the initialized worker and full workspace with the root build, tests, typechecks, and typetests. - **`@webnet/state-transfer` — review follow-up**: Codex (GPT-5.6 Luna) restored the original object-only type-guard semantics, excluded typetests from the production build, added package coverage metadata, and validated the initialized worker and full workspace with the root build, tests, typechecks, and typetests.
- **Root Node/npm engine declaration**: `gpt-5.6-sol` declared Node 24 and npm 11 as the supported root workspace toolchain and synchronized the lockfile metadata. - **Root Node/npm engine declaration**: `gpt-5.6-sol` declared Node 24 and npm 11 as the supported root workspace toolchain and synchronized the lockfile metadata.
- **`@webnet/tailshare` - original implementation review**: Initial implementation (PR #28) hand-written, but reviewed by multiple agents: Claude Sonnet 4.6 during writing and Claude Opus 5 and GPT 5.6 Sol before merge. - **`@webnet/tailshare` - original implementation review**: Initial implementation (PR #28) hand-written, but reviewed by multiple agents: Claude Sonnet 4.6 during writing and Claude Opus 5 and GPT 5.6 Sol before merge.
- **`@webnet/tailshare` — SharedWorker support, type fixes, config consolidation, and fileOps UI**: Claude Code (Claude Sonnet 4.6) rewired the IPN connection in `tailshare` to use `@webnet/tsconnect-worker` via `useBuildIpnWorker`, enabling multiple browser tabs to share a single IPN instance through a SharedWorker (with transparent fallback to main-thread mode when SharedWorker is unavailable). The `IPN` concrete class was replaced with the `IpnClient` interface throughout, and `IpnClientHandle` is used as the return type from the worker hook. The `ipnPrepare` Redux slice was removed entirely and replaced with an `IpnPrepareContext` (React context) that reads/writes a single `tailshare:config` localStorage key as a JSON object — consolidating `ipn#hostname`, `ipn#controlURL`, `ipn#authKey`, `ipn#exitNode`, `ipn#auto` and the new `useWorker` and `fileOps` settings into one key, with explicit defaults (`useWorker: true`, `fileOps: "memory"`) set in `parseConfig` so call sites need no inline fallbacks. The config modal (`TailscaleConfig`) gained a file-storage select (`memory` / `opfs`, with OPFS disabled when SharedWorker is unavailable) and a SharedWorker toggle; the mode indicator shows whether the active connection is `worker` or `main-thread`. In worker mode, selecting OPFS enables `fileOps: true` in the WorkerConfig so received files survive page reloads; in main-thread fallback the storage is always in-memory and a warning is shown if OPFS is selected without a worker. `autostart` remains a tri-state: `undefined` latches to `true` on the first `Running` event (so subsequent page loads auto-connect), `false` permanently disables autostart, and `true` triggers it immediately on load. `TailscaleRoute` shows a spinner rather than a disabled "Enable Tailscale" button during the worker connection window. `@webnet/react` gained a `useSharedWorkerAvailable()` hook (SSR-safe via `useSyncExternalStore`) replacing the inline pattern in `IpnContext.tsx`. `useBuildIpnWorker` in `@webnet/tsconnect-react` was extended to accept `(() => SharedWorker) | null` in its first overload (implementation already handled it). `@webnet/react/tsconfig.json` and `@webnet/tsconnect-react/tsconfig.json` both received `skipLibCheck: true` to resolve a type conflict between `@types/eslint-scope` and `eslint`'s built-in types.
+74
View File
@@ -10525,11 +10525,15 @@
"@mantine/notifications": "^9.3.1", "@mantine/notifications": "^9.3.1",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.11.2", "@reduxjs/toolkit": "^2.11.2",
"@webnet/drive": "*",
"@webnet/http": "*",
"@webnet/react": "*", "@webnet/react": "*",
"@webnet/tsconnect": "*", "@webnet/tsconnect": "*",
"@webnet/tsconnect-react": "*", "@webnet/tsconnect-react": "*",
"@webnet/tsconnect-redux": "*", "@webnet/tsconnect-redux": "*",
"@webnet/tsconnect-worker": "*",
"@webnet/utils": "*", "@webnet/utils": "*",
"@webnet/websocket": "*",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -10834,6 +10838,76 @@
"engines": { "engines": {
"node": ">=14.17" "node": ">=14.17"
} }
},
"node_modules/@turbo/darwin-64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.9.6.tgz",
"integrity": "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@turbo/darwin-arm64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.9.6.tgz",
"integrity": "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@turbo/linux-arm64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.9.6.tgz",
"integrity": "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@turbo/windows-64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.9.6.tgz",
"integrity": "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@turbo/windows-arm64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.9.6.tgz",
"integrity": "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
} }
} }
} }
+1
View File
@@ -1,2 +1,3 @@
export { useClient } from "./useClient.js" export { useClient } from "./useClient.js"
export { useLocalStorage } from "./useLocalStorage.js" export { useLocalStorage } from "./useLocalStorage.js"
export { useSharedWorkerAvailable } from "./useSharedWorkerAvailable.js"
@@ -0,0 +1,9 @@
import { useSyncExternalStore } from "react"
const sub = () => () => void 0
const getClient = () => typeof SharedWorker !== "undefined"
const getServer = () => false
export function useSharedWorkerAvailable(): boolean {
return useSyncExternalStore(sub, getClient, getServer)
}
+4
View File
@@ -12,11 +12,15 @@
"@mantine/notifications": "^9.3.1", "@mantine/notifications": "^9.3.1",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.11.2", "@reduxjs/toolkit": "^2.11.2",
"@webnet/drive": "*",
"@webnet/http": "*",
"@webnet/react": "*", "@webnet/react": "*",
"@webnet/tsconnect": "*", "@webnet/tsconnect": "*",
"@webnet/tsconnect-react": "*", "@webnet/tsconnect-react": "*",
"@webnet/tsconnect-redux": "*", "@webnet/tsconnect-redux": "*",
"@webnet/tsconnect-worker": "*",
"@webnet/utils": "*", "@webnet/utils": "*",
"@webnet/websocket": "*",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -13,7 +13,7 @@ import {
useMantineTheme, useMantineTheme,
} from "@mantine/core" } from "@mantine/core"
import { NetworkXIcon, NetworkIcon, UserIcon } from "@phosphor-icons/react" import { NetworkXIcon, NetworkIcon, UserIcon } from "@phosphor-icons/react"
import { IpnContext, useIpnSelector } from "../contexts/IpnContext" import { IpnContext, IpnPrepareContext, useIpnSelector } from "../contexts/IpnContext"
import { import {
getExitNode, getExitNode,
getIpnSelfNode, getIpnSelfNode,
@@ -22,12 +22,7 @@ import {
getPeers, getPeers,
getWaitingFileCount, getWaitingFileCount,
} from "@webnet/tsconnect-redux" } from "@webnet/tsconnect-redux"
import { useTailshareDispatch, useTailshareSelector, useTailshareStore } from "../store/store" import { useTailshareDispatch } from "../store/store"
import {
getIpnPrepareAlreadyRunning,
getIpnPrepareWillBuild,
triggerIpnBuild,
} from "../store/slices/ipnPrepare"
import { use, type ReactNode } from "react" import { use, type ReactNode } from "react"
import { Link } from "wouter" import { Link } from "wouter"
import { useDisclosure } from "@mantine/hooks" import { useDisclosure } from "@mantine/hooks"
@@ -119,16 +114,6 @@ function TailscaleQuickSettings({ onClose }: { onClose: () => void }) {
) )
} }
function TailscaleAlreadyRunningStatus() {
return (
<>
Tailscale cannot start
<br />
Typically, this is caused by another tab running Tailshare
</>
)
}
function TailscaleRunningStatus() { function TailscaleRunningStatus() {
const ipnSelfNode = useIpnSelector(getIpnSelfNode) const ipnSelfNode = useIpnSelector(getIpnSelfNode)
const exitNode = useIpnSelector(getExitNode) const exitNode = useIpnSelector(getExitNode)
@@ -174,27 +159,21 @@ function TailscaleRunningStatus() {
} }
export function TailscaleStateButton() { export function TailscaleStateButton() {
const willBuild = useTailshareSelector(getIpnPrepareWillBuild) const prepare = use(IpnPrepareContext)!
const alreadyRunning = useTailshareSelector(getIpnPrepareAlreadyRunning)
const store = useTailshareStore()
const ipnState = useIpnSelector(getIpnState) const ipnState = useIpnSelector(getIpnState)
const ipnLoginUrl = useIpnSelector(getLoginUrl) const ipnLoginUrl = useIpnSelector(getLoginUrl)
const [opened, { open, close }] = useDisclosure() const [opened, { open, close }] = useDisclosure()
const running = !alreadyRunning && ipnState === "Running" const running = ipnState === "Running"
const needsLogin = !running && ipnState === "NeedsLogin" && !!ipnLoginUrl const needsLogin = !running && ipnState === "NeedsLogin" && !!ipnLoginUrl
const building = !needsLogin && ipnState === "NoState" && !!willBuild const building = !needsLogin && !running && prepare.willBuild
const notStarting = !building && !willBuild const notStarting = !building && !prepare.willBuild
let label: string let label: string
let tooltip: ReactNode let tooltip: ReactNode
let icon: ReactNode let icon: ReactNode
if (alreadyRunning) { if (running) {
label = "Tailscale cannot start: open status"
tooltip = <TailscaleAlreadyRunningStatus />
icon = <NetworkXIcon />
} else if (running) {
label = "Tailscale running: open status" label = "Tailscale running: open status"
tooltip = <TailscaleRunningStatus /> tooltip = <TailscaleRunningStatus />
icon = <NetworkIcon /> icon = <NetworkIcon />
@@ -223,11 +202,11 @@ export function TailscaleStateButton() {
<ActionIcon <ActionIcon
onClick={() => { onClick={() => {
if (needsLogin) window.open(ipnLoginUrl) if (needsLogin) window.open(ipnLoginUrl)
else if (notStarting) triggerIpnBuild(store) else if (notStarting) prepare.build()
else open() else open()
}} }}
variant={running || needsLogin || alreadyRunning ? "filled" : "default"} variant={running || needsLogin ? "filled" : "default"}
color={needsLogin || alreadyRunning ? "red" : undefined} color={needsLogin ? "red" : undefined}
size="xl" size="xl"
aria-label={label} aria-label={label}
> >
@@ -1,19 +1,15 @@
import { createContext, type ReactNode, useEffect, useState } from "react" import { createContext, type ReactNode, useEffect, useState } from "react"
import certUrl from "@webnet/tsconnect/cacert.pem" import certUrl from "@webnet/tsconnect/cacert.pem"
import { useTailshareSelector } from "../store/store"
import { getIpnPrepareWillBuild } from "../store/slices/ipnPrepare"
export const CaCertContext = createContext<string | null>(null) export const CaCertContext = createContext<string | null>(null)
export function CaCertProvider({ children }: { children: ReactNode }) { export function CaCertProvider({ children }: { children: ReactNode }) {
const [caCerts, setCaCerts] = useState<string | null>(null) const [caCerts, setCaCerts] = useState<string | null>(null)
const willBuild = useTailshareSelector(getIpnPrepareWillBuild)
useEffect(() => { useEffect(() => {
if (!willBuild) return
fetch(certUrl) fetch(certUrl)
.then((x) => x.text()) .then((x) => x.text())
.then(setCaCerts) .then(setCaCerts)
}, [willBuild]) }, [])
return <CaCertContext value={caCerts}>{children}</CaCertContext> return <CaCertContext value={caCerts}>{children}</CaCertContext>
} }
+175 -105
View File
@@ -6,6 +6,7 @@ import {
createUseIpnSelector, createUseIpnSelector,
createUseIpnStore, createUseIpnStore,
useBuildIpn, useBuildIpn,
useBuildIpnWorker,
} from "@webnet/tsconnect-react" } from "@webnet/tsconnect-react"
import { import {
buildIpnStore, buildIpnStore,
@@ -14,20 +15,22 @@ import {
getPeers, getPeers,
type IpnStore, type IpnStore,
} from "@webnet/tsconnect-redux" } from "@webnet/tsconnect-redux"
import { type ReactNode, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"
import { initIPN, InMemoryFileOps, IPN, WebStorageState } from "@webnet/tsconnect"
import { useClient, useLocalStorage } from "@webnet/react"
import { useTailshareDispatch, useTailshareSelector, useTailshareStore } from "../store/store"
import { import {
getIpnPrepareWillBuild, type ContextType,
selectIpnPrepareSlice, createContext,
setIpnPrepareAuthKey, type ReactNode,
setIpnPrepareControlURL, useCallback,
setIpnPrepareHostname, useEffect,
triggerIpnBuild, useEffectEvent,
type IpnPrepare, useMemo,
} from "../store/slices/ipnPrepare" useRef,
useState,
} from "react"
import { initIPN, InMemoryFileOps, InMemoryState } from "@webnet/tsconnect"
import type { IpnClient } from "@webnet/tsconnect"
import { useLocalStorage, useSharedWorkerAvailable } from "@webnet/react"
import wasmUrl from "@webnet/tsconnect/main.wasm" import wasmUrl from "@webnet/tsconnect/main.wasm"
import type { WorkerConfig } from "@webnet/tsconnect-worker"
export const IpnStoreContext = createIpnStoreContext() export const IpnStoreContext = createIpnStoreContext()
export const IpnContext = createIpnContext() export const IpnContext = createIpnContext()
@@ -37,63 +40,154 @@ export const useIpnStore = createUseIpnStore(IpnStoreContext)
export const useIpnSelector = createUseIpnSelector(IpnStoreContext) export const useIpnSelector = createUseIpnSelector(IpnStoreContext)
export const useIpnDispatch = createUseIpnDispatch(IpnStoreContext) export const useIpnDispatch = createUseIpnDispatch(IpnStoreContext)
type IpnBuilder = Awaited<ReturnType<typeof initIPN>> export type TailshareConfig = {
hostname?: string
controlURL?: string
authKey?: string
exitNode?: string
autostart?: boolean
useWorker?: boolean
fileOps?: "memory" | "opfs"
}
function parseConfig(raw: string): TailshareConfig {
const cfg: TailshareConfig = { useWorker: true, fileOps: "memory" }
try {
const parsed = JSON.parse(raw)
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return cfg
if (typeof parsed.hostname === "string") cfg.hostname = parsed.hostname
if (typeof parsed.controlURL === "string") cfg.controlURL = parsed.controlURL
if (typeof parsed.authKey === "string") cfg.authKey = parsed.authKey
if (typeof parsed.exitNode === "string") cfg.exitNode = parsed.exitNode
if (typeof parsed.autostart === "boolean") cfg.autostart = parsed.autostart
if (typeof parsed.useWorker === "boolean") cfg.useWorker = parsed.useWorker
if (parsed.fileOps === "memory" || parsed.fileOps === "opfs") cfg.fileOps = parsed.fileOps
} catch {
// return defaults
}
return cfg
}
export const IpnPrepareContext = createContext<{
config: TailshareConfig
configAtBuild: TailshareConfig | null
setConfig: (patch: Partial<TailshareConfig>) => void
willBuild: boolean
build: () => void
workerAvailable: boolean
connectionMode: "worker" | "main-thread" | null
} | null>(null)
export function IpnProvider({ children }: { children: ReactNode }) { export function IpnProvider({ children }: { children: ReactNode }) {
const storeRef = useRef<IpnStore | null>(null) const storeRef = useRef<IpnStore | null>(null)
if (!storeRef.current) { if (!storeRef.current) {
storeRef.current = buildIpnStore() storeRef.current = buildIpnStore()
} }
const store = storeRef.current const localStore = storeRef.current
const willBuild = useTailshareSelector(getIpnPrepareWillBuild)
const [ipnBuilder, setIpnBuilder] = useState<IpnBuilder | null>(null) const workerAvailable = useSharedWorkerAvailable()
const [configRaw, setConfigRaw] = useLocalStorage("tailshare:config")
const config = useMemo(() => parseConfig(configRaw), [configRaw])
const setConfig = useCallback(
(patch: Partial<TailshareConfig>) => {
setConfigRaw((prev) => JSON.stringify({ ...parseConfig(prev), ...patch }))
},
[setConfigRaw],
)
const [willBuild, setWillBuild] = useState(false)
const build = useCallback(() => setWillBuild(true), [])
// Snapshot config at the moment willBuild becomes true; all connection decisions use this.
const configAtBuildRef = useRef<TailshareConfig | null>(null)
if (willBuild && !configAtBuildRef.current) configAtBuildRef.current = config
const configAtBuild = configAtBuildRef.current
const buildConfig = configAtBuild ?? config
const useWorker = !!buildConfig.useWorker && workerAvailable
// ── Worker path ───────────────────────────────────────────────────────────────
const buildWorker = useCallback(
() =>
new SharedWorker(
/* webpackChunkName: 'tsconnect-worker' */ new URL(
"@webnet/tsconnect-worker/worker",
import.meta.url,
),
),
[],
)
const workerConfig = useMemo<WorkerConfig>(
() => ({
wasmUrl: wasmUrl as string,
hostname: buildConfig.hostname || undefined,
controlURL: buildConfig.controlURL || undefined,
authKey: buildConfig.authKey || undefined,
stateStorage: "indexeddb",
fileOps: buildConfig.fileOps === "opfs",
fileOpsDir: "taildrop",
}),
[buildConfig.hostname, buildConfig.controlURL, buildConfig.authKey, buildConfig.fileOps],
)
const workerClient = useBuildIpnWorker(useWorker && willBuild ? buildWorker : null, workerConfig)
// ── Main-thread path ──────────────────────────────────────────────────────────
const [ipnBuilder, setIpnBuilder] = useState<Awaited<ReturnType<typeof initIPN>> | null>(null)
useEffect(() => { useEffect(() => {
if (!willBuild) return if (!willBuild || useWorker) return
initIPN(wasmUrl).then((ipnBuilder) => setIpnBuilder(() => ipnBuilder)) initIPN(wasmUrl).then((b) => setIpnBuilder(() => b))
}, [willBuild]) }, [willBuild, useWorker])
const client = useClient()
const builderParams = useMemo(() => { const builderParams = useMemo(() => {
const builderParams: Parameters<IpnBuilder>[0] = {} const params: Parameters<Exclude<typeof ipnBuilder, null>>[0] = {}
if (!willBuild) return builderParams params.fileOps = new InMemoryFileOps()
params.stateStorage = new InMemoryState()
if (buildConfig.hostname) params.hostname = buildConfig.hostname
if (buildConfig.controlURL) params.controlURL = buildConfig.controlURL
if (buildConfig.authKey) params.authKey = buildConfig.authKey
return params
}, [buildConfig.hostname, buildConfig.controlURL, buildConfig.authKey])
builderParams.fileOps = new InMemoryFileOps() const mainIpn = useBuildIpn(
localStore,
!useWorker && willBuild ? ipnBuilder : null,
builderParams,
)
if (client && localStorage) { // ── Active handle + store ─────────────────────────────────────────────────────
builderParams.stateStorage = new WebStorageState(localStorage, "ipn:") const ipn: IpnClient | null = useWorker ? workerClient : mainIpn
} const activeStore = useWorker && workerClient ? workerClient.store : localStore
if (willBuild.hostname) builderParams.hostname = willBuild.hostname const connectionMode: "worker" | "main-thread" | null =
if (willBuild.controlURL) builderParams.controlURL = willBuild.controlURL workerClient?.connectionMode ?? (mainIpn ? "main-thread" : null)
if (willBuild.authKey) builderParams.authKey = willBuild.authKey
return builderParams
}, [willBuild, client])
const ipn = useBuildIpn(store, client && willBuild ? ipnBuilder : null, builderParams)
if (typeof window !== "undefined") Object.assign(window, { ipn }) if (typeof window !== "undefined") Object.assign(window, { ipn })
const prepare = useMemo<NonNullable<ContextType<typeof IpnPrepareContext>>>(
() => ({ config, configAtBuild, setConfig, willBuild, build, workerAvailable, connectionMode }),
[config, configAtBuild, setConfig, willBuild, build, workerAvailable, connectionMode],
)
return ( return (
<IpnStoreProvider store={store}> <IpnStoreProvider store={activeStore}>
{ipn && ( {ipn && (
<> <>
<AutoLogin ipn={ipn} /> <AutoLogin ipn={ipn} />
<AutoExitNode ipn={ipn} /> <AutoExitNode ipn={ipn} config={config} setConfig={setConfig} />
</> </>
)} )}
{client && ( <AutoInit build={build} config={config} setConfig={setConfig} />
<> <IpnContext value={ipn}>
<AutoInit /> <IpnPrepareContext value={prepare}>{children}</IpnPrepareContext>
<AutoConfig /> </IpnContext>
</>
)}
<IpnContext value={ipn}>{children}</IpnContext>
</IpnStoreProvider> </IpnStoreProvider>
) )
} }
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()
@@ -101,91 +195,67 @@ function AutoLogin({ ipn }: { ipn: IPN }) {
return null return null
} }
function AutoExitNode({ ipn }: { ipn: IPN }) { function AutoExitNode({
ipn,
config,
setConfig,
}: {
ipn: IpnClient
config: TailshareConfig
setConfig: (patch: Partial<TailshareConfig>) => void
}) {
const currentExitNode = useIpnSelector(getExitNode) const currentExitNode = useIpnSelector(getExitNode)
const peers = useIpnSelector(getPeers) const peers = useIpnSelector(getPeers)
const state = useIpnSelector(getIpnState) const state = useIpnSelector(getIpnState)
const [savedExitNode, setSavedExitNode, clearSavedExitNode] = useLocalStorage("ipn#exitNode")
const setExitNode = useEffectEvent(() => { const restoreExitNode = useEffectEvent(() => {
if (!savedExitNode) return if (!config.exitNode) return
if (!Object.values(peers).some((x) => x.stableNodeID === savedExitNode && x.exitNodeOption)) if (!Object.values(peers).some((x) => x.stableNodeID === config.exitNode && x.exitNodeOption))
return return
ipn.setExitNode(savedExitNode) ipn.setExitNode(config.exitNode)
}) })
useEffect(() => { useEffect(() => {
if (state !== "Running") return if (state !== "Running") return
setExitNode() restoreExitNode()
}, [state]) }, [state])
const updateSavedExitNode = useEffectEvent((exitNode: string | null) => {
if (exitNode) setSavedExitNode(exitNode)
else clearSavedExitNode()
})
const exitNodeChangedRef = useRef(false) const exitNodeChangedRef = useRef(false)
const saveExitNode = useEffectEvent((exitNode: string | null) => {
setConfig({ exitNode: exitNode ?? undefined })
})
useEffect(() => { useEffect(() => {
if (state !== "Running") return if (state !== "Running") return
if (currentExitNode) exitNodeChangedRef.current = true if (currentExitNode) exitNodeChangedRef.current = true
if (!exitNodeChangedRef.current) return if (!exitNodeChangedRef.current) return
updateSavedExitNode(currentExitNode) saveExitNode(currentExitNode)
}, [state, currentExitNode]) }, [state, currentExitNode])
return null return null
} }
function AutoInit() { function AutoInit({
build,
config,
setConfig,
}: {
build: () => void
config: TailshareConfig
setConfig: (patch: Partial<TailshareConfig>) => void
}) {
const state = useIpnSelector(getIpnState) const state = useIpnSelector(getIpnState)
const store = useTailshareStore()
const [autostart, setAutostart] = useLocalStorage("ipn#auto") const markAutostart = useEffectEvent(() => {
if (config.autostart !== false) setConfig({ autostart: true })
})
useEffect(() => {
if (state === "Running") markAutostart()
}, [state])
useEffect(() => { useEffect(() => {
if (state === "Running") { if (!config.autostart) return
setAutostart((prev) => (prev === "0" ? "0" : "1")) const timeout = window.setTimeout(build, 1000)
} return () => window.clearTimeout(timeout)
}, [state, setAutostart]) }, [build, config.autostart])
useEffect(() => {
if (autostart !== "1") return
const timeout = window.setTimeout(() => triggerIpnBuild(store), 1000)
return () => {
window.clearTimeout(timeout)
}
}, [store, autostart])
return null
}
function AutoConfig() {
const ipnPrepare = useTailshareSelector(selectIpnPrepareSlice)
const dispatch = useTailshareDispatch()
const [hostname, setHostname, clearHostname] = useLocalStorage("ipn#hostname")
const [controlURL, setControlURL, clearControlURL] = useLocalStorage("ipn#controlURL")
const [authKey, setAuthKey, clearAuthKey] = useLocalStorage("ipn#authKey")
const [hasPrepared, setHasPrepared] = useState(false)
const updateLocalStorage = useEffectEvent((ipnPrepare: IpnPrepare) => {
if (ipnPrepare.hostname) setHostname(ipnPrepare.hostname)
else clearHostname()
if (ipnPrepare.controlURL) setControlURL(ipnPrepare.controlURL)
else clearControlURL()
if (ipnPrepare.authKey) setAuthKey(ipnPrepare.authKey)
else clearAuthKey()
})
useEffect(() => {
if (!hasPrepared) return
updateLocalStorage(ipnPrepare)
}, [ipnPrepare, hasPrepared])
const setPrepare = useEffectEvent(() => {
if (hostname) dispatch(setIpnPrepareHostname(hostname))
if (controlURL) dispatch(setIpnPrepareControlURL(controlURL))
if (authKey) dispatch(setIpnPrepareAuthKey(authKey))
setHasPrepared(true)
})
useEffect(() => {
setPrepare()
}, [])
return null return null
} }
+75 -40
View File
@@ -19,16 +19,7 @@ import {
Tooltip, Tooltip,
useMantineTheme, useMantineTheme,
} from "@mantine/core" } from "@mantine/core"
import { useTailshareDispatch, useTailshareSelector, useTailshareStore } from "../store/store" import { useTailshareDispatch } from "../store/store"
import {
getIpnPrepareAlreadyRunning,
getIpnPrepareWillBuild,
selectIpnPrepareSlice,
setIpnPrepareAuthKey,
setIpnPrepareControlURL,
setIpnPrepareHostname,
triggerIpnBuild,
} from "../store/slices/ipnPrepare"
import { import {
getExitNode, getExitNode,
getIpnSelfNode, getIpnSelfNode,
@@ -38,33 +29,25 @@ import {
getWaitingFileCount, getWaitingFileCount,
getWaitingFiles, getWaitingFiles,
} from "@webnet/tsconnect-redux" } from "@webnet/tsconnect-redux"
import { IpnContext, useIpnSelector } from "../contexts/IpnContext" import { IpnContext, IpnPrepareContext, useIpnSelector } from "../contexts/IpnContext"
import { use } from "react" import { use } from "react"
import styles from "./tailscale.scss" import styles from "./tailscale.scss"
import { CopyIcon, DownloadIcon, TrashIcon } from "@phosphor-icons/react" import { CopyIcon, DownloadIcon, TrashIcon } from "@phosphor-icons/react"
import { useDisclosure } from "@mantine/hooks" import { useDisclosure } from "@mantine/hooks"
import clsx from "clsx" import clsx from "clsx"
import { useLocalStorage } from "@webnet/react"
import type { IpnClient, IPNWaitingFile } from "@webnet/tsconnect" import type { IpnClient, IPNWaitingFile } from "@webnet/tsconnect"
import { fmtSize, download } from "@webnet/utils" import { fmtSize, download } from "@webnet/utils"
import { setTailnetDrawerOpen } from "../store/slices/states" import { setTailnetDrawerOpen } from "../store/slices/states"
function ConnectTailscale() { function ConnectTailscale() {
const willBuild = useTailshareSelector(getIpnPrepareWillBuild) const prepare = use(IpnPrepareContext)!
const alreadyRunning = useTailshareSelector(getIpnPrepareAlreadyRunning)
const store = useTailshareStore()
return ( return (
<Group> <Group>
<Button disabled={!!willBuild || alreadyRunning} onClick={() => triggerIpnBuild(store)}> <Button disabled={prepare.willBuild} onClick={prepare.build}>
Enable Tailscale Enable Tailscale
</Button> </Button>
{!!willBuild && <Loader />} {prepare.willBuild && <Loader />}
{alreadyRunning && (
<Text>
Cannot start Tailscale, this is typically caused by another tab running Tailshare
</Text>
)}
</Group> </Group>
) )
} }
@@ -105,22 +88,30 @@ function TailscaleConfig() {
const [opened, { open, close }] = useDisclosure() const [opened, { open, close }] = useDisclosure()
const exitNode = useIpnSelector(getExitNode) const exitNode = useIpnSelector(getExitNode)
const peers = useIpnSelector(getPeers) const peers = useIpnSelector(getPeers)
const ipnPrepare = useTailshareSelector(selectIpnPrepareSlice)
const dispatch = useTailshareDispatch() const dispatch = useTailshareDispatch()
const ipn = use(IpnContext) const ipn = use(IpnContext)
const [autostart, setAutostart] = useLocalStorage("ipn#auto", "0") const prepare = use(IpnPrepareContext)!
const { config, configAtBuild: built, setConfig, workerAvailable, connectionMode } = prepare
const requireRestart = const requireRestart =
ipnPrepare.willBuild && built &&
(ipnPrepare.authKey !== ipnPrepare.willBuild.authKey || (config.hostname !== built.hostname ||
ipnPrepare.controlURL !== ipnPrepare.willBuild.controlURL || config.controlURL !== built.controlURL ||
ipnPrepare.hostname !== ipnPrepare.willBuild.hostname) config.authKey !== built.authKey ||
!!config.useWorker !== !!built.useWorker ||
config.fileOps !== built.fileOps)
const opfsWithoutWorker = config.fileOps === "opfs" && (!workerAvailable || !config.useWorker)
return ( return (
<> <>
<Group> <Group>
<Button onClick={open}>Tailscale config</Button> <Button onClick={open}>Tailscale config</Button>
<Button onClick={() => dispatch(setTailnetDrawerOpen(true))}>Tailnet</Button> <Button onClick={() => dispatch(setTailnetDrawerOpen(true))}>Tailnet</Button>
{connectionMode && (
<Text c="dimmed" size="sm">
Mode: <Code>{connectionMode}</Code>
</Text>
)}
</Group> </Group>
<Modal centered opened={opened} onClose={close} title="Tailscale config"> <Modal centered opened={opened} onClose={close} title="Tailscale config">
{ipn && ( {ipn && (
@@ -141,8 +132,8 @@ function TailscaleConfig() {
withAsterisk withAsterisk
label={<>Hostname</>} label={<>Hostname</>}
placeholder="(Autogen on startup)" placeholder="(Autogen on startup)"
value={ipnPrepare.hostname ?? ""} value={config.hostname ?? ""}
onChange={(e) => dispatch(setIpnPrepareHostname(e.target.value))} onChange={(e) => setConfig({ hostname: e.target.value || undefined })}
/> />
<TextInput <TextInput
mt="xs" mt="xs"
@@ -156,26 +147,61 @@ function TailscaleConfig() {
</> </>
} }
placeholder="(Manual login flow)" placeholder="(Manual login flow)"
value={ipnPrepare.authKey ?? ""} value={config.authKey ?? ""}
onChange={(e) => dispatch(setIpnPrepareAuthKey(e.target.value))} onChange={(e) => setConfig({ authKey: e.target.value || undefined })}
/> />
<TextInput <TextInput
mt="xs" mt="xs"
withAsterisk withAsterisk
label={<>Control plane URL</>} label={<>Control plane URL</>}
placeholder="(Default Tailscale control plane)" placeholder="(Default Tailscale control plane)"
value={ipnPrepare.controlURL ?? ""} value={config.controlURL ?? ""}
onChange={(e) => dispatch(setIpnPrepareControlURL(e.target.value))} onChange={(e) => setConfig({ controlURL: e.target.value || undefined })}
/> />
<NativeSelect
mt="xs"
label="File storage (taildrop)"
description={
config.fileOps === "opfs"
? "OPFS: files persist across reloads. Requires SharedWorker mode."
: "Memory: files are lost on reload."
}
value={config.fileOps}
onChange={(e) => setConfig({ fileOps: e.target.value as "memory" | "opfs" })}
data={[
{ label: "Memory (volatile)", value: "memory" },
{
label: "OPFS (persistent, requires SharedWorker)",
value: "opfs",
disabled: !workerAvailable,
},
]}
/>
{opfsWithoutWorker && (
<Text mt="xs" c="orange" size="sm">
OPFS requires SharedWorker mode. Taildrop will be disabled until SharedWorker is enabled
and the app restarted.
</Text>
)}
{workerAvailable && (
<Checkbox
mt="md"
label="Use SharedWorker (enables multi-tab support)"
checked={!!config.useWorker}
onChange={(e) => setConfig({ useWorker: e.target.checked })}
/>
)}
<Checkbox <Checkbox
mt="md" mt="md"
label="Automatically start Tailscale with the app" label="Automatically start Tailscale with the app"
checked={autostart === "1"} checked={!!config.autostart}
onChange={(e) => setAutostart("" + +e.target.checked)} onChange={(e) => setConfig({ autostart: e.target.checked || undefined })}
/> />
<Box className={clsx(styles.restartLine, requireRestart || styles.hidden)}> <Box className={clsx(styles.restartLine, requireRestart || styles.hidden)}>
<Text mt="xs" c="dimmed"> <Text mt="xs" c="dimmed">
Restart required to apply the changes. {connectionMode === "worker"
? "Close all Tailshare tabs, then reload to apply changes to the shared worker."
: "Restart required to apply the changes."}
</Text> </Text>
<Button onClick={() => location.reload()}>Restart</Button> <Button onClick={() => location.reload()}>Restart</Button>
</Box> </Box>
@@ -250,7 +276,7 @@ function TailscaleSelfNodeStatus() {
<>No exit node</> <>No exit node</>
)} )}
</Text> </Text>
<Text>Adresses:</Text> <Text>Addresses:</Text>
<List ml="xs"> <List ml="xs">
{ipnSelfNode.addresses.map((address) => ( {ipnSelfNode.addresses.map((address) => (
<List.Item mt="xs" key={address} classNames={{ itemLabel: styles.addressWrapper }}> <List.Item mt="xs" key={address} classNames={{ itemLabel: styles.addressWrapper }}>
@@ -310,6 +336,7 @@ function TailscaleWaitingFiles({
export default function TailscaleRoute() { export default function TailscaleRoute() {
const waitingFiles = useIpnSelector(getWaitingFiles) const waitingFiles = useIpnSelector(getWaitingFiles)
const ipn = use(IpnContext) const ipn = use(IpnContext)
const prepare = use(IpnPrepareContext)!
return ( return (
<> <>
@@ -323,7 +350,15 @@ export default function TailscaleRoute() {
and an exit node. and an exit node.
</Text> </Text>
<Divider m={"md"} /> <Divider m={"md"} />
{!ipn ? <ConnectTailscale /> : <TailscaleState />} {!ipn && !prepare.willBuild ? (
<ConnectTailscale />
) : !ipn ? (
<Group>
<Loader />
</Group>
) : (
<TailscaleState />
)}
<Divider m={"md"} /> <Divider m={"md"} />
<TailscaleConfig /> <TailscaleConfig />
{!!Object.keys(waitingFiles).length && !!ipn && ( {!!Object.keys(waitingFiles).length && !!ipn && (
@@ -1,74 +0,0 @@
import { createSlice, type PayloadAction, type Store } from "@reduxjs/toolkit"
export type IpnPrepare = {
hostname: string | null
controlURL: string | null
authKey: string | null
willBuild: null | Omit<IpnPrepare, "willBuild">
alreadyRunning: boolean
}
const initialState: IpnPrepare = {
hostname: null,
controlURL: null,
authKey: null,
willBuild: null,
alreadyRunning: false,
}
export const ipnPrepareSlice = createSlice({
name: "ipnPrepare",
initialState,
reducers: {
setIpnPrepareHostname: (state, action: PayloadAction<string | null>) => {
state.hostname = action.payload || null
},
setIpnPrepareControlURL: (state, action: PayloadAction<string | null>) => {
state.controlURL = action.payload || null
},
setIpnPrepareAuthKey: (state, action: PayloadAction<string | null>) => {
state.authKey = action.payload || null
},
triggerIpnPrepareBuild: (state, _: PayloadAction<void>) => {
state.willBuild = { ...state }
},
setIpnAlreadyRunning: (state, _: PayloadAction<void>) => {
state.alreadyRunning = true
},
},
selectors: {
getIpnPrepareHostname: (state) => state.hostname,
getIpnPrepareControlURL: (state) => state.controlURL,
getIpnPrepareAuthKey: (state) => state.authKey,
getIpnPrepareWillBuild: (state) => state.willBuild,
getIpnPrepareAlreadyRunning: (state) => state.alreadyRunning,
},
})
export const {
actions: { setIpnPrepareAuthKey, setIpnPrepareControlURL, setIpnPrepareHostname },
selectors: {
getIpnPrepareControlURL,
getIpnPrepareAuthKey,
getIpnPrepareHostname,
getIpnPrepareWillBuild,
getIpnPrepareAlreadyRunning,
},
selectSlice: selectIpnPrepareSlice,
} = ipnPrepareSlice
const {
actions: { triggerIpnPrepareBuild, setIpnAlreadyRunning },
} = ipnPrepareSlice
export function triggerIpnBuild(store: Store<{ ipnPrepare: IpnPrepare }>) {
const state = store.getState()
if (state.ipnPrepare.willBuild) return
navigator.locks.request("ipn.triggerBuild", { ifAvailable: true }, (lock) => {
if (lock) {
store.dispatch(triggerIpnPrepareBuild())
return new Promise(() => void 0)
} else {
store.dispatch(setIpnAlreadyRunning())
}
})
}
+1 -2
View File
@@ -1,9 +1,8 @@
import { combineSlices, configureStore } from "@reduxjs/toolkit" import { combineSlices, configureStore } from "@reduxjs/toolkit"
import { ipnPrepareSlice } from "./slices/ipnPrepare"
import { createDispatchHook, createSelectorHook, createStoreHook } from "react-redux" import { createDispatchHook, createSelectorHook, createStoreHook } from "react-redux"
import { statesSlice } from "./slices/states" import { statesSlice } from "./slices/states"
const tailshareReducer = combineSlices(ipnPrepareSlice, statesSlice) const tailshareReducer = combineSlices(statesSlice)
export function createTailshareStore(preloadedState?: TailshareState) { export function createTailshareStore(preloadedState?: TailshareState) {
return configureStore({ return configureStore({
+1 -1
View File
@@ -36,7 +36,7 @@ export function useBuildIpn(
} }
export function useBuildIpnWorker( export function useBuildIpnWorker(
worker: () => SharedWorker, worker: (() => SharedWorker) | null,
config?: WorkerConfig | null, config?: WorkerConfig | null,
runParams?: Parameters<IPN["run"]>[0] | null, runParams?: Parameters<IPN["run"]>[0] | null,
): IpnClientHandle | null ): IpnClientHandle | null
+1
View File
@@ -4,6 +4,7 @@
"module": "ES2020", "module": "ES2020",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"strict": true, "strict": true,
"skipLibCheck": true,
"declaration": true, "declaration": true,
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "src",
@@ -88,5 +88,6 @@ if (WASM_BUILT) {
h.disconnect() h.disconnect()
h.disconnect() h.disconnect()
assert.equal(h.running, false) assert.equal(h.running, false)
await h.shutdown()
}) })
} }
+17 -10
View File
@@ -102,12 +102,14 @@ suite("claim", () => {
assert.throws(() => registry.claim(transferId), /transfer already claimed or expired/) assert.throws(() => registry.claim(transferId), /transfer already claimed or expired/)
}) })
test("claim after TTL expiry throws and the resource was closed", async () => { test("claim after TTL expiry throws and the resource was closed", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] })
const registry = createTransferRegistry({ ttl: 10 }) const registry = createTransferRegistry({ ttl: 10 })
const { res, obj } = connResource() const { res, obj } = connResource()
const { transferId } = registry.detach(entryWith(res), 0) const { transferId } = registry.detach(entryWith(res), 0)
await waitFor(() => obj.closed) t.mock.timers.tick(10)
assert.equal(obj.closed, true)
assert.throws(() => registry.claim(transferId), /transfer already claimed or expired/) assert.throws(() => registry.claim(transferId), /transfer already claimed or expired/)
}) })
}) })
@@ -140,7 +142,8 @@ suite("send", () => {
assert.deepEqual(flushed, ["chan"]) assert.deepEqual(flushed, ["chan"])
}) })
test("queue TTL expiry drops the envelope and closes referenced pending resources", async () => { test("queue TTL expiry drops the envelope and closes referenced pending resources", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] })
const registry = createTransferRegistry({ ttl: 10 }) const registry = createTransferRegistry({ ttl: 10 })
const { res, obj } = connResource() const { res, obj } = connResource()
const { transferId } = registry.detach(entryWith(res), 0) const { transferId } = registry.detach(entryWith(res), 0)
@@ -154,7 +157,8 @@ suite("send", () => {
throw new Error("should not deliver") throw new Error("should not deliver")
}) })
await waitFor(() => obj.closed) t.mock.timers.tick(10)
assert.equal(obj.closed, true)
assert.equal(registry.hasTransfer(transferId), false) assert.equal(registry.hasTransfer(transferId), false)
const flushed: string[] = [] const flushed: string[] = []
@@ -175,13 +179,14 @@ suite("onEmpty", () => {
assert.equal(empties, 1) assert.equal(empties, 1)
}) })
test("fires when the last pending transfer expires", async () => { test("fires when the last pending transfer expires", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] })
let empties = 0 let empties = 0
const registry = createTransferRegistry({ ttl: 10, onEmpty: () => empties++ }) const registry = createTransferRegistry({ ttl: 10, onEmpty: () => empties++ })
const { res, obj } = connResource() const { res } = connResource()
registry.detach(entryWith(res), 0) registry.detach(entryWith(res), 0)
await waitFor(() => obj.closed) t.mock.timers.tick(10)
await waitFor(() => empties === 1) assert.equal(empties, 1)
}) })
}) })
@@ -299,7 +304,8 @@ suite("backlog lifecycle", () => {
assert.equal(chan.gen, 1) assert.equal(chan.gen, 1)
}) })
test("TTL expiry closes backlogged accepted conns", async () => { test("TTL expiry closes backlogged accepted conns", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] })
const registry = createTransferRegistry({ ttl: 10 }) const registry = createTransferRegistry({ ttl: 10 })
const { res, obj, chan } = listenerResource() const { res, obj, chan } = listenerResource()
const stray = { const stray = {
@@ -310,7 +316,8 @@ suite("backlog lifecycle", () => {
} }
registry.detach(entryWith(res), 0) registry.detach(entryWith(res), 0)
chan.backlog.push(stray) chan.backlog.push(stray)
await waitFor(() => obj.closed) t.mock.timers.tick(10)
assert.equal(obj.closed, true)
assert.equal(stray.closed, true) assert.equal(stray.closed, true)
}) })
+12 -13
View File
@@ -748,11 +748,9 @@ suite("WorkerConn state transfer", () => {
port2.close() port2.close()
}) })
test("transferState detaches via host and posts nothing on the port", async () => { test("transferState detaches via host and posts nothing on the port", async (t) => {
const { port1, port2 } = new MessageChannel() const { port1, port2 } = new MessageChannel()
const sent: unknown[] = [] const postMessage = t.mock.method(port1, "postMessage")
port2.onmessage = (e: MessageEvent) => sent.push(e.data)
port2.start()
let detachedArg = -1 let detachedArg = -1
const host: TransferHost = { const host: TransferHost = {
@@ -771,24 +769,20 @@ suite("WorkerConn state transfer", () => {
await assert.rejects(conn.read(), /detached/) await assert.rejects(conn.read(), /detached/)
await assert.rejects(conn.write(new Uint8Array([1])), /detached/) await assert.rejects(conn.write(new Uint8Array([1])), /detached/)
await new Promise((r) => setTimeout(r, 20)) assert.equal(postMessage.mock.callCount(), 0, "transferState must not post on the conn port")
assert.equal(sent.length, 0, "transferState must not post on the conn port")
port2.close() port2.close()
}) })
test("detach posts no close message and rejects further ops", async () => { test("detach posts no close message and rejects further ops", async (t) => {
const { port1, port2 } = new MessageChannel() const { port1, port2 } = new MessageChannel()
const sent: unknown[] = [] const postMessage = t.mock.method(port1, "postMessage")
port2.onmessage = (e: MessageEvent) => sent.push(e.data)
port2.start()
const host: TransferHost = { detachResource: async () => connToken } const host: TransferHost = { detachResource: async () => connToken }
const conn = new WorkerConn(port1, "a", "b", false, 1, host) const conn = new WorkerConn(port1, "a", "b", false, 1, host)
conn.detach() conn.detach()
assert.equal(conn.closed, true) assert.equal(conn.closed, true)
await new Promise((r) => setTimeout(r, 20)) assert.equal(postMessage.mock.callCount(), 0, "detach must not post a close message")
assert.equal(sent.length, 0, "detach must not post a close message")
await assert.rejects(conn.read(), /detached/) await assert.rejects(conn.read(), /detached/)
port2.close() port2.close()
}) })
@@ -875,8 +869,13 @@ suite("IpnWorkerClient state transfer", () => {
const { client, workerPort } = await fakeConnect((msg, port) => { const { client, workerPort } = await fakeConnect((msg, port) => {
if (msg.type === "hello") port.postMessage({ type: "ready" }) if (msg.type === "hello") port.postMessage({ type: "ready" })
}) })
let barrierReached = false
client.onTransfer("barrier", () => {
barrierReached = true
})
workerPort.postMessage({ type: "transfer", channel: "ch", state: { hi: 1 }, tokens: [] }) workerPort.postMessage({ type: "transfer", channel: "ch", state: { hi: 1 }, tokens: [] })
await new Promise((r) => setTimeout(r, 20)) workerPort.postMessage({ type: "transfer", channel: "barrier", state: {}, tokens: [] })
await waitFor(() => barrierReached)
let got: { state: unknown } | null = null let got: { state: unknown } | null = null
client.onTransfer("ch", (t) => { client.onTransfer("ch", (t) => {