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
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:
@@ -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`).
|
||||
|
||||
- **`@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/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.
|
||||
- **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` — 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.
|
||||
|
||||
Generated
+74
@@ -10525,11 +10525,15 @@
|
||||
"@mantine/notifications": "^9.3.1",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@webnet/drive": "*",
|
||||
"@webnet/http": "*",
|
||||
"@webnet/react": "*",
|
||||
"@webnet/tsconnect": "*",
|
||||
"@webnet/tsconnect-react": "*",
|
||||
"@webnet/tsconnect-redux": "*",
|
||||
"@webnet/tsconnect-worker": "*",
|
||||
"@webnet/utils": "*",
|
||||
"@webnet/websocket": "*",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -10834,6 +10838,76 @@
|
||||
"engines": {
|
||||
"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,2 +1,3 @@
|
||||
export { useClient } from "./useClient.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)
|
||||
}
|
||||
@@ -12,11 +12,15 @@
|
||||
"@mantine/notifications": "^9.3.1",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@webnet/drive": "*",
|
||||
"@webnet/http": "*",
|
||||
"@webnet/react": "*",
|
||||
"@webnet/tsconnect": "*",
|
||||
"@webnet/tsconnect-react": "*",
|
||||
"@webnet/tsconnect-redux": "*",
|
||||
"@webnet/tsconnect-worker": "*",
|
||||
"@webnet/utils": "*",
|
||||
"@webnet/websocket": "*",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useMantineTheme,
|
||||
} from "@mantine/core"
|
||||
import { NetworkXIcon, NetworkIcon, UserIcon } from "@phosphor-icons/react"
|
||||
import { IpnContext, useIpnSelector } from "../contexts/IpnContext"
|
||||
import { IpnContext, IpnPrepareContext, useIpnSelector } from "../contexts/IpnContext"
|
||||
import {
|
||||
getExitNode,
|
||||
getIpnSelfNode,
|
||||
@@ -22,12 +22,7 @@ import {
|
||||
getPeers,
|
||||
getWaitingFileCount,
|
||||
} from "@webnet/tsconnect-redux"
|
||||
import { useTailshareDispatch, useTailshareSelector, useTailshareStore } from "../store/store"
|
||||
import {
|
||||
getIpnPrepareAlreadyRunning,
|
||||
getIpnPrepareWillBuild,
|
||||
triggerIpnBuild,
|
||||
} from "../store/slices/ipnPrepare"
|
||||
import { useTailshareDispatch } from "../store/store"
|
||||
import { use, type ReactNode } from "react"
|
||||
import { Link } from "wouter"
|
||||
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() {
|
||||
const ipnSelfNode = useIpnSelector(getIpnSelfNode)
|
||||
const exitNode = useIpnSelector(getExitNode)
|
||||
@@ -174,27 +159,21 @@ function TailscaleRunningStatus() {
|
||||
}
|
||||
|
||||
export function TailscaleStateButton() {
|
||||
const willBuild = useTailshareSelector(getIpnPrepareWillBuild)
|
||||
const alreadyRunning = useTailshareSelector(getIpnPrepareAlreadyRunning)
|
||||
const store = useTailshareStore()
|
||||
const prepare = use(IpnPrepareContext)!
|
||||
const ipnState = useIpnSelector(getIpnState)
|
||||
const ipnLoginUrl = useIpnSelector(getLoginUrl)
|
||||
const [opened, { open, close }] = useDisclosure()
|
||||
|
||||
const running = !alreadyRunning && ipnState === "Running"
|
||||
const running = ipnState === "Running"
|
||||
const needsLogin = !running && ipnState === "NeedsLogin" && !!ipnLoginUrl
|
||||
const building = !needsLogin && ipnState === "NoState" && !!willBuild
|
||||
const notStarting = !building && !willBuild
|
||||
const building = !needsLogin && !running && prepare.willBuild
|
||||
const notStarting = !building && !prepare.willBuild
|
||||
|
||||
let label: string
|
||||
let tooltip: ReactNode
|
||||
let icon: ReactNode
|
||||
|
||||
if (alreadyRunning) {
|
||||
label = "Tailscale cannot start: open status"
|
||||
tooltip = <TailscaleAlreadyRunningStatus />
|
||||
icon = <NetworkXIcon />
|
||||
} else if (running) {
|
||||
if (running) {
|
||||
label = "Tailscale running: open status"
|
||||
tooltip = <TailscaleRunningStatus />
|
||||
icon = <NetworkIcon />
|
||||
@@ -223,11 +202,11 @@ export function TailscaleStateButton() {
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
if (needsLogin) window.open(ipnLoginUrl)
|
||||
else if (notStarting) triggerIpnBuild(store)
|
||||
else if (notStarting) prepare.build()
|
||||
else open()
|
||||
}}
|
||||
variant={running || needsLogin || alreadyRunning ? "filled" : "default"}
|
||||
color={needsLogin || alreadyRunning ? "red" : undefined}
|
||||
variant={running || needsLogin ? "filled" : "default"}
|
||||
color={needsLogin ? "red" : undefined}
|
||||
size="xl"
|
||||
aria-label={label}
|
||||
>
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { createContext, type ReactNode, useEffect, useState } from "react"
|
||||
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 function CaCertProvider({ children }: { children: ReactNode }) {
|
||||
const [caCerts, setCaCerts] = useState<string | null>(null)
|
||||
const willBuild = useTailshareSelector(getIpnPrepareWillBuild)
|
||||
useEffect(() => {
|
||||
if (!willBuild) return
|
||||
fetch(certUrl)
|
||||
.then((x) => x.text())
|
||||
.then(setCaCerts)
|
||||
}, [willBuild])
|
||||
}, [])
|
||||
|
||||
return <CaCertContext value={caCerts}>{children}</CaCertContext>
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createUseIpnSelector,
|
||||
createUseIpnStore,
|
||||
useBuildIpn,
|
||||
useBuildIpnWorker,
|
||||
} from "@webnet/tsconnect-react"
|
||||
import {
|
||||
buildIpnStore,
|
||||
@@ -14,20 +15,22 @@ import {
|
||||
getPeers,
|
||||
type IpnStore,
|
||||
} 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 {
|
||||
getIpnPrepareWillBuild,
|
||||
selectIpnPrepareSlice,
|
||||
setIpnPrepareAuthKey,
|
||||
setIpnPrepareControlURL,
|
||||
setIpnPrepareHostname,
|
||||
triggerIpnBuild,
|
||||
type IpnPrepare,
|
||||
} from "../store/slices/ipnPrepare"
|
||||
type ContextType,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
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 type { WorkerConfig } from "@webnet/tsconnect-worker"
|
||||
|
||||
export const IpnStoreContext = createIpnStoreContext()
|
||||
export const IpnContext = createIpnContext()
|
||||
@@ -37,63 +40,154 @@ export const useIpnStore = createUseIpnStore(IpnStoreContext)
|
||||
export const useIpnSelector = createUseIpnSelector(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 }) {
|
||||
const storeRef = useRef<IpnStore | null>(null)
|
||||
if (!storeRef.current) {
|
||||
storeRef.current = buildIpnStore()
|
||||
}
|
||||
const store = storeRef.current
|
||||
const willBuild = useTailshareSelector(getIpnPrepareWillBuild)
|
||||
const localStore = storeRef.current
|
||||
|
||||
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(() => {
|
||||
if (!willBuild) return
|
||||
initIPN(wasmUrl).then((ipnBuilder) => setIpnBuilder(() => ipnBuilder))
|
||||
}, [willBuild])
|
||||
if (!willBuild || useWorker) return
|
||||
initIPN(wasmUrl).then((b) => setIpnBuilder(() => b))
|
||||
}, [willBuild, useWorker])
|
||||
|
||||
const client = useClient()
|
||||
const builderParams = useMemo(() => {
|
||||
const builderParams: Parameters<IpnBuilder>[0] = {}
|
||||
if (!willBuild) return builderParams
|
||||
const params: Parameters<Exclude<typeof ipnBuilder, null>>[0] = {}
|
||||
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) {
|
||||
builderParams.stateStorage = new WebStorageState(localStorage, "ipn:")
|
||||
}
|
||||
// ── Active handle + store ─────────────────────────────────────────────────────
|
||||
const ipn: IpnClient | null = useWorker ? workerClient : mainIpn
|
||||
const activeStore = useWorker && workerClient ? workerClient.store : localStore
|
||||
|
||||
if (willBuild.hostname) builderParams.hostname = willBuild.hostname
|
||||
if (willBuild.controlURL) builderParams.controlURL = willBuild.controlURL
|
||||
if (willBuild.authKey) builderParams.authKey = willBuild.authKey
|
||||
const connectionMode: "worker" | "main-thread" | null =
|
||||
workerClient?.connectionMode ?? (mainIpn ? "main-thread" : null)
|
||||
|
||||
return builderParams
|
||||
}, [willBuild, client])
|
||||
|
||||
const ipn = useBuildIpn(store, client && willBuild ? ipnBuilder : null, builderParams)
|
||||
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 (
|
||||
<IpnStoreProvider store={store}>
|
||||
<IpnStoreProvider store={activeStore}>
|
||||
{ipn && (
|
||||
<>
|
||||
<AutoLogin ipn={ipn} />
|
||||
<AutoExitNode ipn={ipn} />
|
||||
<AutoExitNode ipn={ipn} config={config} setConfig={setConfig} />
|
||||
</>
|
||||
)}
|
||||
{client && (
|
||||
<>
|
||||
<AutoInit />
|
||||
<AutoConfig />
|
||||
</>
|
||||
)}
|
||||
<IpnContext value={ipn}>{children}</IpnContext>
|
||||
<AutoInit build={build} config={config} setConfig={setConfig} />
|
||||
<IpnContext value={ipn}>
|
||||
<IpnPrepareContext value={prepare}>{children}</IpnPrepareContext>
|
||||
</IpnContext>
|
||||
</IpnStoreProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function AutoLogin({ ipn }: { ipn: IPN }) {
|
||||
function AutoLogin({ ipn }: { ipn: IpnClient }) {
|
||||
const state = useIpnSelector(getIpnState)
|
||||
useEffect(() => {
|
||||
if (state === "NeedsLogin") ipn.login()
|
||||
@@ -101,91 +195,67 @@ function AutoLogin({ ipn }: { ipn: IPN }) {
|
||||
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 peers = useIpnSelector(getPeers)
|
||||
const state = useIpnSelector(getIpnState)
|
||||
const [savedExitNode, setSavedExitNode, clearSavedExitNode] = useLocalStorage("ipn#exitNode")
|
||||
|
||||
const setExitNode = useEffectEvent(() => {
|
||||
if (!savedExitNode) return
|
||||
if (!Object.values(peers).some((x) => x.stableNodeID === savedExitNode && x.exitNodeOption))
|
||||
const restoreExitNode = useEffectEvent(() => {
|
||||
if (!config.exitNode) return
|
||||
if (!Object.values(peers).some((x) => x.stableNodeID === config.exitNode && x.exitNodeOption))
|
||||
return
|
||||
ipn.setExitNode(savedExitNode)
|
||||
ipn.setExitNode(config.exitNode)
|
||||
})
|
||||
useEffect(() => {
|
||||
if (state !== "Running") return
|
||||
setExitNode()
|
||||
restoreExitNode()
|
||||
}, [state])
|
||||
|
||||
const updateSavedExitNode = useEffectEvent((exitNode: string | null) => {
|
||||
if (exitNode) setSavedExitNode(exitNode)
|
||||
else clearSavedExitNode()
|
||||
})
|
||||
const exitNodeChangedRef = useRef(false)
|
||||
const saveExitNode = useEffectEvent((exitNode: string | null) => {
|
||||
setConfig({ exitNode: exitNode ?? undefined })
|
||||
})
|
||||
useEffect(() => {
|
||||
if (state !== "Running") return
|
||||
if (currentExitNode) exitNodeChangedRef.current = true
|
||||
if (!exitNodeChangedRef.current) return
|
||||
updateSavedExitNode(currentExitNode)
|
||||
saveExitNode(currentExitNode)
|
||||
}, [state, currentExitNode])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function AutoInit() {
|
||||
function AutoInit({
|
||||
build,
|
||||
config,
|
||||
setConfig,
|
||||
}: {
|
||||
build: () => void
|
||||
config: TailshareConfig
|
||||
setConfig: (patch: Partial<TailshareConfig>) => void
|
||||
}) {
|
||||
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(() => {
|
||||
if (state === "Running") {
|
||||
setAutostart((prev) => (prev === "0" ? "0" : "1"))
|
||||
}
|
||||
}, [state, setAutostart])
|
||||
|
||||
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()
|
||||
}, [])
|
||||
if (!config.autostart) return
|
||||
const timeout = window.setTimeout(build, 1000)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [build, config.autostart])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -19,16 +19,7 @@ import {
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core"
|
||||
import { useTailshareDispatch, useTailshareSelector, useTailshareStore } from "../store/store"
|
||||
import {
|
||||
getIpnPrepareAlreadyRunning,
|
||||
getIpnPrepareWillBuild,
|
||||
selectIpnPrepareSlice,
|
||||
setIpnPrepareAuthKey,
|
||||
setIpnPrepareControlURL,
|
||||
setIpnPrepareHostname,
|
||||
triggerIpnBuild,
|
||||
} from "../store/slices/ipnPrepare"
|
||||
import { useTailshareDispatch } from "../store/store"
|
||||
import {
|
||||
getExitNode,
|
||||
getIpnSelfNode,
|
||||
@@ -38,33 +29,25 @@ import {
|
||||
getWaitingFileCount,
|
||||
getWaitingFiles,
|
||||
} from "@webnet/tsconnect-redux"
|
||||
import { IpnContext, useIpnSelector } from "../contexts/IpnContext"
|
||||
import { IpnContext, IpnPrepareContext, useIpnSelector } from "../contexts/IpnContext"
|
||||
import { use } from "react"
|
||||
import styles from "./tailscale.scss"
|
||||
import { CopyIcon, DownloadIcon, TrashIcon } from "@phosphor-icons/react"
|
||||
import { useDisclosure } from "@mantine/hooks"
|
||||
import clsx from "clsx"
|
||||
import { useLocalStorage } from "@webnet/react"
|
||||
import type { IpnClient, IPNWaitingFile } from "@webnet/tsconnect"
|
||||
import { fmtSize, download } from "@webnet/utils"
|
||||
import { setTailnetDrawerOpen } from "../store/slices/states"
|
||||
|
||||
function ConnectTailscale() {
|
||||
const willBuild = useTailshareSelector(getIpnPrepareWillBuild)
|
||||
const alreadyRunning = useTailshareSelector(getIpnPrepareAlreadyRunning)
|
||||
const store = useTailshareStore()
|
||||
const prepare = use(IpnPrepareContext)!
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Button disabled={!!willBuild || alreadyRunning} onClick={() => triggerIpnBuild(store)}>
|
||||
<Button disabled={prepare.willBuild} onClick={prepare.build}>
|
||||
Enable Tailscale
|
||||
</Button>
|
||||
{!!willBuild && <Loader />}
|
||||
{alreadyRunning && (
|
||||
<Text>
|
||||
Cannot start Tailscale, this is typically caused by another tab running Tailshare
|
||||
</Text>
|
||||
)}
|
||||
{prepare.willBuild && <Loader />}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
@@ -105,22 +88,30 @@ function TailscaleConfig() {
|
||||
const [opened, { open, close }] = useDisclosure()
|
||||
const exitNode = useIpnSelector(getExitNode)
|
||||
const peers = useIpnSelector(getPeers)
|
||||
const ipnPrepare = useTailshareSelector(selectIpnPrepareSlice)
|
||||
const dispatch = useTailshareDispatch()
|
||||
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 =
|
||||
ipnPrepare.willBuild &&
|
||||
(ipnPrepare.authKey !== ipnPrepare.willBuild.authKey ||
|
||||
ipnPrepare.controlURL !== ipnPrepare.willBuild.controlURL ||
|
||||
ipnPrepare.hostname !== ipnPrepare.willBuild.hostname)
|
||||
built &&
|
||||
(config.hostname !== built.hostname ||
|
||||
config.controlURL !== built.controlURL ||
|
||||
config.authKey !== built.authKey ||
|
||||
!!config.useWorker !== !!built.useWorker ||
|
||||
config.fileOps !== built.fileOps)
|
||||
|
||||
const opfsWithoutWorker = config.fileOps === "opfs" && (!workerAvailable || !config.useWorker)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
<Button onClick={open}>Tailscale config</Button>
|
||||
<Button onClick={() => dispatch(setTailnetDrawerOpen(true))}>Tailnet</Button>
|
||||
{connectionMode && (
|
||||
<Text c="dimmed" size="sm">
|
||||
Mode: <Code>{connectionMode}</Code>
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Modal centered opened={opened} onClose={close} title="Tailscale config">
|
||||
{ipn && (
|
||||
@@ -141,8 +132,8 @@ function TailscaleConfig() {
|
||||
withAsterisk
|
||||
label={<>Hostname</>}
|
||||
placeholder="(Autogen on startup)"
|
||||
value={ipnPrepare.hostname ?? ""}
|
||||
onChange={(e) => dispatch(setIpnPrepareHostname(e.target.value))}
|
||||
value={config.hostname ?? ""}
|
||||
onChange={(e) => setConfig({ hostname: e.target.value || undefined })}
|
||||
/>
|
||||
<TextInput
|
||||
mt="xs"
|
||||
@@ -156,26 +147,61 @@ function TailscaleConfig() {
|
||||
</>
|
||||
}
|
||||
placeholder="(Manual login flow)"
|
||||
value={ipnPrepare.authKey ?? ""}
|
||||
onChange={(e) => dispatch(setIpnPrepareAuthKey(e.target.value))}
|
||||
value={config.authKey ?? ""}
|
||||
onChange={(e) => setConfig({ authKey: e.target.value || undefined })}
|
||||
/>
|
||||
<TextInput
|
||||
mt="xs"
|
||||
withAsterisk
|
||||
label={<>Control plane URL</>}
|
||||
placeholder="(Default Tailscale control plane)"
|
||||
value={ipnPrepare.controlURL ?? ""}
|
||||
onChange={(e) => dispatch(setIpnPrepareControlURL(e.target.value))}
|
||||
value={config.controlURL ?? ""}
|
||||
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
|
||||
mt="md"
|
||||
label="Automatically start Tailscale with the app"
|
||||
checked={autostart === "1"}
|
||||
onChange={(e) => setAutostart("" + +e.target.checked)}
|
||||
checked={!!config.autostart}
|
||||
onChange={(e) => setConfig({ autostart: e.target.checked || undefined })}
|
||||
/>
|
||||
<Box className={clsx(styles.restartLine, requireRestart || styles.hidden)}>
|
||||
<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>
|
||||
<Button onClick={() => location.reload()}>Restart</Button>
|
||||
</Box>
|
||||
@@ -250,7 +276,7 @@ function TailscaleSelfNodeStatus() {
|
||||
<>No exit node</>
|
||||
)}
|
||||
</Text>
|
||||
<Text>Adresses:</Text>
|
||||
<Text>Addresses:</Text>
|
||||
<List ml="xs">
|
||||
{ipnSelfNode.addresses.map((address) => (
|
||||
<List.Item mt="xs" key={address} classNames={{ itemLabel: styles.addressWrapper }}>
|
||||
@@ -310,6 +336,7 @@ function TailscaleWaitingFiles({
|
||||
export default function TailscaleRoute() {
|
||||
const waitingFiles = useIpnSelector(getWaitingFiles)
|
||||
const ipn = use(IpnContext)
|
||||
const prepare = use(IpnPrepareContext)!
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -323,7 +350,15 @@ export default function TailscaleRoute() {
|
||||
and an exit node.
|
||||
</Text>
|
||||
<Divider m={"md"} />
|
||||
{!ipn ? <ConnectTailscale /> : <TailscaleState />}
|
||||
{!ipn && !prepare.willBuild ? (
|
||||
<ConnectTailscale />
|
||||
) : !ipn ? (
|
||||
<Group>
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<TailscaleState />
|
||||
)}
|
||||
<Divider m={"md"} />
|
||||
<TailscaleConfig />
|
||||
{!!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,9 +1,8 @@
|
||||
import { combineSlices, configureStore } from "@reduxjs/toolkit"
|
||||
import { ipnPrepareSlice } from "./slices/ipnPrepare"
|
||||
import { createDispatchHook, createSelectorHook, createStoreHook } from "react-redux"
|
||||
import { statesSlice } from "./slices/states"
|
||||
|
||||
const tailshareReducer = combineSlices(ipnPrepareSlice, statesSlice)
|
||||
const tailshareReducer = combineSlices(statesSlice)
|
||||
|
||||
export function createTailshareStore(preloadedState?: TailshareState) {
|
||||
return configureStore({
|
||||
|
||||
@@ -36,7 +36,7 @@ export function useBuildIpn(
|
||||
}
|
||||
|
||||
export function useBuildIpnWorker(
|
||||
worker: () => SharedWorker,
|
||||
worker: (() => SharedWorker) | null,
|
||||
config?: WorkerConfig | null,
|
||||
runParams?: Parameters<IPN["run"]>[0] | null,
|
||||
): IpnClientHandle | null
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
|
||||
@@ -88,5 +88,6 @@ if (WASM_BUILT) {
|
||||
h.disconnect()
|
||||
h.disconnect()
|
||||
assert.equal(h.running, false)
|
||||
await h.shutdown()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -102,12 +102,14 @@ suite("claim", () => {
|
||||
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 { res, obj } = connResource()
|
||||
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/)
|
||||
})
|
||||
})
|
||||
@@ -140,7 +142,8 @@ suite("send", () => {
|
||||
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 { res, obj } = connResource()
|
||||
const { transferId } = registry.detach(entryWith(res), 0)
|
||||
@@ -154,7 +157,8 @@ suite("send", () => {
|
||||
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)
|
||||
|
||||
const flushed: string[] = []
|
||||
@@ -175,13 +179,14 @@ suite("onEmpty", () => {
|
||||
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
|
||||
const registry = createTransferRegistry({ ttl: 10, onEmpty: () => empties++ })
|
||||
const { res, obj } = connResource()
|
||||
const { res } = connResource()
|
||||
registry.detach(entryWith(res), 0)
|
||||
await waitFor(() => obj.closed)
|
||||
await waitFor(() => empties === 1)
|
||||
t.mock.timers.tick(10)
|
||||
assert.equal(empties, 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -299,7 +304,8 @@ suite("backlog lifecycle", () => {
|
||||
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 { res, obj, chan } = listenerResource()
|
||||
const stray = {
|
||||
@@ -310,7 +316,8 @@ suite("backlog lifecycle", () => {
|
||||
}
|
||||
registry.detach(entryWith(res), 0)
|
||||
chan.backlog.push(stray)
|
||||
await waitFor(() => obj.closed)
|
||||
t.mock.timers.tick(10)
|
||||
assert.equal(obj.closed, true)
|
||||
assert.equal(stray.closed, true)
|
||||
})
|
||||
|
||||
|
||||
@@ -748,11 +748,9 @@ suite("WorkerConn state transfer", () => {
|
||||
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 sent: unknown[] = []
|
||||
port2.onmessage = (e: MessageEvent) => sent.push(e.data)
|
||||
port2.start()
|
||||
const postMessage = t.mock.method(port1, "postMessage")
|
||||
|
||||
let detachedArg = -1
|
||||
const host: TransferHost = {
|
||||
@@ -771,24 +769,20 @@ suite("WorkerConn state transfer", () => {
|
||||
await assert.rejects(conn.read(), /detached/)
|
||||
await assert.rejects(conn.write(new Uint8Array([1])), /detached/)
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
assert.equal(sent.length, 0, "transferState must not post on the conn port")
|
||||
assert.equal(postMessage.mock.callCount(), 0, "transferState must not post on the conn port")
|
||||
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 sent: unknown[] = []
|
||||
port2.onmessage = (e: MessageEvent) => sent.push(e.data)
|
||||
port2.start()
|
||||
const postMessage = t.mock.method(port1, "postMessage")
|
||||
|
||||
const host: TransferHost = { detachResource: async () => connToken }
|
||||
const conn = new WorkerConn(port1, "a", "b", false, 1, host)
|
||||
conn.detach()
|
||||
assert.equal(conn.closed, true)
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
assert.equal(sent.length, 0, "detach must not post a close message")
|
||||
assert.equal(postMessage.mock.callCount(), 0, "detach must not post a close message")
|
||||
await assert.rejects(conn.read(), /detached/)
|
||||
port2.close()
|
||||
})
|
||||
@@ -875,8 +869,13 @@ suite("IpnWorkerClient state transfer", () => {
|
||||
const { client, workerPort } = await fakeConnect((msg, port) => {
|
||||
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: [] })
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
workerPort.postMessage({ type: "transfer", channel: "barrier", state: {}, tokens: [] })
|
||||
await waitFor(() => barrierReached)
|
||||
|
||||
let got: { state: unknown } | null = null
|
||||
client.onTransfer("ch", (t) => {
|
||||
|
||||
Reference in New Issue
Block a user