- 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>
Add five tests covering the previously-missed branches:
- read() after clean channel close (no stored error)
- read() after error event re-throws the stored error
- error event with null error field falls back to generic message
- close event while write drain is pending rejects the write
- open-phase error with null error field uses generic message
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements RawTransport over an RTCDataChannel with write-side and
receive-side backpressure. Write-side: send() is always synchronous, but
write() defers its promise until bufferedamountlow fires when
bufferedAmount exceeds sendHighWatermark, using
bufferedAmountLowThreshold for the resume threshold. Receive-side:
removes the message event listener when buffered bytes exceed
receiveHighWatermark, letting SCTP's internal buffer fill and signal
flow control back to the sender; re-adds the listener once read() drains
below receiveLowWatermark.
Factory functions openDataChannel (creates + waits for open) and
acceptDataChannel (wraps an incoming channel) return a transport only
once the channel is fully open. Both accept separate send/receive
watermark options. All WebRTC types are defined as structural interfaces
so the package requires no DOM lib and tests use an in-process mock.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When close() was called, it fired #errcallback synchronously and also
left the server's "close" event free to fire it a second time.
Null out #errcallback before invoking it so both event-driven paths
are guarded and only one caller receives the rejection.
In accept(), the once("connection") listener was never removed when the
promise was rejected (e.g. via close()), so any late-arriving connection
would create a NodeTransport that was immediately dropped.
Replace the bare once() with a named handler that the error callback
removes before rejecting.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
no-empty, no-useless-assignment, and prefer-const violations introduced
by this branch's earlier test additions, plus stray formatting drift.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
bridgeDriveHandler still exposed ctx.req.stream() as an AsyncIterable
and had no iter() method, and didn't support ReadableStream response
bodies — both stale from before packages/http's stream()/iter() split.
Add a BYOB-aware byte stream for the request body and a ReadableStream
branch when writing the response body.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers default (non-BYOB) and BYOB byte-stream paths at chunk sizes just
below, at, and just above FLUSH_THRESHOLD (65536). Verifies both the
correctness of the chunked encoding output and that intermediate flushes
to the underlying transport occur at the right boundary.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ReadableHttp.stream() renamed to iter() (returns AsyncIterable)
- ReadableHttp.stream() now returns ReadableStream<Uint8Array> with BYOB
byte-stream support where the environment supports it
- WritableHttp.body now accepts ReadableStream<Uint8Array>; sent as
chunked transfer encoding with BYOB reads and periodic 64 KiB flushes
- Removed iterableToStream() and streamToIterable() helpers from
packages/drive (no longer needed)
- Updated all consumers in packages/drive to use the new APIs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers bridgeDriveHandler request/response translation (headers, body
streaming, status, all Body union variants) and IPN.serveDrive /
listDrivePeers argument validation and JSON handling, using fake
RawIPN/JsDriveRequest/JsDriveResponse objects so no WASM build is needed.
Bumps the tailscale submodule pointer to the rebased feat/drive-wasm-bridge
tip.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add drive types to @webnet/tsconnect:
- DriveSharePermission, DrivePermissions, JsDriveRequest, JsDriveResponse,
RawDriveHandler, IPNDrivePeer types in types.ts
- setDriveHandler / listDrivePeers on the raw IPN interface
Add IPN class methods in ipn.ts:
- serveDrive(fn): registers a WebDAV handler for /v0/drive peerapi endpoint
- listDrivePeers(): lists peers with PeerCapabilityTaildriveSharer in their ACL caps
Add new package @webnet/taildrive with ./server sub-export:
- bridgeDriveHandler(handler: Handler): RawDriveHandler adapts an
@webnet/http Handler to the Go bridge's raw callback interface, streaming
the request body chunk-by-chunk and piping the response body to write()/end()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The first notifyNetMap after setServices() can be an unrelated update
(other nodes joining) that fires before the service advertisement
propagates, so checking only the first one was flaky. Poll like the
peer.services test already does.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Points to 3a9f6f4 which adds RestartMap() to controlclient.Auto and
calls it from SetExplicitServices, so notifyNetMap fires immediately
after setServices instead of waiting for the next periodic control
plane push.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers three scenarios:
- setServices() resolves without error
- self.services is reflected in the next notifyNetMap after advertising
- A second IPN (watcher) sees the peer's services in its netmap update
Also extends connectIPN() to accept additional IPNRunOptions so callers
can wire notifyNetMap (and other callbacks) at run() time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getSelfServices returned a new [] literal on every call when self was
null/undefined, causing re-render loops. Replace with a shared constant.
getPeersByService and the new getPeersByServiceDescription are wrapped
with createSelector so the result array is only replaced when the peers
slice or the selector arguments actually change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Points the submodule at feat/tsconnect-service-advertisement
(dd9c9f68), which adds SetExplicitServices and the setServices
WASM binding.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add IPNService type and a services field on every netmap node (self and
peers). Browser WASM nodes can now call ipn.setServices([{proto, port,
description?}]) to advertise TCP/UDP services on the tailnet; the
declaration is distributed to all peers via the control server.
Two convenience selectors added to tsconnect-redux:
getPeersByService(proto, port) — peers advertising a given service
getSelfServices() — services declared by this node
Bumps the tailscale submodule to webnet/tailscale#9.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DERP servers run an integrated RFC 5389 STUN server (UDP 3478 by
default). Since tsconnect in the browser relays all traffic through
DERP over WebSocket and has no direct UDP path, WebRTC is the only
way to establish a direct peer-to-peer connection. getIceServers()
fetches the tailnet's DERPMap via LocalAPI and converts it to an
RTCIceServer[] list suitable for RTCPeerConnection({ iceServers }).
Unit tests cover parsing, port defaulting, and all filter conditions
(STUNPort < 0, empty HostName, non-200 status) — no WASM required.
Integration test verifies a live IPN returns at least one stun: URL.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unit tests cover InMemoryFileOps (read/write/stat/list/rename/remove, seek,
misuse guards, constructor seed) and InMemoryState. No WASM needed.
Integration tests spin up three real Tailscale nodes against a headscale
control server, verifying initIPN WASM loading, /localapi/v0/status, and
two-node TCP dial/listen. Credentials loaded from .env.local (gitignored).
All IPN instances share one Go WASM runtime (factory compiled once); shutdown()
on any one exits the runtime, so a single before()/after() pair wraps the suite.
test:coverage script added for consistency with other packages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
go.run() returns a Promise that resolves exactly when the Go runtime exits.
Storing it in initIPN and awaiting it in shutdown() eliminates a race where
Go deletes _inst before a callback-based resolve fires.
Go side: add nil guards on lb and ln so shutdown() is safe even when run()
was never called (e.g. testing IPN construction without connecting).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two Go-side bugs fixed in the tailscale submodule:
- safesocket_js.go: hardcoded "Tailscale-IPN" memconn addr caused log.Fatal
on a second newIPN() call; atomic counter now gives each instance a unique name.
- wasm_js.go: listen() rejected ":port" (any-interface form); netstack requires
an explicit bind addr; now normalises :port → 0.0.0.0:port.
wasm_exec.js ENOSYS stubs (installed when globalThis.fs is falsy) are the correct
behaviour: all network calls go through JS fetch()/WebSocket. Setting globalThis.fs
to Node.js's real fs caused Go to read /etc/resolv.conf and use host nameservers.
tsconfig.json: add test-file exclude; add DOM.AsyncIterable to lib.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a shutdown() method to the IPN class (TypeScript) and RawIPN
interface (types.ts), backed by the new Go shutdown() on jsIPN in
the tailscale wasm submodule.
Calling shutdown() stops the Tailscale backend, closes all held
resources, and lets the Go runtime exit cleanly — so Node.js processes
can exit without hanging and browser service workers can be paused or
killed for inactivity.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace whole-file buffering on both send and receive paths with Web
Streams API so arbitrarily large files can be transferred without being
bounded by available RAM.
Send: IPN.sendFile now accepts a ReadableStream<Uint8Array> + declaredSize
instead of a Uint8Array. The WASM layer pulls chunks from the stream via
ReadableStreamDefaultReader.read() Promises, feeding them directly to the
HTTP PUT body — no js.CopyBytesToGo of the full file.
Receive: IPN.openWaitingFile now returns Promise<ReadableStream<Uint8Array>>.
The WASM layer wraps the Go io.ReadCloser in a pull-based ReadableStream,
enqueuing 64 KiB chunks on demand — no io.ReadAll.
FileOps: UserIPNFileOps.openReader now returns ReadableStream<Uint8Array>
instead of Uint8Array. The JS→Go bridge wraps the stream in a new
jsStreamReader (io.ReadCloser) that blocks on a channel per chunk, matching
the existing channel+FuncOf pattern used throughout taildrop.go.
Add FsaFileOps in packages/tsconnect/src/helpers.ts: a UserIPNFileOps
backed by the File System Access API (any FileSystemDirectoryHandle), with
FsaFileOps.createFromOpfs(subdir?) as the OPFS factory. Received chunks
land directly in OPFS via FileSystemWritableFileStream with no intermediate
buffer. openReader returns file.stream(), so downloads are also zero-copy
through Go.
InMemoryFileOps.openReader is updated to return a ReadableStream that emits
stored chunks one by one (no concatenation).
The example app is updated to use file.stream()/file.size for send and
showSaveFilePicker (with Response.blob() fallback) for receive.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>