- example-app: replace local useClient duplicate with @webnet/react, add
useLocalStorage to persist SharedWorker preference across page reloads
- example-app: replace local fmtSize duplicate with @webnet/utils/fmtSize,
use download() util in WaitingFileDebug instead of inline logic
- test-app: add @webnet/utils dependency and expose it on window alongside
other globals for console testing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extracts the useClient and useLocalStorage hooks into @webnet/react,
and the download, upload, readBlob, and fmtSize utilities into @webnet/utils,
so they can be shared by tailshare and other apps without living inside
a single app package.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add instructions for agents to apply `Agentic` + `Agent/<model-line>`
labels to every PR they open (creating the labels via `tea labels create`
if absent), and to auto-finalise PRs without user prompting when the
work is trivial or was fully specified upfront.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- IpnClientHandle.disconnect() JSDoc clarifies that the main-thread path
terminates IPN while the worker path is a soft detach
- connectWithFallback logs a console.warn with the caught error when falling
back from the SharedWorker path, so unexpected fallbacks are diagnosable
- IpnMainThreadHandle constructor comment explains that pre-run() callbacks
are intentionally dropped (run() snapshots current state on subscribe)
- lock?.release ?? null -> lock ? () => lock.release() : null to avoid
passing an unbound method reference
- Browser test: first test adds stateStorage: "memory" to avoid leaving IDB
state between runs
- Browser test: new test covers the IDB lock-contention path — acquires the
lock manually then confirms connectMainThread rejects immediately
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WorkerConfig.wasmUrl now accepts string | ArrayBuffer | ArrayBufferView,
allowing tests (and callers) to pass raw WASM bytes without going through
a URL fetch (useful in Node.js where file:// fetch is unsupported)
- Restructured connect.test.ts to share a single WASM+IPN instance across
tests via before()/after() hooks, preventing uncaught Go goroutine errors
that fired after sequential shutdown of multiple WASM runtimes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- IpnClientHandle interface: shared contract for both SharedWorker client and
main-thread handle (connectionMode, store, state, running, fileOps, run,
disconnect)
- IpnMainThreadHandle: wraps IPN + Redux store; all IpnClient methods proxy
directly; run() fires user callbacks with current state and delegates future
state changes via runWithStore; disconnect() releases the lock and shuts down
- connectMainThread: IndexedDB-backed state uses a web lock keyed to the DB
name ("tsconnect-idb:<name>"), rejecting immediately if held; in-memory
state skips the lock entirely
- connectWithFallback: tries SharedWorker first, falls back to main thread if
unavailable, disabled (disableSharedWorker option), or on worker init failure
- useBuildIpnWorker updated to use connectWithFallback, returns IpnClientHandle
- Tests: 6 Node tests verifying main-thread init and fallback behaviour; 4
browser tests (Chromium + Firefox) verifying worker path and explicit
disable fallback via esbuild-bundled inline build
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds connectMainThread(config) for browsers that do not support
SharedWorker (e.g. Chrome on Android before 2025). It mirrors the
worker's init path but runs in the main thread, returning an IPN
instance directly instead of a worker-proxy client.
Uses navigator.locks.request with { ifAvailable: true } to protect
against concurrent tabs; rejects immediately if the lock is already
held. The lock is released when the returned IPN.shutdown() resolves.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verify that the second close() call returns false (the #closed guard path
added in the previous commit).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WorkerConn/WorkerTCPListener/WorkerPacketConn/WorkerSSHSession.close() sent a
"close" message but never called port.close(). The port stayed active until the
worker sent a "closed" reply, which never happens in tests. Node.js's --test
runner uses process.on('exit') rather than process.exit(), so an undrained event
loop (open MessagePort handles) caused the test process to hang indefinitely.
Fix: close() now calls this.#port.close() and immediately rejects pending
promises. WorkerSSHSession gains a #closed guard to make close() idempotent.
Test cleanup updated to call close() methods rather than leaking ports.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Always base PRs on origin/main (fetch before branching)
- Watch CI results after push and address errors
- Spawn autonomous Sonnet-class code review when PR work is complete
- Send push notification to user when work settles or needs unblocking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The setFileOpsConfig handler in worker.ts and the setFileOpsConfig method
in client.ts previously used duplicate inline type literals. Extracting
FileOpsLimits from protocol.ts ties both to a single source of truth so
the compiler catches any future skew.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add fileOpsMaxFiles, fileOpsMaxTotalSize, and fileOpsMaxFileSize to
WorkerConfig so callers can set initial Taildrop limits at startup rather
than having to call setFileOpsConfig() after connecting. The options are
passed straight through to FsaFileOps.createFromOpfs(). Runtime changes
via setFileOpsConfig() continue to work as before.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
src/index.ts imports ../dist/wasm_exec.js which only exists after build.sh
runs; the generic typecheck task only declares ^build (dependencies' build)
as a prerequisite, leaving the package's own dist absent in a fresh env.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a ControlledFileOps interface (UserIPNFileOps + observable limits +
onChange) satisfied by InMemoryFileOps and FsaFileOps. Bind its state
into a new fileOps Redux slice so file storage metrics and limits are
observable via store.getState().fileOps and broadcast to all clients.
In tsconnect-worker: hoist the FsaFileOps instance to module scope,
wire bindFileOpsToStore after init, and handle a new setFileOpsConfig
call that lets clients reconfigure maxFiles/maxTotalSize/maxFileSize
at runtime. Limit changes propagate back via the existing action
broadcast path so all connected clients see updated state immediately.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tsx hardcodes keepNames:true in its esbuild transform, injecting a __name
helper at module scope. Playwright's page.evaluate() serializes callbacks
via .toString(), so the helper is absent in the browser context. Inject a
matching polyfill via addInitScript on every new page.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- serveDirectory: replace regex path traversal guard with resolve+relative
check, which is simpler to reason about and handles encoded edge cases
- drop test:browser:coverage scripts; c8 only covers Node orchestration
code, not browser-executed code inside page.evaluate() — the resulting
lcov is nearly empty and would confuse CI coverage dashboards
- guard server?.close() in after() hooks so teardown doesn't throw a
TypeError if the before() hook failed to start the server
- extract setupLoopback() helper inside each page.evaluate() in
transport.browser.ts to avoid verbatim repetition between tests
- add comment in helpers.browser.ts explaining the IDB readonly-transaction
trick that ensures setState()'s async write has settled before opening
a second IndexedDBState instance
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Uses Playwright as a library within node:test (not @playwright/test) to
keep the same runner and script conventions. Browser test files use the
*.browser.ts extension so the existing src/**/*.test.ts glob picks up zero
browser tests, leaving the regular test suite unaffected.
Key pieces:
- .npmrc: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 prevents binary downloads on
npm ci; browsers are installed explicitly in CI via playwright install
- @webnet/browser-test-utils: new private package exporting forBrowsers()
(iterates chromium + firefox, handles browser lifecycle within node:test
suite/before/after) and a serveDirectory() helper (minimal http.createServer
that serves a built dist/ or out/ directory so the browser can fetch ES
modules via dynamic import())
- test:browser / test:browser:coverage scripts added to transport, vfs,
tsconnect following the same c8 + lcov pattern as test:coverage
- turbo.json: test:browser and test:browser:coverage tasks depend on build +
^build (dist/ must exist before the browser can import from it)
- .gitea/workflows/test-browser.yml: CI pipeline that installs browsers with
--with-deps then runs npm run test:browser
Integration tests:
- DataChannelTransport: real RTCPeerConnection loopback (both peers in one
page context), exercises send/receive and close propagation
- FsaVFS: OPFS round-trip (writeFile/readFile), stat, readdir, delete
- IndexedDBState: multi-instance persistence (write via instance 1, open
fresh instance 2 and verify IDB round-trip), empty-DB initialisation
- FsaFileOps: write/read/stat/remove cycle and rename/listFiles over OPFS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move #fileSizes.set in openWriter to after all async FSA ops, so a
failed getFileHandle/createWritable/seek doesn't leave a phantom entry
inflating fileCount and totalSize
- Fix rename to stat the file when it wasn't previously tracked, so
untracked files don't become permanently invisible after a rename
- Document stat/totalSize inconsistency during active writes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds maxFiles/maxTotalSize/maxFileSize limit getters/setters,
fileCount/totalSize/openFiles getters, and onChange/offChange
notification to FsaFileOps, mirroring InMemoryFileOps. File sizes
are tracked in memory via a #fileSizes map; createFromOpfs now scans
the directory at startup so pre-existing files count toward limits.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Splits the VFS abstraction (AsyncVFS, Stat, VFSError, VFSErrorCode) and
its implementations (MemoryVFS, NodeVFS, FsaVFS) into a new standalone
@webnet/vfs package, following the same pattern as the @webnet/transport
split from @webnet/http.
packages/drive now depends on @webnet/vfs and imports directly from it.
packages/test-app likewise imports MemoryVFS and FsaVFS from @webnet/vfs.
The drive package.json export map drops the ./vfs/* sub-exports.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add "Reviewing pull requests" section to AGENTS.md: use `tea` (not
`gh`), validate assumptions with the user in interactive mode, and
post feedback directly on the PR in autonomous mode.
- Consolidate the AI disclosure out of README.md into a standalone
AI_CHANGES.md; README.md now links to it.
- Add .gitattributes with merge=union on AI_CHANGES.md so parallel
branches each appending entries never produce conflicts.
- Update the "AI disclosure" section in AGENTS.md to point agents at
AI_CHANGES.md instead of README.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bugs:
- Liveness detection now fires for all clients, including those that connect
while init is pending. Previously, the "hello" message was consumed by the
onFirst handler before registerClient set up its port.onmessage listener,
so navigator.locks.request was never called for the first tab and any tab
that connected during init. Fix: unified onHello handler in onconnect
extracts lockName from every "hello"; registerClient always takes lockName
and acquires the lock immediately.
- Remove dead bodyCallbacks field from bridgeDriveHandler pending map. The
map was allocated on every request but never read or populated; body chunk
reads go directly through entry.req.readBodyChunk().
- Drive cleanup in cleanupClient now only installs the no-op handler when no
other client still has driveRegistered = true, preventing the surviving
client's handler from being silently replaced.
Code quality:
- wrapDispatch: replace (s as any).dispatch with a two-step cast through
unknown — avoids the any escape hatch while satisfying RTK's overloaded
ThunkDispatch type.
- IndexedDBState.setState: add tx.onerror handler so failed IDB writes are
logged rather than silently dropped.
- pumpStreamToPort fire-and-forget in openWaitingFile fallback: add
.catch(() => {}) to make the intentional discard explicit.
- tsconfig.json: add skipLibCheck: true to match tsconfig.worker.json (both
transitively import @webnet/tsconnect which has an unresolved wasm_exec.js
declaration issue).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add src/worker.ts as the webpack worker entry (imports @webnet/tsconnect-worker/worker)
- IpnProvider: add useWorker/workerAvailable state; when useWorker is on,
useBuildIpnWorker() creates the client and its embedded store is passed to
IpnStoreProvider; when off, existing useBuildIpn() path runs unchanged
- Debug: add "use SharedWorker" checkbox (checked by default when available,
disabled when not); state dropdown shows indexeddb/memory in worker mode
and localStorage/sessionStorage/disable in main-thread mode; taildrop
option label reflects opfs vs memory depending on mode
- useBuildIpnWorker: add optional workerOptions param instead of hardcoding
{ type: "module" } — webpack bundles the worker as a classic script, so
the caller passes no options; module-mode callers opt in explicitly
- Add @webnet/tsconnect-react, @webnet/tsconnect-redux, @webnet/tsconnect-worker
to example-app package.json dependencies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Accepting a SharedWorker instance was unsafe: in StrictMode, React runs
effects twice in quick succession, and since the connect() promise is
async, clientRef.current is still null when the second effect fires —
so both calls would share the same sw.port, causing two IpnWorkerClient
instances to collide on one MessagePort.
With string | URL, each call to new SharedWorker() fires a fresh connect
event in the worker and returns an independent MessagePort, so the
StrictMode double-invoke is safe: the first connection gets disconnect()ed
when its promise resolves (cleanedUp is true), and the second becomes
the active client.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add disconnect() to IpnWorkerClient (releases Web Lock without shutdown)
- Widen IpnContext from IPN to IpnClient
- Add useBuildIpnWorker(worker, config, runParams) hook with StrictMode-safe
cleanup: disconnect() fires if the cleanup runs before connect() resolves,
or immediately after if it resolves first
- Export useBuildIpnWorker from package index
- Add @webnet/tsconnect-worker to package dependencies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Place IndexedDBState in the state-storage section alongside
InMemoryState and WebStorageState rather than between the
getIceServers doc comment and its function body.
Change getIceServers to accept IpnClient instead of the raw IPN
interface so callers can pass either IPN or IpnWorkerClient.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move IndexedDBState to @webnet/tsconnect/helpers and export from
package index; remove it from tsconnect-worker/storage.ts
- Add IpnClient interface, IpnSSHTermConfig, and IpnPacketConn to
@webnet/tsconnect; IPN class and IpnWorkerClient both implement it
- Make IPN.ssh() async to unify the sync/async split
- Add preloadedState parameter to buildIpnStore; worker now sends a
preloadState message (full Redux state) on connect instead of
replaying synthetic actions
- WorkerConn implements RawTransport, WorkerTCPListener implements
RawListener, WorkerPacketConn implements IpnPacketConn
- WorkerSSHSession.resize/close fire-and-forget (sync) to implement
IPNSSHSession; remove resizeResult/closeResult round-trips
- ReadableStream transfer fallback via MessageChannel sub-protocol for
Safari compatibility on sendFile and openWaitingFile
- Add stateStorage: "indexeddb" | "memory" option to WorkerConfig
- Export TLSCertKeyPair, WhoIsResponse, PingType, PingResult,
DNSQueryResult from @webnet/tsconnect package index
- Add @webnet/transport to tsconnect-worker dependencies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wraps the IPN and tsconnect-redux store in a SharedWorker, exposing every
IPN method to clients via message-port RPC with per-object MessageChannels
for Conn, TCPListener, PacketConn, drive handler, and SSH sessions.
Key design points:
- IndexedDBState: sync-read/write Map cache with async IDB flush, replacing
the unavailable localStorage in SharedWorkerGlobalScope
- Redux store on the worker broadcasts every dispatched action to all clients;
new clients receive a full state snapshot as synthetic actions on connect
- Web Locks API tracks client liveness: when the client's tab closes its lock
is released and the worker closes all resources that client opened
(Conn, TCPListener, PacketConn, drive handler)
- Graceful IPN shutdown when the last client leaves
- FsaFileOps (OPFS) available for Taildrop when fileOps: true in config
- Two tsconfigs: DOM lib for client code, WebWorker lib for worker.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>