Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e15718a012 | ||
|
|
2798322a0a | ||
|
|
6a84570c63 | ||
|
|
ba9983de32 | ||
|
|
493eef3b7b | ||
|
|
790a795d5a | ||
|
|
f659f230cd | ||
|
|
2cfb14eb29 | ||
|
|
0947533a4a | ||
|
|
0a25ed2e7c | ||
|
|
6d8b38eb79 | ||
|
|
1c344eff74 | ||
|
|
cb236406a0 | ||
|
|
6dd8bf378f | ||
|
|
136fb457a0 | ||
|
|
d66067f9fb | ||
|
|
dd4fcdd5b7 | ||
|
|
c06289a262 | ||
|
|
e9b90a2c64 | ||
|
|
90bbc2fb2f | ||
|
|
d51a33cae0 | ||
|
|
6d95aab10d | ||
|
|
a121d03257 | ||
|
|
0b02dcede0 | ||
|
|
1c75e2de6f | ||
|
|
12828c4f39 | ||
|
|
802ea449dd | ||
|
|
9c9db0c17a | ||
|
|
ced0e59fba | ||
|
|
529e83223e | ||
|
|
132c542b3f | ||
|
|
e83a37da6c | ||
|
|
1458423679 | ||
|
|
da7c3d3e21 | ||
|
|
6c66acb882 | ||
|
|
abd605a2ab |
@@ -1 +0,0 @@
|
||||
AI_CHANGES.md merge=union
|
||||
@@ -11,6 +11,20 @@ Guidelines for AI agents (Claude Code, etc.) working in this repository.
|
||||
- Turbo is used as the task runner behind several root scripts (`build`, `test`, `typecheck`, and related commands). Its presence does not change the package manager or workspace layout.
|
||||
- `tailscale/` is a git submodule containing the forked Tailscale source used by `packages/tsconnect`; it is not another npm workspace.
|
||||
|
||||
### Package map
|
||||
|
||||
- Foundations: `transport`, `state-transfer`, and `vfs`.
|
||||
- Tailscale integration: `tsconnect`, `tsconnect-worker`, `tsconnect-redux`, `tsconnect-react`, and `taildrive`.
|
||||
- Protocols: `http`, `http-static`, `websocket`, `webdav`, `ftp`, `ssh`, `sftp`, and `smb2`.
|
||||
- Shared UI and utilities: `react`, `utils`, `xml`, and `browser-test-utils`.
|
||||
- Applications: `example-app`, `test-app`, and `tailshare`. These are private workspaces; do not treat them as published libraries.
|
||||
|
||||
Use the package's manifest and public exports as the source of truth. The package table in `README.md` is the concise repository overview and should be updated whenever a workspace is added, removed, renamed, or substantially repurposed.
|
||||
|
||||
Every workspace has its own `README.md` describing what it does and, where applicable, its entry points and a short usage example. Keep it accurate when a package's public API changes, and add one for any new workspace along with its row in the root table.
|
||||
|
||||
`packages/tailshare` is the flagship application: the libraries exist to be composed into it, and implemented features are expected to surface there. Do not describe `example-app` or `test-app` as the main application.
|
||||
|
||||
## Model attribution
|
||||
|
||||
Before creating commits, PRs, reviews, or applying `Agent/*` labels, use the exact model currently running — not merely its generic model family.
|
||||
@@ -26,16 +40,29 @@ Use the model's established display name for commit attribution. For PRs and iss
|
||||
- Always base PRs against `origin/main`: fetch and pull `origin/main` before branching unless a different base is specified.
|
||||
- Keep the PR description up to date: update it after each push to reflect the current state of the branch, not just the initial intent.
|
||||
- Before committing, run linting, formatting, and typechecking scoped to the affected packages or files where practical.
|
||||
- Apply the org-level `Agentic` label and the established org-level `Agent/<model-slug>` label for the exact coordinating model to every PR or issue you open. These labels already exist at the organisation level — do not create repo-level duplicates. The `Agent/*` labels are exclusive (only one may be set at a time). The org-level `Human` label identifies work opened by a human; do not apply it to work opened by an agent. Use `tea api` (the `tea` label flags are unreliable): look up the label IDs with `tea api orgs/webnet/labels`, then apply them with `tea api repos/{owner}/{repo}/issues/<number>/labels -d '{"labels":[<id1>,<id2>]}'`.
|
||||
- Apply labels according to the label taxonomy below.
|
||||
- After pushing to the PR branch or opening the PR, watch the CI results (e.g. via the `tea` CLI or available harness tools) and address any errors before proceeding.
|
||||
- When the PR work appears complete (typically after push and CI passes): if the harness provides a tool for spawning a new thread or agent, spawn an autonomous code review using a medium-size model (e.g. Sonnet for the Claude lineup). Watch the review output and address findings as appropriate.
|
||||
- When everything has settled, or if you need the user to unblock you: if the harness or other instructions provide a push notification tool, send the user a push notification with a short summary; include the full details in the main conversation thread.
|
||||
- When asked to finalise a PR: run all relevant tests and scoped checks as appropriate; rely on CI for global linting, formatting, and typechecking; ensure the AI disclosure in `AI_CHANGES.md` and the PR description are up to date; then remove the `WIP:` prefix from the PR title to mark it ready for merging.
|
||||
- When asked to finalise a PR: run all relevant tests and scoped checks as appropriate; rely on CI for global linting, formatting, and typechecking; ensure the PR description is up to date; then remove the `WIP:` prefix from the PR title to mark it ready for merging.
|
||||
- Reading `tea` mergeability output:
|
||||
- `tea` reports `mergeable: false` not only for git-level conflicts but also whenever the `WIP:` prefix is present, since that blocks merging in the web UI. Only treat `mergeable` as meaningful during finalisation, after the prefix has been removed.
|
||||
- `tea` output includes a section header listing conflicting files; the header itself is always printed. Its mere presence does not mean there are conflicts — actual conflicting files are listed under it, if any.
|
||||
- Auto-finalise the PR (without waiting to be asked) if you are confident the user will not request changes — for example, if the work is trivial, mechanical, or was fully specified upfront with no ambiguity. In that case, run the finalise steps above immediately after CI passes and the review is clean.
|
||||
|
||||
## Issue and pull request labels
|
||||
|
||||
Labels are defined at the `webnet` organization level. Do not create repository-level duplicates, and query `tea api orgs/webnet/labels?limit=100` before applying labels rather than hard-coding numeric IDs.
|
||||
|
||||
- Provenance: every issue and PR gets `Human`, `Agentic`, or — when a human and an agent genuinely wrote it together, rather than one drafting and the other editing at the margins — both. Work an agent contributed to also gets exactly one `Agent/<model-slug>` label for the coordinating model; never combine multiple `Agent/*` labels. Do not remove one of a co-authored pair to satisfy the usual one-of rule.
|
||||
- Kind: classify issues with exactly one of `Kind/Bug`, `Kind/Feature`, `Kind/Enhancement`, or `Kind/Maintenance`.
|
||||
- Priority: classify issues with exactly one `Priority/P0` through `Priority/P4`, where P0 is critical and P4 is wishlist-tier.
|
||||
- Area: apply the `Component/*` and `Protocol/*` labels for areas that directly own part of the implementation or are a primary target of the work. These families are not exclusive, but mentions, known callers, and downstream effects alone do not require a label.
|
||||
- Security: add `Security` to security-sensitive work in addition to its other labels.
|
||||
- Pull requests must always carry provenance labels. Also add the relevant classification labels when the PR needs to be discoverable independently of a linked issue; do not add unrelated labels mechanically.
|
||||
|
||||
Use `tea api` because the `tea` label flags are unreliable. Resolve the current organization label IDs, then apply the complete desired set with `tea api -X PUT repos/{owner}/{repo}/issues/<number>/labels -d '{"labels":[<id1>,<id2>]}'`. `PUT` replaces the label set; the default `POST` only adds to it, so removing a label needs `PUT`. Label endpoints treat pull requests as issues.
|
||||
|
||||
## Reviewing pull requests
|
||||
|
||||
- Always use the `tea` CLI to read PR details and post comments — use `tea pr` subcommands, not `gh`.
|
||||
@@ -62,8 +89,3 @@ Follow the existing style of the codebase. Use `packages/http` as the primary re
|
||||
## Dependencies
|
||||
|
||||
Do not add new dependencies without first asking the user, unless the intent to do so is unambiguously implied by the task (e.g. initialising a React app, adding a Postgres client when the task is explicitly about connecting to Postgres). When in doubt, ask.
|
||||
|
||||
## AI disclosure
|
||||
|
||||
`AI_CHANGES.md` contains the full AI disclosure log and must be kept up to date. `README.md` links to it but does not contain the disclosure itself.
|
||||
At the end of every branch, review the commits (e.g. `git log main..HEAD --oneline`) and append entries to `AI_CHANGES.md` for any new work that was assisted or authored by an AI agent.
|
||||
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
# AI disclosure
|
||||
|
||||
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` — `listDrivePeers` honours online and peerAPI reachability (issue #143)**: Claude Code (Claude Opus 5) fixed `listDrivePeers` in the tailscale fork (`cmd/tsconnect/wasm/drive.go`), which returned every peer carrying `PeerCapabilityTaildriveSharer` — in practice most of the tailnet, since the cap is an ACL grant usually given to a whole group or tag. It now applies the same conjunction as `LocalBackend.driveRemotesFromPeers`: online, non-empty peerAPI base, and the sharer cap. The peerAPI URL was already computed and discarded. The TypeScript doc comments on `IPN.listDrivePeers` and `IPNDrivePeer` were corrected to say the result is peers _allowed_ to share, not peers that expose a share, and the headscale integration test now asserts no returned peer is offline or unreachable. Deciding whether to add an opt-in stricter mode that queries each peer's Taildrive endpoint (N peerAPI round-trips) is left open in the issue.
|
||||
- **`@webnet/tsconnect` — no late "Go program has already exited" after `shutdown()` (issue #147)**: Claude Code (Claude Opus 5) fixed the wasm runtime teardown at its source. Go's `wasm_exec.js` carries the runtime's pending work across program exit: the scheduler's `setTimeout` stays armed, and every callback Go handed to JS (DERP WebSocket event listeners, `fetch` continuations) stays registered; all of them route through `_resume`, which throws `"Go program has already exited"` once the instance is gone. A live probe against headscale confirmed all three shapes after a clean `shutdown()` — a `Timeout._onTimeout` uncaught error, two `WebSocket` close-listener uncaught errors, and one unhandled rejection. `initIPN` now wraps the Go instance it creates: `runtime.wasmExit` clears the pending scheduler timeouts, and `_resume` returns quietly once the runtime has exited (nothing can be resumed after the instance is dropped). Because a quiet `_resume` also means a call into a dead Go handle resolves with `undefined` instead of throwing — which would spin a `read()` loop rather than break it — `Conn`, `PacketConn` and `TCPListener` now report themselves `closed` once their runtime has exited, so such calls raise `ClosedError` and `close()` becomes a no-op. An intermediate attempt to hand back a rejected promise instead was rejected after testing: `node:test` attributes the rejection to the creating hook and fails the run just as the original bug did. No Go-side change was needed — the backend shutdown itself is already clean; the leak is entirely in the JS glue.
|
||||
- **`@webnet/tsconnect` / `@webnet/tsconnect-worker` — reverted the #147 shutdown workarounds**: Claude Code (Claude Opus 5) removed the three workarounds the late-`_resume` bug forced, now that it is fixed: both `*.shutdown.probe.ts` child processes are gone (the shutdown tests run in-process again, each on its own `initIPN` runtime), the shared `@webnet/tsconnect` integration suite shuts its runtime down in `after()` again, and the `*.probe.ts` tsconfig excludes both packages carried are removed.
|
||||
- **CI — scheduled tsconnect CA bundle refresh (issue #142)**: `gpt-5.6-sol` added a monthly, manually dispatchable Gitea Actions workflow that checksum-verifies the current curl.se Mozilla CA bundle, exits without changes when it is current, and otherwise force-updates one automation branch and creates or refreshes its pull request. The generated PR body includes curl.se's Mozilla data date and fingerprint-based added/removed certificate common-name counts and lists.
|
||||
- **`@webnet/tsconnect-worker` — isolated Go/WASM connection tests**: `gpt-5.6-sol` fixed the flaky main-thread connection tests by moving their real Go runtimes into a dedicated child process that owns the runtime's known late scheduler errors, covering connection fallback, shutdown, and repeated disconnect without leaking live runtimes into normal or coverage test processes.
|
||||
- **`@webnet/tsconnect` — integration coverage for the untested bridge surface (PR #146)**: Claude Code (Claude Opus 5) extended the headscale integration suite from a TCP listen/dial pair to real two-node coverage of UDP datagrams, `listenICMP` (including a hand-built ICMP echo request and its reply), `listenTLS`/`dialTLS` against a run-time-generated self-signed CA plus the wrong-CA rejection case, `upgradeTls` on a live connection, Taildrop end-to-end (`listFileTargets`, `sendFile`, `waitingFiles`, `openWaitingFile`, `deleteWaitingFile`), `serveDrive`/`listDrivePeers` over a real netmap, `suggestExitNode`, and `setExitNode`. `serveDrive`, `listDrivePeers` and `upgradeTls` previously had tests only against mock raw objects that never loaded the wasm. Taildrop registers its own node pair from a new optional `TSCONNECT_TEST_USER_AUTH_KEY`, because `canPutFile` requires an untagged peer and the shared key registers `tag:test`; `setExitNode` is gated on `TSCONNECT_TEST_EXIT_NODE_ID`, and both are documented in `.env.example`. `shutdown()` runs in a child process (`src/shutdown.probe.ts`, excluded from the browser build via a new `*.probe.ts` tsconfig exclude) and the shared suite no longer tears the runtime down, because an exiting Go runtime leaves scheduler timeouts that throw afterwards and `node:test` claims the exception first (issue #147). Writing the tests surfaced two pre-existing gaps, worked around rather than fixed: `":port"` is normalised only for TCP `listen`, so `listen("udp4", ":0")` and `listenTLS(":0", …)` fail with `ParseAddrPort(":0"): no IP`; and a UDP socket bound to `0.0.0.0` cannot reach the tailnet. Verified against a tailscale build from the rebased fork branch across three consecutive green runs.
|
||||
- **Tailshare and example-app frontend typechecking (issue #122)**: `gpt-5.6-terra` added Turbo-discoverable TypeScript checks to both frontend packages, updated their library targets and declarations for current dependency typings, removed stale tsconnect source includes, and corrected the example HTTP demo for the current listener and server APIs so both applications typecheck cleanly.
|
||||
- **Tailshare and example-app frontend typechecking — review follow-up**: `gpt-5.6-terra` preserved the example HTTP demo's original `GET /` behavior while migrating it to the current server API, following an independent Claude review.
|
||||
- **Repository package-boundary enforcement (issue #107)**: `gpt-5.6-sol` removed all cross-package `_internals` imports and cross-package re-exports, replaced wildcard barrels with explicit reviewed exports, moved shared length-prefixed wire encoding into the utilities package, relocated shared path-helper coverage to VFS, preserved SSH malformed-auth and SFTP cleanup regressions, and added tested ESLint enforcement, package-boundary documentation, and SSH public-surface typetests.
|
||||
- **`@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.
|
||||
|
||||
This project was set up with the assistance of [Claude Code](https://claude.ai/code) (Anthropic). The following were written by Claude Code:
|
||||
|
||||
- **`@webnet/http-static` — VFS-backed static HTTP handler**: GPT-5.6 Luna added a new workspace package exposing `createStaticHandler`, with request/context path resolution, prefix and suffix lookup, index files, HTML/JSON directory listings, fallback paths, metadata and conditional responses, HEAD support, and single-byte range streaming. Added focused unit coverage and package build/test/typecheck scaffolding.
|
||||
- **`@webnet/http-static` — review fixes**: GPT-5.6 Luna addressed review findings by rejecting requests outside a configured prefix, returning 416 for ranges on empty files, normalizing method matching, and honoring `If-None-Match: *`.
|
||||
- **`@webnet/http-static` — precedence test vectors**: GPT-5.6 Luna added a seeded `MemoryVFS` matrix covering every requested path across default/custom/disabled indexes, suffix probing, and fallback configurations.
|
||||
- **`@webnet/http-static` — HTTP-relative directory listings**: GPT-5.6 Luna updated generated JSON paths and HTML links to use the request URL pathname, including configured HTTP prefixes, and added prefixed listing coverage.
|
||||
|
||||
- The `tailscale` submodule fork and its Go-side `tsconnect` patches (the `webnet` branch)
|
||||
- The `packages/tsconnect` TypeScript SDK
|
||||
- The `packages/test-app` Vite test application
|
||||
- The repo tooling setup (ESLint, Prettier, lint-staged, commitlint, dpdm, TypeScript 6)
|
||||
- `AGENTS.md` — the agent guidelines file (worktree/submodule rules, commit-trailer convention, PR finalisation checklist)
|
||||
|
||||
The following were hand-written:
|
||||
|
||||
- The `packages/http` HTTP/1.1 server library (HTTP parsing, server/client connection management, chunked transfer encoding, router/middleware system, response serialisation)
|
||||
|
||||
The following were hand-written but with substantial Claude Code contributions:
|
||||
|
||||
- **`packages/http` — low-level transport layer**: reviewed and significantly reworked by Claude Code, fixing multiple bugs (errcallback not cleared after a successful read, `ServerConnection` draining the body after closing the connection, `PooledDialer` throwing instead of queuing waiters, `shouldClose()` doing a case-sensitive header comparison) and adding transport primitives (`halfClose`, `readEnded`, `whenClosed`, `remoteAddr`, `localAddr`)
|
||||
- **`packages/http` — timeout support**: the `headersTimeout`, `keepAliveTimeout`, and `bodyTimeout` options across both client and server were implemented by Claude Code
|
||||
- **`packages/http` — parse error messages**: improved by Claude Code to include the offending values
|
||||
- **`packages/http` — package and build scaffolding**: initial `package.json`, `tsconfig.json`, and build configuration were set up by Claude Code
|
||||
|
||||
The test suite for `packages/http` was mostly generated by Claude Code, which also identified and fixed several further bugs: a body-size-limit error being swallowed on the final body chunk, a stale idle-connection entry left behind by `rejectConnection`, and a `read()` call hanging indefinitely after a socket close.
|
||||
|
||||
- **`packages/http` — `hijack()` on server and client responses**: the `hijack()` method on `ServerResponse` and `ClientResponse`, the `ReadBuffer.drain()` helper, and the `prependTransport()` utility were implemented by Claude Code
|
||||
- **`packages/http` — 1xx informational response support**: implemented by Claude Code. Server side: automatic `100 Continue` (sent lazily when the handler reads the body) and `res.sendInformational()` for 103 Early Hints etc. Client side: default skip mode, `interim: "collect"` to capture 1xx into `res.informational[]`, `conn.requestStream()` async generator that yields each interim response and the final one as they arrive, and `fetchStream()` / `f.stream()` to expose the same streaming behaviour through the fetch API with proper connection pool management.
|
||||
- **`packages/http` — WebSocket support**: implemented by Claude Code. `upgradeWebSocket(req, res)` for server-side handshake; `connectWebSocket(dialer, url, options?)` to open a new WebSocket connection, or `connectWebSocket(res, key)` to promote an existing `fetch()`/`fetchStream()` 101 response — both return a `WebSocketConnection` async iterable. Frame codec (read/write), masking, fragmented-message reassembly, ping/pong, and the close handshake are all implemented from scratch using the Web Crypto API (`crypto.subtle.digest` for SHA-1, `crypto.getRandomValues` for mask keys) with no external dependencies. Two fixed bugs in `fetch.ts` were required for pool safety: a case-insensitive `Connection: upgrade` check and immediate pool ejection on 101 to prevent a microtask race before hijack. Exported as three tree-shakeable entry points: `@webnet/http/websocket` (combined), `@webnet/http/websocket/client`, and `@webnet/http/websocket/server`.
|
||||
- **`packages/http` — 3xx redirect support**: implemented by Claude Code. `redirect.mode` (`"manual"` / `"same-connection"` / `"same-origin"` / `"follow"`), `redirect.max`, `redirect.filter` (string array / Set / callback), `redirect.credentials` (`"keep"` / `"strip-cross-origin"` / `"strip"`), `redirect.body` (`"resubmit"` / `"strip-non-resubmit"` / `"strip"`), and `redirect.collect` to gather followed 3xx into `res.redirects[]`. In streaming mode all redirects and interims are yielded as they arrive; a `drain()` method was added to `ClientConnection` to support clean connection hand-off between redirect hops.
|
||||
- **`packages/http` — lint fix and WebSocket test coverage**: Claude Code fixed a `prefer-const` lint error in `ServerConnection` by refactoring `HijackFn` to accept `res` as a parameter (eliminating a forward-reference `let res!` pattern), updated the ESLint config to recognise `_`-prefixed variables as intentionally unused (`varsIgnorePattern`), and added test coverage for extended-length WebSocket frames (2-byte and 8-byte), multi-chunk `ReadBuffer` slicing, empty close frames, unsolicited PONG frames, invalid port URLs, and the `fetchStream()` 101 early-break path.
|
||||
- **`packages/http` — typed context extension via `next(extra)`**: the `Router<T>` generic, the `Next<TAdd>` conditional type, and the context-passing chain were designed and implemented by Claude Code
|
||||
- **Monorepo restructuring**: the following packages were created or substantially reworked by Claude Code — `@webnet/transport` (transport abstractions, buffer utilities, node and loopback implementations extracted from `@webnet/http`); `@webnet/websocket` (WebSocket support extracted from `@webnet/http`); `@webnet/tsconnect-redux` (Redux state bindings extracted from `@webnet/tsconnect`); `@webnet/tsconnect-react` (React hooks and context extracted from `@webnet/tsconnect`). `@webnet/http`'s `Server` class was refactored to take a `Handler` argument instead of extending `Router`; `Router` was moved to a `./router` sub-export. `@webnet/tsconnect`'s `Conn` and `TCPListener` were updated to formally implement `RawTransport` and `RawListener`; `IPNDialer` implementing `RawDialer` was added.
|
||||
- **`@webnet/tsconnect` — drop pre-compression of assets**: Claude Code removed the gzip/brotli pre-compression of `main.wasm` (from the `tsconnect` fork's build-pkg) and `cacert.pem` (from `packages/tsconnect/build.sh`), dropping the corresponding `.br`/`.gz` package exports and adding `scripts/asset-sizes.sh` (exposed as `npm run asset-sizes`) to let consumers measure raw, gzip, and brotli sizes of the built assets.
|
||||
- **`packages/xml`**: a thin XML parse/serialize package with conditional exports — browser builds use the native `DOMParser`, Node.js builds use `@xmldom/xmldom`. Authored by Claude Code.
|
||||
- **`packages/drive`**: WebDAV client and server with an async VFS abstraction. Includes `MemoryVFS`, `NodeVFS` (node:fs), `FsaVFS` (browser File System Access API, with an OPFS factory method), `createDAVHandler()` (server-side HTTP handler compatible with `@webnet/http`), and `DAVClient` (which implements `AsyncVFS` so it can be used as a backing store for another server instance). Full WebDAV Level 2 locking support (`LockStore` interface, `InMemoryLockStore`, LOCK/UNLOCK methods, `If:` header enforcement, `lockdiscovery`/`supportedlock` properties, and client-side `lock()`/`unlock()`/`refreshLock()` with optional `lockToken` on all mutating methods). Authored by Claude Code.
|
||||
- **`@webnet/tsconnect` — streaming Taildrop**: Claude Code removed all full-file buffering from the Taildrop send and receive paths. `IPN.sendFile` now accepts a `ReadableStream<Uint8Array>` + `declaredSize`; `IPN.openWaitingFile` returns `Promise<ReadableStream<Uint8Array>>`. On the Go/WASM side, a new `jsStreamReader` (`io.ReadCloser`) pulls chunks from a JS `ReadableStreamDefaultReader` via awaited `.read()` Promises (channel+`js.FuncOf` pattern), and `jsReadableStream` wraps a Go `io.ReadCloser` in a pull-based JS `ReadableStream`. `UserIPNFileOps.openReader` now returns a `ReadableStream` instead of a `Uint8Array`. A new `FsaFileOps` class (with `FsaFileOps.createFromOpfs()`) provides an OPFS-backed `UserIPNFileOps` where received chunks land directly on disk and downloads stream back through Go without buffering. `InMemoryFileOps.openReader` was updated to emit stored chunks one-by-one via a `ReadableStream`.
|
||||
- **`@webnet/tsconnect` — `IPN.shutdown()`**: Claude Code added a `shutdown()` method to `IPN` (TypeScript) and `jsIPN` (Go/WASM). Calling it stops the `LocalBackend`, closes the safesocket listener to unblock `srv.Run`, and signals `main()` to return so the Go runtime exits. The TypeScript side awaits the `go.run()` Promise (captured in `initIPN` and threaded into each `IPN`) as the authoritative "Go runtime has exited" signal, avoiding a race where Go deletes `_inst` before a callback-based resolve could fire. Go-side nil guards were added for `lb` and `ln` so `shutdown()` is safe to call even when `run()` was never invoked.
|
||||
- **`@webnet/tsconnect` — multi-environment WASM loading and Node.js fixes**: Claude Code investigated Worker/SharedWorker and Node.js/Bun compatibility. The Go WASM and `wasm_exec.js` are already Worker-compatible (`js.Global()` maps to the Worker's `globalThis`; `wasm_exec.js` stubs `fs`/`process`/`path` when absent). Three issues were found and fixed for Node.js:
|
||||
1. `initIPN` accepted only a URL string and called `fetch()`, which does not support `file://` URLs in Node.js. Claude Code added a `WasmSource` union type (`string | URL | ArrayBuffer | ArrayBufferView | Response | ReadableStream<Uint8Array>`) dispatching to `WebAssembly.instantiate` for binary sources and `WebAssembly.instantiateStreaming` for URL/Response/ReadableStream inputs.
|
||||
2. `wasm_exec.js` installs ENOSYS stubs for `globalThis.fs` when it is falsy. In Node.js `globalThis.fs` is undefined, so the stubs are installed — and this is the correct behaviour: tsconnect's WASM routes all network calls through JavaScript's `fetch()` and WebSocket APIs (which use Node.js's own DNS resolver), so the Go net package's `/etc/resolv.conf` read should remain a no-op. An earlier iteration set `globalThis.fs` to Node.js's real `fs`, which caused Go to read the host's `/etc/resolv.conf` directly and attempt to use those nameservers, breaking in environments where they are unreachable (e.g. Tailscale-managed entries without Tailscale running). `wasm_exec.js` is now imported directly in `index.ts` and `Go` is extracted from `globalThis` inline — a separate `env-node.ts`/`env-web.ts` split was explored but both files were identical, so the indirection was removed.
|
||||
3. In the `tailscale` submodule, two Go-side bugs were fixed: `safesocket_js.go` used a hardcoded memconn address `"Tailscale-IPN"`, so a second `newIPN()` call in the same WASM process would `log.Fatal`; an atomic counter now gives each instance a unique address. And `wasm_js.go`'s `listen()` rejected the standard `":0"` (any-interface) address form that netstack does not accept; it now normalises `:port` to `0.0.0.0:port`.
|
||||
Claude Code also wrote the full automated test suite for `@webnet/tsconnect`: unit tests for `InMemoryFileOps` and `InMemoryState` (no WASM required), and integration tests that spin up real Tailscale nodes against a headscale control server, verifying `initIPN` WASM loading, `/localapi/v0/status`, and two-node TCP dial/listen.
|
||||
- **`@webnet/tsconnect` — `getIceServers()`**: Claude Code implemented `getIceServers(ipn)`, which fetches the tailnet's DERPMap via LocalAPI and converts it to an `RTCIceServer[]` list for use with `new RTCPeerConnection({ iceServers })`. DERP servers run an integrated RFC 5389-compliant STUN server on UDP 3478 by default; since tsconnect in the browser cannot use raw UDP (all traffic goes through DERP over WebSocket), WebRTC is the only way to establish a direct peer-to-peer connection. Also added `DERPMap`, `DERPRegion`, and `DERPNode` types. Unit and integration tests included.
|
||||
- **`@webnet/tsconnect` — service advertisement**: Claude Code added `SetExplicitServices` to `LocalBackend` in the tailscale fork (bypassing the OS portlist gate), wired a `setServices(services)` WASM binding, and added a `services` field to the netmap JSON for both self and peers (stripping internal peerapi entries). On the TypeScript side: `IPNService` type, `services: IPNService[]` on `IPNNetMapNode`, and `IPN.setServices()`. In `@webnet/tsconnect-redux`: `getPeersByService`, `getPeersByServiceDescription` (both memoized with `createSelector`), and `getSelfServices` (referentially stable empty-array fallback). Claude Code also wrote integration tests for `setServices()` and fixed two bugs found while making them reliable: `SetExplicitServices` previously only sent a "lite" map update that discarded the control server's response, so `notifyNetMap` never fired with the new services — fixed by adding `Auto.RestartMap()` to force a fresh streaming netmap; and `userServicesFromView` returned a nil slice when a node advertised no services, which serialized as `null` instead of `[]` in the netmap JSON.
|
||||
- **`@webnet/tsconnect` — taildrive WebDAV bridge and `listDrivePeers()`**: Claude Code implemented `jsFileSystemForRemote` in the tailscale fork (`cmd/tsconnect/wasm/drive.go`), a `drive.FileSystemForRemote` backed by a JS callback: request bodies stream to JS chunk-by-chunk via `readBodyChunk()`, response bodies stream back via `write()`/`end()` with `http.Flusher.Flush()` after each chunk, so large transfers never buffer in memory. `sys.DriveForRemote` is set at `newIPN` time; `setDriveHandler` (wired to `IPN.serveDrive(fn)`) registers the actual handler later, since the Tailscale auth/permission-parsing (`DriveSharingEnabled`, `ParsePermissions`) happens entirely Go-side before the JS handler is invoked. Also added `listDrivePeers` (wired to `IPN.listDrivePeers()`), mirroring native `driveRemotesFromPeers`: returns peers carrying `PeerCapabilityTaildriveSharer`, gated on `DriveAccessEnabled()`. On the TypeScript side: `DriveSharePermission`, `DrivePermissions`, `JsDriveRequest`, `JsDriveResponse`, `RawDriveHandler`, `IPNDrivePeer` types. Claude Code also created the new `@webnet/taildrive` package (`./server` sub-export) with `bridgeDriveHandler(handler)`, which adapts an `@webnet/http` `Handler` (e.g. from `@webnet/drive`'s `createDAVHandler`) to the Go bridge's raw request/response callback shape — kept in a separate package from `@webnet/tsconnect` and `@webnet/drive` to avoid a circular dependency between the two. Unit tests cover `bridgeDriveHandler`'s request/response translation (headers, all `Body` union variants, `hasBody` detection) and `IPN.serveDrive`/`listDrivePeers` argument validation and JSON handling against a fake `RawIPN`, so they run without a WASM build. A true end-to-end test against a live control plane is blocked on Headscale ACL support for `nodeAttrs`/`grants`/Taildrive, which only lands in Headscale v0.29.0 (not yet released stably at the time of writing).
|
||||
- **`packages/http` — `ReadableStream` body support**: implemented by Claude Code. Renamed the `AsyncIterable`-returning method to `iter()` and added a new `stream()` returning a `ReadableStream<Uint8Array>` with BYOB (byte stream) support where available. `WritableHttp.body` now accepts `ReadableStream<Uint8Array>`, written as chunked transfer encoding with BYOB reads and periodic flushes every 2^16 bytes. The `iterableToStream()`/`streamToIterable()` helpers in `packages/drive` were removed as they are no longer needed. `@webnet/taildrive`'s `bridgeDriveHandler` (predating this rename) was updated to match: `ctx.req.stream()` now returns a real BYOB-capable `ReadableStream<Uint8Array>` instead of an `AsyncIterable`, a `ctx.req.iter()` method was added for the old semantics, and response bodies that are a `ReadableStream` are now forwarded correctly instead of falling through to JSON serialization.
|
||||
- **`@webnet/transport` — `NodeListener` bug fixes**: two bugs fixed by Claude Code (Claude Sonnet 4.6): double invocation of the error callback (fired both directly from `close()` and from the server's `"close"` event), and a connection leak when `accept()` was rejected while a late-arriving connection triggered the still-registered `once("connection")` handler.
|
||||
- **`@webnet/transport` — WebRTC DataChannel transport**: Claude Code (Claude Sonnet 4.6) implemented `@webnet/transport/webrtc` — a `RawTransport` over an `RTCDataChannel`. Designed to coexist on a shared `RTCPeerConnection` alongside other channels (multiplexed file transfers, audio/video). Write-side backpressure defers `write()` promises via `bufferedamountlow` when `bufferedAmount` exceeds the high-watermark. Receive-side backpressure for slow local readers (e.g. OPFS writes) removes the `message` listener so the browser's internal SCTP buffer fills and signals flow control back to the sender; the listener is re-added once the read queue drains. All WebRTC types are defined as local structural interfaces so the package needs no DOM lib. 114 tests at 100% coverage.
|
||||
- **`@webnet/tsconnect-worker`**: new package authored by Claude Code (Claude Sonnet 4.6). Wraps the `IPN` and `tsconnect-redux` Redux store in a `SharedWorker`, exposing every IPN method to clients via message-port RPC with per-object `MessageChannel`s for `Conn`, `TCPListener`, `PacketConn`, drive handler, and SSH sessions. Key features: `IndexedDBState` for sync IPN state storage (unavailable `localStorage` workaround — loads entire store into a `Map` at startup, serves reads/writes synchronously, fires async IDB writes on mutation); Web Locks API for client liveness detection (worker cleans up all resources opened by a disconnected client: connections, listeners, packet connections, drive handler); Redux action broadcast to all clients on every dispatch; full state snapshot sent to each new client on connect; graceful IPN shutdown when the last client leaves. Two tsconfigs: DOM lib for `IpnWorkerClient` and proxy classes (`WorkerConn`, `WorkerTCPListener`, `WorkerPacketConn`, `WorkerSSHSession`); WebWorker lib for the worker entry point. Round-2 improvements (Claude Sonnet 4.6): `IndexedDBState` moved to `@webnet/tsconnect/helpers` and exported from the package; `buildIpnStore` now accepts an optional `preloadedState`, and the worker sends the full Redux state snapshot (`preloadState` message) instead of synthetic actions on client connect; `WorkerConn` formally implements `RawTransport`, `WorkerTCPListener` implements `RawListener`, `WorkerPacketConn` implements `IpnPacketConn`; a new `IpnClient` interface added to `@webnet/tsconnect` (with `IpnSSHTermConfig` and `IpnPacketConn`) implemented by both `IPN` and `IpnWorkerClient` to prevent method drift; `IPN.ssh()` made async; `WorkerSSHSession.resize()`/`close()` made fire-and-forget (sync) to implement `IPNSSHSession`; `ReadableStream` transfer fallback via `MessageChannel` sub-protocol for Safari compatibility on both `sendFile` and `openWaitingFile`; `stateStorage: "memory"` option added to `WorkerConfig` for ephemeral (non-persisted) IPN state. `@webnet/tsconnect-react` updated by Claude Code (Claude Sonnet 4.6): `IpnContext` widened from `IPN` to `IpnClient`; `useBuildIpnWorker(worker, config, runParams?, workerOptions?)` hook added to connect to a SharedWorker and return an `IpnWorkerClient`, with StrictMode-safe cleanup via `disconnect()`; `disconnect()` method added to `IpnWorkerClient` to release the Web Lock without stopping the worker; `workerOptions` parameter added to support both classic (webpack-bundled) and module workers. `example-app` updated by Claude Code (Claude Sonnet 4.6): SharedWorker demo added — `src/worker.ts` is the webpack worker entry, `IpnProvider` runs `useBuildIpnWorker` when SharedWorker is available (falling back to main-thread `useBuildIpn`), switches the Redux `Provider` to the worker client's embedded store on connect; the Debug UI shows a "use SharedWorker" checkbox (checked by default when available, with appropriate state/taildrop labels for each mode). Round-3 bug fixes (Claude Sonnet 4.6): liveness detection now fires for all clients including those that connect during init (unified `onHello` handler in `onconnect`; `registerClient` always receives `lockName` and acquires the lock immediately); dead `bodyCallbacks` field removed from drive pending map; drive cleanup only installs the no-op handler when no other client still has drive registered; `wrapDispatch` `as any` replaced with targeted two-step cast; `IndexedDBState.setState` logs write failures via `tx.onerror`; `pumpStreamToPort` fire-and-forget made explicit with `.catch(() => {})`.
|
||||
- **`AGENTS.md` / `CLAUDE.md` and `AI_CHANGES.md`**: PR review instructions (use `tea`, interactive vs autonomous modes) and AI disclosure consolidation into `AI_CHANGES.md` authored by Claude Code (Claude Sonnet 4.6).
|
||||
- **`@webnet/vfs` — VFS package extraction**: Claude Code (Claude Sonnet 4.6) split the VFS abstraction (`AsyncVFS`, `Stat`, `VFSError`, `VFSErrorCode`) and its three implementations (`MemoryVFS`, `NodeVFS`, `FsaVFS`) out of `@webnet/drive` into a new standalone `@webnet/vfs` package, following the same pattern as the earlier `@webnet/transport` split from `@webnet/http`. `@webnet/drive` and `@webnet/test-app` now import directly from `@webnet/vfs`. The drive package's `./vfs/*` sub-exports were removed. The `NodeVFS` test suite moved with the implementation.
|
||||
- **`@webnet/tsconnect` — `FsaFileOps` limits and change notification**: Claude Code (Claude Sonnet 4.6) added `maxFiles`/`maxTotalSize`/`maxFileSize` limit getters/setters, `fileCount`/`totalSize`/`openFiles` getters, and `onChange`/`offChange` change-notification to `FsaFileOps`, mirroring the existing `InMemoryFileOps` API. File sizes are tracked in memory via a `#fileSizes` map that is updated on every `openWriter`/`write`/`remove`/`rename`; `createFromOpfs` now scans the directory at startup so pre-existing files count toward limits. The constructor was updated to accept an optional `{ maxFiles, maxTotalSize, maxFileSize, initialSizes }` options bag. Follow-up fixes (also Claude Sonnet 4.6): moved `#fileSizes.set` in `openWriter` to after all async FSA ops so a failure doesn't leave a phantom entry; fixed `rename` to stat and track previously-untracked files under their new name rather than letting them become invisible; documented the `stat`/`totalSize` lag during active writes.
|
||||
- **`@webnet/tsconnect`** - `InMemoryFileOps` limits and change handlers reviewed by Claude Code (Sonnet 4.6)
|
||||
- **Browser testing infrastructure**: Claude Code (Claude Sonnet 4.6) added browser integration testing using Playwright as a library within the existing node:test runner. Key design decisions: `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` in `.npmrc` prevents binary downloads on `npm ci` (explicit `npx playwright install` in CI only); browser test files use the `*.browser.ts` extension to avoid matching the existing `src/**/*.test.ts` glob so the regular test suite is unaffected; `@webnet/browser-test-utils` is a new private package that exports `forBrowsers()` (registers test suites for Chromium and Firefox, handles browser lifecycle) and `serveDirectory()` (minimal HTTP server using `path.resolve`/`path.relative` to guard against path traversal). `test:browser` per-package scripts added; `test:browser:coverage` intentionally omitted since c8 only sees Node orchestration code, not browser-side code inside `page.evaluate()`. Browser tests use `page.evaluate()` with dynamic `import()` to load built package modules into real browser contexts. Integration tests written for: `DataChannelTransport` (real `RTCPeerConnection` loopback, send/receive and close-propagation); `FsaVFS` (OPFS round-trip, stat, readdir, delete); `IndexedDBState` (multi-instance persistence, empty-DB initialisation); `FsaFileOps` (write/read/stat/remove/rename/listFiles cycle). CI pipeline added at `.gitea/workflows/test-browser.yml`.
|
||||
- **`@webnet/browser-test-utils` — `__name` polyfill**: Claude Code (Claude Sonnet 4.6) fixed a `ReferenceError: __name is not defined` crash in all `*.browser.ts` tests. tsx hardcodes `keepNames: true` in its internal esbuild options, which injects a `__name` helper at module scope and wraps named function/const assignments with `__name(fn, "name")`. Playwright's `page.evaluate()` serializes callbacks via `.toString()`, capturing only the function body — so the module-level helper is absent in the browser context. Fixed by calling `page.addInitScript()` in `forBrowsers`'s `newPage` to inject a matching polyfill (`Object.defineProperty(target, "name", ...)`) into every page before any `evaluate()` runs.
|
||||
- **`ControlledFileOps` — observable file-ops binding**: Claude Code (Claude Sonnet 4.6) added a `ControlledFileOps` interface to `@webnet/tsconnect` that extends `UserIPNFileOps` with readable metrics (`fileCount`, `openFiles`, `totalSize`), configurable limits (`maxFiles`, `maxTotalSize`, `maxFileSize`), and an `onChange(handler) => unsubscribe` subscription — already satisfied structurally by `InMemoryFileOps` and `FsaFileOps`. Added a `fileOps` Redux slice to `@webnet/tsconnect-redux` (stores `FileOpsState | null`; `null` when file ops are not configured) with `setFileOpsState` action, `selectFileOps` selector, and a `getFileOpsState` convenience selector. Added `bindFileOpsToStore(fileOps, store)` to `@webnet/tsconnect-redux`'s binding module: takes an initial snapshot and re-dispatches on every `onChange` call; the returned unsubscribe is stored by the worker for lifecycle safety. In `@webnet/tsconnect-worker`: the `FsaFileOps` instance was hoisted from `init()` to module scope; `bindFileOpsToStore` is called after the store is created; a new `setFileOpsConfig({ maxFiles?, maxTotalSize?, maxFileSize? })` method on `IpnWorkerClient` proxies to a new worker-side `handleCall` case that mutates the live instance and throws back any limit-violation errors. All state updates propagate via the existing `wrapDispatch` broadcast, so every connected client's store reflects the current file-ops state and limits in real time. Unit tests for `bindFileOpsToStore` added to `@webnet/tsconnect-redux`.
|
||||
- **`ControlledFileOps` — observable file-ops binding**: Claude Code (Claude Sonnet 4.6) added a `ControlledFileOps` interface to `@webnet/tsconnect` that extends `UserIPNFileOps` with readable metrics (`fileCount`, `openFiles`, `totalSize`), configurable limits (`maxFiles`, `maxTotalSize`, `maxFileSize`), and an `onChange(handler) => unsubscribe` subscription — already satisfied structurally by `InMemoryFileOps` and `FsaFileOps`. Added a `fileOps` Redux slice to `@webnet/tsconnect-redux` (stores `FileOpsState | null`; `null` when file ops are not configured) with `setFileOpsState` action, `selectFileOps` selector, and a `getFileOpsState` convenience selector. Added `bindFileOpsToStore(fileOps, store)` to `@webnet/tsconnect-redux`'s binding module: takes an initial snapshot and re-dispatches on every `onChange` call. In `@webnet/tsconnect-worker`: the `FsaFileOps` instance was hoisted from `init()` to module scope; `bindFileOpsToStore` is called after the store is created; a new `setFileOpsConfig({ maxFiles?, maxTotalSize?, maxFileSize? })` method on `IpnWorkerClient` proxies to a new worker-side `handleCall` case that mutates the live instance and throws back any limit-violation errors. All state updates propagate via the existing `wrapDispatch` broadcast, so every connected client's store reflects the current file-ops state and limits in real time.
|
||||
- **`@webnet/tsconnect-redux` — typecheck fix**: Claude Code (Claude Sonnet 4.6) added `@types/node` to `devDependencies` and `"types": ["node"]` to `tsconfig.json` so that the `node:test`/`node:assert/strict` imports in `binding.test.ts` resolve during `tsc --noEmit`.
|
||||
- **`@webnet/tsconnect-worker` — main-thread fallback and `connectWithFallback`**: Claude Code (Claude Sonnet 4.6) added a full main-thread fallback path for environments that do not support `SharedWorker` (e.g. Chrome on Android before 2025). Key additions:
|
||||
- **`IpnClientHandle` interface**: extends `IpnClient`, adding `connectionMode`, `store`, `state`, `running`, `fileOps`, `run()`, `disconnect()`. Both `IpnWorkerClient` and the new `IpnMainThreadHandle` implement it.
|
||||
- **`IpnMainThreadHandle`**: wraps an `IPN` instance with a Redux store (wired via `runWithStore` at construction so future state changes fire user callbacks set later in `run()`). Proxies all `IpnClient` methods directly. `disconnect()` shuts down IPN and releases the lock.
|
||||
- **`connectMainThread(config)`**: initialises IPN in the main thread. For IndexedDB-backed state, acquires a web lock keyed to the DB name (`"tsconnect-idb:<dbName>"`); rejects immediately if held. In-memory state skips the lock entirely.
|
||||
- **`connectWithFallback(workerUrlOrFactory, config, opts?)`**: tries the SharedWorker path first; falls back to `connectMainThread` if `SharedWorker` is undefined, `opts.disableSharedWorker` is true, or the worker path throws.
|
||||
- **`useBuildIpnWorker` updated** in `@webnet/tsconnect-react`: now uses `connectWithFallback` and returns `IpnClientHandle | null` instead of `IpnWorkerClient | null`.
|
||||
- **Tests**: 6 Node tests (all pass — WASM loads, `IpnMainThreadHandle` is returned, `run()` fires callbacks, `disconnect()` is idempotent, `connectWithFallback` falls back in Node); 6 browser tests in Chromium and Firefox (SharedWorker path → `connectionMode=worker`; `disableSharedWorker: true` → `connectionMode=main-thread`; IDB lock contention → `connectMainThread` rejects immediately). Browser tests use esbuild (hoisted transitive dep) to bundle the package inline in the test's `before()` hook. `WorkerConfig.wasmUrl` widened to `string | ArrayBuffer | ArrayBufferView` so tests can pass raw WASM bytes (Node.js `fetch()` does not support `file://` URLs). Node tests restructured to share a single WASM+IPN instance per suite via `before()`/`after()` hooks, preventing uncaught Go goroutine errors from sequentially shutting down multiple WASM runtimes. Code review addressed: `IpnClientHandle.disconnect()` JSDoc clarifies the main-thread-vs-worker semantics difference; `connectWithFallback` logs a `console.warn` for diagnosed fallbacks; lock release uses a closure rather than an unbound method reference.
|
||||
- **CI — lint, format, typecheck, typetest, and build workflows**: Claude Code (Claude Sonnet 4.6) added `.gitea/workflows/checks.yml` with five jobs (`lint`, `format`, `typecheck`, `typetest`, `build`) following the same structure as the existing `test-node.yml` and `test-browser.yml` workflows. Also added a `@webnet/tsconnect#typecheck` package-level override in `turbo.json` so that package's typecheck task depends on its own build (required because `src/index.ts` imports `../dist/wasm_exec.js`, a WASM artifact absent in a fresh environment).
|
||||
- **`@webnet/tsconnect-worker` — FileOps limits at init**: Claude Code (Claude Sonnet 4.6) added `fileOpsMaxFiles`, `fileOpsMaxTotalSize`, and `fileOpsMaxFileSize` fields to `WorkerConfig` in `protocol.ts`, and wired them through to `FsaFileOps.createFromOpfs()` in `worker.ts`. Limits can also be changed after startup via the existing `setFileOpsConfig()` method on `IpnWorkerClient`.
|
||||
- **`@webnet/tsconnect-worker` — FileOps limits at init**: Claude Code (Claude Sonnet 4.6) added `fileOpsMaxFiles`, `fileOpsMaxTotalSize`, and `fileOpsMaxFileSize` fields to `WorkerConfig` in `protocol.ts`, and wired them through to `FsaFileOps.createFromOpfs()` in `worker.ts`. Limits can also be changed after startup via the existing `setFileOpsConfig()` method on `IpnWorkerClient`. A `FileOpsLimits` named type was extracted to `protocol.ts` and shared between the worker-side handler and the client-side `setFileOpsConfig` signature to prevent future drift.
|
||||
- **`AGENTS.md` / `CLAUDE.md` — PR workflow improvements**: Claude Code (Claude Sonnet 4.6) added guidance for always basing PRs on `origin/main` (fetch/pull before branching), watching CI results after push, spawning an autonomous code-review agent (Sonnet-class) once CI passes, and sending a push notification to the user when work settles or needs unblocking.
|
||||
- **Test coverage gap-fill**: Claude Code (Claude Sonnet 4.6) added unit tests to four previously uncovered packages. `packages/xml`: 17 tests for `el()`, `text()`, and `stringify()` (namespace prefixing, escaping, self-closing vs. open/close tags, all namespaces hoisted to root), plus `test`/`test:coverage` scripts. `packages/tsconnect-redux`: 60 tests covering all ~25 exported selectors (state, login, exitNode, self, lockedOut, suggestedExitNode, fileTargets, peer lookups by id/name/service/description, self-services, outgoing/incoming/waiting file selectors, fileOps state). `packages/vfs`: 44 direct `MemoryVFS` unit tests (stat, readdir, writeFile, readFileRange, mkdir, delete recursive, copy, move, setProps; all VFSError codes). `packages/tsconnect-worker`: 38 tests for `WorkerConn`, `WorkerTCPListener`, `WorkerPacketConn`, `WorkerSSHSession`, `pumpStreamToPort`, and `portToReadableStream` using `MessageChannel` pairs (no browser APIs required); added `@types/node` devDep and `"types":["node"]` to tsconfig. HTTP redirect tests were found to already exist in `packages/http/src/client/fetch.test.ts` (lines 585–1203).
|
||||
- **`AGENTS.md` / `CLAUDE.md` — PR labeling and auto-finalise**: Claude Code (Claude Sonnet 4.6) added guidance to apply the org-level `Agentic` and `Agent/<model-line>` labels (which already exist at org level; `Agent/*` are exclusive) to every agent-opened PR, and to auto-finalise PRs without waiting for the user when the work is trivial or fully specified upfront.
|
||||
- **`@webnet/react` and `@webnet/utils` — new packages**: Claude Code (Claude Sonnet 4.6) extracted the `useClient` and `useLocalStorage` hooks from `packages/tailshare` into a new standalone `@webnet/react` package, and extracted the `download`, `upload`, `readBlob`, and `fmtSize` utilities into a new `@webnet/utils` package. Both packages are plain TypeScript with no dependencies beyond React (peer dep for `@webnet/react`) and are built with `tsc`.
|
||||
- **`@webnet/react` and `@webnet/utils` — integration into `example-app` and `test-app`**: Claude Code (Claude Sonnet 4.6) wired `@webnet/react` and `@webnet/utils` into the consumer apps. In `example-app`: replaced the local `useClient` duplicate with the package version; added `useLocalStorage` to persist the SharedWorker toggle (`ipn:useWorker`) across page reloads; replaced the local `fmtSize` with `@webnet/utils`; simplified the `WaitingFileDebug` download handler to use `download()` from `@webnet/utils`. Two bugs were fixed in `@webnet/utils/download` during integration: a typo (`suggegestedName` → `suggestedName`) in the `showSaveFilePicker` call, and synchronous `URL.revokeObjectURL` reverted to a deferred `setTimeout` for Safari compatibility. In `test-app`: added `@webnet/utils` as a dependency and exposed it on `window.utils` for console testing.
|
||||
- **`@webnet/tsconnect-worker` — flaky closed-state test fix**: Claude Code (Claude Sonnet 4.6) replaced racy `setTimeout(r, 10)` waits in four "rejects immediately when closed" tests (`read`, `write`, `accept`, `readFrom`) with a deterministic `while (!x.closed) await setImmediate()` poll. The 10 ms sleep was not always sufficient under CI load, causing the "closed" message to arrive _after_ the method under test created a pending promise, yielding `"packet conn closed"` instead of the expected `"already closed"` error.
|
||||
- **CI — consolidate workflows and eliminate redundant install/build**: Claude Code (Claude Sonnet 5) merged `checks.yml`, `test-node.yml`, and `test-browser.yml` into a single `.gitea/workflows/ci.yml`. Previously all 7 jobs independently repeated checkout + `tailscale` submodule clone + `npm ci`, and 5 of them independently rebuilt the whole workspace via Turbo. Live smoke tests against this Gitea instance found `actions/cache` (and `setup-node`'s built-in `cache: npm`, same API) times out against the built-in cache proxy, and `actions/upload-artifact`/`download-artifact` v4 refuse to run at all (GHES detection), but v3 of the artifact actions work correctly. Given that, a new `install` job now does the real work once (submodule clone, `npm ci`, build) and hands `node_modules` plus Turbo's local cache off to `typecheck`/`typetest`/`node-tests`/`browser-tests` (all `needs: install`) via `actions/upload-artifact@v3`/`download-artifact@v3`, instead of each job reinstalling and rebuilding from scratch. `lint`/`format` never touched the submodule or build output, so they dropped that step and stay independent for fast feedback. The shared checkout+submodule+setup-node preamble was factored into a composite action, `.gitea/actions/setup/action.yml` (composite actions require the repo to already be checked out, so `actions/checkout` stays a separate first step in every job rather than being absorbed into the composite action). A `concurrency` group cancels superseded runs on the same ref. One real bug was found and fixed during this work: `packages/xml` and `packages/vfs` pin a newer local TypeScript than the workspace root, installed by npm as nested `packages/{xml,vfs}/node_modules/typescript`; the first version of the `install` job's `node_modules` archive only captured the root `node_modules`, so downstream jobs silently typechecked those two packages against the wrong TypeScript version and produced spurious errors — fixed by archiving `packages/*/node_modules` alongside the root. Cross-run caching (reusing a previous run's install/build) is not available until the Gitea instance's cache backend is fixed server-side; that's a separate, out-of-scope follow-up.
|
||||
- **`@webnet/tsconnect-worker` — remaining flaky message-wait fixes**: Claude Code (Claude Sonnet 5) found the same fixed-delay race pattern still present in the rest of `worker.test.ts` (the `WorkerSSHSession.resize()`/`close()` tests called out as flaky in CI, plus the other `close()`-message, callback-firing, and `pumpStreamToPort` tests), where a flat `setTimeout(r, 10)` was used to wait for a `postMessage` to be delivered before asserting on it. Added a generic `waitFor(predicate, timeoutMs?)` helper that polls via `setImmediate` and replaced every such fixed sleep with a poll on the actual condition (message present in the captured array, or callback fired). Following an autonomous review, the four remaining ad hoc `while (!x.closed) await setImmediate()` spin-loops from the earlier b917cd8 fix were also consolidated onto the same `waitFor` helper for consistency.
|
||||
- **`AGENTS.md` / `CLAUDE.md` — `tea` mergeability and symlink clarifications**: Claude Code (Claude Fable 5) documented that `CLAUDE.md` is a symlink to `AGENTS.md`, that `tea` reports `mergeable: false` while the `WIP:` title prefix is present (so mergeability is only meaningful at finalisation), and that the conflicting-files section header in `tea` output is always printed — only files listed under it indicate actual conflicts.
|
||||
- **`AGENTS.md` / `CLAUDE.md` — exact model attribution**: Codex (GPT-5.6-Sol) documented that agents must use the exact running model for commits, PRs, reviews, and `Agent/*` labels; prefer harness-provided identity, fall back to Codex CLI session metadata when needed, and never guess or reuse another session's model identity.
|
||||
- **`AGENTS.md` / `CLAUDE.md` — project structure**: Codex (GPT-5.6 Luna) documented that this repository uses npm workspaces and `package-lock.json`, with Turbo as the task runner and `tailscale/` as a git submodule rather than an npm workspace; agents should not treat it as a pnpm repository.
|
||||
|
||||
- **CI — require built WASM test artifacts**: Codex (GPT-5.6 Terra) made `test` and `test:coverage` depend on each package's own build, archives/restores workspace build outputs for every downstream CI job, and turns missing tsconnect WASM artifacts into CI-only test failures while preserving local skips.
|
||||
- **`@webnet/ftp` — new package (FTP/FTPS client and server)**: Claude Code (Claude Fable 5, with Claude Sonnet 5 subagents on the common protocol layer) authored a new `@webnet/ftp` package: an FTP + implicit-FTPS server exposing any `AsyncVFS` over the `RawListener` transport interface, and an `FTPClient implements AsyncVFS` (mirroring `DAVClient`) over a `RawDialer`. Server command set targets FileZilla/KDE kio/classic `ftp` compatibility: USER/PASS (pluggable `authenticate` callback returning a per-user VFS), FEAT/OPTS UTF8/SYST/TYPE/MODE/STRU/PBSZ/PROT, PWD/CWD/CDUP, PASV/EPSV passive data channels via a caller-injected `dataListen` factory (TLS listeners = FTPS data channels; PORT/EPRT and AUTH TLS are 502 stubs pending active-mode and transport TLS-upgrade support), LIST/NLST (unix `ls` format), MLSD/MLST, STAT, SIZE/MDTM, REST/RETR/STOR streaming directly between the data connection and VFS web streams, DELE/RMD/MKD, RNFR/RNTO, ABOR. Client prefers EPSV/MLSD and falls back to PASV and unix-`ls` LIST parsing (for vsftpd, which lacks MLSD); operations are serialized on a single control connection with a mutex held across each verb+data-stream sequence; `readFileRange` maps to REST plus client-side truncation; `copy` is omitted (no server-side copy in FTP). Error translation both ways via `vfsErrorToReply`/`replyToVFSError` tables. 125 tests: unit tests for the codec/listing/address/time/path helpers and loopback integration suites (full AsyncVFS conformance, LIST/PASV fallback paths, raw protocol-level server assertions).
|
||||
- **Development apps — explicit remote-host mode**: Codex (GPT-5.6 Terra) added a safe local `dev` mode and an explicit `dev:host` mode to the Vite test app and webpack example app. The hosted mode binds to all interfaces and permits forwarded hosts only when `WEBNET_DEV_HOST=1`; workspace documentation covers trusted-network exposure and WebSocket proxying for HMR.
|
||||
- **`@webnet/ftp` — real-server integration coverage**: Codex (GPT-5.5) added an opt-in integration suite for a real FTP server. It is skipped unless `FTP_TEST_HOST`, `FTP_TEST_USER`, and `FTP_TEST_PASS` are configured, with optional `FTP_TEST_PORT` and `FTP_TEST_DIR`; it verifies upload, listing, download, rename, and recursive cleanup.
|
||||
- **`AGENTS.md` — scoped pre-commit checks and CI-based PR finalisation**: Codex (GPT-5.6 Luna) documented running linting, formatting, and typechecking scoped to affected packages or files before commits, relying on CI for global checks during PR finalisation, and monitoring CI after pushes.
|
||||
- **`AGENTS.md` — issue labeling**: Codex (GPT-5.6 Luna) extended the required `Agentic` and model-specific `Agent/*` labels from pull requests to issues, and documented the org-level `Human` label for human-opened work.
|
||||
- **Explicit TLS upgrade (STARTTLS / AUTH TLS) on `RawTransport`**: Claude Code (Claude Fable 5) added an optional in-place `upgradeTls(options?: TlsUpgradeOptions)` method and an optional `isTls` getter to `RawTransport` in `@webnet/transport`, for protocols that negotiate in plaintext and then upgrade the existing connection (SMTP STARTTLS, FTPS AUTH TLS). `TlsUpgradeOptions` is a union of client mode (`serverName`/`insecureSkipVerify`/`caCerts`; `serverName` required unless skipping verification, since an upgraded connection only knows its peer IP) and server mode (`isServer: true` with `certPem`/`keyPem`). All implementations require a quiescent transport (no pending read, no buffered data) and close the connection on handshake failure. `NodeTransport` swaps its socket for a `node:tls` `TLSSocket` wrapping the same socket (listener re-wiring extracted into `#attach`/`#detach`; a peer aborting the handshake surfaces as a plain close on the server side, handled explicitly); a checked-in self-signed localhost cert fixture backs a real bidirectional STARTTLS test suite. `@webnet/tsconnect`'s `Conn` swaps its raw Go handle via a new `upgradeTLS` wasm bridge method (added to `wrapConn` in the `tailscale` submodule, with the TLS client-config construction factored out of `dialTLS` into `tlsClientConfigFromJS`; server mode uses `tls.X509KeyPair` + `tls.Server` like `listenTLS`) and guards against in-flight reads/writes with an operation counter; `dialTLS`-created conns now report `isTls: true`. `@webnet/tsconnect-worker` gained `upgradeTls`/`upgraded`/`upgradeError` messages in the per-conn protocol, an `isTls` field on the `conn`/`accepted` messages, and matching `WorkerConn` support. Verified end-to-end with a live STARTTLS handshake against smtp.gmail.com:587. Three AI review rounds hardened the feature: an autonomous Sonnet review found that reads/writes weren't blocked while an upgrade handshake was in flight (fixed with an `upgrading` guard in all three implementations); a neutral Fable 5 review found the server-side pipelined-ClientHello race (a fast peer's handshake bytes landing in the internal buffer before `upgradeTls` is called are now unshifted back into the stream) and the worker `upgradeError` conflating validation failures with handshake failures (the reply now carries a `closed` flag); a GPT-5.6 review found that pre-handshake configuration failures leaked the Go conn (the wrapper marked it closed while Go left it open — Go now closes on every error path) and that a synchronous invalid-PEM throw could permanently strand the node transport in upgrading state (TLS socket construction moved inside the failure path). Final semantics, documented on `RawTransport`: synchronous option-validation rejections leave the transport usable; any failure after the upgrade starts closes it.
|
||||
- **Cross-tab state and ownership transfer (`@webnet/transport`, `@webnet/tsconnect-worker`, `@webnet/http`, `@webnet/drive`)**: designed and orchestrated by Claude Code (Claude Fable 5), implemented by Claude Opus 4.8 and Claude Sonnet 4.5 subagents. Adds a mechanism to move live resources (tcp/tls conns, listeners, udp packet conns, plus arbitrary JSON/byte/transferable payloads) from one SharedWorker client tab to another without reopening connections, e.g. to pop a protocol panel out into its own window that survives the originator closing. `@webnet/transport` gains the `StateTransferable` interface (`transferState()` detaches and returns a structured-cloneable state) and `isStateTransferable`. `@webnet/tsconnect-worker`: worker-side resources are tracked per client under stable resource ids and can be detached into a TTL'd pending-transfer registry (`transfers.ts`) that survives the owning client's death (IPN shutdown is deferred while transfers are pending) and re-bridged to the claiming client; clients register an app-supplied `clientKey` in `hello`, and `IpnWorkerClient` gains a broker API (`sendTransfer`/`onTransfer`, convenience `transfer`/`onAdopt`, `claim`, `transferSupported`, `workerDialer`); envelopes addressed to a not-yet-connected key are queued in the worker and flushed on registration (Fable 5 fixed a handshake bug found by the browser tests where flushed envelopes arriving before `ready` were dropped). `@webnet/http`: `ClientConnection.canExport()/exportTransport()` and `ConnectionPool.exportIdle()/seed()` move idle keep-alive connections (with any buffered prefix bytes) between pools. `@webnet/drive`: `DAVClient` implements `StateTransferable<DavTransferState>` and `DAVClient.adopt()` rebuilds a client from claimed transports, degrading gracefully to re-dialing when claims fail. Covered by Node unit tests (registry, proxies, broker, pool, DAV) and a two-tab Playwright suite proving a transferred listener survives originator-tab death.
|
||||
- **Cross-tab state and ownership transfer (`@webnet/transport`, `@webnet/tsconnect-worker`, `@webnet/http`, `@webnet/drive`)**: designed and orchestrated by Claude Code (Claude Fable 5), implemented by Claude Opus 4.8 and Claude Sonnet 4.5 subagents. Adds a mechanism to move live resources (tcp/tls conns, listeners, udp packet conns, plus arbitrary JSON/byte/transferable payloads) from one SharedWorker client tab to another without reopening connections, e.g. to pop a protocol panel out into its own window that survives the originator closing. `@webnet/transport` gains the `StateTransferable` interface (`transferState()` detaches and returns a structured-cloneable state) and `isStateTransferable`. `@webnet/tsconnect-worker`: worker-side resources are tracked per client under stable resource ids and can be detached into a TTL'd pending-transfer registry (`transfers.ts`) that survives the owning client's death (IPN shutdown is deferred while transfers are pending) and re-bridged to the claiming client; clients register an app-supplied `clientKey` in `hello`, and `IpnWorkerClient` gains a broker API (`sendTransfer`/`onTransfer`, convenience `transfer`/`onAdopt`, `claim`, `transferSupported`, `workerDialer`); envelopes addressed to a not-yet-connected key are queued in the worker and flushed on registration (Fable 5 fixed a handshake bug found by the browser tests where flushed envelopes arriving before `ready` were dropped). `@webnet/http`: `ClientConnection.canExport()/exportTransport()` and `ConnectionPool.exportIdle()/seed()` move idle keep-alive connections (with any buffered prefix bytes) between pools. `@webnet/drive`: `DAVClient` implements `StateTransferable<DavTransferState>` and `DAVClient.adopt()` rebuilds a client from claimed transports, degrading gracefully to re-dialing when claims fail. Covered by Node unit tests (registry, proxies, broker, pool, DAV) and a two-tab Playwright suite proving a transferred listener survives originator-tab death. Following review (GPT 5.6-Terra and an autonomous Claude Sonnet 4.5 pass), Fable 5 fixed an ownership-crossing bug (results of in-flight reads/accepts at detach time are now parked in a per-resource generation-tagged backlog and delivered to the claiming owner in order, instead of leaking on the old client entry) and five smaller findings (nested-token collection in `transfer()`, handshake replay ordering, DAV export error path, pool slot pruning, main-thread stub rejection semantics). After the branch was rebased onto the TLS-upgrade work, Fable 5 integrated the two features: transferred conns keep their TLS state across ownership transfer (isTls on claim replies and conn ResourceMeta), upgradeTls is serialized through the per-resource channel lock, and transferState refuses while an upgrade is in flight.
|
||||
- **`@webnet/ftp` — explicit FTPS (`AUTH TLS`)**: Codex (GPT-5.6-Sol) added an `"explicit"` client security mode with verified control/data upgrades, configurable CA roots and an explicit insecure override that still sends the configured hostname for SNI. The server now handles `AUTH TLS`, advertises it through `FEAT`, supports optional or required TLS-before-login policy, and applies `PROT P` to passive data transports. Passive listeners arm acceptance when PASV/EPSV starts so Node transports cannot lose early connections, while TLS handshakes remain deferred until the transfer command. Real TLS tests cover protected round trips, SNI in insecure mode, custom and untrusted roots, hostname mismatch, required-login policy, invalid server credentials, missing upgrade support, and data-handshake recovery.
|
||||
- **`@webnet/ftp` — explicit FTPS (`AUTH TLS`)**: Codex (GPT-5.6-Sol) added an `"explicit"` client security mode with verified control/data upgrades, configurable CA roots and an explicit insecure override that still sends the configured hostname for SNI. The server now handles `AUTH TLS`, advertises it through `FEAT`, supports optional or required TLS-before-login policy, and applies `PROT P` to passive data transports after the RFC-required protected-control and `PBSZ 0` sequence. Passive listeners arm acceptance when PASV/EPSV starts so Node transports cannot lose early connections, while TLS handshakes remain deferred until the transfer command; abandoned early accepts are closed when a passive channel is replaced. Real TLS tests cover protected round trips, SNI in insecure mode, custom and untrusted roots, hostname mismatch, required-login policy, invalid server credentials, missing upgrade support, data-handshake recovery, and passive transport cleanup.
|
||||
|
||||
- **`@webnet/smb2` — new SMB2/3 client package**: Claude Code (Fable 5) designed and implemented a new `@webnet/smb2` package: an SMB2/3 client that runs over the existing `RawTransport`/`RawDialer` abstraction (TCP/445 in Node, relayed through tsconnect's `IPNDialer` in the browser) and exposes a remote Windows/Samba share as an `@webnet/vfs` `AsyncVFS` (`SMB2Client`), mirroring how `@webnet/drive` exposes WebDAV. It targets default Windows 10/11 (including 24H2's mandatory SMB signing) and supported Samba 4.x, negotiating dialects {2.0.2, 2.1, 3.1.1}. Highlights:
|
||||
- **Isomorphic crypto, no Node APIs, no new dependencies.** The primitives Web Crypto lacks are hand-written in TypeScript and only run over small NTLM blobs: MD4, MD5, HMAC-MD5. SMB3 AES-CMAC signing is built on Web Crypto `AES-CBC` (RFC 4493 subkey construction); the SP800-108 key-derivation function on Web Crypto `HMAC-SHA256`; SHA-512 preauth-integrity and HMAC-SHA256 signing use Web Crypto natively. All primitives are covered by published test vectors (RFC 1320/1321/2202/4231/4493, NIST SP800-108, and the MS-NLMP §4.2.4 NTLMv2 sample).
|
||||
- **Authentication:** NTLMv2 inside NTLMSSP inside a minimal hand-rolled SPNEGO (DER) wrapper, with the message-integrity code (MIC) and channel-binding AV pair.
|
||||
- **Signing** (HMAC-SHA256 for 2.x, AES-CMAC for 3.x) and 3.1.1 SHA-512 preauth-integrity hashing are implemented; the client both signs its requests and **verifies inbound response signatures** (rejecting unsigned or invalid responses on a signed session, per [MS-SMB2] 3.2.5.1.3). SMB3 encryption is deferred (the transform layer is structured so it can be added without restructuring), and no cipher is advertised so an encryption-mandating share fails cleanly at TREE_CONNECT.
|
||||
- **VFS surface:** `stat`, `readdir`, `readFile`/`readFileRange` (streaming, credit-aware, chunked to the negotiated max read size), `writeFile`, `mkdir`, `delete` (recursive), `move` (rename), `copy`, and `setProps` (timestamps), plus `connect`/`disconnect`. NTSTATUS codes are translated to `VFSError` codes at the boundary.
|
||||
- **Tests:** unit tests with the crypto/auth vectors above, protocol codec round-trip tests, and an in-repo mock SMB2 server that performs a real SMB 3.1.1 signed round-trip end-to-end over the loopback transport (including tests that tampered and unsigned responses are rejected). An opt-in `smb2.integration.test.ts` (gated behind `SMB2_TEST_*` env vars) runs against a real Samba/Windows share.
|
||||
- Wired into `@webnet/test-app` (exposed as `window.smb2`) for a browser smoke check.
|
||||
- The protocol command codecs and the mock server were drafted by subordinate Sonnet agents from exact wire-layout specifications; the crypto, authentication, connection/session state machine, and VFS mapping were written and reviewed by Fable 5.
|
||||
- **`@webnet/sftp` — new package (SFTP v3 client and server)**: Claude Code (Claude Fable 5, coordinating; Claude Opus 4.8 subagents implemented the SSH transport, auth/channels, and SFTP client/server layers, and a Claude Sonnet 5 subagent scaffolded the package and pure codecs) authored a new `@webnet/sftp` package: an SFTP v3 client (`SFTPClient implements AsyncVFS`, mirroring `DAVClient`/`FTPClient`) over a `RawDialer`, and an `SFTPServer` serving any `AsyncVFS` over a `RawListener` via an http-style `listen(listener, opts)` accept loop. Both run in the browser (via tsconnect's `IPNDialer`) and Node with no Node APIs in package source and no new dependencies. The package implements a full SSH-2 transport from scratch on Web Crypto (`crypto.subtle`/`crypto.getRandomValues`) with zero hand-written primitives: version-banner exchange, binary packet framing with per-direction sequence numbers, `curve25519-sha256` key exchange with `ssh-ed25519`/`rsa-sha2-256`/`rsa-sha2-512` host-key verification, RFC 4253 key derivation, `aes128/256-gcm@openssh.com` and `aes128/256-ctr` with `hmac-sha2-256`/`hmac-sha2-256-etm@openssh.com` ciphers (continuous CTR counter and GCM invocation nonce tracked across packets), and transparent peer- or self-initiated rekey. On top of that: ssh-userauth (client offers a direct signed Ed25519 publickey request then falls back to password; server drives none/publickey(PK_OK)/password through a pluggable `authenticate` callback returning a per-user `AsyncVFS`), a connection-protocol mux with a session channel and bidirectional window flow control giving end-to-end backpressure, and the SFTP v3 layer. The client pipelines requests over one channel (request-id dispatch map, serialized wire writes so a split WRITE stays contiguous), maps AsyncVFS verbs onto FXP operations with pull-driven read streams (≤8 outstanding 32 KiB READs) and bounded-inflight writes, client-side recursive delete, and `posix-rename@openssh.com` for overwriting `move`. The server maps FXP back onto the VFS with a handle table, 100-entry READDIR batches with unix `ls -l` longnames, sequential streaming reads/writes, `SETSTAT`/`FSETSTAT` no-ops (so OpenSSH `put` succeeds), REALPATH, and v3 rename semantics; responses are serialized per session and handler errors reply a status without tearing down the connection. Ed25519 auth keys are parsed from the unencrypted `openssh-key-v1` format (encrypted keys are out of scope); host keys are optionally verified via a `verifyHostKey({ type, key, fingerprint })` callback and can be generated with the exported `generateHostKey()`. 203 tests: crypto/kex/cipher/codec/key/path units, loopback transport suites (incl. a 1000-packet encrypted echo and mid-stream rekey), per-layer auth/channel/client/server suites, and a real-client-against-real-server end-to-end suite (4 MiB windowed transfer, ranged reads, pipelined concurrency, cancel-then-continue, password/publickey/per-user auth, host-key verification), plus an env-gated suite against a real OpenSSH sshd. Verified interoperable against the OpenSSH `sftp` CLI (which surfaced and fixed a pre-subsystem `env` channel-request handling gap). An autonomous code review (Claude Sonnet 5) followed by fixes (Claude Fable 5) hardened the server against a malformed post-handshake packet leaking the transport, a write-queue deadlock when a backing `vfs.writeFile` fails mid-stream, an unenforced channel receive window, and a zero-length SFTP packet. A second independent review (Claude Fable 5) found and fixed a client-side data-corruption bug: the pipelined reader trusted requested offsets, so a spec-legal short mid-file READ (pipes/special files, some non-OpenSSH servers) left an un-requested gap; the reader now reconciles against the bytes actually returned and re-issues from the true offset. It also now cancels the source stream on a `writeFile` error. Known intentional limitations (streaming model over `AsyncVFS`, which has no chmod/utimes/positioned-write primitives): SETSTAT/FSETSTAT are accepted as no-ops, and writes must be sequential from offset 0.
|
||||
- **`@webnet/sftp` — new package (SFTP v3 client and server)**: Claude Code (Claude Fable 5, coordinating; Claude Opus 4.8 subagents implemented the SSH transport, auth/channels, and SFTP client/server layers, and a Claude Sonnet 5 subagent scaffolded the package and pure codecs) authored a new `@webnet/sftp` package: an SFTP v3 client (`SFTPClient implements AsyncVFS`, mirroring `DAVClient`/`FTPClient`) over a `RawDialer`, and an `SFTPServer` serving any `AsyncVFS` over a `RawListener` via an http-style `listen(listener, opts)` accept loop. Both run in the browser (via tsconnect's `IPNDialer`) and Node with no Node APIs in package source and no new dependencies. The package implements a full SSH-2 transport from scratch on Web Crypto (`crypto.subtle`/`crypto.getRandomValues`) with zero hand-written primitives: version-banner exchange, binary packet framing with per-direction sequence numbers, `curve25519-sha256` key exchange with `ssh-ed25519`/`rsa-sha2-256`/`rsa-sha2-512` host-key verification, RFC 4253 key derivation, `aes128/256-gcm@openssh.com` and `aes128/256-ctr` with `hmac-sha2-256`/`hmac-sha2-256-etm@openssh.com` ciphers (continuous CTR counter and GCM invocation nonce tracked across packets), and transparent peer- or self-initiated rekey. On top of that: ssh-userauth (client offers a direct signed Ed25519 publickey request then falls back to password; server drives none/publickey(PK_OK)/password through a pluggable `authenticate` callback returning a per-user `AsyncVFS`), a connection-protocol mux with a session channel and bidirectional window flow control giving end-to-end backpressure, and the SFTP v3 layer. The client pipelines requests over one channel (request-id dispatch map, serialized wire writes so a split WRITE stays contiguous), maps AsyncVFS verbs onto FXP operations with pull-driven read streams (≤8 outstanding 32 KiB READs) and bounded-inflight writes, client-side recursive delete, and `posix-rename@openssh.com` for overwriting `move`. The server maps FXP back onto the VFS with a handle table, 100-entry READDIR batches with unix `ls -l` longnames, sequential streaming reads/writes, `SETSTAT`/`FSETSTAT` no-ops (so OpenSSH `put` succeeds), REALPATH, and v3 rename semantics; responses are serialized per session and handler errors reply a status without tearing down the connection. Ed25519 auth keys are parsed from the unencrypted `openssh-key-v1` format (encrypted keys are out of scope); host keys are optionally verified via a `verifyHostKey({ type, key, fingerprint })` callback and can be generated with the exported `generateHostKey()`. 202 tests: crypto/kex/cipher/codec/key/path units, loopback transport suites (incl. a 1000-packet encrypted echo and mid-stream rekey), per-layer auth/channel/client/server suites, and a real-client-against-real-server end-to-end suite (4 MiB windowed transfer, ranged reads, pipelined concurrency, cancel-then-continue, password/publickey/per-user auth, host-key verification), plus an env-gated suite against a real OpenSSH sshd. Verified interoperable against the OpenSSH `sftp` CLI (which surfaced and fixed a pre-subsystem `env` channel-request handling gap). An autonomous code review (Claude Sonnet 5) followed by fixes (Claude Fable 5) hardened the server against a malformed post-handshake packet leaking the transport, a write-queue deadlock when a backing `vfs.writeFile` fails mid-stream, an unenforced channel receive window, and a zero-length SFTP packet. A second independent review (Claude Fable 5) found and fixed a client-side data-corruption bug: the pipelined reader trusted requested offsets, so a spec-legal short mid-file READ (pipes/special files, some non-OpenSSH servers) left an un-requested gap; the reader now reconciles against the bytes actually returned and re-issues from the true offset. It also now cancels the source stream on a `writeFile` error. Known intentional limitations (streaming model over `AsyncVFS`, which has no chmod/utimes/positioned-write primitives): SETSTAT/FSETSTAT are accepted as no-ops, and writes must be sequential from offset 0.
|
||||
- **`@webnet/ssh` — SSH-2 package split out of `@webnet/sftp`, plus TCP port forwarding**: Claude Code (Claude Fable 5) extracted the hand-written SSH-2 stack (transport, curve25519 kex, ciphers, host keys, userauth, connection-protocol channels) out of `@webnet/sftp` into a new standalone `@webnet/ssh` package via pure `git mv`s, so it can be reused independently; `@webnet/sftp` now depends on `@webnet/ssh` and consumes its low-level pieces through `@webnet/ssh/_internals`. The server auth path was decoupled from `@webnet/vfs`: `authenticateServer<T>` is now generic over an authentication context and rejects with a new `SSHAuthError` rather than `VFSError`, which `@webnet/sftp` maps back to `VFSError("forbidden")` at its own boundary to preserve behaviour. On top of the split, TCP forwarding primitives were added. The channel mux (`ConnectionMux`) gained configurable accepted channel types and a global-request handler, generic `CHANNEL_OPEN` handling that surfaces incoming opens (session / direct-tcpip / forwarded-tcpip with parsed endpoints) as an `IncomingOpen` the consumer can `accept()`/`reject()`, outbound `openDirectTcpip`/`openForwardedTcpip`, and an in-order `globalRequest`/`onGlobalRequest` path for `tcpip-forward`/`cancel-tcpip-forward` (RFC 4254 §7). A `channelTransport()` adapter wraps a `Channel` as a `@webnet/transport` `RawTransport` (EOF surfaced as a throw with `readEnded`, `halfClose` as `CHANNEL_EOF`, non-blocking `close()` so a forwarded stream can't deadlock on the peer close handshake), and an internal `pipe()` bridges two transports. The public API exposes `SSHClientConnection` (connect over a `RawTransport`, `openSubsystem`, `openSession`, `openDirectTcpip`, `dialer()` returning a `RawDialer` for local forwarding, and `requestRemoteForward()` returning a `RemoteForward` that implements `RawListener` for remote forwarding) and `SSHServerConnection<T>` (auth hook returning the context, `acceptSession()`, and optional `directTcpip`/`tcpipForward` hooks that plug arbitrary `RawTransport`/`RawListener` implementations into the forwarding paths). The design deliberately keeps `openSession()`/session-channel handling generic so later PRs can add exec/shell/pty request plumbing (for scp/rsync clients and a shell-delegating server) without reshaping the connection API. `@webnet/sftp`'s client and server were refactored onto the new connection classes with no behaviour change (its full suite passes unmodified). New tests cover direct-tcpip open round-trips and accept/reject, global-request ordering/failure, the channel/RawTransport adapter (EOF, half-close, windowed backpressure), and loopback end-to-end forwarding in both directions; an env-gated (`SSH_TEST_*`) interop suite exercises `ssh -L`/`ssh -R` equivalents against a real OpenSSH sshd (direct-tcpip to the sshd banner, and a remote-forward loop dialed back through direct-tcpip). Two autonomous code-review rounds followed. A Sonnet review found port-only remote-forward keying (two forwards on the same port collided), unguarded `CHANNEL_OPEN_CONFIRMATION` sends in the accept loops, and unrejected pending `RemoteForward.accept()` waiters on close — all fixed. A second Fable review then found three more serious issues, all fixed by Claude Fable 5 with regression tests: (1) `Channel.send()` deadlocked forever if the peer closed the channel while the sender was blocked on window exhaustion (`_deliverClose` now wakes window waiters and `send()` aborts on a remotely-closed channel); (2) `pipe()`'s copy loop only guarded reads, so a write failure on a forwarded socket became an `unhandledRejection` that crashes the Node process by default — a remotely-triggerable DoS — now the write is guarded and both `void pipe()` call sites swallow; (3) connect-phase failures leaked the underlying socket (`SFTPClient.#doConnect` and `SSHClientConnection.connect` now close on `openSubsystem`/auth failure; verified against an sshd with no sftp subsystem, which previously hung the process to the runner timeout). Four smaller fixes: `authenticateServer` accepts a falsy auth context (uid `0` etc.) instead of rejecting it, `#matchForward`'s port fallback fails closed on an ambiguous port rather than misrouting, `acceptSession()` rejects instead of hanging once the accept loop has died, and a duplicate `tcpip-forward` closes the superseded listener (and `RemoteForward` teardown closes queued-but-unaccepted transports).
|
||||
- **`@webnet/ftp` — FTP client state transfer**: GPT-5.6 Terra implemented transferable FTP control connections: `FTPClient.transferState()` exports worker-backed connections plus buffered reply bytes and negotiated session state; `FTPClient.adopt()` claims them or reconnects and authenticates on expiry. Active data transfers are refused, and focused tests cover claim, fallback, and the safety guard.
|
||||
- **`@webnet/ftp` — state-transfer ownership fix**: GPT-5.6 Terra addressed a GPT-5.6 Sol review finding by making detachment atomic under the control lock; queued commands now reject after handoff and a regression test covers that race.
|
||||
- **`@webnet/smb2` — cross-tab state transfer (`StateTransferable`)**: Claude Code (Fable 5) implemented worker-side ownership transfer for `SMB2Client` (issue #99), consistent with `DAVClient`. `SMB2Client.transferState()` detaches an idle client and exports a structured-cloneable `SMB2TransferState`: the parked-transport token plus everything needed to keep using the authenticated session on the adopting side — server/share/port config, negotiated dialect and max transact/read/write sizes, message-id and credit counters, session and tree ids, the 3.1.1 preauth hash, and the _derived_ signing key with the signing-required flag. Credentials are never serialized; `SMB2Client.adopt(state, { claim, dialer, username, password, reconnect? })` takes them from the adopter's configuration, validates state version/shape before claiming, resumes message-id and signing state exactly once on the claimed transport, closes the claimed transport if resumption fails, and (only with `reconnect: true`) falls back to a fresh dial-on-demand session when the claim is expired/duplicate — transferred signing/session state is never reused on a new transport. Transfer is rejected unless the client is quiescent (no request in flight, no connect pending, no read/write stream still owning an SMB file handle, no buffered incoming bytes), and the source client becomes permanently detached after a successful transfer instead of silently reconnecting. Tests extend the mock server with per-connection signing state, an SMB 2.1 mode, request-signature verification, and message-id recording, covering: signed 2.1/3.1.1 sessions continuing with the next message id after a `structuredClone`d transfer (with response-signature verification still enforced), busy/streaming/connect-pending rejection, config-only transfer for never-connected clients or non-transferable transports, malformed/version-mismatched/duplicate/expired state handling, claimed-resource cleanup on failed adoption, and reconnect fallback; the opt-in Samba integration suite gained a live transfer round-trip. A browser two-tab transfer test is deferred: it needs a reachable SMB server inside the worker's tailnet, which the browser CI environment does not provide (the same limitation applies to the existing DAV/FTP transfer work).
|
||||
- **Agent label documentation**: `gpt-5.6-sol` updated `AGENTS.md` to require exact `Agent/<model-slug>` labels and coordinating-model attribution for orchestrated work.
|
||||
- **`@webnet/utils` — shared binary reader/writer (issue #79)**: GPT-5.6 Luna implemented a dependency-free, endian-aware binary codec primitive with bounds-checked reads, growable writes, offset-correct `DataView` handling, alignment, skipping, random access, patching, and explicit finish-copy semantics. SSH and SMB2 now delegate their cursor mechanics to it while retaining protocol-specific encodings, with coverage for both endiannesses, growth, subarray offsets, overruns, alignment, patching, and zero-length operations.
|
||||
- **`@webnet/state-transfer` — generic state-transfer contract extraction**: Codex (GPT-5.6 Luna) extracted `StateTransferable` and `isStateTransferable` from `@webnet/transport` into a dependency-free workspace package, migrated current DAV/FTP/SMB2/worker consumers, removed the unused transport re-exports, and added guard tests and package metadata.
|
||||
- **`@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.
|
||||
- **`@webnet/tsconnect-react` — initialization error callbacks**: `gpt-5.6-sol` added optional error callbacks to `useBuildIpn` and `useBuildIpnWorker`, covering synchronous builder/run failures and asynchronous worker/fallback connection failures while suppressing callbacks after effect cleanup.
|
||||
- **`@webnet/vfs` — reusable AsyncVFS conformance suite (issue #80)**: Claude Code (Claude Opus 5, coordinating; Claude Sonnet 5 subagents adopted the suite in the FTP, SFTP and SMB2 packages) added a reusable conformance harness owned by `@webnet/vfs` and exposed from a test-only `@webnet/vfs/conformance` entry point, so production bundles do not acquire test code. `testAsyncVFSConformance({ name, create, capabilities, errorCodes })` takes a factory producing a fresh filesystem per test, a capability descriptor for optional operations (optional methods absent from the instance are skipped rather than failed), and an error-code alias map for protocols that cannot distinguish two `VFSError` codes; the entry point also exports the stream helpers each package had been redefining. The baseline suite covers write/read round trips and multi-chunk streaming, stat and directory listing, mkdir/delete/recursive delete/move/copy/setProps, `readFileRange` inclusive-end semantics, empty files, unicode names, nested paths, the full `VFSError` contract, stream cancellation and write-source error propagation, and sequential plus concurrent operations. MemoryVFS and NodeVFS were migrated as the reference implementations and Drive, FTP, SFTP and SMB2 adopted it, each keeping its protocol-specific tests. The suite surfaced pre-existing bugs in every package it touched, all fixed here: `MemoryVFS.readFile` handed out the entry's own array, which a byte-stream consumer transfers and detaches, so reading a file over WebDAV and cancelling the stream truncated the stored file to zero bytes; `NodeVFS.readFile` on a directory deferred `is-a-directory` to the first stream read and `delete("/", true)` removed the entire served root; the DAV client defaulted `copy`/`move` to overwriting, only honoured a non-recursive `delete` when `recursive` was explicitly `false`, returned an empty listing for `readdir` of a file, and merged rather than replaced in `setProps`, while the DAV server reported `is-a-directory` as 409 instead of RFC 4918's 405; the FTP client silently clobbered on a default `move` and discarded the server's explicit "not a directory"/"is a directory" replies; the SFTP client guessed error codes from the request verb instead of reading the code its own server embeds, and opening a directory for reading succeeded; the SMB2 client accepted deleting the share root and ignored `opts.overwrite` in `copy` (which also could not copy directories), and its mock server ignored `CreateOptions` directory flags and `ReplaceIfExists`. Three of the six packages defaulted a destructive operation to overwriting, which is the contract drift the issue describes. The harness compares large payloads byte-wise rather than with `assert.deepEqual`, because building a diff between a 128 KiB array and an empty one exhausts memory and kills the process before the failure can be reported. The SFTP client was additionally verified against a real OpenSSH `sshd` to confirm the status-parsing change still falls back correctly for third-party servers. Two independent autonomous reviews followed — Claude Sonnet 5 and Codex (GPT-5.6) — and both were addressed by Claude Opus 5: the suite had lost `readFileRange`'s error contract when the per-package tests were removed and never covered `copy` replacing an existing directory (which `NodeVFS` merged into instead); SMB2 `copy` could recurse without bound when the destination lay inside the source and could not overwrite a destination of the other type; the DAV client read every 405 as `is-a-directory` although RFC 4918 only gives it that meaning for GET and PUT; the FTP server relayed the backing filesystem's prose as 550 text and now sends conventional wording per code, which removed the need for the `forbidden`/`not-found` alias the reviewers correctly identified as covering a fixable bug rather than a protocol limit; and SFTP now marks the VFS code it embeds in `SSH_FX_FAILURE` messages so a third-party server's prose cannot be decoded as one of ours.
|
||||
- **CI — bounded Node heap (issue #136)**: Claude Code (Claude Opus 5) added a workflow-level `NODE_OPTIONS: --max-old-space-size=4096` to `.gitea/workflows/ci.yml`, so every CI job that runs Node (including the per-file workers `node --test` spawns and Turbo-invoked package scripts, which inherit the variable) fails with a legible V8 heap-limit error instead of growing until the runner's own memory limit kills the task and any jobs sharing the machine.
|
||||
- **`@webnet/tsconnect` — minimal build output (issue #154)**: `gpt-5.6-sol` changed the Tailscale package build to use a temporary staging directory and copy only `main.wasm`, `build-info.json`, `wasm_exec.js`, and `cacert.pem` into `dist/`, excluding unused upstream demo bundles, source maps, styles, and package metadata from published packages and Turbo caches.
|
||||
- **turbo setup - cache issue**: written collaboratively between human codinget and agent claude-opus-5 who double checked and debugged my work.
|
||||
- **`@webnet/taildrive` — share-bearing peer discovery**: `gpt-5.6-sol` added a Webnet-only `listDrivePeersWithShares(ipn)` helper that probes the existing candidates through the shared WebDAV client, positively retains peers exporting at least one share, bounds concurrent and malformed responses, and exposes a reusable per-peer `DAVClient` factory for a later whole-tailnet `AsyncVFS` hierarchy. Focused tests cover populated, empty, invalid, unreachable, and ordered peer results.
|
||||
- **`@webnet/taildrive` — peer-probe review hardening**: `gpt-5.6-sol` addressed autonomous review findings by covering the IPN dial phase with the probe deadline, closing a connection that arrives after timeout, and requiring a valid root-directory response before accepting a nonempty WebDAV listing. Regression coverage includes a permanently stalled dial and a malformed 207 response that omits the requested root.
|
||||
- **`@webnet/taildrive` — discovery API review follow-up**: `gpt-5.6-sol` renamed the narrower discovery helper to `listDrivePeersWithShares` to distinguish it from `IpnClient.listDrivePeers`, and added the conventional package-root export alongside the existing client and server subpaths.
|
||||
- **`@webnet/vfs` — combined stat and directory listing**: `gpt-5.6-sol` added the optional `AsyncVFS.statAndReaddir()` operation, with optimized Memory, File System Access, WebDAV, and SMB2 implementations. Drive PROPFIND/COPY/MOVE and delete handling, FTP server listings, SMB2 recursive operations, and Taildrive peer discovery reuse combined metadata where available while retaining inline fallbacks for other VFS implementations. Conformance, browser, protocol, malformed-response, request-count, and integration coverage preserve existing `readdir()` semantics and verify files return their own stat with no entries. A Claude Opus 5 review found that SMB recursive deletion could accidentally request read-data access on files and identified implicit listing/native-copy contracts; `gpt-5.6-sol` fixed the access regression, aligned DAV self-resource validation, documented the contracts, and added request-level and conformance coverage. Fresh `gpt-5.6-terra` reviews then caught missing-source COPY/MOVE deleting an overwrite destination in the Drive fallback and native Node, Memory, and SFTP paths; `gpt-5.6-sol` reordered fallback handling, made each native operation source-safe, and added data-preservation regressions. A final Claude Opus 5 pass confirmed the fixes and prompted documentation that successful overwrites replace rather than merge directory destinations until #137 introduces explicit policies.
|
||||
- **`@webnet/vfs` — unsupported error code and optional-operation fallbacks (issue #82)**: Claude Code (Claude Opus 5) added the `unsupported` `VFSErrorCode` and a `@webnet/vfs/fallback` entry point. The code is documented as a capability failure, distinct from `forbidden` (permitted but denied), `precondition-failed` (state prevents it) and a backend or transport failure, and carries the requirement that rejecting with it leaves the filesystem unchanged — which is what makes answering it by doing the work another way safe. Optional `AsyncVFS` members are correspondingly documented as absent when an implementation can never perform the operation and present-but-rejecting when it can for some paths and not others, so a backend spanning several shares, servers or negotiated dialects can say so per call rather than per instance. Each protocol adapter maps the code both ways: SFTP `SSH_FX_OP_UNSUPPORTED`, FTP 502 and WebDAV 501 outbound, and the same statuses plus SMB2 `STATUS_NOT_SUPPORTED` inbound — the SMB2 status previously reached callers as `forbidden`, claiming a permission problem the server never reported, and the WebDAV and FTP clients discarded their own servers' 501/502 entirely. `withFallbacks(vfs, options)` returns a `Required<AsyncVFS>` so a consumer can call any optional operation without branching on method presence; `statAndReaddirFallback`, `readFileRangeFallback`, `copyFallback`, `moveFallback` and `setPropsFallback` are also exported individually for call sites that need one operation without wrapping the filesystem they were handed. A per-operation policy chooses when a fallback may stand in (`native-only`, `on-missing`, `on-unsupported`), and only an `unsupported` rejection of the returned promise ever triggers one: any other code, a non-`VFSError`, or a stream that errors after bytes have been delivered propagates untouched, so a permission, state or transport failure is never retried into a partial success. `setProps` has no fallback, because arbitrary property storage cannot be built from the required operations, and says `unsupported` rather than appearing absent. The copy fallback rejects overlapping source and destination paths in both directions — a destination inside the source never terminates, and a source inside the destination would be deleted by the overwrite step before being read — reuses the complete metadata `readdir` guarantees rather than restatting each child, recurses sequentially so a wide tree does not open one transfer per entry, removes a partial destination on failure, and is documented as neither transactional nor atomic. The range fallback windows `readFile` lazily, holds at most one chunk, and cancels the source as soon as the window is satisfied, so serving a short range of a large file does not read the whole thing. The three byte-identical copies of the `statAndReaddir` fallback that arrived with #172 were replaced by the shared one. The conformance suite's optional-method capabilities became tri-state so a runtime `unsupported` rejection is itself testable, including that nothing changed, and the fallbacks are exercised by running the whole suite against a `MemoryVFS` with its optional operations first removed and then rejecting. Pinning `readFileRange`'s boundary behaviour — a start at or past the end of a file, and an end below the start, yield an empty stream rather than an error — surfaced three genuine bugs, all fixed rather than excused by a capability opt-out: `NodeVFS` passed an inverted range straight to `createReadStream`, which throws a raw `ERR_OUT_OF_RANGE`; the WebDAV client reported the 416 that RFC 9110 requires for a range at or past the end of a representation as an unexpected status; and the FTP client, whose `REST` carries no end bound, transferred from the start to the end of the file instead of nothing.
|
||||
@@ -1,10 +1,20 @@
|
||||
# webnet
|
||||
|
||||
A TypeScript monorepo for transport-based networking, anchored by a WebAssembly [Tailscale](https://tailscale.com) SDK. It provides a layered stack of packages — from raw transport abstractions up through HTTP, WebSocket, and WebDAV — that work in browsers, Node.js, and any environment that can supply a transport.
|
||||
A TypeScript monorepo for transport-based networking, anchored by a WebAssembly [Tailscale](https://tailscale.com) SDK. It provides a layered stack of packages — from raw transport and virtual filesystem abstractions through HTTP, WebSocket, WebDAV, FTP, SSH, SFTP, SMB, and Taildrive — that work in browsers, Node.js, and any environment that can supply a transport.
|
||||
|
||||
Those packages exist to be composed into an application. That application is [**Tailshare**](packages/tailshare).
|
||||
|
||||
## Tailshare
|
||||
|
||||
[`packages/tailshare`](packages/tailshare) is the flagship application of this repository, and the reason the rest of it exists. It is a browser app that joins a tailnet directly from the page and moves files across it, and it is the intended surface for everything the stack implements: Tailscale connectivity over WebAssembly, remote filesystems browsed over the protocol packages, and peer-to-peer transfers with no server in the middle.
|
||||
|
||||
The other packages are libraries; Tailshare is the product. When a capability lands in a library package — or is planned in the [open issues](https://gitea.codinget.me/webnet/webnet/issues) — Tailshare is where it is expected to become usable. `example-app` and `test-app` are development and protocol-testing surfaces, not the product.
|
||||
|
||||
Tailshare is under active development and does not yet expose the whole stack. Its README tracks what works today and what the open issues intend for it.
|
||||
|
||||
## Inspiration
|
||||
|
||||
This is heavily inspired by the [WebVM](https://webvm.io/) networking stack that levrages Tailscale in the browser.
|
||||
This is heavily inspired by the [WebVM](https://webvm.io/) networking stack that leverages Tailscale in the browser.
|
||||
Note that this doesn't lift any code from that project, only ideas.
|
||||
|
||||
Another inspiration is the [ElysiaJS](https://elysiajs.com/) documentation that seemingly allows running a webserver from the docs directly in the browser (even if it is just a feature of the framework itself and not real networking).
|
||||
@@ -14,29 +24,44 @@ Another inspiration is the [ElysiaJS](https://elysiajs.com/) documentation that
|
||||
Tailscale already ships a `tsconnect` package that compiles the IPN (in-process networking) stack to WASM via `GOARCH=wasm`. This repo builds on top of that with a layered set of packages:
|
||||
|
||||
1. **Patching tsconnect**: the `tailscale` submodule tracks a fork on the `webnet` branch that extends the Go-to-JS bridge (`wasm_js.go`) to expose lower-level networking primitives: raw TCP/UDP connections, ICMP, TLS dialing, and TCP listening.
|
||||
2. **`@webnet/transport`**: declares the `RawTransport`, `RawListener`, and `RawDialer` interfaces, plus buffer utilities and transport implementations that have no external dependencies — a loopback transport and a Node.js streams adapter.
|
||||
2. **`@webnet/transport`**: declares the `RawTransport`, `RawListener`, and `RawDialer` interfaces, plus buffer utilities and transport implementations for loopback, Node.js streams, and WebRTC.
|
||||
3. **`@webnet/state-transfer`**: provides the generic `StateTransferable` ownership-transfer contract and runtime type guard without transport or protocol dependencies.
|
||||
4. **`@webnet/tsconnect`**: builds the WASM artifact, ships it alongside a Mozilla CA bundle and `wasm_exec.js`, and wraps the raw JS bridge in typed TypeScript classes. Its `Conn`, `TCPListener`, and `IPNDialer` implement the `@webnet/transport` interfaces, making it a drop-in transport source for the rest of the stack.
|
||||
5. **`@webnet/tsconnect-redux`** / **`@webnet/tsconnect-react`**: Redux Toolkit (RTK) slice and React hooks/context for IPN state management and control, extracted from the core SDK so consumers can bring their own UI framework.
|
||||
6. **`@webnet/http`**: a full HTTP/1.1 client and server over any `@webnet/transport` implementation. Features include request/response streaming, chunked transfer encoding, keep-alive, a client connection pool, automatic redirect following, and a Koa-inspired middleware router.
|
||||
7. **`@webnet/websocket`**: WebSocket client and server built on `@webnet/http`. Handles the upgrade handshake, frame codec, masking, fragmented-message reassembly, ping/pong, and the close handshake — no external dependencies.
|
||||
8. **`@webnet/drive`**: WebDAV Level 1 (and optionally Level 2) client and server built on `@webnet/http`. Includes an async VFS abstraction with `MemoryVFS`, `NodeVFS`, and `FsaVFS` (with an OPFS factory method) implementations, a `createDAVHandler()` server handler, and a `DAVClient` that itself implements `AsyncVFS`.
|
||||
6. **`@webnet/tsconnect-worker`**: runs the IPN in a `SharedWorker`, synchronizes state through Redux, and can fall back to the main thread when workers are unavailable.
|
||||
7. **`@webnet/vfs`**: defines the shared async virtual filesystem used by the file protocols, with in-memory, Node.js, and File System Access API implementations.
|
||||
8. **Protocol packages**: client and server implementations for HTTP, WebSocket, WebDAV, FTP/FTPS, SSH, and SFTP are built on the transport and VFS layers, alongside an SMB client. `@webnet/http-static` serves a VFS over HTTP, while `@webnet/taildrive` connects WebDAV shares to Tailscale peers.
|
||||
9. **Apps and UI helpers**: reusable browser and React utilities support the example and test applications, and above all **Tailshare**, the flagship app that composes the whole stack into a usable product.
|
||||
|
||||
### Packages
|
||||
|
||||
| Package | Description |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `packages/transport` | Transport interfaces (`RawTransport`, `RawListener`, `RawDialer`), loopback and Node.js implementations |
|
||||
| `packages/state-transfer` | Generic ownership-transfer contract and runtime type guard |
|
||||
| `packages/tsconnect` | Tailscale WASM SDK — IPN lifecycle, typed TS wrappers, transport implementation |
|
||||
| `packages/tsconnect-redux` | RTK slice and thunks for IPN state management and control |
|
||||
| `packages/tsconnect-react` | React hooks and context for IPN state management and control |
|
||||
| `packages/http` | HTTP/1.1 client and server, connection pool, redirect following, Koa-inspired router |
|
||||
| `packages/websocket` | WebSocket client and server based on `@webnet/http` |
|
||||
| `packages/drive` | WebDAV (Level 1 + optional Level 2) client and server based on `@webnet/http` |
|
||||
| `packages/xml` | Thin XML parse/serialize with conditional exports (native DOM / `@xmldom/xmldom`) |
|
||||
| `packages/test-app` | Vite dev app for manual browser testing |
|
||||
| `packages/example-app` | Example app demonstrating the full stack |
|
||||
Each package has its own README with entry points and usage; the table links to them.
|
||||
|
||||
| Package | Description |
|
||||
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- |
|
||||
| [`packages/tailshare`](packages/tailshare) | **Flagship application**: tailnet file sharing in the browser |
|
||||
| [`packages/transport`](packages/transport) | Transport interfaces with loopback, Node.js streams, and WebRTC implementations |
|
||||
| [`packages/state-transfer`](packages/state-transfer) | Generic ownership-transfer contract and runtime type guard |
|
||||
| [`packages/tsconnect`](packages/tsconnect) | Tailscale WASM SDK, IPN lifecycle, typed wrappers, and transport implementation |
|
||||
| [`packages/tsconnect-worker`](packages/tsconnect-worker) | SharedWorker and main-thread bindings for `@webnet/tsconnect` with Redux state synchronization |
|
||||
| [`packages/tsconnect-redux`](packages/tsconnect-redux) | Redux Toolkit slice, bindings, actions, and selectors for IPN state management |
|
||||
| [`packages/tsconnect-react`](packages/tsconnect-react) | React hooks and context for IPN state management and control |
|
||||
| [`packages/vfs`](packages/vfs) | Async virtual filesystem interface, implementations, fallbacks, and conformance tests |
|
||||
| [`packages/http`](packages/http) | HTTP/1.1 client and server, connection pool, redirect following, and middleware router |
|
||||
| [`packages/http-static`](packages/http-static) | Static file HTTP handler backed by an async VFS |
|
||||
| [`packages/websocket`](packages/websocket) | WebSocket client and server based on `@webnet/http` |
|
||||
| [`packages/webdav`](packages/webdav) | WebDAV Level 1 and optional Level 2 client and server based on `@webnet/http` |
|
||||
| [`packages/ftp`](packages/ftp) | FTP/FTPS client and server backed by an async VFS |
|
||||
| [`packages/ssh`](packages/ssh) | SSH-2 client and server connections, channels, sessions, and TCP forwarding |
|
||||
| [`packages/sftp`](packages/sftp) | SFTP client and server backed by an async VFS |
|
||||
| [`packages/smb2`](packages/smb2) | SMB2/3 client implementing the async VFS interface |
|
||||
| [`packages/taildrive`](packages/taildrive) | Taildrive peer discovery, WebDAV client helpers, and server bridge |
|
||||
| [`packages/xml`](packages/xml) | XML parsing and serialization with browser-native and Node.js backends |
|
||||
| [`packages/react`](packages/react) | Shared React hooks, error display, and browser capability helpers |
|
||||
| [`packages/utils`](packages/utils) | Browser, binary-data, upload, download, and formatting utilities |
|
||||
| [`packages/browser-test-utils`](packages/browser-test-utils) | Shared Playwright utilities for browser integration tests |
|
||||
| [`packages/example-app`](packages/example-app) | Example app demonstrating the browser stack |
|
||||
| [`packages/test-app`](packages/test-app) | Vite app for manual and end-to-end protocol testing |
|
||||
|
||||
### Submodules
|
||||
|
||||
@@ -49,7 +74,10 @@ git submodule update --init tailscale
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build the WASM and TypeScript declarations
|
||||
# Build the WASM assets
|
||||
npm run build-go --workspace=packages/tsconnect
|
||||
|
||||
# Build the TypeScript package
|
||||
npm run build --workspace=packages/tsconnect
|
||||
|
||||
# Start the test app
|
||||
@@ -91,4 +119,4 @@ fixtures instead.
|
||||
|
||||
## AI disclosure
|
||||
|
||||
See [AI_CHANGES.md](AI_CHANGES.md) for the full log of AI-assisted and AI-authored work in this repository.
|
||||
Most of the code in this repository was AI-generated. The entire patch set in the Tailscale fork under `tailscale/` was also AI-generated.
|
||||
|
||||
Generated
+8
-8
@@ -3544,8 +3544,8 @@
|
||||
"resolved": "packages/browser-test-utils",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@webnet/drive": {
|
||||
"resolved": "packages/drive",
|
||||
"node_modules/@webnet/webdav": {
|
||||
"resolved": "packages/webdav",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@webnet/example-app": {
|
||||
@@ -10192,8 +10192,8 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"packages/drive": {
|
||||
"name": "@webnet/drive",
|
||||
"packages/webdav": {
|
||||
"name": "@webnet/webdav",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@webnet/http": "*",
|
||||
@@ -10209,7 +10209,7 @@
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
},
|
||||
"packages/drive/node_modules/typescript": {
|
||||
"packages/webdav/node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
@@ -10495,7 +10495,7 @@
|
||||
"name": "@webnet/taildrive",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@webnet/drive": "*",
|
||||
"@webnet/webdav": "*",
|
||||
"@webnet/http": "*",
|
||||
"@webnet/transport": "*",
|
||||
"@webnet/tsconnect": "*"
|
||||
@@ -10531,7 +10531,7 @@
|
||||
"@mantine/notifications": "^9.3.1",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@webnet/drive": "*",
|
||||
"@webnet/webdav": "*",
|
||||
"@webnet/http": "*",
|
||||
"@webnet/react": "*",
|
||||
"@webnet/tsconnect": "*",
|
||||
@@ -10587,7 +10587,7 @@
|
||||
"name": "@webnet/test-app",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@webnet/drive": "*",
|
||||
"@webnet/webdav": "*",
|
||||
"@webnet/ftp": "*",
|
||||
"@webnet/http": "*",
|
||||
"@webnet/sftp": "*",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# @webnet/browser-test-utils
|
||||
|
||||
Shared Playwright utilities for browser integration tests.
|
||||
|
||||
This is a private workspace, not a published library: it is used only by this repo's own `node:test` suites that need a real browser. `forBrowsers` wraps a test body in a `node:test` `suite` per configured browser (Chromium and Firefox), launching a headless `Browser` in `before` and closing it in `after`, and hands the test a `BrowserTestContext` with `newPage()` and `serve(dir)`. `newPage()` injects an `__name` shim into the page, working around `tsx`'s esbuild `keepNames` output losing function names when `page.evaluate()` serializes callbacks via `toString()`. `serve(dir)` starts a local static file server (`TestServer`) rooted at `dir`, rejecting paths that escape it.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { forBrowsers } from "@webnet/browser-test-utils"
|
||||
|
||||
forBrowsers(({ browserName, newPage, serve }) => {
|
||||
test(`loads in ${browserName}`, async () => {
|
||||
const server = await serve("dist")
|
||||
const page = await newPage()
|
||||
await page.goto(server.url)
|
||||
await server.close()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/test-app`](../test-app) — one of the apps commonly served under test via `serve()`
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { Context } from "@webnet/http/server"
|
||||
import type { AsyncVFS, Stat } from "@webnet/vfs"
|
||||
import { VFSError } from "@webnet/vfs"
|
||||
import { statAndReaddirFallback } from "@webnet/vfs/fallback"
|
||||
import { hrefToVfsPath } from "../../common/utils.js"
|
||||
import type { DAVServerOptions } from "../types.js"
|
||||
|
||||
async function copyRecursive(
|
||||
vfs: AsyncVFS,
|
||||
src: string,
|
||||
dest: string,
|
||||
knownStat?: Stat,
|
||||
knownEntries?: Stat[],
|
||||
): Promise<void> {
|
||||
let stat = knownStat
|
||||
let entries = knownEntries
|
||||
if (!stat) {
|
||||
const result = await statAndReaddirFallback(vfs, src, { policy: "on-unsupported" })
|
||||
stat = result.self
|
||||
entries = result.entries
|
||||
}
|
||||
if (stat.isDirectory) {
|
||||
try {
|
||||
await vfs.mkdir(dest)
|
||||
} catch (e) {
|
||||
if (!(e instanceof VFSError && e.code === "already-exists")) throw e
|
||||
}
|
||||
const children = entries ?? (await vfs.readdir(src))
|
||||
for (const child of children) {
|
||||
const childDest = dest + "/" + child.name
|
||||
await copyRecursive(vfs, child.path, childDest, child)
|
||||
}
|
||||
} else {
|
||||
const stream = await vfs.readFile(src)
|
||||
await vfs.writeFile(dest, stream, stat.size)
|
||||
}
|
||||
if (stat.props && vfs.setProps) {
|
||||
await vfs.setProps(dest, stat.props)
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCopyMove(
|
||||
ctx: Context,
|
||||
vfs: AsyncVFS,
|
||||
vfsPath: string,
|
||||
method: "COPY" | "MOVE",
|
||||
opts: DAVServerOptions,
|
||||
): Promise<void> {
|
||||
const rawDest = ctx.req.getHeader("destination")
|
||||
if (!rawDest || typeof rawDest !== "string") {
|
||||
ctx.res.setStatus(400, "Bad Request")
|
||||
ctx.res.body = null
|
||||
return
|
||||
}
|
||||
|
||||
const overwriteHeader = ctx.req.getHeader("overwrite")
|
||||
const overwrite = !overwriteHeader || overwriteHeader === "T"
|
||||
|
||||
const destVfsPath = hrefToVfsPath(rawDest, opts.prefix)
|
||||
if (destVfsPath === vfsPath) {
|
||||
ctx.res.setStatus(403, "Forbidden")
|
||||
ctx.res.body = null
|
||||
return
|
||||
}
|
||||
|
||||
let destExisted = false
|
||||
try {
|
||||
await vfs.stat(destVfsPath)
|
||||
destExisted = true
|
||||
} catch (e) {
|
||||
if (!(e instanceof VFSError && e.code === "not-found")) throw e
|
||||
}
|
||||
|
||||
if (destExisted && !overwrite) {
|
||||
ctx.res.setStatus(412, "Precondition Failed")
|
||||
ctx.res.body = null
|
||||
return
|
||||
}
|
||||
|
||||
if (method === "COPY") {
|
||||
if (vfs.copy) {
|
||||
await vfs.copy(vfsPath, destVfsPath, { overwrite })
|
||||
} else {
|
||||
const source = await statAndReaddirFallback(vfs, vfsPath, { policy: "on-unsupported" })
|
||||
if (destExisted) await vfs.delete(destVfsPath, true)
|
||||
await copyRecursive(vfs, vfsPath, destVfsPath, source.self, source.entries)
|
||||
}
|
||||
} else {
|
||||
if (vfs.move) {
|
||||
await vfs.move(vfsPath, destVfsPath, { overwrite })
|
||||
} else {
|
||||
const source = await statAndReaddirFallback(vfs, vfsPath, { policy: "on-unsupported" })
|
||||
if (destExisted) await vfs.delete(destVfsPath, true)
|
||||
await copyRecursive(vfs, vfsPath, destVfsPath, source.self, source.entries)
|
||||
await vfs.delete(vfsPath, true)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.res.setStatus(destExisted ? 204 : 201, destExisted ? "No Content" : "Created")
|
||||
ctx.res.setHeader("Content-Length", "0")
|
||||
ctx.res.body = null
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# @webnet/example-app
|
||||
|
||||
A React demo app for the tsconnect browser stack.
|
||||
|
||||
It wires up `@webnet/tsconnect`'s IPN into a React tree via `@webnet/tsconnect-react` and `@webnet/tsconnect-redux` (`IpnBuilderProvider`, `CaCertProvider`, `IpnProvider`), and demonstrates dialing and listening from the browser: an HTTPS server behind a TCP and TLS listener, served over Tailscale Funnel using a cert obtained through the IPN, plus a debug panel. This is a private workspace in this repository, not a published package, and is a development and demo surface for the stack rather than the product application.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
npm run dev --workspace=packages/example-app
|
||||
```
|
||||
|
||||
`dev` starts the webpack dev server bound to `localhost` only. `dev:host` (`WEBNET_DEV_HOST=1 webpack serve --mode development`) additionally binds all interfaces and accepts forwarded hostnames, for use behind a trusted reverse proxy or on a Tailnet; see the root README's "Remote-host development" section before using it. `build` produces a production bundle (`NODE_ENV=production webpack --mode production`), and `typecheck` runs `tsc --noEmit`.
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/tailshare`](../tailshare) — the repository's flagship tailnet file-sharing application
|
||||
- [`@webnet/test-app`](../test-app) — the other development/testing surface, covering the non-React protocol packages
|
||||
- [`@webnet/tsconnect-react`](../tsconnect-react) — the React hooks and context this app builds on
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "@webnet/tsconnect-redux"
|
||||
import styles from "./Debug.scss"
|
||||
import { download, fmtSize } from "@webnet/utils"
|
||||
import { useSecureContext } from "../hooks/useSecureContext"
|
||||
import { useSecureContext } from "@webnet/react"
|
||||
import { CaCertContext } from "../contexts/CaCertContext"
|
||||
import type { IPN } from "@webnet/tsconnect"
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { useSyncExternalStore } from "react"
|
||||
|
||||
const sub = () => () => void 0
|
||||
const getClient = () => !!window.isSecureContext
|
||||
const getServer = () => true
|
||||
|
||||
export function useSecureContext(): boolean {
|
||||
return useSyncExternalStore(sub, getClient, getServer)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# @webnet/ftp
|
||||
|
||||
FTP/FTPS client and server built on `@webnet/transport` and `@webnet/vfs`.
|
||||
|
||||
`FTPClient` implements `AsyncVFS`, so it can be used anywhere an `AsyncVFS` is expected (`stat`, `readdir`, `readFile`/`readFileRange`, `writeFile`, `delete`, `mkdir`, `move`). It supports plaintext FTP, implicit FTPS (TLS from the first byte, via `dialer.dialTls`), and explicit FTPS (plaintext control connection upgraded with `AUTH TLS`/`PROT P`). `FTPServer` serves an `AsyncVFS` over FTP, with optional per-login `authenticate` to select a different VFS per user and optional TLS credentials for explicit `AUTH TLS`. Errors from both sides surface as `FTPError`, carrying the underlying `Reply`.
|
||||
|
||||
## Entry points
|
||||
|
||||
| Entry point | Description |
|
||||
| -------------------- | ------------------------------------------------------------------- |
|
||||
| `@webnet/ftp` | `FTPClient`, `FTPServer`, `FTPError`, and their option/reply types. |
|
||||
| `@webnet/ftp/client` | `FTPClient` and its option types only. |
|
||||
| `@webnet/ftp/server` | `FTPServer` and its option types only. |
|
||||
|
||||
`_internals` entry points are unstable and are not part of the public API.
|
||||
|
||||
## Usage
|
||||
|
||||
### Client
|
||||
|
||||
```ts
|
||||
import { FTPClient } from "@webnet/ftp/client"
|
||||
import type { RawDialer } from "@webnet/transport"
|
||||
|
||||
declare const dialer: RawDialer
|
||||
|
||||
const client = new FTPClient({ dialer, host: "ftp.example.com", user: "anonymous" })
|
||||
const entries = await client.readdir("/pub")
|
||||
const stream = await client.readFile("/pub/readme.txt")
|
||||
await client.close()
|
||||
```
|
||||
|
||||
### Server
|
||||
|
||||
```ts
|
||||
import { FTPServer } from "@webnet/ftp/server"
|
||||
import type { RawListener } from "@webnet/transport"
|
||||
import type { AsyncVFS } from "@webnet/vfs"
|
||||
|
||||
declare const vfs: AsyncVFS
|
||||
declare const controlListener: RawListener
|
||||
declare const dataListen: (port: number) => Promise<RawListener>
|
||||
|
||||
const server = new FTPServer({ vfs, dataListen })
|
||||
await server.listen(controlListener)
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/vfs`](../vfs) — the `AsyncVFS` interface this package implements and serves
|
||||
- [`@webnet/transport`](../transport) — the `RawDialer`/`RawListener`/`RawTransport` primitives this package is built on
|
||||
- [`@webnet/sftp`](../sftp) — an alternative file-transfer protocol over SSH instead of FTP
|
||||
@@ -10,6 +10,6 @@ export {
|
||||
export { formatPasv227, parsePasv227, formatEpsv229, parseEpsv229 } from "./common/addr.js"
|
||||
export { quotePath } from "./common/paths.js"
|
||||
export { formatTimeval, parseTimeval } from "./common/time.js"
|
||||
export { transportToStream, writeAll, pumpToTransport, skipBytes } from "./common/stream.js"
|
||||
export { transportToStream, writeAll, pumpToTransport } from "./common/stream.js"
|
||||
export { ControlConnection } from "./client/_internals.js"
|
||||
export { Session, commands, PREAUTH_COMMANDS, PassiveDataChannel } from "./server/_internals.js"
|
||||
|
||||
@@ -412,6 +412,11 @@ export class FTPClient implements AsyncVFS {
|
||||
return
|
||||
}
|
||||
if (recursive) {
|
||||
// Kept rather than shared with the SFTP client: `delete` is a required operation, so there
|
||||
// is no optional one to fall back for, and the recursion is protocol-shaped — DELE and RMD
|
||||
// are different commands chosen from a stat this method already holds. A generic shim would
|
||||
// restat every entry and still could not tell a symlink from what it points at, which is
|
||||
// the difference that decides whether descending is correct at all.
|
||||
for (const entry of await this.readdir(path)) await this.delete(entry.path, true)
|
||||
}
|
||||
const reply = await conn.exchange("RMD", path)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { suite, test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import type { RawTransport } from "@webnet/transport"
|
||||
import { transportToStream } from "./stream.js"
|
||||
|
||||
function transport(read: RawTransport["read"]): RawTransport {
|
||||
return {
|
||||
closed: false,
|
||||
close() {},
|
||||
read,
|
||||
async write() {},
|
||||
}
|
||||
}
|
||||
|
||||
suite("transportToStream", () => {
|
||||
test("clean EOF closes the stream", async () => {
|
||||
const reader = transportToStream(transport(async () => null)).getReader()
|
||||
assert.deepEqual(await reader.read(), { done: true, value: undefined })
|
||||
})
|
||||
|
||||
test("transport failures error the stream", async () => {
|
||||
const error = new Error("connection reset")
|
||||
const reader = transportToStream(
|
||||
transport(async () => {
|
||||
throw error
|
||||
}),
|
||||
).getReader()
|
||||
await assert.rejects(() => reader.read(), error)
|
||||
})
|
||||
})
|
||||
@@ -4,11 +4,11 @@ export function transportToStream(transport: RawTransport): ReadableStream<Uint8
|
||||
return new ReadableStream({
|
||||
async pull(controller) {
|
||||
try {
|
||||
controller.enqueue(await transport.read())
|
||||
} catch {
|
||||
// stream-mode FTP has no length framing: end-of-data and connection
|
||||
// errors are indistinguishable, both terminate the stream
|
||||
controller.close()
|
||||
const chunk = await transport.read()
|
||||
if (chunk === null) controller.close()
|
||||
else controller.enqueue(chunk)
|
||||
} catch (err) {
|
||||
controller.error(err)
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
@@ -39,32 +39,3 @@ export async function pumpToTransport(
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
export function skipBytes(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
count: bigint,
|
||||
): ReadableStream<Uint8Array> {
|
||||
let remaining = count
|
||||
const reader = stream.getReader()
|
||||
return new ReadableStream({
|
||||
async pull(controller) {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
if (remaining >= BigInt(value.length)) {
|
||||
remaining -= BigInt(value.length)
|
||||
continue
|
||||
}
|
||||
controller.enqueue(remaining > 0n ? value.subarray(Number(remaining)) : value)
|
||||
remaining = 0n
|
||||
return
|
||||
}
|
||||
},
|
||||
cancel(reason) {
|
||||
return reader.cancel(reason)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ import type { RawDialer, RawListener, RawTransport, TlsUpgradeOptions } from "@w
|
||||
import type { StateTransferable } from "@webnet/state-transfer"
|
||||
import { MemoryVFS } from "@webnet/vfs/memory"
|
||||
import { VFSError, type AsyncVFS } from "@webnet/vfs"
|
||||
import { testAsyncVFSConformance } from "@webnet/vfs/conformance"
|
||||
import {
|
||||
testAsyncVFSConformance,
|
||||
withoutOptional,
|
||||
unsupportedOptional,
|
||||
} from "@webnet/vfs/conformance"
|
||||
import { FTPServer } from "./server/server.js"
|
||||
import type { FTPServerOptions } from "./server/types.js"
|
||||
import { FTPClient } from "./client/client.js"
|
||||
@@ -241,11 +245,12 @@ async function dataCommand(
|
||||
const data = await dataPromise
|
||||
const decoder = new TextDecoder()
|
||||
let text = ""
|
||||
try {
|
||||
for (;;) text += decoder.decode(await data.read(), { stream: true })
|
||||
} catch {
|
||||
text += decoder.decode()
|
||||
for (;;) {
|
||||
const chunk = await data.read()
|
||||
if (chunk === null) break
|
||||
text += decoder.decode(chunk, { stream: true })
|
||||
}
|
||||
text += decoder.decode()
|
||||
assert.equal((await conn.reader.readReply()).code, 226)
|
||||
return text
|
||||
}
|
||||
@@ -298,6 +303,19 @@ testAsyncVFSConformance({
|
||||
capabilities: { etag: false },
|
||||
})
|
||||
|
||||
// The same client against a server that has only the required operations to work with, so the
|
||||
// server's fallback paths are held to the same contract as its native ones.
|
||||
testAsyncVFSConformance({
|
||||
name: "FTPClient (server over a minimal filesystem)",
|
||||
create: () => {
|
||||
const { client, close } = makeTestPair({ vfs: withoutOptional(new MemoryVFS()) })
|
||||
return { vfs: client, close }
|
||||
},
|
||||
// RNFR/RNTO answers 502 rather than renaming by hand when the filesystem cannot move, so the
|
||||
// client reports the operation as unsupported. Whether to shim it instead is issue #179.
|
||||
capabilities: { etag: false, move: "unsupported" },
|
||||
})
|
||||
|
||||
// -- suites --
|
||||
|
||||
suite("FTPClient + FTPServer over loopback", () => {
|
||||
@@ -896,10 +914,36 @@ suite("FTP server protocol", () => {
|
||||
assert.equal((await conn.cmd("RETR", "/f.txt")).code, 150)
|
||||
const data = await dataPromise
|
||||
const chunks: Uint8Array[] = []
|
||||
try {
|
||||
for (;;) chunks.push(await data.read())
|
||||
} catch {
|
||||
// socket ended
|
||||
for (;;) {
|
||||
const chunk = await data.read()
|
||||
if (chunk === null) break
|
||||
chunks.push(chunk)
|
||||
}
|
||||
assert.equal(new TextDecoder().decode(await readAll(streamOf(chunks[0]))), "world")
|
||||
assert.equal((await conn.reader.readReply()).code, 226)
|
||||
} finally {
|
||||
await conn.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("REST + RETR resumes at offset when readFileRange rejects unsupported", async () => {
|
||||
const base = new MemoryVFS()
|
||||
await base.writeFile("/f.txt", streamOf("hello world"))
|
||||
const conn = await rawConnect({ vfs: unsupportedOptional(base, ["readFileRange"]) })
|
||||
try {
|
||||
await conn.login()
|
||||
assert.equal((await conn.cmd("REST", "6")).code, 350)
|
||||
const epsv = await conn.cmd("EPSV")
|
||||
assert.equal(epsv.code, 229)
|
||||
const port = parseEpsv229(epsv.text)!
|
||||
const dataPromise = conn.net.dialer.dial("127.0.0.1", port)
|
||||
assert.equal((await conn.cmd("RETR", "/f.txt")).code, 150)
|
||||
const data = await dataPromise
|
||||
const chunks: Uint8Array[] = []
|
||||
for (;;) {
|
||||
const chunk = await data.read()
|
||||
if (chunk === null) break
|
||||
chunks.push(chunk)
|
||||
}
|
||||
assert.equal(new TextDecoder().decode(await readAll(streamOf(chunks[0]))), "world")
|
||||
assert.equal((await conn.reader.readReply()).code, 226)
|
||||
@@ -919,10 +963,10 @@ suite("FTP server protocol", () => {
|
||||
assert.equal((await conn.cmd("LIST")).code, 150)
|
||||
const data = await dataPromise
|
||||
let text = ""
|
||||
try {
|
||||
for (;;) text += new TextDecoder().decode(await data.read())
|
||||
} catch {
|
||||
// socket ended
|
||||
for (;;) {
|
||||
const chunk = await data.read()
|
||||
if (chunk === null) break
|
||||
text += new TextDecoder().decode(chunk)
|
||||
}
|
||||
assert.equal((await conn.reader.readReply()).code, 226)
|
||||
const lines = text.split("\r\n").filter(Boolean)
|
||||
@@ -977,6 +1021,21 @@ suite("FTP server protocol", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("RNFR/RNTO answers 502 when move rejects unsupported, matching an absent move", async () => {
|
||||
const base = new MemoryVFS()
|
||||
await base.writeFile("/a", streamOf("x"))
|
||||
const conn = await rawConnect({ vfs: unsupportedOptional(base, ["move"]) })
|
||||
try {
|
||||
await conn.login()
|
||||
assert.equal((await conn.cmd("RNFR", "/a")).code, 350)
|
||||
assert.equal((await conn.cmd("RNTO", "/b")).code, 502)
|
||||
assert.equal(await readAllText(await base.readFile("/a")), "x")
|
||||
await assert.rejects(base.stat("/b"), rejectsVfs("not-found"))
|
||||
} finally {
|
||||
await conn.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("passive replies and ABOR", async () => {
|
||||
const conn = await rawConnect()
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { parentPath, resolvePath, type Stat } from "@webnet/vfs"
|
||||
import { statAndReaddirFallback } from "@webnet/vfs/fallback"
|
||||
import { moveFallback, readFileRangeFallback, statAndReaddirFallback } from "@webnet/vfs/fallback"
|
||||
import { formatEpsv229, formatPasv227 } from "../common/addr.js"
|
||||
import { formatMLSX, formatUnixList } from "../common/listing.js"
|
||||
import { quotePath } from "../common/paths.js"
|
||||
import { skipBytes, transportToStream } from "../common/stream.js"
|
||||
import { transportToStream } from "../common/stream.js"
|
||||
import { formatTimeval } from "../common/time.js"
|
||||
import type { Session } from "./session.js"
|
||||
|
||||
@@ -55,9 +55,11 @@ async function openRetrStream(
|
||||
path: string,
|
||||
offset: bigint,
|
||||
): Promise<ReadableStream<Uint8Array>> {
|
||||
// REST carries no end bound, so this is always a read to the end of the file. A zero offset is
|
||||
// the whole file and does not need a ranged read; above that the shared fallback windows the
|
||||
// stream, including when the backend has readFileRange but rejects it for this path.
|
||||
if (offset === 0n) return session.vfs.readFile(path)
|
||||
if (session.vfs.readFileRange) return session.vfs.readFileRange(path, offset)
|
||||
return skipBytes(await session.vfs.readFile(path), offset)
|
||||
return readFileRangeFallback(session.vfs, path, offset, undefined, { policy: "on-unsupported" })
|
||||
}
|
||||
|
||||
export const commands = new Map<string, CommandHandler>([
|
||||
@@ -373,8 +375,14 @@ export const commands = new Map<string, CommandHandler>([
|
||||
const from = s.state.renameFrom
|
||||
s.state.renameFrom = null
|
||||
if (!from) return s.reply(503, "RNFR required first")
|
||||
if (!s.vfs.move) return s.reply(502, "Rename not supported")
|
||||
await s.vfs.move(from, resolvePath(s.state.cwd, arg), { overwrite: true })
|
||||
// native-only: RENAME is expected to be cheap and roughly atomic, and a shim would turn a
|
||||
// rename of a large directory into a recursive copy and delete with no way for the client
|
||||
// to know. The fallback is here so a backend without move and one that rejects unsupported
|
||||
// for this path both reach the session's error mapping as 502.
|
||||
await moveFallback(s.vfs, from, resolvePath(s.state.cwd, arg), {
|
||||
overwrite: true,
|
||||
policy: "native-only",
|
||||
})
|
||||
await s.reply(250, "Rename successful")
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# @webnet/http-static
|
||||
|
||||
Static file HTTP handler backed by an `@webnet/vfs` `AsyncVFS`.
|
||||
|
||||
`createStaticHandler` turns an `AsyncVFS` into a `@webnet/http` `Handler`: it resolves request paths against the VFS, serves files with `ETag`/`Last-Modified` conditional requests and `Range` support, and can render directory listings (HTML or JSON, based on `Accept`) or fall back to a single file (e.g. an SPA's `index.html`) when nothing matches.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { createStaticHandler } from "@webnet/http-static"
|
||||
import { Server } from "@webnet/http/server"
|
||||
import type { AsyncVFS } from "@webnet/vfs"
|
||||
import type { RawListener } from "@webnet/transport"
|
||||
|
||||
declare const vfs: AsyncVFS
|
||||
declare const listener: RawListener
|
||||
|
||||
const handler = createStaticHandler(vfs, {
|
||||
prefix: "/static",
|
||||
index: ["index.html"],
|
||||
fallback: "/index.html",
|
||||
})
|
||||
|
||||
const server = new Server(handler)
|
||||
await server.listen(listener)
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/http`](../http) — `Handler`/`Server` this package's output is used with
|
||||
- [`@webnet/vfs`](../vfs) — the `AsyncVFS` interface this package serves
|
||||
- [`@webnet/webdav`](../webdav) — a read/write, protocol-level alternative to serving a VFS statically
|
||||
@@ -3,6 +3,7 @@ import { suite, test } from "node:test"
|
||||
import type { Body } from "@webnet/http"
|
||||
import { MemoryVFS } from "@webnet/vfs/memory"
|
||||
import type { AsyncVFS } from "@webnet/vfs"
|
||||
import { unsupportedOptional } from "@webnet/vfs/conformance"
|
||||
import { createStaticHandler } from "./handler.js"
|
||||
|
||||
function streamOf(value: string): ReadableStream<Uint8Array> {
|
||||
@@ -391,6 +392,19 @@ suite("createStaticHandler", () => {
|
||||
assert.equal(await readBody(context.res.body), "bcd")
|
||||
})
|
||||
|
||||
test("falls back like an absent readFileRange when it rejects unsupported at runtime", async () => {
|
||||
const base = new MemoryVFS()
|
||||
await write(base, "/data", "abcdef")
|
||||
const vfs = unsupportedOptional(base, ["readFileRange"])
|
||||
const handler = createStaticHandler(vfs)
|
||||
const context = makeContext("GET", "/data", { range: "bytes=1-3" })
|
||||
await handler(context)
|
||||
assert.equal(context.res.status, 206)
|
||||
assert.equal(await readBody(context.res.body), "bcd")
|
||||
assert.equal(context.res.getHeader("content-range"), "bytes 1-3/6")
|
||||
assert.equal(context.res.getHeader("content-length"), "3")
|
||||
})
|
||||
|
||||
test("returns 400 for malformed encoded paths and respects unacceptable listings", async () => {
|
||||
const vfs = new MemoryVFS()
|
||||
const handler = createStaticHandler(vfs)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Context, Handler } from "@webnet/http"
|
||||
import { VFSError, type AsyncVFS, type Stat } from "@webnet/vfs"
|
||||
import { readFileRangeFallback } from "@webnet/vfs/fallback"
|
||||
|
||||
export type StaticPathResolver = (ctx: Context) => string | Promise<string>
|
||||
|
||||
@@ -99,46 +100,6 @@ function setMetadata(ctx: Context, stat: Stat): void {
|
||||
if (stat.modifiedAt) ctx.res.setHeader("Last-Modified", formatHttpDate(stat.modifiedAt))
|
||||
}
|
||||
|
||||
function boundedStream(stream: ReadableStream<Uint8Array>, start: bigint, length: bigint) {
|
||||
const reader = stream.getReader()
|
||||
let offset = 0n
|
||||
let remaining = length
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
while (remaining > 0n) {
|
||||
const result = await reader.read()
|
||||
if (result.done) {
|
||||
controller.close()
|
||||
reader.releaseLock()
|
||||
return
|
||||
}
|
||||
const chunk = result.value
|
||||
const chunkStart = offset
|
||||
offset += BigInt(chunk.byteLength)
|
||||
const chunkEnd = offset
|
||||
if (chunkEnd <= start) continue
|
||||
const from = Number(start > chunkStart ? start - chunkStart : 0n)
|
||||
const count = Math.min(Number(remaining), chunk.byteLength - from)
|
||||
if (count > 0) {
|
||||
controller.enqueue(chunk.slice(from, from + count))
|
||||
remaining -= BigInt(count)
|
||||
}
|
||||
if (remaining === 0n) {
|
||||
await reader.cancel()
|
||||
reader.releaseLock()
|
||||
return
|
||||
}
|
||||
}
|
||||
controller.close()
|
||||
reader.releaseLock()
|
||||
},
|
||||
cancel() {
|
||||
void reader.cancel()
|
||||
reader.releaseLock()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function quality(value: string, type: string): number {
|
||||
let best = -1
|
||||
for (const part of value.split(",")) {
|
||||
@@ -264,9 +225,13 @@ async function serveFile(ctx: Context, vfs: AsyncVFS, lookup: Lookup): Promise<v
|
||||
if (ctx.req.method === "HEAD") {
|
||||
ctx.res.body = null
|
||||
} else {
|
||||
ctx.res.body = vfs.readFileRange
|
||||
? await vfs.readFileRange(path, range.start, range.end)
|
||||
: boundedStream(await vfs.readFile(path), range.start, length)
|
||||
// A file server that could serve the range should not refuse it, so a backend that says
|
||||
// unsupported for this path is answered the same way as one that has no readFileRange at
|
||||
// all. The shim discards the prefix, which is wasted transfer the client cannot see; the
|
||||
// alternative is a 501 many clients handle worse than a slow response.
|
||||
ctx.res.body = await readFileRangeFallback(vfs, path, range.start, range.end, {
|
||||
policy: "on-unsupported",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# @webnet/http
|
||||
|
||||
HTTP/1.1 client and server built on `@webnet/transport`.
|
||||
|
||||
The client provides `fetch`/`fetchStream` and a connection pool (`PooledDialer`/`UnpooledDialer`) with redirect following via `ClientConnection`. The server provides `Server`, which drives a `Handler` over accepted connections, and a `Router` for method/path dispatch with typed middleware. TLS is not handled by this package directly: the client dials TLS through `RawDialer.dialTls` (from `@webnet/transport`) when a `https:` URL is used, and `fetch` throws if the dialer has no `dialTls`; the server listens on whatever `RawListener` it is given, so plain vs. TLS listening is determined by the listener/transport passed to `Server.listen`.
|
||||
|
||||
## Entry points
|
||||
|
||||
| Entry point | Description |
|
||||
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `@webnet/http` | The `client`, `server`, and `common` entry points re-exported together. `Router` is not included; import it from `@webnet/http/router`. |
|
||||
| `@webnet/http/client` | `fetch`, `fetchStream`, `makeFetch`, `ClientConnection`, `PooledDialer`, `UnpooledDialer`, and client types. |
|
||||
| `@webnet/http/server` | `Server`, `ServerConnection`, and server types (`Context`, `Handler`, `ServerRequest`, `ServerResponse`). |
|
||||
| `@webnet/http/router` | `Router` and `RouteProxy` for path/method dispatch. |
|
||||
| `@webnet/http/common` | `Headers`, `MutableHeaders`, `methods`, `statusCodes`. |
|
||||
|
||||
`_internals` entry points are unstable and are not part of the public API.
|
||||
|
||||
## Usage
|
||||
|
||||
### Client
|
||||
|
||||
```ts
|
||||
import { makeFetch } from "@webnet/http/client"
|
||||
import type { RawDialer } from "@webnet/transport"
|
||||
|
||||
declare const dialer: RawDialer
|
||||
|
||||
const fetch = makeFetch(dialer)
|
||||
const res = await fetch("http://example.com/")
|
||||
console.log(res.status, await res.text())
|
||||
```
|
||||
|
||||
### Server
|
||||
|
||||
```ts
|
||||
import { Server } from "@webnet/http/server"
|
||||
import { Router } from "@webnet/http/router"
|
||||
import type { RawListener } from "@webnet/transport"
|
||||
|
||||
declare const listener: RawListener
|
||||
|
||||
const router = new Router()
|
||||
router.get("/hello/:name", async (ctx) => {
|
||||
ctx.res.body = `hello ${ctx.keys.name}`
|
||||
})
|
||||
|
||||
const server = new Server(router.handler)
|
||||
await server.listen(listener)
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/transport`](../transport) — the `RawDialer`/`RawListener`/`RawTransport` primitives this package is built on
|
||||
- [`@webnet/http-static`](../http-static) — static file serving on top of a `Handler`
|
||||
- [`@webnet/websocket`](../websocket) — WebSocket upgrade on top of `@webnet/http`
|
||||
- [`@webnet/webdav`](../webdav) — WebDAV client and server on top of `@webnet/http`
|
||||
@@ -1,7 +1,7 @@
|
||||
import test, { suite } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import { fetch, fetchStream, makeFetch } from "./fetch.js"
|
||||
import { UnpooledDialer } from "./pool.js"
|
||||
import { UnpooledDialer, type ConnectionPool } from "./pool.js"
|
||||
import { loopbackListener, loopbackTransportPair } from "@webnet/transport/loopback"
|
||||
import { ReadBuffer } from "@webnet/transport/buffer"
|
||||
|
||||
@@ -74,6 +74,57 @@ function redirectServer(
|
||||
return { dialer, serverDone }
|
||||
}
|
||||
|
||||
function truncatedRedirectServer(): {
|
||||
dialer: ReturnType<typeof loopbackListener>[1]
|
||||
serverDone: Promise<void>
|
||||
} {
|
||||
const [listener, dialer] = loopbackListener()
|
||||
const serverDone = (async () => {
|
||||
const first = await listener.accept()
|
||||
await collectRequest(new ReadBuffer(first))
|
||||
await first.write(
|
||||
enc.encode(
|
||||
"HTTP/1.1 302 Redirect\r\n" +
|
||||
"Location: http://other.example/\r\n" +
|
||||
"Content-Length: 10\r\n" +
|
||||
"Connection: close\r\n\r\n" +
|
||||
"ab",
|
||||
),
|
||||
)
|
||||
first.close()
|
||||
|
||||
const second = await listener.accept()
|
||||
await collectRequest(new ReadBuffer(second))
|
||||
await second.write(enc.encode(ok200))
|
||||
second.close()
|
||||
listener.close()
|
||||
})()
|
||||
return { dialer, serverDone }
|
||||
}
|
||||
|
||||
function trackRejections(dialer: ReturnType<typeof loopbackListener>[1]): {
|
||||
pool: ConnectionPool
|
||||
count: () => number
|
||||
} {
|
||||
const inner = new UnpooledDialer(dialer)
|
||||
let count = 0
|
||||
return {
|
||||
pool: {
|
||||
tlsSupported: inner.tlsSupported,
|
||||
getConnection: inner.getConnection.bind(inner),
|
||||
releaseConnection: inner.releaseConnection.bind(inner),
|
||||
rejectConnection(...args) {
|
||||
count++
|
||||
inner.rejectConnection(...args)
|
||||
},
|
||||
exportIdle: inner.exportIdle.bind(inner),
|
||||
seed: inner.seed.bind(inner),
|
||||
shutdown: inner.shutdown.bind(inner),
|
||||
},
|
||||
count: () => count,
|
||||
}
|
||||
}
|
||||
|
||||
// Single-connection server that handles two requests (for same-connection redirect testing).
|
||||
function sameConnRedirectServer(
|
||||
redirectStatus: number,
|
||||
@@ -258,16 +309,11 @@ suite("fetch()", () => {
|
||||
await serverDone
|
||||
})
|
||||
|
||||
test("stream() swallows body error when shouldClose closes transport (body.closed=true path)", async () => {
|
||||
// fetch() without Connection:upgrade → keepAlive=false adds Connection:close → shouldClose=true
|
||||
// Server sends 2 of 10 promised bytes then closes. Body read fails, conn.close() anyHandler fires
|
||||
// (because shouldClose=true), which sets body.closed=true. stream() catch sees body.closed=true
|
||||
// → `continue` → loop exits cleanly → bytes() resolves with partial data rather than rejecting.
|
||||
test("truncated response body rejects even when the connection should close", async () => {
|
||||
const { dialer, serverDone } = simpleServer("HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nab")
|
||||
const res = await fetch(dialer, "http://localhost/")
|
||||
assert.strictEqual(res.status, 200)
|
||||
const body = await res.bytes()
|
||||
assert.strictEqual(body.length, 2)
|
||||
await assert.rejects(() => res.bytes(), /Unexpected end of stream/)
|
||||
await serverDone
|
||||
})
|
||||
|
||||
@@ -636,6 +682,31 @@ suite("fetch() — redirect error handling", () => {
|
||||
await assert.rejects(() => fetch(dialer, "http://localhost/", { redirect: { mode: "follow" } }))
|
||||
await serverDone
|
||||
})
|
||||
|
||||
test("rejects a truncated redirect connection and continues on a new one", async () => {
|
||||
const { dialer, serverDone } = truncatedRedirectServer()
|
||||
const tracked = trackRejections(dialer)
|
||||
const f = makeFetch(tracked.pool)
|
||||
const res = await f("http://localhost/", { redirect: { mode: "follow" } })
|
||||
assert.strictEqual(res.status, 200)
|
||||
assert.strictEqual(tracked.count(), 1)
|
||||
await serverDone
|
||||
})
|
||||
|
||||
test("fetchStream rejects a truncated redirect connection and continues on a new one", async () => {
|
||||
const { dialer, serverDone } = truncatedRedirectServer()
|
||||
const tracked = trackRejections(dialer)
|
||||
const f = makeFetch(tracked.pool)
|
||||
const statuses: number[] = []
|
||||
for await (const res of f.stream("http://localhost/", {
|
||||
redirect: { mode: "follow" },
|
||||
})) {
|
||||
statuses.push(res.status)
|
||||
}
|
||||
assert.deepStrictEqual(statuses, [302, 200])
|
||||
assert.strictEqual(tracked.count(), 1)
|
||||
await serverDone
|
||||
})
|
||||
})
|
||||
|
||||
suite("fetch() — redirect.mode: follow", () => {
|
||||
|
||||
@@ -361,17 +361,12 @@ async function rawFetch(
|
||||
if (!reuseConn) {
|
||||
// Drain the redirect body so the connection can be cleanly released.
|
||||
let drainOk = true
|
||||
// drain() exits via the while-condition rather than throwing: BasicBodyReader sets
|
||||
// closed=true via buffer.ended before read() is re-entered, so the catch and the
|
||||
// rejectConnection branch are unreachable with any standard transport.
|
||||
/* c8 ignore start */
|
||||
try {
|
||||
await conn.drain()
|
||||
} catch {
|
||||
drainOk = false
|
||||
}
|
||||
if (!drainOk) pool.rejectConnection(hostname, port, isTls, conn)
|
||||
/* c8 ignore stop */
|
||||
if (drainOk) pool.releaseConnection(hostname, port, isTls, conn)
|
||||
|
||||
hostname = location.hostname
|
||||
@@ -498,15 +493,12 @@ async function* rawFetchStream(
|
||||
|
||||
if (!reuseConn) {
|
||||
let drainOk = true
|
||||
// Same reasoning as above: drain() cannot throw with standard transports.
|
||||
/* c8 ignore start */
|
||||
try {
|
||||
await conn.drain()
|
||||
} catch {
|
||||
drainOk = false
|
||||
}
|
||||
if (!drainOk) done(true)
|
||||
/* c8 ignore stop */
|
||||
if (drainOk) done(false)
|
||||
|
||||
hostname = location.hostname
|
||||
|
||||
@@ -57,7 +57,7 @@ export class ReadableHttpImpl implements ReadableHttp {
|
||||
const deadline =
|
||||
bodyTimeout !== undefined && isFinite(bodyTimeout) ? Date.now() + bodyTimeout : undefined
|
||||
while (!body.closed) {
|
||||
let chunk: Uint8Array
|
||||
let chunk: Uint8Array | null
|
||||
try {
|
||||
const remaining = deadline !== undefined ? Math.max(0, deadline - Date.now()) : undefined
|
||||
chunk = await withTimeout(body.read(), remaining, "Body timeout")
|
||||
@@ -65,6 +65,7 @@ export class ReadableHttpImpl implements ReadableHttp {
|
||||
if (body.closed) continue
|
||||
throw e
|
||||
}
|
||||
if (chunk === null) break
|
||||
if (!chunk.length) continue
|
||||
bodyLength += chunk.length
|
||||
if (bodyLength > maxBodyLength)
|
||||
@@ -89,7 +90,7 @@ export class ReadableHttpImpl implements ReadableHttp {
|
||||
|
||||
const pull = async (controller: ReadableByteStreamController) => {
|
||||
while (!body.closed) {
|
||||
let chunk: Uint8Array
|
||||
let chunk: Uint8Array | null
|
||||
try {
|
||||
const remaining = deadline !== undefined ? Math.max(0, deadline - Date.now()) : undefined
|
||||
chunk = await withTimeout(body.read(), remaining, "Body timeout")
|
||||
@@ -98,6 +99,7 @@ export class ReadableHttpImpl implements ReadableHttp {
|
||||
controller.error(e)
|
||||
return
|
||||
}
|
||||
if (chunk === null) break
|
||||
if (!chunk.length) continue
|
||||
bodyLength += chunk.length
|
||||
if (bodyLength > maxBodyLength) {
|
||||
|
||||
@@ -15,7 +15,7 @@ function makeReader(data: string | Uint8Array, chunkSize = Infinity): Reader {
|
||||
return offset >= bytes.length
|
||||
},
|
||||
async read() {
|
||||
if (offset >= bytes.length) return new Uint8Array(0)
|
||||
if (offset >= bytes.length) return null
|
||||
const end = isFinite(chunkSize) ? Math.min(offset + chunkSize, bytes.length) : bytes.length
|
||||
const chunk = bytes.slice(offset, end)
|
||||
offset = end
|
||||
@@ -343,6 +343,33 @@ suite("ChunkedBodyReader", () => {
|
||||
const reader = new ChunkedBodyReader(buf)
|
||||
await assert.rejects(() => reader.read(), /Chunk declared/)
|
||||
})
|
||||
|
||||
test("throws when EOF arrives before the final chunk terminator", async () => {
|
||||
const reader = new ChunkedBodyReader(rb("0\r\n"))
|
||||
let ko = false
|
||||
reader.onFinish(undefined, () => {
|
||||
ko = true
|
||||
})
|
||||
|
||||
await assert.rejects(() => reader.read(), /Unexpected end of stream/)
|
||||
assert.strictEqual(reader.closed, false)
|
||||
assert.strictEqual(ko, true)
|
||||
})
|
||||
|
||||
test("throws when EOF arrives during a chunk terminator", async () => {
|
||||
const reader = new ChunkedBodyReader(rb("1\r\na\r"))
|
||||
await assert.rejects(() => reader.read(), /Unexpected end of stream/)
|
||||
})
|
||||
|
||||
test("throws when a chunk has an invalid terminator", async () => {
|
||||
const reader = new ChunkedBodyReader(rb("1\r\naXX"))
|
||||
await assert.rejects(() => reader.read(), /Invalid chunk terminator/)
|
||||
})
|
||||
|
||||
test("throws when the final chunk has an invalid terminator", async () => {
|
||||
const reader = new ChunkedBodyReader(rb("0\r\nXX"))
|
||||
await assert.rejects(() => reader.read(), /Invalid chunk terminator/)
|
||||
})
|
||||
})
|
||||
|
||||
suite("bodyReader", () => {
|
||||
|
||||
@@ -3,7 +3,15 @@ import type { ReadBuffer } from "@webnet/transport/buffer"
|
||||
import { Headers } from "./headers.js"
|
||||
import { hasOwnProperty } from "./utils.js"
|
||||
|
||||
function assertChunkTerminator(buffer: ReadBuffer, offset: number): void {
|
||||
const parts = buffer.slice(offset, 2)
|
||||
const last = parts[parts.length - 1]
|
||||
if (parts[0][0] !== 0x0d || last[last.length - 1] !== 0x0a)
|
||||
throw new Error("Invalid chunk terminator")
|
||||
}
|
||||
|
||||
export interface BodyReader extends Reader {
|
||||
read(): Promise<Uint8Array>
|
||||
onFinish(
|
||||
okHandler?: () => void | Promise<void>,
|
||||
koHandler?: (err: unknown) => void | Promise<void>,
|
||||
@@ -84,7 +92,6 @@ export class BasicBodyReader extends BaseBodyReader implements BodyReader {
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
if (this.#buffer.ended) return true
|
||||
return this.#remaining === 0
|
||||
}
|
||||
|
||||
@@ -96,7 +103,8 @@ export class BasicBodyReader extends BaseBodyReader implements BodyReader {
|
||||
return chunk
|
||||
}
|
||||
if (this.closed) throw new Error("BasicBodyReader is already closed")
|
||||
while (!this.#buffer.len) await this.#buffer.readOnce()
|
||||
while (!this.#buffer.len && !this.#buffer.ended) await this.#buffer.readOnce()
|
||||
if (!this.#buffer.len) throw new Error("Unexpected end of stream")
|
||||
const len = Math.min(this.#buffer.len, this.#remaining)
|
||||
this.#remaining -= len
|
||||
const data = this.#buffer.slice(0, len)
|
||||
@@ -131,7 +139,7 @@ export class ChunkedBodyReader extends BaseBodyReader implements BodyReader {
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.#buffer.ended || this.#finished
|
||||
return this.#finished
|
||||
}
|
||||
|
||||
async read(): Promise<Uint8Array> {
|
||||
@@ -146,15 +154,19 @@ export class ChunkedBodyReader extends BaseBodyReader implements BodyReader {
|
||||
if (this.#bodyLength > this.#maxBodyLength)
|
||||
throw new Error(`Body too large: ${this.#bodyLength} (max: ${this.#maxBodyLength})`)
|
||||
if (!len) {
|
||||
this.#finished = true
|
||||
await this.#buffer.read(2)
|
||||
if (this.#buffer.len < 2) throw new Error("Unexpected end of stream")
|
||||
assertChunkTerminator(this.#buffer, 0)
|
||||
this.#buffer.forward(2)
|
||||
this.#finished = true
|
||||
await this._triggerFinishOk()
|
||||
return new Uint8Array(0)
|
||||
}
|
||||
await this.#buffer.read(len + 2)
|
||||
if (len > this.#buffer.len)
|
||||
throw new Error(`Chunk declared ${len} bytes but buffer only has ${this.#buffer.len}`)
|
||||
if (this.#buffer.len < len + 2) throw new Error("Unexpected end of stream")
|
||||
assertChunkTerminator(this.#buffer, len)
|
||||
const data = this.#buffer.slice(0, len)
|
||||
this.#buffer.forward(len + 2)
|
||||
for (const chunk of data) this.#chunks.push(chunk)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# @webnet/react
|
||||
|
||||
Shared React hooks and components for webnet apps.
|
||||
|
||||
It provides SSR-safe capability hooks built on `useSyncExternalStore` (`useClient`, `useSecureContext`, `useSharedWorkerAvailable`), a `useLocalStorage` hook that syncs a string value across tabs via the `storage` event, and error-serialization and display helpers (`serializeError`, `ErrorDetails`) for preserving and rendering caught errors, including causes and aggregate errors. `ErrorDetails` keeps the technical details collapsed by default; callers can pass a redactor to `serializeError` when error content may be sensitive.
|
||||
|
||||
## Entry points
|
||||
|
||||
| Entry point | Description |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `@webnet/react` | Hooks and components: `useClient`, `useLocalStorage`, `useSecureContext`, `useSharedWorkerAvailable`, `serializeError`, `ErrorDetails` |
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { useClient, useLocalStorage, serializeError, ErrorDetails } from "@webnet/react"
|
||||
|
||||
function Panel() {
|
||||
const isClient = useClient()
|
||||
const [theme, setTheme] = useLocalStorage("theme", "light")
|
||||
|
||||
if (!isClient) return null
|
||||
|
||||
return <button onClick={() => setTheme("dark")}>Current theme: {theme}</button>
|
||||
}
|
||||
|
||||
function Fallback({ error }: { error: unknown }) {
|
||||
return <ErrorDetails error={serializeError(error)} />
|
||||
}
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/utils`](../utils) — browser utilities without a React dependency
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { SerializedError } from "./error.js"
|
||||
|
||||
function ErrorTrace({ error, label }: { error: SerializedError; label?: string }) {
|
||||
return (
|
||||
<>
|
||||
<strong>{label ?? error.name}</strong>
|
||||
<pre>{error.stack ?? `${error.name}: ${error.message}`}</pre>
|
||||
{error.cause && <ErrorTrace error={error.cause} label="Cause" />}
|
||||
{error.errors?.map((nested, index) => (
|
||||
<ErrorTrace key={index} error={nested} label={`Error ${index + 1}`} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorDetails({
|
||||
error,
|
||||
reactComponentStack,
|
||||
summary = "Technical details",
|
||||
children,
|
||||
}: {
|
||||
error: SerializedError
|
||||
reactComponentStack?: string | null
|
||||
summary?: ReactNode
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<details>
|
||||
<summary>{summary}</summary>
|
||||
<ErrorTrace error={error} />
|
||||
{reactComponentStack && (
|
||||
<>
|
||||
<strong>React component stack</strong>
|
||||
<pre>{reactComponentStack}</pre>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export type SerializedError = {
|
||||
name: string
|
||||
message: string
|
||||
stack?: string
|
||||
cause?: SerializedError
|
||||
errors?: SerializedError[]
|
||||
}
|
||||
|
||||
export type ErrorRedactor = (value: string) => string
|
||||
|
||||
function isErrorLike(error: unknown): error is {
|
||||
name?: unknown
|
||||
message?: unknown
|
||||
stack?: unknown
|
||||
cause?: unknown
|
||||
errors?: unknown
|
||||
} {
|
||||
return typeof error === "object" && error !== null
|
||||
}
|
||||
|
||||
export function serializeError(
|
||||
error: unknown,
|
||||
redact: ErrorRedactor = (value) => value,
|
||||
): SerializedError {
|
||||
if (!isErrorLike(error)) return { name: "Error", message: redact(String(error)) }
|
||||
|
||||
const serialized: SerializedError = {
|
||||
name: typeof error.name === "string" ? redact(error.name) : "Error",
|
||||
message: typeof error.message === "string" ? redact(error.message) : redact(String(error)),
|
||||
}
|
||||
if (typeof error.stack === "string") serialized.stack = redact(error.stack)
|
||||
if ("cause" in error && error.cause !== undefined)
|
||||
serialized.cause = serializeError(error.cause, redact)
|
||||
if (Array.isArray(error.errors))
|
||||
serialized.errors = error.errors.map((item) => serializeError(item, redact))
|
||||
return serialized
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
export { ErrorDetails } from "./ErrorDetails.js"
|
||||
export { type ErrorRedactor, serializeError, type SerializedError } from "./error.js"
|
||||
export { useClient } from "./useClient.js"
|
||||
export { useLocalStorage } from "./useLocalStorage.js"
|
||||
export { useSecureContext } from "./useSecureContext.js"
|
||||
export { useSharedWorkerAvailable } from "./useSharedWorkerAvailable.js"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useSyncExternalStore } from "react"
|
||||
|
||||
const subscribe = () => () => undefined
|
||||
const getClientSnapshot = () => window.isSecureContext
|
||||
const getServerSnapshot = () => true
|
||||
|
||||
export function useSecureContext(): boolean {
|
||||
return useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot)
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# @webnet/sftp
|
||||
|
||||
SFTP (protocol version 3) client and server built on `@webnet/ssh` and `@webnet/vfs`.
|
||||
|
||||
`SFTPClient` implements `AsyncVFS` (`stat`, `readdir`, `readFile`/`readFileRange`, `writeFile`, `delete`, `mkdir`, `move`) over an SFTP session. It can be created three ways: `SFTPClient.connect` runs SFTP as a new subsystem channel on an `SSHClientConnection` the caller owns, `SFTPClient.fromChannel` runs it over a subsystem channel the caller already opened, or `new SFTPClient({ dialer, host, user, ... })` dials its own SSH connection. `SFTPServer` drives a full SSH server (via `@webnet/ssh`) and serves an `AsyncVFS` over the SFTP subsystem to accepted connections, with optional per-login `authenticate` to select a different VFS per credential; `serveSFTPChannel` runs the SFTP server protocol directly over a `Channel` for callers who already have an authenticated SSH connection. Errors surface as `SFTPError`, and `SSH_FX` holds the SFTP status codes.
|
||||
|
||||
## Entry points
|
||||
|
||||
| Entry point | Description |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `@webnet/sftp` | `SFTPClient`, `SFTPServer`, `serveSFTPChannel`, `SFTPError`, `SSH_FX`, and their option types. |
|
||||
| `@webnet/sftp/client` | `SFTPClient` and its option types only. |
|
||||
| `@webnet/sftp/server` | `SFTPServer`, `serveSFTPChannel`, and their option types only. |
|
||||
|
||||
`_internals` entry points are unstable and are not part of the public API.
|
||||
|
||||
## Usage
|
||||
|
||||
### Client
|
||||
|
||||
```ts
|
||||
import { SFTPClient } from "@webnet/sftp/client"
|
||||
import type { RawDialer } from "@webnet/transport"
|
||||
|
||||
declare const dialer: RawDialer
|
||||
|
||||
const client = new SFTPClient({ dialer, host: "example.com", user: "u", password: "p" })
|
||||
const entries = await client.readdir("/home/u")
|
||||
const stream = await client.readFile("/home/u/file.txt")
|
||||
await client.close()
|
||||
```
|
||||
|
||||
### Server
|
||||
|
||||
```ts
|
||||
import { SFTPServer } from "@webnet/sftp/server"
|
||||
import type { RawListener } from "@webnet/transport"
|
||||
import type { AsyncVFS } from "@webnet/vfs"
|
||||
|
||||
declare const vfs: AsyncVFS
|
||||
declare const listener: RawListener
|
||||
|
||||
const server = new SFTPServer({ vfs })
|
||||
await server.listen(listener)
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/ssh`](../ssh) — the SSH connections, channels, and subsystems this package runs over
|
||||
- [`@webnet/vfs`](../vfs) — the `AsyncVFS` interface this package implements and serves
|
||||
- [`@webnet/ftp`](../ftp) — an alternative file-transfer protocol using FTP/FTPS instead of SSH
|
||||
@@ -1,5 +1,5 @@
|
||||
import { VFSError, baseName, resolvePath, type AsyncVFS, type Stat } from "@webnet/vfs"
|
||||
import { SSHAuthError, SSHClientConnection } from "@webnet/ssh"
|
||||
import { SSHAuthError, SSHClientConnection, type Channel } from "@webnet/ssh"
|
||||
import { Reader, Writer } from "../sftp/cursor.js"
|
||||
import { FXP, SSH_FXF, attrsToStat, decodeAttrs, encodeAttrs } from "../sftp/packets.js"
|
||||
import { SSH_FX, statusToVFSError } from "../sftp/status.js"
|
||||
@@ -12,11 +12,27 @@ const MAX_INFLIGHT = 8
|
||||
export class SFTPClient implements AsyncVFS {
|
||||
readonly #options: SFTPClientOptions
|
||||
#session: Promise<SftpSession> | null = null
|
||||
// Only a connection this client dialed itself is closed by close().
|
||||
#ownedConnection: SSHClientConnection | null = null
|
||||
|
||||
constructor(options: SFTPClientOptions) {
|
||||
this.#options = options
|
||||
}
|
||||
|
||||
/** Run SFTP over an SSH connection the caller owns and keeps. */
|
||||
static async connect(connection: SSHClientConnection): Promise<SFTPClient> {
|
||||
const client = new SFTPClient({ connection })
|
||||
await client.#connect()
|
||||
return client
|
||||
}
|
||||
|
||||
/** Run SFTP over a subsystem channel the caller already opened. */
|
||||
static async fromChannel(channel: Channel): Promise<SFTPClient> {
|
||||
const client = new SFTPClient({ channel })
|
||||
await client.#connect()
|
||||
return client
|
||||
}
|
||||
|
||||
#connect(): Promise<SftpSession> {
|
||||
if (!this.#session) {
|
||||
const session = this.#doConnect()
|
||||
@@ -29,7 +45,20 @@ export class SFTPClient implements AsyncVFS {
|
||||
}
|
||||
|
||||
async #doConnect(): Promise<SftpSession> {
|
||||
const { dialer, host, port, user, password, privateKey, verifyHostKey } = this.#options
|
||||
const options = this.#options
|
||||
if ("channel" in options) return startSession(options.channel)
|
||||
if ("connection" in options) {
|
||||
// The connection is the caller's; only the channel we open is ours to
|
||||
// clean up if the handshake fails.
|
||||
const channel = await options.connection.openSubsystem("sftp")
|
||||
try {
|
||||
return await startSession(channel)
|
||||
} catch (e) {
|
||||
await channel.close().catch(() => {})
|
||||
throw e
|
||||
}
|
||||
}
|
||||
const { dialer, host, port, user, password, privateKey, verifyHostKey } = options
|
||||
const raw = await dialer.dial(host, port ?? 22)
|
||||
let connection: SSHClientConnection
|
||||
try {
|
||||
@@ -44,9 +73,8 @@ export class SFTPClient implements AsyncVFS {
|
||||
throw e
|
||||
}
|
||||
try {
|
||||
const channel = await connection.openSubsystem("sftp")
|
||||
const session = new SftpSession(channel, connection)
|
||||
await session.init()
|
||||
const session = await startSession(await connection.openSubsystem("sftp"))
|
||||
this.#ownedConnection = connection
|
||||
return session
|
||||
} catch (e) {
|
||||
await connection.close().catch(() => {})
|
||||
@@ -56,7 +84,9 @@ export class SFTPClient implements AsyncVFS {
|
||||
|
||||
async close(): Promise<void> {
|
||||
const session = this.#session
|
||||
const connection = this.#ownedConnection
|
||||
this.#session = null
|
||||
this.#ownedConnection = null
|
||||
if (session) {
|
||||
try {
|
||||
await (await session).close()
|
||||
@@ -64,6 +94,7 @@ export class SFTPClient implements AsyncVFS {
|
||||
/* already closed or never connected */
|
||||
}
|
||||
}
|
||||
await connection?.close().catch(() => {})
|
||||
}
|
||||
|
||||
async stat(path: string): Promise<Stat> {
|
||||
@@ -325,6 +356,11 @@ export class SFTPClient implements AsyncVFS {
|
||||
return
|
||||
}
|
||||
if (recursive) {
|
||||
// Kept rather than shared with the FTP client: `delete` is a required operation, so there is
|
||||
// no optional one to fall back for, and the recursion is protocol-shaped — REMOVE and RMDIR
|
||||
// are different requests chosen from the ATTRS reply this method already holds. A generic
|
||||
// shim would restat every entry and still could not tell a symlink from what it points at,
|
||||
// which is the difference that decides whether descending is correct at all.
|
||||
for (const entry of await this.readdir(path)) await this.delete(entry.path, true)
|
||||
}
|
||||
expectOk(await session.request(FXP.RMDIR, new Writer().string(path).finish()), path, "rmdir")
|
||||
@@ -360,6 +396,12 @@ export class SFTPClient implements AsyncVFS {
|
||||
}
|
||||
}
|
||||
|
||||
async function startSession(channel: Channel): Promise<SftpSession> {
|
||||
const session = new SftpSession(channel)
|
||||
await session.init()
|
||||
return session
|
||||
}
|
||||
|
||||
function expectOk(reply: SftpResponse, path: string, verb: string): void {
|
||||
if (reply.type !== FXP.STATUS) throw statusError(reply, path, verb)
|
||||
const r = new Reader(reply.payload)
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
export { SFTPClient } from "./client.js"
|
||||
export type { SFTPClientOptions } from "./types.js"
|
||||
export type {
|
||||
SFTPClientOptions,
|
||||
SFTPDialOptions,
|
||||
SFTPConnectionOptions,
|
||||
SFTPChannelOptions,
|
||||
} from "./types.js"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Channel, SSHClientConnection } from "@webnet/ssh"
|
||||
import type { Channel } from "@webnet/ssh"
|
||||
import { Reader, Writer } from "../sftp/cursor.js"
|
||||
import { FXP, PacketReassembler, encodePacket, type ReassembledPacket } from "../sftp/packets.js"
|
||||
|
||||
@@ -8,7 +8,6 @@ type Resolver = { resolve: (v: SftpResponse) => void; reject: (e: unknown) => vo
|
||||
|
||||
export class SftpSession {
|
||||
readonly #channel: Channel
|
||||
readonly #connection: SSHClientConnection
|
||||
readonly #reassembler = new PacketReassembler()
|
||||
readonly #pending = new Map<number, Resolver>()
|
||||
readonly #extensions = new Set<string>()
|
||||
@@ -17,9 +16,8 @@ export class SftpSession {
|
||||
#error: unknown = null
|
||||
#versionResolver: { resolve: () => void; reject: (e: unknown) => void } | null = null
|
||||
|
||||
constructor(channel: Channel, connection: SSHClientConnection) {
|
||||
constructor(channel: Channel) {
|
||||
this.#channel = channel
|
||||
this.#connection = connection
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
@@ -48,10 +46,10 @@ export class SftpSession {
|
||||
return promise
|
||||
}
|
||||
|
||||
// Closes only the SFTP channel. Whoever owns the SSH connection closes that.
|
||||
async close(): Promise<void> {
|
||||
this.#fail(new Error("sftp session closed"))
|
||||
this.#channel.close().catch(() => {})
|
||||
await this.#connection.close().catch(() => {})
|
||||
await this.#channel.close().catch(() => {})
|
||||
}
|
||||
|
||||
#allocId(): number {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RawDialer } from "@webnet/transport"
|
||||
import type { Channel, SSHClientConnection } from "@webnet/ssh"
|
||||
|
||||
export type SFTPClientOptions = {
|
||||
export type SFTPDialOptions = {
|
||||
dialer: RawDialer
|
||||
host: string
|
||||
port?: number
|
||||
@@ -13,3 +14,11 @@ export type SFTPClientOptions = {
|
||||
fingerprint: string
|
||||
}) => boolean | Promise<boolean>
|
||||
}
|
||||
|
||||
/** Use an SSH connection the caller owns; SFTP opens its own subsystem channel. */
|
||||
export type SFTPConnectionOptions = { connection: SSHClientConnection }
|
||||
|
||||
/** Use a subsystem channel the caller already opened. */
|
||||
export type SFTPChannelOptions = { channel: Channel }
|
||||
|
||||
export type SFTPClientOptions = SFTPDialOptions | SFTPConnectionOptions | SFTPChannelOptions
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export { SFTPClient } from "./client/index.js"
|
||||
export type { SFTPClientOptions } from "./client/index.js"
|
||||
export { SFTPServer } from "./server/index.js"
|
||||
export type {
|
||||
SFTPClientOptions,
|
||||
SFTPDialOptions,
|
||||
SFTPConnectionOptions,
|
||||
SFTPChannelOptions,
|
||||
} from "./client/index.js"
|
||||
export { SFTPServer, serveSFTPChannel } from "./server/index.js"
|
||||
export type { SFTPServerOptions, ListenOptions, Credential } from "./server/index.js"
|
||||
export { SFTPError, SSH_FX } from "./sftp/status.js"
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { after, before, suite, test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { spawn, spawnSync } from "node:child_process"
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { nodeListen, type NodeListener } from "@webnet/transport/node"
|
||||
import { MemoryVFS } from "@webnet/vfs/memory"
|
||||
import type { AsyncVFS } from "@webnet/vfs"
|
||||
import { generateHostKey, serveSession, SSHServerConnection, type Channel } from "@webnet/ssh"
|
||||
import { serveSFTPChannel } from "./server/session.js"
|
||||
|
||||
const enc = new TextEncoder()
|
||||
const dec = new TextDecoder()
|
||||
|
||||
const available = spawnSync("sftp", [], { stdio: "ignore" }).status !== null
|
||||
|
||||
function streamOf(text: string): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(enc.encode(text))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
const chunks: Uint8Array[] = []
|
||||
const reader = stream.getReader()
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
}
|
||||
return dec.decode(Buffer.concat(chunks))
|
||||
}
|
||||
|
||||
type Fixture = {
|
||||
port: number
|
||||
keyPath: string
|
||||
dir: string
|
||||
vfs: MemoryVFS
|
||||
listener: NodeListener
|
||||
execCommands: string[]
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
let fixture: Fixture
|
||||
|
||||
// A server that routes session channels the way #108 asks: the `sftp`
|
||||
// subsystem goes to serveSFTPChannel, exec to a command handler, on one
|
||||
// connection with a single accept loop.
|
||||
async function start(): Promise<Fixture> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "webnet-sftp-routed-"))
|
||||
const keyPath = join(dir, "id_ed25519")
|
||||
await writeFile(keyPath, await generateHostKey(), { mode: 0o600 })
|
||||
const hostKey = await generateHostKey()
|
||||
const listener = await nodeListen(0)
|
||||
const port = Number(listener.addr.slice(listener.addr.lastIndexOf(":") + 1))
|
||||
const vfs = new MemoryVFS()
|
||||
const execCommands: string[] = []
|
||||
|
||||
const accept = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
let raw
|
||||
try {
|
||||
raw = await listener.accept()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
const connection = await SSHServerConnection.accept<AsyncVFS>(raw, {
|
||||
hostKey,
|
||||
authenticate: async (_user, credential) => (credential.kind === "publickey" ? vfs : null),
|
||||
})
|
||||
for (;;) {
|
||||
let channel: Channel
|
||||
try {
|
||||
channel = await connection.acceptSession()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
void serveSession(channel, {
|
||||
authorize: (start) => start.type !== "subsystem" || start.name === "sftp",
|
||||
subsystem: (_name, s) => serveSFTPChannel(s.channel, connection.context),
|
||||
exec: async (command, s) => {
|
||||
execCommands.push(command)
|
||||
await s.send(enc.encode(`ran:${command}\n`))
|
||||
await s.exit(0)
|
||||
},
|
||||
})
|
||||
}
|
||||
})().catch(() => {})
|
||||
}
|
||||
}
|
||||
void accept()
|
||||
|
||||
return {
|
||||
port,
|
||||
keyPath,
|
||||
dir,
|
||||
vfs,
|
||||
listener,
|
||||
execCommands,
|
||||
stop: async () => {
|
||||
await listener.close()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function run(bin: string, args: string[], stdin?: string): Promise<string> {
|
||||
const child = spawn(
|
||||
bin,
|
||||
[
|
||||
"-F",
|
||||
"/dev/null",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"IdentitiesOnly=yes",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-i",
|
||||
fixture.keyPath,
|
||||
...args,
|
||||
],
|
||||
{ stdio: ["pipe", "pipe", "pipe"] },
|
||||
)
|
||||
const out: Buffer[] = []
|
||||
const err: Buffer[] = []
|
||||
child.stdout.on("data", (c: Buffer) => out.push(c))
|
||||
child.stderr.on("data", (c: Buffer) => err.push(c))
|
||||
child.stdin.end(stdin ?? "")
|
||||
return new Promise((resolve, reject) => {
|
||||
child.on("error", reject)
|
||||
child.on("close", (code) => {
|
||||
const stdout = dec.decode(Buffer.concat(out))
|
||||
const stderr = dec.decode(Buffer.concat(err))
|
||||
if (code !== 0) reject(new Error(`${bin} exited ${code}: ${stderr || stdout}`))
|
||||
else resolve(stdout)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const sftp = (batch: string) =>
|
||||
run("sftp", ["-P", String(fixture.port), "-b", "-", "u@127.0.0.1"], batch)
|
||||
|
||||
suite(
|
||||
"routed sftp server against the OpenSSH client",
|
||||
{ skip: !available && "the sftp binary is not installed" },
|
||||
() => {
|
||||
before(async () => {
|
||||
fixture = await start()
|
||||
})
|
||||
after(async () => {
|
||||
await fixture.stop()
|
||||
})
|
||||
|
||||
test("the OpenSSH sftp client lists a routed server", async () => {
|
||||
await fixture.vfs.writeFile("/listed.txt", streamOf("listed"))
|
||||
const out = await sftp("ls\n")
|
||||
assert.ok(out.includes("listed.txt"), out)
|
||||
})
|
||||
|
||||
test("the OpenSSH sftp client uploads and downloads through the router", async () => {
|
||||
const local = join(fixture.dir, "upload.txt")
|
||||
await writeFile(local, "uploaded through the router")
|
||||
await sftp(`put ${local} /uploaded.txt\n`)
|
||||
assert.equal(
|
||||
await readAll(await fixture.vfs.readFile("/uploaded.txt")),
|
||||
"uploaded through the router",
|
||||
)
|
||||
|
||||
await fixture.vfs.writeFile("/download.txt", streamOf("downloaded"))
|
||||
const target = join(fixture.dir, "download.txt")
|
||||
await sftp(`get /download.txt ${target}\n`)
|
||||
const { readFile } = await import("node:fs/promises")
|
||||
assert.equal(await readFile(target, "utf8"), "downloaded")
|
||||
})
|
||||
|
||||
test("exec still works on the same routed server", async () => {
|
||||
const out = await run("ssh", ["-p", String(fixture.port), "u@127.0.0.1", "uptime"])
|
||||
assert.equal(out, "ran:uptime\n")
|
||||
assert.ok(fixture.execCommands.includes("uptime"))
|
||||
})
|
||||
|
||||
test("a non-sftp subsystem is refused without breaking the server", async () => {
|
||||
await assert.rejects(run("ssh", ["-p", String(fixture.port), "-s", "u@127.0.0.1", "rexec"]))
|
||||
const out = await run("ssh", ["-p", String(fixture.port), "u@127.0.0.1", "after-refusal"])
|
||||
assert.equal(out, "ran:after-refusal\n")
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
import { VFSError, normalizePath, type AsyncVFS } from "@webnet/vfs"
|
||||
import { moveFallback } from "@webnet/vfs/fallback"
|
||||
import { Reader } from "../sftp/cursor.js"
|
||||
import { FXP, SSH_FXF, decodeAttrs, statToAttrs, type Attrs } from "../sftp/packets.js"
|
||||
import { SSH_FX, vfsErrorToStatus } from "../sftp/status.js"
|
||||
@@ -285,10 +286,6 @@ export class Handlers {
|
||||
async #rename(id: number, r: Reader): Promise<void> {
|
||||
const oldPath = normalizePath(r.utf8())
|
||||
const newPath = normalizePath(r.utf8())
|
||||
if (!this.#vfs.move) {
|
||||
this.#reply.status(id, SSH_FX.OP_UNSUPPORTED, "rename not supported")
|
||||
return
|
||||
}
|
||||
let exists = true
|
||||
try {
|
||||
await this.#vfs.stat(newPath)
|
||||
@@ -300,20 +297,24 @@ export class Handlers {
|
||||
this.#reply.status(id, SSH_FX.FAILURE, `destination already exists: ${newPath}`)
|
||||
return
|
||||
}
|
||||
await this.#vfs.move(oldPath, newPath)
|
||||
await this.#move(oldPath, newPath, false)
|
||||
this.#reply.status(id, SSH_FX.OK)
|
||||
}
|
||||
|
||||
// native-only: RENAME is expected to be cheap and roughly atomic, and a shim would turn a
|
||||
// rename of a large directory into a recursive copy and delete with no way for the client to
|
||||
// know. The fallback is here so a backend without move and one that rejects unsupported for
|
||||
// this path both reach the handler's error mapping as SSH_FX_OP_UNSUPPORTED.
|
||||
#move(oldPath: string, newPath: string, overwrite: boolean): Promise<void> {
|
||||
return moveFallback(this.#vfs, oldPath, newPath, { overwrite, policy: "native-only" })
|
||||
}
|
||||
|
||||
async #extended(id: number, r: Reader): Promise<void> {
|
||||
const extension = r.utf8()
|
||||
if (extension === "posix-rename@openssh.com") {
|
||||
const oldPath = normalizePath(r.utf8())
|
||||
const newPath = normalizePath(r.utf8())
|
||||
if (!this.#vfs.move) {
|
||||
this.#reply.status(id, SSH_FX.OP_UNSUPPORTED, "rename not supported")
|
||||
return
|
||||
}
|
||||
await this.#vfs.move(oldPath, newPath, { overwrite: true })
|
||||
await this.#move(oldPath, newPath, true)
|
||||
this.#reply.status(id, SSH_FX.OK)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { suite, test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { WriteQueue } from "./handles.js"
|
||||
import { MemoryVFS } from "@webnet/vfs/memory"
|
||||
import { withoutOptional, unsupportedOptional } from "@webnet/vfs/conformance"
|
||||
import { WriteQueue, readFromHandle, type ReadHandle } from "./handles.js"
|
||||
|
||||
const bytes = (n: number) => new Uint8Array(n).fill(1) as Uint8Array<ArrayBuffer>
|
||||
|
||||
function newReadHandle(path: string): ReadHandle {
|
||||
return {
|
||||
kind: "read",
|
||||
path,
|
||||
leftover: new Uint8Array(0),
|
||||
pos: 0n,
|
||||
started: false,
|
||||
eof: false,
|
||||
mutex: Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
suite("WriteQueue", () => {
|
||||
test("streams chunks in order to the consumer", async () => {
|
||||
const q = new WriteQueue()
|
||||
@@ -43,3 +57,31 @@ suite("WriteQueue", () => {
|
||||
await assert.rejects(blocked!, /writeFile failed/)
|
||||
})
|
||||
})
|
||||
|
||||
async function seeded(): Promise<MemoryVFS> {
|
||||
const vfs = new MemoryVFS()
|
||||
await vfs.writeFile(
|
||||
"/data",
|
||||
new ReadableStream({
|
||||
start(c) {
|
||||
c.enqueue(new TextEncoder().encode("abcdefghij"))
|
||||
c.close()
|
||||
},
|
||||
}),
|
||||
)
|
||||
return vfs
|
||||
}
|
||||
|
||||
suite("readFromHandle", () => {
|
||||
test("reads the correct suffix at a nonzero offset when readFileRange is absent", async () => {
|
||||
const vfs = withoutOptional(await seeded())
|
||||
const out = await readFromHandle(vfs, newReadHandle("/data"), 3n, 4)
|
||||
assert.equal(new TextDecoder().decode(out), "defg")
|
||||
})
|
||||
|
||||
test("reads the correct suffix at a nonzero offset when readFileRange rejects unsupported", async () => {
|
||||
const vfs = unsupportedOptional(await seeded(), ["readFileRange"])
|
||||
const out = await readFromHandle(vfs, newReadHandle("/data"), 3n, 4)
|
||||
assert.equal(new TextDecoder().decode(out), "defg")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AsyncVFS, Stat } from "@webnet/vfs"
|
||||
import { readFileRangeFallback } from "@webnet/vfs/fallback"
|
||||
|
||||
export type DirHandle = {
|
||||
kind: "dir"
|
||||
@@ -119,33 +120,20 @@ export async function openReadStream(
|
||||
if (handle.reader) await handle.reader.cancel().catch(() => {})
|
||||
handle.leftover = new Uint8Array(0)
|
||||
handle.eof = false
|
||||
let stream: ReadableStream<Uint8Array>
|
||||
let discard = 0n
|
||||
if (offset === 0n) {
|
||||
stream = await vfs.readFile(handle.path)
|
||||
} else if (vfs.readFileRange) {
|
||||
stream = await vfs.readFileRange(handle.path, offset)
|
||||
} else {
|
||||
stream = await vfs.readFile(handle.path)
|
||||
discard = offset
|
||||
}
|
||||
// A zero offset wants the whole file, so it does not ask a backend that charges for a ranged
|
||||
// read to open one. Above that the shared fallback windows the stream lazily: the discard used
|
||||
// to run here, before the reader was handed back, holding the SSH_FXP_READ reply open for the
|
||||
// whole prefix. The handle needs no bookkeeping for it — leftover starts empty either way, and
|
||||
// a source shorter than the offset reads done on the first pull.
|
||||
const stream =
|
||||
offset === 0n
|
||||
? await vfs.readFile(handle.path)
|
||||
: await readFileRangeFallback(vfs, handle.path, offset, undefined, {
|
||||
policy: "on-unsupported",
|
||||
})
|
||||
handle.reader = stream.getReader()
|
||||
handle.started = true
|
||||
handle.pos = offset
|
||||
while (discard > 0n) {
|
||||
const { value, done } = await handle.reader.read()
|
||||
if (done) {
|
||||
handle.eof = true
|
||||
break
|
||||
}
|
||||
const chunk = value as Uint8Array<ArrayBuffer>
|
||||
if (BigInt(chunk.length) <= discard) {
|
||||
discard -= BigInt(chunk.length)
|
||||
} else {
|
||||
handle.leftover = chunk.subarray(Number(discard))
|
||||
discard = 0n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function readFromHandle(
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { SFTPServer } from "./server.js"
|
||||
export { serveSFTPChannel } from "./session.js"
|
||||
export type { SFTPServerOptions, ListenOptions, Credential } from "./types.js"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { loopbackListener, loopbackTransportPair } from "@webnet/transport/loopb
|
||||
import type { RawDialer, RawListener } from "@webnet/transport"
|
||||
import { MemoryVFS } from "@webnet/vfs/memory"
|
||||
import type { AsyncVFS } from "@webnet/vfs"
|
||||
import { withoutOptional, unsupportedOptional } from "@webnet/vfs/conformance"
|
||||
import {
|
||||
Channel,
|
||||
generateHostKey,
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
} from "../sftp/packets.js"
|
||||
import { SSH_FX } from "../sftp/status.js"
|
||||
import { SFTPServer } from "./server.js"
|
||||
import { Session } from "./session.js"
|
||||
import { TransportSession } from "./session.js"
|
||||
import type { SFTPServerOptions } from "./types.js"
|
||||
|
||||
type Response = { type: number; r: Reader }
|
||||
@@ -216,6 +217,16 @@ function encoder(s: string): Uint8Array {
|
||||
return new TextEncoder().encode(s)
|
||||
}
|
||||
|
||||
function streamOf(s: string): ReadableStream<Uint8Array> {
|
||||
const data = encoder(s)
|
||||
return new ReadableStream({
|
||||
start(c) {
|
||||
c.enqueue(data)
|
||||
c.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function withServer(
|
||||
options: Partial<SFTPServerOptions> & { vfs: AsyncVFS },
|
||||
fn: (dialer: RawDialer, listener: RawListener) => Promise<void>,
|
||||
@@ -370,6 +381,60 @@ suite("SFTPServer", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("RENAME reports OP_UNSUPPORTED the same way for an absent move and one that rejects unsupported", async () => {
|
||||
const base = new MemoryVFS()
|
||||
await base.writeFile("/from.txt", streamOf("data"))
|
||||
await withServer({ vfs: withoutOptional(base) }, async (dialer) => {
|
||||
const client = await MockClient.connect(dialer, { user: "u", password: "p" })
|
||||
const res = await client.request(
|
||||
FXP.RENAME,
|
||||
new Writer().string("/from.txt").string("/to.txt"),
|
||||
)
|
||||
assert.equal(statusCode(res), SSH_FX.OP_UNSUPPORTED)
|
||||
await client.close()
|
||||
})
|
||||
|
||||
const base2 = new MemoryVFS()
|
||||
await base2.writeFile("/from.txt", streamOf("data"))
|
||||
await withServer({ vfs: unsupportedOptional(base2, ["move"]) }, async (dialer) => {
|
||||
const client = await MockClient.connect(dialer, { user: "u", password: "p" })
|
||||
const res = await client.request(
|
||||
FXP.RENAME,
|
||||
new Writer().string("/from.txt").string("/to.txt"),
|
||||
)
|
||||
assert.equal(statusCode(res), SSH_FX.OP_UNSUPPORTED)
|
||||
await client.close()
|
||||
})
|
||||
})
|
||||
|
||||
test("posix-rename extension reports OP_UNSUPPORTED the same way for an absent move and one that rejects unsupported", async () => {
|
||||
const base = new MemoryVFS()
|
||||
await base.writeFile("/src.txt", streamOf("new"))
|
||||
await base.writeFile("/dst.txt", streamOf("old"))
|
||||
await withServer({ vfs: withoutOptional(base) }, async (dialer) => {
|
||||
const client = await MockClient.connect(dialer, { user: "u", password: "p" })
|
||||
const res = await client.request(
|
||||
FXP.EXTENDED,
|
||||
new Writer().string("posix-rename@openssh.com").string("/src.txt").string("/dst.txt"),
|
||||
)
|
||||
assert.equal(statusCode(res), SSH_FX.OP_UNSUPPORTED)
|
||||
await client.close()
|
||||
})
|
||||
|
||||
const base2 = new MemoryVFS()
|
||||
await base2.writeFile("/src.txt", streamOf("new"))
|
||||
await base2.writeFile("/dst.txt", streamOf("old"))
|
||||
await withServer({ vfs: unsupportedOptional(base2, ["move"]) }, async (dialer) => {
|
||||
const client = await MockClient.connect(dialer, { user: "u", password: "p" })
|
||||
const res = await client.request(
|
||||
FXP.EXTENDED,
|
||||
new Writer().string("posix-rename@openssh.com").string("/src.txt").string("/dst.txt"),
|
||||
)
|
||||
assert.equal(statusCode(res), SSH_FX.OP_UNSUPPORTED)
|
||||
await client.close()
|
||||
})
|
||||
})
|
||||
|
||||
test("REALPATH of . resolves to /", async () => {
|
||||
await withServer({ vfs: new MemoryVFS() }, async (dialer) => {
|
||||
const client = await MockClient.connect(dialer, { user: "u", password: "p" })
|
||||
@@ -514,7 +579,7 @@ suite("SFTPServer", () => {
|
||||
|
||||
test("an authentication failure closes the connection instead of leaking it", async () => {
|
||||
const [clientTransport, serverTransport] = loopbackTransportPair()
|
||||
const running = new Session(serverTransport, {
|
||||
const running = new TransportSession(serverTransport, {
|
||||
vfs: new MemoryVFS(),
|
||||
hostKey: await generateHostKey(),
|
||||
authenticate: async () => null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RawListener, RawTransport } from "@webnet/transport"
|
||||
import { generateHostKey } from "@webnet/ssh"
|
||||
import { Session } from "./session.js"
|
||||
import { TransportSession } from "./session.js"
|
||||
import type { ListenOptions, SFTPServerOptions } from "./types.js"
|
||||
|
||||
const defaultOnError: NonNullable<ListenOptions["onError"]> = (transport, error) => {
|
||||
@@ -32,7 +32,7 @@ export class SFTPServer {
|
||||
return
|
||||
}
|
||||
const hostKey = await this.#getHostKey()
|
||||
await new Session(transport, { ...this.#options, hostKey }).run()
|
||||
await new TransportSession(transport, { ...this.#options, hostKey }).run()
|
||||
} catch (e) {
|
||||
onError(transport, e)
|
||||
}
|
||||
|
||||
@@ -8,34 +8,31 @@ import type { SFTPServerOptions } from "./types.js"
|
||||
|
||||
const VERSION = 3
|
||||
|
||||
/**
|
||||
* Serve SFTP on an already-accepted `sftp` subsystem channel, using a VFS the
|
||||
* caller has already authorized. Performs no SSH authentication and never
|
||||
* touches the underlying connection, so one SSH connection can carry this
|
||||
* alongside exec, shell, and forwarding channels.
|
||||
*/
|
||||
export async function serveSFTPChannel(channel: Channel, vfs: AsyncVFS): Promise<void> {
|
||||
await new Session(channel, vfs).serve()
|
||||
}
|
||||
|
||||
export class Session {
|
||||
readonly #transport: RawTransport
|
||||
readonly #options: SFTPServerOptions
|
||||
readonly #channel: Channel
|
||||
readonly #vfs: AsyncVFS
|
||||
readonly #reassembler = new PacketReassembler()
|
||||
#sendChain: Promise<void> = Promise.resolve()
|
||||
#closed = false
|
||||
|
||||
constructor(transport: RawTransport, options: SFTPServerOptions) {
|
||||
this.#transport = transport
|
||||
this.#options = options
|
||||
constructor(channel: Channel, vfs: AsyncVFS) {
|
||||
this.#channel = channel
|
||||
this.#vfs = vfs
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
let connection: SSHServerConnection<AsyncVFS> | undefined
|
||||
let channel: Channel | undefined
|
||||
async serve(): Promise<void> {
|
||||
const ch = this.#channel
|
||||
try {
|
||||
connection = await SSHServerConnection.accept<AsyncVFS>(this.#transport, {
|
||||
hostKey: this.#options.hostKey,
|
||||
authenticate: async (user, credential) =>
|
||||
this.#options.authenticate
|
||||
? this.#options.authenticate(user, credential)
|
||||
: this.#options.vfs,
|
||||
})
|
||||
const vfs = connection.context
|
||||
channel = await connection.acceptSession()
|
||||
await channel.acceptSubsystem("sftp")
|
||||
const ch = channel
|
||||
|
||||
const enqueue = (type: number, body: Writer): void => {
|
||||
if (this.#closed) return
|
||||
const packet = encodePacket(type, body.finish())
|
||||
@@ -54,7 +51,7 @@ export class Session {
|
||||
name: (id, entries) => enqueue(FXP.NAME, encodeName(id, entries)),
|
||||
attrs: (id, attrs) => enqueue(FXP.ATTRS, new Writer().u32(id).bytes(encodeAttrs(attrs))),
|
||||
}
|
||||
const handlers = new Handlers(vfs, responder)
|
||||
const handlers = new Handlers(this.#vfs, responder)
|
||||
|
||||
for (;;) {
|
||||
const chunk = await ch.read()
|
||||
@@ -68,14 +65,8 @@ export class Session {
|
||||
} finally {
|
||||
this.#closed = true
|
||||
await this.#drainSends()
|
||||
if (channel && connection) await this.#teardown(channel, connection)
|
||||
else if (connection) await connection.close().catch(() => {})
|
||||
else
|
||||
try {
|
||||
await this.#transport.close()
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
await ch.closeSend().catch(() => {})
|
||||
await ch.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,11 +88,40 @@ export class Session {
|
||||
async #drainSends(): Promise<void> {
|
||||
await this.#sendChain.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async #teardown(channel: Channel, connection: SSHServerConnection<AsyncVFS>): Promise<void> {
|
||||
await channel.closeSend().catch(() => {})
|
||||
await channel.close().catch(() => {})
|
||||
await connection.close().catch(() => {})
|
||||
/** Owns the whole SSH lifecycle for one accepted transport: the listen() path. */
|
||||
export class TransportSession {
|
||||
readonly #transport: RawTransport
|
||||
readonly #options: SFTPServerOptions
|
||||
|
||||
constructor(transport: RawTransport, options: SFTPServerOptions) {
|
||||
this.#transport = transport
|
||||
this.#options = options
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
let connection: SSHServerConnection<AsyncVFS> | undefined
|
||||
try {
|
||||
connection = await SSHServerConnection.accept<AsyncVFS>(this.#transport, {
|
||||
hostKey: this.#options.hostKey,
|
||||
authenticate: async (user, credential) =>
|
||||
this.#options.authenticate
|
||||
? this.#options.authenticate(user, credential)
|
||||
: this.#options.vfs,
|
||||
})
|
||||
const channel = await connection.acceptSession()
|
||||
await channel.acceptSubsystem("sftp")
|
||||
await serveSFTPChannel(channel, connection.context)
|
||||
} finally {
|
||||
if (connection) await connection.close().catch(() => {})
|
||||
else
|
||||
try {
|
||||
await this.#transport.close()
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import assert from "node:assert/strict"
|
||||
import { loopbackListener } from "@webnet/transport/loopback"
|
||||
import { MemoryVFS } from "@webnet/vfs/memory"
|
||||
import { VFSError, type AsyncVFS } from "@webnet/vfs"
|
||||
import { testAsyncVFSConformance } from "@webnet/vfs/conformance"
|
||||
import {
|
||||
testAsyncVFSConformance,
|
||||
withoutOptional,
|
||||
type ConformanceCapabilities,
|
||||
} from "@webnet/vfs/conformance"
|
||||
import { SFTPClient } from "./client/client.js"
|
||||
import { SFTPServer } from "./server/server.js"
|
||||
import type { SFTPServerOptions } from "./server/types.js"
|
||||
@@ -16,29 +20,47 @@ async function sharedHostKey(): Promise<string> {
|
||||
return hostKey
|
||||
}
|
||||
|
||||
testAsyncVFSConformance({
|
||||
name: "SFTPClient",
|
||||
capabilities: { etag: false },
|
||||
// SFTP v3's RENAME has no overwrite semantics; the server pre-checks the destination and
|
||||
// fails the same way (SSH_FX_FAILURE) whether or not the source itself would also conflict,
|
||||
// so the client can only report "already-exists" for this case.
|
||||
errorCodes: { "precondition-failed": ["precondition-failed", "already-exists"] },
|
||||
create: async () => {
|
||||
const vfs = new MemoryVFS()
|
||||
const [listener, dialer] = loopbackListener()
|
||||
const server = new SFTPServer({ vfs, hostKey: await sharedHostKey() })
|
||||
const stopped = server.listen(listener, { onError: () => {} })
|
||||
const client = new SFTPClient({ dialer, host: "test", user: "user", password: "pw" })
|
||||
return {
|
||||
vfs: client,
|
||||
close: async () => {
|
||||
await client.close().catch(() => {})
|
||||
listener.close()
|
||||
await stopped.catch(() => {})
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
function conformanceRun(
|
||||
name: string,
|
||||
backing: () => AsyncVFS,
|
||||
capabilities: ConformanceCapabilities = {},
|
||||
): void {
|
||||
testAsyncVFSConformance({
|
||||
name,
|
||||
capabilities: { etag: false, ...capabilities },
|
||||
// SFTP v3's RENAME has no overwrite semantics; the server pre-checks the destination and
|
||||
// fails the same way (SSH_FX_FAILURE) whether or not the source itself would also conflict,
|
||||
// so the client can only report "already-exists" for this case.
|
||||
errorCodes: { "precondition-failed": ["precondition-failed", "already-exists"] },
|
||||
create: async () => {
|
||||
const vfs = backing()
|
||||
const [listener, dialer] = loopbackListener()
|
||||
const server = new SFTPServer({ vfs, hostKey: await sharedHostKey() })
|
||||
const stopped = server.listen(listener, { onError: () => {} })
|
||||
const client = new SFTPClient({ dialer, host: "test", user: "user", password: "pw" })
|
||||
return {
|
||||
vfs: client,
|
||||
close: async () => {
|
||||
await client.close().catch(() => {})
|
||||
listener.close()
|
||||
await stopped.catch(() => {})
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
conformanceRun("SFTPClient", () => new MemoryVFS())
|
||||
|
||||
// The same client against a server that has only the required operations to work with, so the
|
||||
// server's fallback paths are held to the same contract as its native ones.
|
||||
// RENAME answers SSH_FX_OP_UNSUPPORTED rather than renaming by hand when the filesystem cannot
|
||||
// move, so the client reports the operation as unsupported. Whether to shim it instead is #179.
|
||||
conformanceRun(
|
||||
"SFTPClient (server over a minimal filesystem)",
|
||||
() => withoutOptional(new MemoryVFS()),
|
||||
{ move: "unsupported" },
|
||||
)
|
||||
|
||||
type Harness = {
|
||||
client: SFTPClient
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { suite, test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { loopbackTransportPair } from "@webnet/transport/loopback"
|
||||
import { MemoryVFS } from "@webnet/vfs/memory"
|
||||
import type { AsyncVFS } from "@webnet/vfs"
|
||||
import {
|
||||
generateHostKey,
|
||||
serveSession,
|
||||
SSHClientConnection,
|
||||
SSHServerConnection,
|
||||
type Channel,
|
||||
type ServerSession,
|
||||
} from "@webnet/ssh"
|
||||
import { SFTPClient } from "./client/client.js"
|
||||
import { serveSFTPChannel } from "./server/session.js"
|
||||
|
||||
const enc = new TextEncoder()
|
||||
const dec = new TextDecoder()
|
||||
|
||||
function streamOf(text: string): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(enc.encode(text))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
const chunks: Uint8Array[] = []
|
||||
const reader = stream.getReader()
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
}
|
||||
return dec.decode(Buffer.concat(chunks))
|
||||
}
|
||||
|
||||
type Routed = {
|
||||
client: SSHClientConnection
|
||||
server: SSHServerConnection<AsyncVFS>
|
||||
vfs: MemoryVFS
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* One SSH connection whose session channels are routed by `serveSession`:
|
||||
* `sftp` goes to the SFTP server, `exec` to a trivial echo command. This is
|
||||
* the dispatch shape #108 asks #103 to coordinate with.
|
||||
*/
|
||||
async function routed(): Promise<Routed> {
|
||||
const [a, b] = loopbackTransportPair()
|
||||
const hostKey = await generateHostKey()
|
||||
const vfs = new MemoryVFS()
|
||||
const [client, server] = await Promise.all([
|
||||
SSHClientConnection.connect(a, { user: "u", password: "p" }),
|
||||
SSHServerConnection.accept<AsyncVFS>(b, { hostKey, authenticate: async () => vfs }),
|
||||
])
|
||||
const loop = (async () => {
|
||||
for (;;) {
|
||||
let channel: Channel
|
||||
try {
|
||||
channel = await server.acceptSession()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
void serveSession(channel, {
|
||||
authorize: (start) => start.type !== "subsystem" || start.name === "sftp",
|
||||
subsystem: (_name, s: ServerSession) => serveSFTPChannel(s.channel, server.context),
|
||||
exec: async (command, s) => {
|
||||
await s.send(enc.encode(`ran:${command}`))
|
||||
await s.exit(0)
|
||||
},
|
||||
})
|
||||
}
|
||||
})()
|
||||
return {
|
||||
client,
|
||||
server,
|
||||
vfs,
|
||||
close: async () => {
|
||||
await client.close().catch(() => {})
|
||||
await server.close().catch(() => {})
|
||||
await loop
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
suite("sftp over a shared ssh connection", () => {
|
||||
test("SFTPClient.connect runs over a caller-owned connection", async () => {
|
||||
const { client, vfs, close } = await routed()
|
||||
try {
|
||||
const sftp = await SFTPClient.connect(client)
|
||||
await sftp.writeFile("/hello.txt", streamOf("shared"))
|
||||
assert.equal(await readAll(await sftp.readFile("/hello.txt")), "shared")
|
||||
assert.equal(await readAll(await vfs.readFile("/hello.txt")), "shared")
|
||||
await sftp.close()
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("SFTPClient.fromChannel runs over a pre-opened subsystem channel", async () => {
|
||||
const { client, close } = await routed()
|
||||
try {
|
||||
const channel = await client.openSubsystem("sftp")
|
||||
const sftp = await SFTPClient.fromChannel(channel)
|
||||
await sftp.writeFile("/from-channel.txt", streamOf("preopened"))
|
||||
assert.equal(await readAll(await sftp.readFile("/from-channel.txt")), "preopened")
|
||||
await sftp.close()
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("sftp and exec share one connection concurrently", async () => {
|
||||
const { client, close } = await routed()
|
||||
try {
|
||||
const sftp = await SFTPClient.connect(client)
|
||||
const [listing, execOut] = await Promise.all([
|
||||
(async () => {
|
||||
await sftp.writeFile("/a.txt", streamOf("a"))
|
||||
return (await sftp.readdir("/")).map((s) => s.name)
|
||||
})(),
|
||||
(async () => {
|
||||
const result = await client.run("uptime")
|
||||
return dec.decode(result.stdout)
|
||||
})(),
|
||||
])
|
||||
assert.deepEqual(listing, ["a.txt"])
|
||||
assert.equal(execOut, "ran:uptime")
|
||||
await sftp.close()
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("multiple sftp channels share one connection independently", async () => {
|
||||
const { client, close } = await routed()
|
||||
try {
|
||||
const [first, second] = await Promise.all([
|
||||
SFTPClient.connect(client),
|
||||
SFTPClient.connect(client),
|
||||
])
|
||||
await first.writeFile("/first.txt", streamOf("one"))
|
||||
await second.writeFile("/second.txt", streamOf("two"))
|
||||
|
||||
// Closing one leaves the shared connection and the other client alive.
|
||||
await first.close()
|
||||
assert.equal(await readAll(await second.readFile("/first.txt")), "one")
|
||||
await second.writeFile("/third.txt", streamOf("three"))
|
||||
assert.deepEqual((await second.readdir("/")).map((s) => s.name).sort(), [
|
||||
"first.txt",
|
||||
"second.txt",
|
||||
"third.txt",
|
||||
])
|
||||
await second.close()
|
||||
|
||||
// The connection itself is untouched by SFTP client teardown.
|
||||
const result = await client.run("still-alive")
|
||||
assert.equal(dec.decode(result.stdout), "ran:still-alive")
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("closing an SFTP client never closes a caller-owned connection", async () => {
|
||||
const { client, close } = await routed()
|
||||
try {
|
||||
const sftp = await SFTPClient.connect(client)
|
||||
await sftp.close()
|
||||
const again = await SFTPClient.connect(client)
|
||||
await again.writeFile("/after.txt", streamOf("after"))
|
||||
assert.equal(await readAll(await again.readFile("/after.txt")), "after")
|
||||
await again.close()
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("a failed init leaves the caller-owned connection usable", async () => {
|
||||
const [a, b] = loopbackTransportPair()
|
||||
const hostKey = await generateHostKey()
|
||||
const vfs = new MemoryVFS()
|
||||
const [client, server] = await Promise.all([
|
||||
SSHClientConnection.connect(a, { user: "u", password: "p" }),
|
||||
SSHServerConnection.accept<AsyncVFS>(b, { hostKey, authenticate: async () => vfs }),
|
||||
])
|
||||
let refuseSftp = true
|
||||
const loop = (async () => {
|
||||
for (;;) {
|
||||
let channel: Channel
|
||||
try {
|
||||
channel = await server.acceptSession()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
void serveSession(channel, {
|
||||
// Refuse the first subsystem request outright, then allow it.
|
||||
authorize: (start) => {
|
||||
if (start.type !== "subsystem") return true
|
||||
const allow = !refuseSftp
|
||||
refuseSftp = false
|
||||
return allow
|
||||
},
|
||||
subsystem: (_name, s) => serveSFTPChannel(s.channel, vfs),
|
||||
exec: async (command, s) => {
|
||||
await s.send(enc.encode(`ran:${command}`))
|
||||
await s.exit(0)
|
||||
},
|
||||
})
|
||||
}
|
||||
})()
|
||||
try {
|
||||
await assert.rejects(SFTPClient.connect(client))
|
||||
// The refused subsystem must not have taken the connection down.
|
||||
const result = await client.run("survived")
|
||||
assert.equal(dec.decode(result.stdout), "ran:survived")
|
||||
const sftp = await SFTPClient.connect(client)
|
||||
await sftp.writeFile("/ok.txt", streamOf("ok"))
|
||||
assert.equal(await readAll(await sftp.readFile("/ok.txt")), "ok")
|
||||
await sftp.close()
|
||||
} finally {
|
||||
await client.close().catch(() => {})
|
||||
await server.close().catch(() => {})
|
||||
await loop
|
||||
}
|
||||
})
|
||||
|
||||
test("a non-sftp subsystem is refused by the router", async () => {
|
||||
const { client, close } = await routed()
|
||||
try {
|
||||
await assert.rejects(client.openSubsystem("rexec"), /channel request failed/)
|
||||
// The refusal is per-channel; the connection keeps working.
|
||||
const sftp = await SFTPClient.connect(client)
|
||||
await sftp.writeFile("/after-refusal.txt", streamOf("fine"))
|
||||
await sftp.close()
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("the authenticated context selects the per-user vfs", async () => {
|
||||
const [a, b] = loopbackTransportPair()
|
||||
const hostKey = await generateHostKey()
|
||||
const alice = new MemoryVFS()
|
||||
const bob = new MemoryVFS()
|
||||
await alice.writeFile("/whose.txt", streamOf("alice"))
|
||||
await bob.writeFile("/whose.txt", streamOf("bob"))
|
||||
const [client, server] = await Promise.all([
|
||||
SSHClientConnection.connect(a, { user: "bob", password: "p" }),
|
||||
SSHServerConnection.accept<AsyncVFS>(b, {
|
||||
hostKey,
|
||||
authenticate: async (user) => (user === "alice" ? alice : bob),
|
||||
}),
|
||||
])
|
||||
const loop = (async () => {
|
||||
for (;;) {
|
||||
let channel: Channel
|
||||
try {
|
||||
channel = await server.acceptSession()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
void serveSession(channel, {
|
||||
subsystem: (_name, s) => serveSFTPChannel(s.channel, server.context),
|
||||
})
|
||||
}
|
||||
})()
|
||||
try {
|
||||
const sftp = await SFTPClient.connect(client)
|
||||
assert.equal(await readAll(await sftp.readFile("/whose.txt")), "bob")
|
||||
await sftp.close()
|
||||
} finally {
|
||||
await client.close().catch(() => {})
|
||||
await server.close().catch(() => {})
|
||||
await loop
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
# @webnet/smb2
|
||||
|
||||
SMB2/3 client built on `@webnet/transport` and `@webnet/vfs`. There is no server.
|
||||
|
||||
`SMB2Client` implements `AsyncVFS` (`stat`, `readdir`, `statAndReaddir`, `readFile`/`readFileRange`, `writeFile`, `mkdir`, `delete`, `move`, `copy`, `setProps`) over a single share. It dials through a `RawDialer`, negotiates the highest of SMB 2.0.2, 2.1.0, or 3.1.1 that the server supports, and authenticates with NTLM wrapped in SPNEGO via `Credentials` (`username`/`password`/optional `domain`/`spn`). `SMB2Client` also implements `StateTransferable<SMB2TransferState>` from `@webnet/state-transfer`, so an established connection's state (dialect, session, tree, signing key) can be serialized and later resumed with `SMB2Client.adopt` — the transferred state carries no credentials, so `SMB2AdoptOptions` supplies them again for reconnect fallback. Errors surface as `Smb2Error`.
|
||||
|
||||
`_internals` entry points are unstable and are not part of the public API.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { SMB2Client } from "@webnet/smb2"
|
||||
import type { RawDialer } from "@webnet/transport"
|
||||
|
||||
declare const dialer: RawDialer
|
||||
|
||||
const client = new SMB2Client({
|
||||
dialer,
|
||||
host: "example.com",
|
||||
share: "shared",
|
||||
username: "u",
|
||||
password: "p",
|
||||
})
|
||||
await client.connect()
|
||||
const entries = await client.readdir("/")
|
||||
const stream = await client.readFile("/file.txt")
|
||||
await client.disconnect()
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/vfs`](../vfs) — the `AsyncVFS` interface this package implements
|
||||
- [`@webnet/transport`](../transport) — the `RawDialer` primitive this package dials through
|
||||
- [`@webnet/state-transfer`](../state-transfer) — the `StateTransferable` interface this package implements for connection handoff
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { RawDialer, RawTransport } from "@webnet/transport"
|
||||
import type { StateTransferable } from "@webnet/state-transfer"
|
||||
import { VFSError, type AsyncVFS, type Stat, type StatAndReaddirResult } from "@webnet/vfs"
|
||||
import {
|
||||
normalizePath,
|
||||
pathsOverlap,
|
||||
VFSError,
|
||||
type AsyncVFS,
|
||||
type Stat,
|
||||
type StatAndReaddirResult,
|
||||
} from "@webnet/vfs"
|
||||
import {
|
||||
Access,
|
||||
ShareAccess,
|
||||
@@ -524,12 +531,16 @@ export class SMB2Client implements AsyncVFS, StateTransferable<SMB2TransferState
|
||||
})
|
||||
}
|
||||
|
||||
// Kept rather than delegated to copyFallback: this is the implementation of the optional
|
||||
// operation, not a caller of it, and SMB paths are case-insensitive, so the overlap check has to
|
||||
// fold case where the shared one compares normalized paths exactly. `\dir\A` copied into
|
||||
// `\dir\a\sub` is the same non-terminating copy the shared check exists to reject.
|
||||
async copy(src: string, dest: string, opts?: { overwrite?: boolean }): Promise<void> {
|
||||
const { self: st, entries } = await this.statAndReaddir(src)
|
||||
const srcKey = smbPath(src)
|
||||
const destKey = smbPath(dest)
|
||||
if (destKey === srcKey || destKey.startsWith(srcKey + "\\"))
|
||||
throw new VFSError("forbidden", `cannot copy ${src} into itself`)
|
||||
const from = normalizePath(src)
|
||||
const to = normalizePath(dest)
|
||||
if (pathsOverlap(from.toLowerCase(), to.toLowerCase()))
|
||||
throw new VFSError("precondition-failed", `${from} and ${to} overlap`)
|
||||
const existing = await this.stat(dest).then(
|
||||
(s) => s,
|
||||
(e) => {
|
||||
|
||||
@@ -1158,7 +1158,26 @@ suite("smb2 client e2e", () => {
|
||||
await client.writeFile("/tree/a.txt", streamOf(new TextEncoder().encode("a")))
|
||||
await assert.rejects(
|
||||
() => client.copy("/tree", "/tree/inner"),
|
||||
(e: unknown) => e instanceof VFSError && e.code === "forbidden",
|
||||
(e: unknown) => e instanceof VFSError && e.code === "precondition-failed",
|
||||
)
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("copy detects a mixed-case source inside its destination", async () => {
|
||||
const { client, stop } = setup()
|
||||
try {
|
||||
await client.mkdir("/Tree")
|
||||
await client.mkdir("/Tree/Inner")
|
||||
await client.writeFile("/Tree/Inner/a.txt", streamOf(new TextEncoder().encode("a")))
|
||||
await assert.rejects(
|
||||
() => client.copy("/Tree/Inner", "/tree", { overwrite: true }),
|
||||
(e: unknown) => e instanceof VFSError && e.code === "precondition-failed",
|
||||
)
|
||||
assert.equal(
|
||||
new TextDecoder().decode(await readAll(await client.readFile("/Tree/Inner/a.txt"))),
|
||||
"a",
|
||||
)
|
||||
} finally {
|
||||
await stop()
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# @webnet/ssh
|
||||
|
||||
SSH-2 client and server: transport, authentication, channels, sessions, and TCP forwarding, built on `@webnet/transport`.
|
||||
|
||||
`SSHClientConnection` and `SSHServerConnection` wrap the transport layer, which negotiates `curve25519-sha256`/`curve25519-sha256@libssh.org` key exchange, `ssh-ed25519` host keys on the server side (clients also accept `rsa-sha2-512`/`rsa-sha2-256`), `aes128-gcm@openssh.com`/`aes256-gcm@openssh.com`/`aes128-ctr`/`aes256-ctr` ciphers, and `hmac-sha2-256-etm@openssh.com`/`hmac-sha2-256` MACs for non-AEAD ciphers, all via WebCrypto. `SSHClientConnection.connect` takes an optional `verifyHostKey` callback (`HostKeyVerifier`) for host-key trust policy, such as checking against a known-hosts store; when it is omitted, the host key is not checked against anything beyond the cryptographic key-exchange signature. `SSHClientConnection` exposes `exec`/`run`/`openShell` for commands and shells, `openDirectTcpip`/`dialer()` and `requestRemoteForward` for TCP forwarding, and `openSubsystem` for subsystems such as SFTP. `SSHServerConnection` accepts sessions via `acceptSession`; `serveSession` drives a `Channel` opened this way through env/pty/exec/shell/subsystem/window-change/signal handling with `SessionHandlers`. `generateHostKey` creates an ed25519 host key.
|
||||
|
||||
`_internals` entry points are unstable and are not part of the public API.
|
||||
|
||||
## Usage
|
||||
|
||||
### Client
|
||||
|
||||
Pin a trusted host-key fingerprint obtained through a separate authenticated channel:
|
||||
|
||||
```ts
|
||||
import { SSHClientConnection } from "@webnet/ssh"
|
||||
import type { RawTransport } from "@webnet/transport"
|
||||
|
||||
declare const raw: RawTransport
|
||||
const expectedHostKey = "SHA256:..."
|
||||
|
||||
const client = await SSHClientConnection.connect(raw, {
|
||||
user: "u",
|
||||
password: "p",
|
||||
verifyHostKey: ({ fingerprint }) => fingerprint === expectedHostKey,
|
||||
})
|
||||
const result = await client.run("echo hello")
|
||||
console.log(new TextDecoder().decode(result.stdout), result.exit)
|
||||
await client.close()
|
||||
```
|
||||
|
||||
### Server
|
||||
|
||||
```ts
|
||||
import { SSHServerConnection, serveSession, generateHostKey } from "@webnet/ssh"
|
||||
import type { RawTransport } from "@webnet/transport"
|
||||
|
||||
declare const raw: RawTransport
|
||||
|
||||
const hostKey = await generateHostKey()
|
||||
const server = await SSHServerConnection.accept(raw, {
|
||||
hostKey,
|
||||
authenticate: async (user) => ({ user }),
|
||||
})
|
||||
|
||||
const channel = await server.acceptSession()
|
||||
await serveSession(channel, {
|
||||
exec: async (command, session) => {
|
||||
await session.send(new TextEncoder().encode(`ran: ${command}\n`))
|
||||
await session.exit(0)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/transport`](../transport) — the `RawTransport`/`RawDialer`/`RawListener` primitives this package is built on
|
||||
- [`@webnet/sftp`](../sftp) — SFTP client and server layered on this package's subsystem channels
|
||||
+131
-45
@@ -1,5 +1,5 @@
|
||||
import { Reader, Writer } from "./cursor.js"
|
||||
import { SSH_MSG, SSH_OPEN } from "./constants.js"
|
||||
import { SSH_EXTENDED_DATA, SSH_MSG, SSH_OPEN } from "./constants.js"
|
||||
import type { SSHTransport } from "./transport.js"
|
||||
|
||||
const INITIAL_WINDOW = 1024 * 1024
|
||||
@@ -19,6 +19,12 @@ export type ConnectionMuxOptions = {
|
||||
onGlobalRequest?: GlobalRequestHandler
|
||||
}
|
||||
|
||||
export type ChannelRequest = {
|
||||
name: string
|
||||
wantReply: boolean
|
||||
payload: Uint8Array<ArrayBuffer>
|
||||
}
|
||||
|
||||
export type IncomingOpen = {
|
||||
type: string
|
||||
dest?: TcpipEndpoint
|
||||
@@ -41,13 +47,15 @@ export class Channel {
|
||||
|
||||
#queue: Uint8Array<ArrayBuffer>[] = []
|
||||
#readWaiters: Resolver<Uint8Array<ArrayBuffer> | null>[] = []
|
||||
#stderrQueue: Uint8Array<ArrayBuffer>[] = []
|
||||
#stderrWaiters: Resolver<Uint8Array<ArrayBuffer> | null>[] = []
|
||||
#consumed = 0
|
||||
#localWindow = INITIAL_WINDOW
|
||||
#eofReceived = false
|
||||
|
||||
#requestQueue: { name: string; wantReply: boolean; subsystem: string }[] = []
|
||||
#requestWaiters: Resolver<{ name: string; wantReply: boolean; subsystem: string }>[] = []
|
||||
#replyResolver: Resolver<void> | null = null
|
||||
#requestQueue: ChannelRequest[] = []
|
||||
#requestWaiters: Resolver<ChannelRequest>[] = []
|
||||
#replyWaiters: Resolver<void>[] = []
|
||||
|
||||
#eofSent = false
|
||||
#closeSent = false
|
||||
@@ -98,9 +106,16 @@ export class Channel {
|
||||
this.#pump()
|
||||
}
|
||||
|
||||
_noteExtendedData(length: number): void {
|
||||
if (!this.#receive(length)) return
|
||||
this.#consume(length)
|
||||
_deliverExtendedData(dataType: number, data: Uint8Array<ArrayBuffer>): void {
|
||||
if (!this.#receive(data.length)) return
|
||||
// Only stderr is exposed; other extended types are drained so an unread
|
||||
// stream of them cannot stall the shared receive window.
|
||||
if (dataType !== SSH_EXTENDED_DATA.STDERR) {
|
||||
this.#consume(data.length)
|
||||
return
|
||||
}
|
||||
this.#stderrQueue.push(data)
|
||||
this.#pump()
|
||||
}
|
||||
|
||||
#receive(n: number): boolean {
|
||||
@@ -120,6 +135,23 @@ export class Channel {
|
||||
_deliverClose(): void {
|
||||
this.#remoteClosed = true
|
||||
this.#pump()
|
||||
// RFC 4254 §5.3: a peer's CHANNEL_CLOSE must be answered with our own, or
|
||||
// its close() never completes.
|
||||
if (!this.#closeSent) {
|
||||
this.#closeSent = true
|
||||
this.#t
|
||||
.send(new Writer().u8(SSH_MSG.CHANNEL_CLOSE).u32(this.#remoteId).finish())
|
||||
.catch(() => {})
|
||||
}
|
||||
const closed = new Error("channel closed by peer")
|
||||
for (const w of this.#replyWaiters) w.reject(closed)
|
||||
this.#replyWaiters = []
|
||||
// Requests already queued stay readable; only waiters that would now block
|
||||
// forever are woken, since a closed peer sends no further requests.
|
||||
if (!this.#requestQueue.length) {
|
||||
for (const w of this.#requestWaiters) w.reject(closed)
|
||||
this.#requestWaiters = []
|
||||
}
|
||||
// A closed peer will send no further WINDOW_ADJUST, so wake any
|
||||
// window-blocked send() — it re-checks #remoteClosed and aborts.
|
||||
const windowWaiters = this.#windowWaiters
|
||||
@@ -130,16 +162,15 @@ export class Channel {
|
||||
for (const w of waiters) w()
|
||||
}
|
||||
|
||||
_deliverRequest(name: string, wantReply: boolean, subsystem: string): void {
|
||||
const req = { name, wantReply, subsystem }
|
||||
_deliverRequest(name: string, wantReply: boolean, payload: Uint8Array<ArrayBuffer>): void {
|
||||
const req = { name, wantReply, payload }
|
||||
const w = this.#requestWaiters.shift()
|
||||
if (w) w.resolve(req)
|
||||
else this.#requestQueue.push(req)
|
||||
}
|
||||
|
||||
_deliverReply(ok: boolean): void {
|
||||
const r = this.#replyResolver
|
||||
this.#replyResolver = null
|
||||
const r = this.#replyWaiters.shift()
|
||||
if (!r) return
|
||||
if (ok) r.resolve()
|
||||
else r.reject(new Error("channel request failed"))
|
||||
@@ -150,10 +181,12 @@ export class Channel {
|
||||
this.#error = err
|
||||
this.#openResolver?.reject(err)
|
||||
this.#openResolver = null
|
||||
this.#replyResolver?.reject(err)
|
||||
this.#replyResolver = null
|
||||
for (const w of this.#replyWaiters) w.reject(err)
|
||||
this.#replyWaiters = []
|
||||
for (const w of this.#readWaiters) w.reject(err)
|
||||
this.#readWaiters = []
|
||||
for (const w of this.#stderrWaiters) w.reject(err)
|
||||
this.#stderrWaiters = []
|
||||
for (const w of this.#requestWaiters) w.reject(err)
|
||||
this.#requestWaiters = []
|
||||
const wq = this.#windowWaiters
|
||||
@@ -165,16 +198,22 @@ export class Channel {
|
||||
}
|
||||
|
||||
#pump(): void {
|
||||
while (this.#readWaiters.length && this.#queue.length) {
|
||||
const w = this.#readWaiters.shift()!
|
||||
const chunk = this.#queue.shift()!
|
||||
this.#pumpQueue(this.#queue, this.#readWaiters)
|
||||
this.#pumpQueue(this.#stderrQueue, this.#stderrWaiters)
|
||||
}
|
||||
|
||||
#pumpQueue(
|
||||
queue: Uint8Array<ArrayBuffer>[],
|
||||
waiters: Resolver<Uint8Array<ArrayBuffer> | null>[],
|
||||
): void {
|
||||
while (waiters.length && queue.length) {
|
||||
const w = waiters.shift()!
|
||||
const chunk = queue.shift()!
|
||||
this.#consume(chunk.length)
|
||||
w.resolve(chunk)
|
||||
}
|
||||
if (this.#eofReceived || this.#remoteClosed) {
|
||||
while (this.#readWaiters.length && !this.#queue.length) {
|
||||
this.#readWaiters.shift()!.resolve(null)
|
||||
}
|
||||
while (waiters.length && !queue.length) waiters.shift()!.resolve(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,45 +229,67 @@ export class Channel {
|
||||
}
|
||||
}
|
||||
|
||||
async requestSubsystem(name: string): Promise<void> {
|
||||
const reply = new Promise<void>((resolve, reject) => {
|
||||
this.#replyResolver = { resolve, reject }
|
||||
})
|
||||
async request(name: string, payload: Uint8Array<ArrayBuffer>, wantReply: boolean): Promise<void> {
|
||||
if (this.#error) throw this.#error
|
||||
const reply = wantReply
|
||||
? new Promise<void>((resolve, reject) => this.#replyWaiters.push({ resolve, reject }))
|
||||
: null
|
||||
await this.#t.send(
|
||||
new Writer()
|
||||
.u8(SSH_MSG.CHANNEL_REQUEST)
|
||||
.u32(this.#remoteId)
|
||||
.string("subsystem")
|
||||
.bool(true)
|
||||
.string(name)
|
||||
.bool(wantReply)
|
||||
.bytes(payload)
|
||||
.finish(),
|
||||
)
|
||||
await reply
|
||||
}
|
||||
|
||||
reply(ok: boolean): Promise<void> {
|
||||
return this.#t.send(
|
||||
new Writer()
|
||||
.u8(ok ? SSH_MSG.CHANNEL_SUCCESS : SSH_MSG.CHANNEL_FAILURE)
|
||||
.u32(this.#remoteId)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
|
||||
requestSubsystem(name: string): Promise<void> {
|
||||
return this.request("subsystem", new Writer().string(name).finish(), true)
|
||||
}
|
||||
|
||||
async acceptSubsystem(expected: string): Promise<void> {
|
||||
for (;;) {
|
||||
const req = await this.#nextRequest()
|
||||
if (req.name === "subsystem" && req.subsystem === expected) {
|
||||
if (req.wantReply)
|
||||
await this.#t.send(new Writer().u8(SSH_MSG.CHANNEL_SUCCESS).u32(this.#remoteId).finish())
|
||||
const req = await this.nextRequest()
|
||||
const subsystem = req.name === "subsystem" ? new Reader(req.payload).utf8() : ""
|
||||
if (req.name === "subsystem" && subsystem === expected) {
|
||||
if (req.wantReply) await this.reply(true)
|
||||
return
|
||||
}
|
||||
if (req.wantReply)
|
||||
await this.#t.send(new Writer().u8(SSH_MSG.CHANNEL_FAILURE).u32(this.#remoteId).finish())
|
||||
if (req.wantReply) await this.reply(false)
|
||||
if (req.name === "subsystem" || req.name === "exec" || req.name === "shell")
|
||||
throw new Error(`unexpected channel request: ${req.name} ${req.subsystem}`)
|
||||
throw new Error(`unexpected channel request: ${req.name} ${subsystem}`)
|
||||
}
|
||||
}
|
||||
|
||||
#nextRequest(): Promise<{ name: string; wantReply: boolean; subsystem: string }> {
|
||||
nextRequest(): Promise<ChannelRequest> {
|
||||
const queued = this.#requestQueue.shift()
|
||||
if (queued) return Promise.resolve(queued)
|
||||
if (this.#error) return Promise.reject(this.#error)
|
||||
if (this.#remoteClosed) return Promise.reject(new Error("channel closed by peer"))
|
||||
return new Promise((resolve, reject) => this.#requestWaiters.push({ resolve, reject }))
|
||||
}
|
||||
|
||||
async send(data: Uint8Array<ArrayBuffer>): Promise<void> {
|
||||
send(data: Uint8Array<ArrayBuffer>): Promise<void> {
|
||||
return this.#sendData(data, null)
|
||||
}
|
||||
|
||||
sendStderr(data: Uint8Array<ArrayBuffer>): Promise<void> {
|
||||
return this.#sendData(data, SSH_EXTENDED_DATA.STDERR)
|
||||
}
|
||||
|
||||
async #sendData(data: Uint8Array<ArrayBuffer>, dataType: number | null): Promise<void> {
|
||||
let off = 0
|
||||
while (off < data.length) {
|
||||
if (this.#error) throw this.#error
|
||||
@@ -241,21 +302,33 @@ export class Channel {
|
||||
const chunk = data.slice(off, off + n)
|
||||
this.#remoteWindow -= n
|
||||
off += n
|
||||
await this.#t.send(
|
||||
new Writer().u8(SSH_MSG.CHANNEL_DATA).u32(this.#remoteId).string(chunk).finish(),
|
||||
)
|
||||
const w = new Writer()
|
||||
if (dataType === null) w.u8(SSH_MSG.CHANNEL_DATA).u32(this.#remoteId)
|
||||
else w.u8(SSH_MSG.CHANNEL_EXTENDED_DATA).u32(this.#remoteId).u32(dataType)
|
||||
await this.#t.send(w.string(chunk).finish())
|
||||
}
|
||||
}
|
||||
|
||||
read(): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
if (this.#queue.length) {
|
||||
const chunk = this.#queue.shift()!
|
||||
return this.#readFrom(this.#queue, this.#readWaiters)
|
||||
}
|
||||
|
||||
readStderr(): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
return this.#readFrom(this.#stderrQueue, this.#stderrWaiters)
|
||||
}
|
||||
|
||||
#readFrom(
|
||||
queue: Uint8Array<ArrayBuffer>[],
|
||||
waiters: Resolver<Uint8Array<ArrayBuffer> | null>[],
|
||||
): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
if (queue.length) {
|
||||
const chunk = queue.shift()!
|
||||
this.#consume(chunk.length)
|
||||
return Promise.resolve(chunk)
|
||||
}
|
||||
if (this.#eofReceived || this.#remoteClosed) return Promise.resolve(null)
|
||||
if (this.#error) return Promise.reject(this.#error)
|
||||
return new Promise((resolve, reject) => this.#readWaiters.push({ resolve, reject }))
|
||||
return new Promise((resolve, reject) => waiters.push({ resolve, reject }))
|
||||
}
|
||||
|
||||
async closeSend(): Promise<void> {
|
||||
@@ -277,6 +350,16 @@ export class Channel {
|
||||
await new Promise<void>((r) => this.#closeWaiters.push(r))
|
||||
}
|
||||
|
||||
async abort(reason: unknown): Promise<void> {
|
||||
if (!this.#closeSent) {
|
||||
this.#closeSent = true
|
||||
await this.#t
|
||||
.send(new Writer().u8(SSH_MSG.CHANNEL_CLOSE).u32(this.#remoteId).finish())
|
||||
.catch(() => {})
|
||||
}
|
||||
this._fail(reason)
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.#closeSent || this.#remoteClosed || this.#error != null
|
||||
}
|
||||
@@ -285,6 +368,10 @@ export class Channel {
|
||||
return this.#eofReceived || this.#remoteClosed
|
||||
}
|
||||
|
||||
get failure(): unknown {
|
||||
return this.#error
|
||||
}
|
||||
|
||||
get whenClosed(): Promise<void> {
|
||||
if (this.#remoteClosed || this.#error) return Promise.resolve()
|
||||
return new Promise<void>((r) => this.#closeWaiters.push(r))
|
||||
@@ -403,9 +490,9 @@ export class ConnectionMux {
|
||||
}
|
||||
case SSH_MSG.CHANNEL_EXTENDED_DATA: {
|
||||
const localId = r.u32()
|
||||
r.u32()
|
||||
const data = r.string()
|
||||
this.#channels.get(localId)?._noteExtendedData(data.length)
|
||||
const dataType = r.u32()
|
||||
const data = r.string().slice()
|
||||
this.#channels.get(localId)?._deliverExtendedData(dataType, data)
|
||||
return
|
||||
}
|
||||
case SSH_MSG.CHANNEL_EOF: {
|
||||
@@ -424,8 +511,7 @@ export class ConnectionMux {
|
||||
const localId = r.u32()
|
||||
const name = r.utf8()
|
||||
const wantReply = r.bool()
|
||||
const subsystem = name === "subsystem" ? r.utf8() : ""
|
||||
this.#channels.get(localId)?._deliverRequest(name, wantReply, subsystem)
|
||||
this.#channels.get(localId)?._deliverRequest(name, wantReply, r.rest().slice())
|
||||
return
|
||||
}
|
||||
case SSH_MSG.CHANNEL_SUCCESS: {
|
||||
|
||||
@@ -37,12 +37,13 @@ async function connected(serverOpts: Partial<SSHServerConnectionOptions<object>>
|
||||
|
||||
async function echo(t: RawTransport): Promise<void> {
|
||||
for (;;) {
|
||||
let chunk: Uint8Array
|
||||
let chunk: Uint8Array | null
|
||||
try {
|
||||
chunk = await t.read()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (chunk === null) return
|
||||
await t.write(chunk).catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -52,6 +53,7 @@ async function readN(t: RawTransport, n: number): Promise<Uint8Array> {
|
||||
let off = 0
|
||||
while (off < n) {
|
||||
const chunk = await t.read()
|
||||
if (chunk === null) throw new Error("unexpected EOF")
|
||||
out.set(chunk, off)
|
||||
off += chunk.length
|
||||
}
|
||||
|
||||
@@ -6,6 +6,49 @@ import { authenticateClient, authenticateServer, type Credential } from "./auth.
|
||||
import { SSH_OPEN } from "./constants.js"
|
||||
import { ConnectionMux, type Channel, type IncomingOpen, type TcpipEndpoint } from "./channel.js"
|
||||
import { channelTransport, pipe } from "./forward.js"
|
||||
import {
|
||||
execRequest,
|
||||
shellRequest,
|
||||
startSession,
|
||||
type SessionExit,
|
||||
type SessionOptions,
|
||||
type SSHSession,
|
||||
} from "./session.js"
|
||||
|
||||
const DEFAULT_MAX_OUTPUT = 1024 * 1024
|
||||
|
||||
export type RunOptions = SessionOptions & {
|
||||
stdin?: Uint8Array<ArrayBuffer>
|
||||
maxOutput?: number
|
||||
}
|
||||
|
||||
export type RunResult = {
|
||||
stdout: Uint8Array<ArrayBuffer>
|
||||
stderr: Uint8Array<ArrayBuffer>
|
||||
exit: SessionExit
|
||||
}
|
||||
|
||||
async function collect(
|
||||
read: () => Promise<Uint8Array<ArrayBuffer> | null>,
|
||||
limit: number,
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const chunks: Uint8Array<ArrayBuffer>[] = []
|
||||
let total = 0
|
||||
for (;;) {
|
||||
const chunk = await read()
|
||||
if (chunk === null) break
|
||||
total += chunk.length
|
||||
if (total > limit) throw new Error(`session output exceeded ${limit} bytes`)
|
||||
chunks.push(chunk)
|
||||
}
|
||||
const out = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, off)
|
||||
off += chunk.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export type SSHClientConnectionOptions = {
|
||||
user: string
|
||||
@@ -71,6 +114,33 @@ export class SSHClientConnection {
|
||||
return this.#mux.openSession()
|
||||
}
|
||||
|
||||
async exec(command: string, opts: SessionOptions = {}): Promise<SSHSession> {
|
||||
return startSession(await this.#mux.openSession(), execRequest(command), opts)
|
||||
}
|
||||
|
||||
async openShell(opts: SessionOptions = {}): Promise<SSHSession> {
|
||||
return startSession(await this.#mux.openSession(), shellRequest(), opts)
|
||||
}
|
||||
|
||||
async run(command: string, opts: RunOptions = {}): Promise<RunResult> {
|
||||
const session = await this.exec(command, opts)
|
||||
const maxOutput = opts.maxOutput ?? DEFAULT_MAX_OUTPUT
|
||||
try {
|
||||
if (opts.stdin) await session.send(opts.stdin)
|
||||
await session.closeSend()
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
collect(() => session.read(), maxOutput),
|
||||
collect(() => session.readStderr(), maxOutput),
|
||||
])
|
||||
return { stdout, stderr, exit: await session.wait() }
|
||||
} catch (err) {
|
||||
await session.abort(err).catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
await session.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
openDirectTcpip(dest: TcpipEndpoint, originator?: TcpipEndpoint): Promise<Channel> {
|
||||
return this.#mux.openDirectTcpip(dest, originator ?? { host: "127.0.0.1", port: 0 })
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ export const SSH_MSG = {
|
||||
CHANNEL_FAILURE: 100,
|
||||
} as const
|
||||
|
||||
export const SSH_EXTENDED_DATA = {
|
||||
STDERR: 1,
|
||||
} as const
|
||||
|
||||
export const SSH_OPEN = {
|
||||
ADMINISTRATIVELY_PROHIBITED: 1,
|
||||
CONNECT_FAILED: 2,
|
||||
|
||||
@@ -50,13 +50,14 @@ async function tcpipPair(): Promise<{
|
||||
}
|
||||
|
||||
suite("channelTransport adapter", () => {
|
||||
test("read surfaces EOF as a throw and sets readEnded", async () => {
|
||||
test("read returns EOF and sets readEnded", async () => {
|
||||
const { clientCh, serverCh, close } = await tcpipPair()
|
||||
try {
|
||||
const rt = channelTransport(clientCh, { remoteAddr: "h:1" })
|
||||
assert.equal(rt.remoteAddr, "h:1")
|
||||
await serverCh.closeSend()
|
||||
await assert.rejects(rt.read(), /socket ended/)
|
||||
assert.equal(await rt.read(), null)
|
||||
assert.equal(await rt.read(), null)
|
||||
assert.equal(rt.readEnded, true)
|
||||
} finally {
|
||||
await close()
|
||||
|
||||
@@ -18,11 +18,10 @@ export function channelTransport(
|
||||
},
|
||||
remoteAddr: addrs.remoteAddr,
|
||||
localAddr: addrs.localAddr,
|
||||
async read(): Promise<Uint8Array> {
|
||||
async read(): Promise<Uint8Array | null> {
|
||||
const chunk = await channel.read()
|
||||
if (chunk === null) {
|
||||
readEnded = true
|
||||
throw new Error("socket ended")
|
||||
}
|
||||
return chunk
|
||||
},
|
||||
@@ -46,12 +45,13 @@ export function channelTransport(
|
||||
export async function pipe(a: RawTransport, b: RawTransport): Promise<void> {
|
||||
const copy = async (from: RawTransport, to: RawTransport): Promise<void> => {
|
||||
for (;;) {
|
||||
let chunk: Uint8Array
|
||||
let chunk: Uint8Array | null
|
||||
try {
|
||||
chunk = await from.read()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (chunk === null) return
|
||||
try {
|
||||
await to.write(chunk)
|
||||
} catch {
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
export { generateHostKey } from "./keys.js"
|
||||
export { Channel } from "./channel.js"
|
||||
export type { TcpipEndpoint, IncomingOpen } from "./channel.js"
|
||||
export type { ChannelRequest, TcpipEndpoint, IncomingOpen } from "./channel.js"
|
||||
export { channelTransport } from "./forward.js"
|
||||
export { SSHClientConnection, SSHServerConnection, RemoteForward } from "./connection.js"
|
||||
export type { SSHClientConnectionOptions, SSHServerConnectionOptions } from "./connection.js"
|
||||
export type {
|
||||
RunOptions,
|
||||
RunResult,
|
||||
SSHClientConnectionOptions,
|
||||
SSHServerConnectionOptions,
|
||||
} from "./connection.js"
|
||||
export { PTY_MODE, SSHSession, serveSession } from "./session.js"
|
||||
export type {
|
||||
PtyOptions,
|
||||
PtyRequest,
|
||||
ServerSession,
|
||||
SessionExit,
|
||||
SessionHandlers,
|
||||
SessionLimits,
|
||||
SessionOptions,
|
||||
SessionStart,
|
||||
WindowSize,
|
||||
} from "./session.js"
|
||||
export { SSHAuthError } from "./auth.js"
|
||||
export type { Credential } from "./auth.js"
|
||||
export type { HostKeyInfo, HostKeyVerifier } from "./transport.js"
|
||||
|
||||
@@ -1,15 +1,43 @@
|
||||
import {
|
||||
Channel,
|
||||
PTY_MODE,
|
||||
SSHAuthError,
|
||||
SSHClientConnection,
|
||||
SSHServerConnection,
|
||||
SSHSession,
|
||||
serveSession,
|
||||
type Credential,
|
||||
type RunResult,
|
||||
type ServerSession,
|
||||
type SessionExit,
|
||||
} from "./index.js"
|
||||
import type { RawTransport } from "@webnet/transport"
|
||||
|
||||
declare const transport: RawTransport
|
||||
declare const channel: Channel
|
||||
declare const credential: Credential
|
||||
declare const client: SSHClientConnection
|
||||
|
||||
const session: Promise<SSHSession> = client.exec("uname -a", {
|
||||
env: { LANG: "C" },
|
||||
pty: { term: "xterm", cols: 80, rows: 24, modes: { [PTY_MODE.ECHO]: 0 } },
|
||||
signal: AbortSignal.timeout(1000),
|
||||
})
|
||||
const shell: Promise<SSHSession> = client.openShell({ pty: true })
|
||||
const runResult: Promise<RunResult> = client.run("uptime", { maxOutput: 4096 })
|
||||
const exit: Promise<SessionExit> = session.then((s) => s.wait())
|
||||
const served: Promise<void> = serveSession(channel, {
|
||||
authorize: (start) =>
|
||||
start.type === "subsystem" ? start.name === "sftp" : start.type === "exec",
|
||||
exec: async (_command: string, s: ServerSession) => {
|
||||
await s.sendStderr(new Uint8Array(0))
|
||||
await s.exit(0)
|
||||
},
|
||||
subsystem: async (_name: string, s: ServerSession) => {
|
||||
await s.exit(0)
|
||||
},
|
||||
onError: (error: unknown) => void error,
|
||||
})
|
||||
|
||||
const clientConnection: Promise<SSHClientConnection> = SSHClientConnection.connect(transport, {
|
||||
user: "user",
|
||||
@@ -31,3 +59,8 @@ void credential
|
||||
void clientConnection
|
||||
void serverConnection
|
||||
void authError
|
||||
void session
|
||||
void shell
|
||||
void runResult
|
||||
void exit
|
||||
void served
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { after, before, suite, test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { spawn, spawnSync } from "node:child_process"
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { nodeListen, type NodeListener } from "@webnet/transport/node"
|
||||
import { generateHostKey } from "./keys.js"
|
||||
import { SSHServerConnection } from "./connection.js"
|
||||
import { serveSession, type PtyRequest, type SessionHandlers } from "./session.js"
|
||||
|
||||
const dec = new TextDecoder()
|
||||
const enc = new TextEncoder()
|
||||
|
||||
const available = spawnSync("ssh", ["-V"], { stdio: "ignore" }).status === 0
|
||||
|
||||
// The OpenSSH client drives these tests, so the handlers implement just enough
|
||||
// shell-like behaviour to assert on: "echo <text>" writes to stdout, "err
|
||||
// <text>" to stderr, "exit <n>" sets the status, "cat" echoes stdin, and
|
||||
// "tty" reports the negotiated pty.
|
||||
async function runCommand(
|
||||
command: string,
|
||||
s: Parameters<NonNullable<SessionHandlers["exec"]>>[1],
|
||||
): Promise<void> {
|
||||
const [verb, ...rest] = command.split(" ")
|
||||
const arg = rest.join(" ")
|
||||
if (verb === "echo") await s.send(enc.encode(`${arg}\n`))
|
||||
else if (verb === "err") await s.sendStderr(enc.encode(`${arg}\n`))
|
||||
else if (verb === "tty")
|
||||
await s.send(enc.encode(s.pty ? `${s.pty.term} ${s.pty.cols}x${s.pty.rows}\n` : "no tty\n"))
|
||||
else if (verb === "cat") {
|
||||
for (;;) {
|
||||
const chunk = await s.read()
|
||||
if (chunk === null) break
|
||||
await s.send(chunk)
|
||||
}
|
||||
} else if (verb === "exit") {
|
||||
await s.exit(Number(arg) || 0)
|
||||
return
|
||||
} else {
|
||||
await s.sendStderr(enc.encode(`unknown command: ${verb}\n`))
|
||||
await s.exit(127)
|
||||
return
|
||||
}
|
||||
await s.exit(0)
|
||||
}
|
||||
|
||||
type Fixture = {
|
||||
port: number
|
||||
keyPath: string
|
||||
ptys: PtyRequest[]
|
||||
dir: string
|
||||
listener: NodeListener
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
let fixture: Fixture
|
||||
|
||||
async function start(): Promise<Fixture> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "webnet-ssh-"))
|
||||
const keyPath = join(dir, "id_ed25519")
|
||||
await writeFile(keyPath, await generateHostKey(), { mode: 0o600 })
|
||||
const hostKey = await generateHostKey()
|
||||
const listener = await nodeListen(0)
|
||||
const port = Number(listener.addr.slice(listener.addr.lastIndexOf(":") + 1))
|
||||
const ptys: PtyRequest[] = []
|
||||
|
||||
const accept = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
let raw
|
||||
try {
|
||||
raw = await listener.accept()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
const connection = await SSHServerConnection.accept(raw, {
|
||||
hostKey,
|
||||
authenticate: async (_user, credential) => (credential.kind === "publickey" ? {} : null),
|
||||
})
|
||||
for (;;) {
|
||||
let channel
|
||||
try {
|
||||
channel = await connection.acceptSession()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
void serveSession(channel, {
|
||||
env: (name) => name === "LANG" || name.startsWith("WEBNET_"),
|
||||
pty: (pty) => {
|
||||
ptys.push(pty)
|
||||
return true
|
||||
},
|
||||
exec: runCommand,
|
||||
shell: (s) => runCommand("cat", s),
|
||||
}).catch(() => {})
|
||||
}
|
||||
})().catch(() => {})
|
||||
}
|
||||
}
|
||||
void accept()
|
||||
|
||||
return {
|
||||
port,
|
||||
keyPath,
|
||||
ptys,
|
||||
dir,
|
||||
listener,
|
||||
stop: async () => {
|
||||
await listener.close()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type SshResult = { code: number | null; stdout: string; stderr: string }
|
||||
|
||||
function ssh(args: string[], stdin?: string): Promise<SshResult> {
|
||||
const child = spawn(
|
||||
"ssh",
|
||||
[
|
||||
"-F",
|
||||
"/dev/null",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"IdentitiesOnly=yes",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-i",
|
||||
fixture.keyPath,
|
||||
"-p",
|
||||
String(fixture.port),
|
||||
...args,
|
||||
],
|
||||
{ stdio: ["pipe", "pipe", "pipe"] },
|
||||
)
|
||||
const out: Buffer[] = []
|
||||
const err: Buffer[] = []
|
||||
child.stdout.on("data", (c: Buffer) => out.push(c))
|
||||
child.stderr.on("data", (c: Buffer) => err.push(c))
|
||||
if (stdin !== undefined) child.stdin.end(stdin)
|
||||
else child.stdin.end()
|
||||
return new Promise((resolve, reject) => {
|
||||
child.on("error", reject)
|
||||
child.on("close", (code) =>
|
||||
resolve({
|
||||
code,
|
||||
stdout: dec.decode(Buffer.concat(out)),
|
||||
stderr: dec.decode(Buffer.concat(err)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
suite(
|
||||
"webnet ssh server against the OpenSSH client",
|
||||
{ skip: !available && "the ssh binary is not installed" },
|
||||
() => {
|
||||
before(async () => {
|
||||
fixture = await start()
|
||||
})
|
||||
after(async () => {
|
||||
await fixture.stop()
|
||||
})
|
||||
|
||||
test("exec returns stdout and a zero exit status", async () => {
|
||||
const r = await ssh(["u@127.0.0.1", "echo hello"])
|
||||
assert.equal(r.stdout, "hello\n")
|
||||
assert.equal(r.code, 0)
|
||||
})
|
||||
|
||||
test("stderr stays on stderr", async () => {
|
||||
const r = await ssh(["u@127.0.0.1", "err oops"])
|
||||
assert.equal(r.stdout, "")
|
||||
assert.ok(r.stderr.includes("oops"), r.stderr)
|
||||
})
|
||||
|
||||
test("a non-zero exit status propagates to the ssh client", async () => {
|
||||
const r = await ssh(["u@127.0.0.1", "exit 42"])
|
||||
assert.equal(r.code, 42)
|
||||
})
|
||||
|
||||
test("stdin is streamed to the command", async () => {
|
||||
const r = await ssh(["-T", "u@127.0.0.1", "cat"], "streamed stdin\n")
|
||||
assert.equal(r.stdout, "streamed stdin\n")
|
||||
assert.equal(r.code, 0)
|
||||
})
|
||||
|
||||
test("an interactive shell echoes through the session channel", async () => {
|
||||
const r = await ssh(["-T", "u@127.0.0.1"], "shell payload\n")
|
||||
assert.equal(r.stdout, "shell payload\n")
|
||||
assert.equal(r.code, 0)
|
||||
})
|
||||
|
||||
test("-tt allocates a pty the server can inspect", async () => {
|
||||
const before = fixture.ptys.length
|
||||
const r = await ssh(["-tt", "-o", "SetEnv=TERM=xterm-256color", "u@127.0.0.1", "tty"])
|
||||
assert.ok(r.stdout.startsWith("xterm-256color "), `${r.stdout} / ${r.stderr}`)
|
||||
assert.equal(fixture.ptys.length, before + 1)
|
||||
assert.equal(fixture.ptys.at(-1)!.term, "xterm-256color")
|
||||
})
|
||||
|
||||
test("a rejected environment variable does not fail the session", async () => {
|
||||
const r = await ssh(["-o", "SetEnv=REJECTED=1", "u@127.0.0.1", "echo still ran"])
|
||||
assert.equal(r.stdout, "still ran\n")
|
||||
assert.equal(r.code, 0)
|
||||
})
|
||||
|
||||
test("an unknown command reports the exit status the handler chose", async () => {
|
||||
const r = await ssh(["u@127.0.0.1", "nonsense"])
|
||||
assert.equal(r.code, 127)
|
||||
assert.ok(r.stderr.includes("unknown command"), r.stderr)
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,714 @@
|
||||
import { suite, test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { loopbackTransportPair } from "@webnet/transport/loopback"
|
||||
import { generateHostKey } from "./keys.js"
|
||||
import { SSHClientConnection, SSHServerConnection } from "./connection.js"
|
||||
import {
|
||||
PTY_MODE,
|
||||
serveSession,
|
||||
type ServerSession,
|
||||
type SessionHandlers,
|
||||
type SessionLimits,
|
||||
type WindowSize,
|
||||
} from "./session.js"
|
||||
|
||||
const enc = new TextEncoder()
|
||||
const dec = new TextDecoder()
|
||||
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
async function settle(done: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!done()) {
|
||||
if (Date.now() > deadline) throw new Error("condition never settled")
|
||||
await delay(5)
|
||||
}
|
||||
}
|
||||
|
||||
function encodeStrings(...values: string[]): Uint8Array<ArrayBuffer> {
|
||||
const parts = values.map((v) => enc.encode(v))
|
||||
const out = new Uint8Array(parts.reduce((n, p) => n + p.length + 4, 0))
|
||||
const view = new DataView(out.buffer)
|
||||
let off = 0
|
||||
for (const part of parts) {
|
||||
view.setUint32(off, part.length)
|
||||
out.set(part, off + 4)
|
||||
off += part.length + 4
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type Ends = {
|
||||
client: SSHClientConnection
|
||||
server: SSHServerConnection<object>
|
||||
errors: unknown[]
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
async function connected(handlers: SessionHandlers, limits?: SessionLimits): Promise<Ends> {
|
||||
const [a, b] = loopbackTransportPair()
|
||||
const hostKey = await generateHostKey()
|
||||
const [client, server] = await Promise.all([
|
||||
SSHClientConnection.connect(a, { user: "u", password: "p" }),
|
||||
SSHServerConnection.accept(b, { hostKey, authenticate: async () => ({}) }),
|
||||
])
|
||||
const errors: unknown[] = []
|
||||
const loop = (async () => {
|
||||
for (;;) {
|
||||
let channel
|
||||
try {
|
||||
channel = await server.acceptSession()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// Deliberately unguarded: this is the documented fire-and-forget
|
||||
// pattern, so serveSession must never reject.
|
||||
void serveSession(channel, { ...handlers, onError: (err) => errors.push(err) }, limits)
|
||||
}
|
||||
})()
|
||||
return {
|
||||
client,
|
||||
server,
|
||||
errors,
|
||||
close: async () => {
|
||||
await client.close().catch(() => {})
|
||||
await server.close().catch(() => {})
|
||||
await loop
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function drain(read: () => Promise<Uint8Array | null>): Promise<string> {
|
||||
let out = ""
|
||||
for (;;) {
|
||||
const chunk = await read()
|
||||
if (chunk === null) return out
|
||||
out += dec.decode(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// A handler that echoes stdin to stdout, mirrors the command to stderr, and
|
||||
// exits with a code encoded in the command ("exit:<n>").
|
||||
async function echoHandler(command: string, s: ServerSession): Promise<void> {
|
||||
await s.sendStderr(enc.encode(`cmd:${command}`))
|
||||
for (;;) {
|
||||
const chunk = await s.read()
|
||||
if (chunk === null) break
|
||||
await s.send(chunk)
|
||||
}
|
||||
const m = /^exit:(\d+)$/.exec(command)
|
||||
await s.exit(m ? Number(m[1]) : 0)
|
||||
}
|
||||
|
||||
suite("ssh session exec", () => {
|
||||
test("exec streams stdin, stdout, stderr and reports exit status", async () => {
|
||||
const { client, close } = await connected({ exec: echoHandler })
|
||||
try {
|
||||
const session = await client.exec("hello")
|
||||
await session.send(enc.encode("ping"))
|
||||
await session.closeSend()
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
drain(() => session.read()),
|
||||
drain(() => session.readStderr()),
|
||||
])
|
||||
assert.equal(stdout, "ping")
|
||||
assert.equal(stderr, "cmd:hello")
|
||||
assert.deepEqual(await session.wait(), { type: "exit", code: 0 })
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("a non-zero exit status is reported", async () => {
|
||||
const { client, close } = await connected({ exec: echoHandler })
|
||||
try {
|
||||
const session = await client.exec("exit:42")
|
||||
await session.closeSend()
|
||||
await drain(() => session.read())
|
||||
assert.deepEqual(await session.wait(), { type: "exit", code: 42 })
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("exit-signal is reported distinctly from an exit code", async () => {
|
||||
const { client, close } = await connected({
|
||||
exec: async (_command, s) => {
|
||||
await s.exitSignal("TERM", { coreDumped: true, message: "killed" })
|
||||
},
|
||||
})
|
||||
try {
|
||||
const session = await client.exec("boom")
|
||||
assert.deepEqual(await session.wait(), {
|
||||
type: "signal",
|
||||
signal: "TERM",
|
||||
coreDumped: true,
|
||||
message: "killed",
|
||||
})
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("a session that closes without an exit status resolves as none", async () => {
|
||||
const { client, close } = await connected({
|
||||
exec: async (_command, s) => {
|
||||
// Close the channel from under serveSession so no exit-status is sent.
|
||||
await s.channel.close()
|
||||
},
|
||||
})
|
||||
try {
|
||||
const session = await client.exec("silent")
|
||||
assert.deepEqual(await session.wait(), { type: "none" })
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("run() buffers stdout, stderr and the exit status", async () => {
|
||||
const { client, close } = await connected({ exec: echoHandler })
|
||||
try {
|
||||
const result = await client.run("exit:3", { stdin: enc.encode("payload") })
|
||||
assert.equal(dec.decode(result.stdout), "payload")
|
||||
assert.equal(dec.decode(result.stderr), "cmd:exit:3")
|
||||
assert.deepEqual(result.exit, { type: "exit", code: 3 })
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("run() rejects once output passes maxOutput", async () => {
|
||||
const { client, close } = await connected({
|
||||
exec: async (_command, s) => {
|
||||
await s.send(new Uint8Array(4096))
|
||||
await s.exit(0)
|
||||
},
|
||||
})
|
||||
try {
|
||||
await assert.rejects(client.run("flood", { maxOutput: 1024 }), /exceeded 1024 bytes/)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("stderr is delivered separately and does not appear on stdout", async () => {
|
||||
const { client, close } = await connected({
|
||||
exec: async (_command, s) => {
|
||||
await s.send(enc.encode("out"))
|
||||
await s.sendStderr(enc.encode("err"))
|
||||
await s.exit(0)
|
||||
},
|
||||
})
|
||||
try {
|
||||
const result = await client.run("split")
|
||||
assert.equal(dec.decode(result.stdout), "out")
|
||||
assert.equal(dec.decode(result.stderr), "err")
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("half-close: the handler sees EOF while the server keeps writing", async () => {
|
||||
let sawEof = false
|
||||
const { client, close } = await connected({
|
||||
exec: async (_command, s) => {
|
||||
assert.equal(await s.read(), null)
|
||||
sawEof = true
|
||||
await s.send(enc.encode("after-eof"))
|
||||
await s.exit(0)
|
||||
},
|
||||
})
|
||||
try {
|
||||
const session = await client.exec("halfclose")
|
||||
await session.closeSend()
|
||||
assert.equal(await drain(() => session.read()), "after-eof")
|
||||
assert.equal(sawEof, true)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("one dispatcher serves exec and a subsystem on the same connection", async () => {
|
||||
const { client, close } = await connected({
|
||||
exec: echoHandler,
|
||||
subsystem: async (name, s) => {
|
||||
await s.send(enc.encode(`subsystem:${name}`))
|
||||
for (;;) {
|
||||
const chunk = await s.read()
|
||||
if (chunk === null) break
|
||||
await s.send(chunk)
|
||||
}
|
||||
await s.exit(0)
|
||||
},
|
||||
})
|
||||
try {
|
||||
const [execOut, subsystemOut] = await Promise.all([
|
||||
(async () => {
|
||||
const session = await client.exec("hello")
|
||||
await session.send(enc.encode("via-exec"))
|
||||
await session.closeSend()
|
||||
return drain(() => session.read())
|
||||
})(),
|
||||
(async () => {
|
||||
const channel = await client.openSubsystem("sftp")
|
||||
await channel.send(enc.encode("via-subsystem"))
|
||||
await channel.closeSend()
|
||||
return drain(() => channel.read())
|
||||
})(),
|
||||
])
|
||||
assert.equal(execOut, "via-exec")
|
||||
assert.equal(subsystemOut, "subsystem:sftpvia-subsystem")
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("a subsystem is refused when no subsystem handler is set", async () => {
|
||||
const { client, close } = await connected({ exec: echoHandler })
|
||||
try {
|
||||
await assert.rejects(client.openSubsystem("sftp"), /channel request failed/)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("several sessions run simultaneously without crosstalk", async () => {
|
||||
const { client, close } = await connected({ exec: echoHandler })
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
[1, 2, 3, 4].map(async (n) => {
|
||||
const session = await client.exec(`exit:${n}`)
|
||||
await session.send(enc.encode(`payload-${n}`))
|
||||
await session.closeSend()
|
||||
const stdout = await drain(() => session.read())
|
||||
return { stdout, exit: await session.wait() }
|
||||
}),
|
||||
)
|
||||
for (const [i, r] of results.entries()) {
|
||||
assert.equal(r.stdout, `payload-${i + 1}`)
|
||||
assert.deepEqual(r.exit, { type: "exit", code: i + 1 })
|
||||
}
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
suite("ssh session pty and shell", () => {
|
||||
test("shell with a PTY reports the requested terminal and modes", async () => {
|
||||
let pty: ServerSession["pty"] = null
|
||||
const { client, close } = await connected({
|
||||
pty: () => true,
|
||||
shell: async (s) => {
|
||||
pty = s.pty
|
||||
await s.exit(0)
|
||||
},
|
||||
})
|
||||
try {
|
||||
const session = await client.openShell({
|
||||
pty: {
|
||||
term: "xterm",
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
modes: { [PTY_MODE.ECHO]: 0, [PTY_MODE.TTY_OP_OSPEED]: 38400 },
|
||||
},
|
||||
})
|
||||
await session.wait()
|
||||
assert.ok(pty)
|
||||
assert.equal(pty!.term, "xterm")
|
||||
assert.equal(pty!.cols, 120)
|
||||
assert.equal(pty!.rows, 40)
|
||||
assert.equal(pty!.modes.get(PTY_MODE.ECHO), 0)
|
||||
assert.equal(pty!.modes.get(PTY_MODE.TTY_OP_OSPEED), 38400)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("resize reaches the running shell as a window-change", async () => {
|
||||
const sizes: WindowSize[] = []
|
||||
let release = (): void => {}
|
||||
const done = new Promise<void>((r) => (release = r))
|
||||
const { client, close } = await connected({
|
||||
pty: () => true,
|
||||
windowChange: (size) => {
|
||||
sizes.push(size)
|
||||
if (sizes.length === 2) release()
|
||||
},
|
||||
shell: async (s) => {
|
||||
await done
|
||||
await s.send(enc.encode(`${s.pty!.cols}x${s.pty!.rows}`))
|
||||
await s.exit(0)
|
||||
},
|
||||
})
|
||||
try {
|
||||
const session = await client.openShell({ pty: true })
|
||||
await session.resize({ cols: 100, rows: 30 })
|
||||
await session.resize({ cols: 132, rows: 43 })
|
||||
assert.equal(await drain(() => session.read()), "132x43")
|
||||
assert.deepEqual(
|
||||
sizes.map((s) => [s.cols, s.rows]),
|
||||
[
|
||||
[100, 30],
|
||||
[132, 43],
|
||||
],
|
||||
)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("oversized pty dimensions are clamped rather than trusted", async () => {
|
||||
let pty: ServerSession["pty"] = null
|
||||
const { client, close } = await connected({
|
||||
pty: () => true,
|
||||
shell: async (s) => {
|
||||
pty = s.pty
|
||||
await s.exit(0)
|
||||
},
|
||||
})
|
||||
try {
|
||||
const session = await client.openShell({ pty: { cols: 1e9, rows: -5 } })
|
||||
await session.wait()
|
||||
assert.equal(pty!.cols, 10000)
|
||||
assert.equal(pty!.rows, 0)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("a signal request reaches the running session", async () => {
|
||||
const signals: string[] = []
|
||||
let release = (): void => {}
|
||||
const done = new Promise<void>((r) => (release = r))
|
||||
const { client, close } = await connected({
|
||||
signal: (name) => {
|
||||
signals.push(name)
|
||||
release()
|
||||
},
|
||||
shell: async (s) => {
|
||||
await done
|
||||
await s.exit(0)
|
||||
},
|
||||
})
|
||||
try {
|
||||
const session = await client.openShell()
|
||||
await session.signal("INT")
|
||||
await session.wait()
|
||||
assert.deepEqual(signals, ["INT"])
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("environment variables are passed when the handler permits them", async () => {
|
||||
let env: ReadonlyMap<string, string> | null = null
|
||||
const { client, close } = await connected({
|
||||
env: (name) => name.startsWith("WEBNET_"),
|
||||
exec: async (_command, s) => {
|
||||
env = s.env
|
||||
await s.exit(0)
|
||||
},
|
||||
})
|
||||
try {
|
||||
const session = await client.exec("printenv", {
|
||||
env: { WEBNET_OK: "yes", DENIED: "no" },
|
||||
})
|
||||
await session.wait()
|
||||
assert.deepEqual([...env!], [["WEBNET_OK", "yes"]])
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("requireEnv surfaces a refused environment variable", async () => {
|
||||
const { client, close } = await connected({ env: () => false, exec: echoHandler })
|
||||
try {
|
||||
await assert.rejects(
|
||||
client.exec("printenv", { env: { NOPE: "1" }, requireEnv: true }),
|
||||
/channel request failed/,
|
||||
)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
suite("ssh session rejection and failure", () => {
|
||||
test("exec is refused when the server has no exec handler", async () => {
|
||||
const { client, close } = await connected({ shell: async (s) => s.exit(0) })
|
||||
try {
|
||||
await assert.rejects(client.exec("ls"), /channel request failed/)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("authorize can deny a specific command without breaking the connection", async () => {
|
||||
const { client, close } = await connected({
|
||||
authorize: (start) => start.type === "exec" && start.command === "allowed",
|
||||
exec: echoHandler,
|
||||
})
|
||||
try {
|
||||
await assert.rejects(client.exec("denied"), /channel request failed/)
|
||||
const session = await client.exec("allowed")
|
||||
await session.closeSend()
|
||||
await drain(() => session.read())
|
||||
assert.deepEqual(await session.wait(), { type: "exit", code: 0 })
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("a pty request is refused when no pty handler is set", async () => {
|
||||
const { client, close } = await connected({ shell: async (s) => s.exit(0) })
|
||||
try {
|
||||
await assert.rejects(client.openShell({ pty: true }), /channel request failed/)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("an unknown channel request is refused and the session still runs", async () => {
|
||||
const { client, close } = await connected({ exec: echoHandler })
|
||||
try {
|
||||
const channel = await client.openSession()
|
||||
await assert.rejects(
|
||||
channel.request("x11-req", new Uint8Array(0), true),
|
||||
/channel request failed/,
|
||||
)
|
||||
await channel.request("exec", new Uint8Array([0, 0, 0, 2, 0x68, 0x69]), true)
|
||||
await channel.closeSend()
|
||||
assert.equal(await drain(() => channel.read()), "")
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("a second start request on one session is refused", async () => {
|
||||
const { client, close } = await connected({ exec: echoHandler, shell: async (s) => s.exit(0) })
|
||||
try {
|
||||
const session = await client.exec("first")
|
||||
await assert.rejects(
|
||||
session.channel.request("shell", new Uint8Array(0), true),
|
||||
/channel request failed/,
|
||||
)
|
||||
await session.closeSend()
|
||||
await drain(() => session.read())
|
||||
assert.deepEqual(await session.wait(), { type: "exit", code: 0 })
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("a handler that throws exits non-zero and reports through onError", async () => {
|
||||
let attempt = 0
|
||||
const { client, errors, close } = await connected({
|
||||
exec: async (_command, s) => {
|
||||
if (++attempt === 1) throw new Error("handler exploded")
|
||||
await s.exit(0)
|
||||
},
|
||||
})
|
||||
try {
|
||||
const failed = await client.exec("bad")
|
||||
assert.deepEqual(await failed.wait(), { type: "exit", code: 1 })
|
||||
const ok = await client.exec("good")
|
||||
assert.deepEqual(await ok.wait(), { type: "exit", code: 0 })
|
||||
await settle(() => errors.length === 1)
|
||||
assert.match(String(errors[0]), /handler exploded/)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("a handler that throws with no onError still cannot reject serveSession", async () => {
|
||||
const [a, b] = loopbackTransportPair()
|
||||
const hostKey = await generateHostKey()
|
||||
const [client, server] = await Promise.all([
|
||||
SSHClientConnection.connect(a, { user: "u", password: "p" }),
|
||||
SSHServerConnection.accept(b, { hostKey, authenticate: async () => ({}) }),
|
||||
])
|
||||
let settled: "resolved" | "rejected" | null = null
|
||||
try {
|
||||
const channel = server.acceptSession().then((ch) =>
|
||||
serveSession(ch, {
|
||||
exec: () => {
|
||||
throw new Error("no reporter configured")
|
||||
},
|
||||
}).then(
|
||||
() => (settled = "resolved"),
|
||||
() => (settled = "rejected"),
|
||||
),
|
||||
)
|
||||
const session = await client.exec("bad")
|
||||
assert.deepEqual(await session.wait(), { type: "exit", code: 1 })
|
||||
await channel
|
||||
assert.equal(settled, "resolved")
|
||||
} finally {
|
||||
await client.close().catch(() => {})
|
||||
await server.close().catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
test("aborting a session rejects wait() and pending reads", async () => {
|
||||
const controller = new AbortController()
|
||||
const { client, close } = await connected({
|
||||
exec: async (_command, s) => {
|
||||
for (;;) if ((await s.read()) === null) break
|
||||
await s.exit(0)
|
||||
},
|
||||
})
|
||||
try {
|
||||
const session = await client.exec("sleep", { signal: controller.signal })
|
||||
const reading = session.read()
|
||||
const waiting = session.wait()
|
||||
controller.abort(new Error("cancelled by caller"))
|
||||
await assert.rejects(reading, /cancelled by caller/)
|
||||
await assert.rejects(waiting, /cancelled by caller/)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("exec on an already-aborted signal never opens a session", async () => {
|
||||
const { client, close } = await connected({ exec: echoHandler })
|
||||
try {
|
||||
await assert.rejects(client.exec("nope", { signal: AbortSignal.abort() }))
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("an abrupt transport disconnect rejects wait()", async () => {
|
||||
const { client, close } = await connected({
|
||||
exec: async (_command, s) => {
|
||||
for (;;) if ((await s.read()) === null) break
|
||||
},
|
||||
})
|
||||
try {
|
||||
const session = await client.exec("hang")
|
||||
const waiting = session.wait()
|
||||
await delay(20)
|
||||
await client.close()
|
||||
await assert.rejects(waiting)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("environment requests past the limit are refused", async () => {
|
||||
const { client, close } = await connected(
|
||||
{
|
||||
env: () => true,
|
||||
exec: async (_command, s) => {
|
||||
await s.send(enc.encode(String(s.env.size)))
|
||||
await s.exit(0)
|
||||
},
|
||||
},
|
||||
{ maxEnv: 2 },
|
||||
)
|
||||
try {
|
||||
const result = await client.run("count", { env: { A: "1", B: "2", C: "3" } })
|
||||
assert.equal(dec.decode(result.stdout), "2")
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
// A payload too short for its fields makes the reader throw. Nothing awaits
|
||||
// the request loops, so an escaping throw would be an unhandled rejection
|
||||
// and, by default, a remotely-triggered process exit.
|
||||
for (const name of ["pty-req", "env", "window-change", "signal", "exec", "subsystem"]) {
|
||||
test(`a truncated ${name} request fails only that request`, async () => {
|
||||
const { client, close } = await connected({
|
||||
pty: () => true,
|
||||
env: () => true,
|
||||
exec: echoHandler,
|
||||
})
|
||||
try {
|
||||
const channel = await client.openSession()
|
||||
await assert.rejects(channel.request(name, new Uint8Array(2), true))
|
||||
const session = await client.exec("still-here")
|
||||
await session.closeSend()
|
||||
await drain(() => session.read())
|
||||
assert.deepEqual(await session.wait(), { type: "exit", code: 0 })
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test("a truncated exit-status from the server rejects wait() instead of crashing", async () => {
|
||||
const { client, close } = await connected({
|
||||
exec: async (_command, s) => {
|
||||
await s.channel.request("exit-status", new Uint8Array(2), false)
|
||||
await s.channel.close()
|
||||
},
|
||||
})
|
||||
try {
|
||||
const session = await client.exec("malformed")
|
||||
await assert.rejects(session.wait(), /overrun/)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("aborting during negotiation rejects the pending start request", async () => {
|
||||
const controller = new AbortController()
|
||||
// No pty handler replies until the request is answered, so hold the reply
|
||||
// back entirely and abort while exec() is still awaiting it.
|
||||
const { client, close } = await connected({
|
||||
pty: () => new Promise<boolean>(() => {}),
|
||||
exec: echoHandler,
|
||||
})
|
||||
try {
|
||||
const pending = client.exec("slow", { pty: true, signal: controller.signal })
|
||||
await delay(30)
|
||||
controller.abort(new Error("gave up waiting"))
|
||||
await assert.rejects(pending, /gave up waiting/)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("re-sending an environment variable does not double-count its budget", async () => {
|
||||
const { client, close } = await connected(
|
||||
{
|
||||
env: () => true,
|
||||
exec: async (_command, s) => {
|
||||
await s.send(enc.encode(`${s.env.size}:${s.env.get("A") ?? ""}`))
|
||||
await s.exit(0)
|
||||
},
|
||||
},
|
||||
{ maxEnvBytes: 12 },
|
||||
)
|
||||
try {
|
||||
const channel = await client.openSession()
|
||||
for (const value of ["1111", "2222", "3333"])
|
||||
await channel.request("env", encodeStrings("A", value), false)
|
||||
await channel.request("exec", encodeStrings("check"), true)
|
||||
await channel.closeSend()
|
||||
assert.equal(await drain(() => channel.read()), "1:3333")
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("a flood of channel requests aborts only that session", async () => {
|
||||
const { client, close } = await connected({ exec: echoHandler }, { maxRequests: 8 })
|
||||
try {
|
||||
const channel = await client.openSession()
|
||||
for (let i = 0; i < 8; i++) await channel.request("signal", new Uint8Array(4), false)
|
||||
await assert.rejects(async () => {
|
||||
for (let i = 0; i < 8; i++) await channel.request("signal", new Uint8Array(4), false)
|
||||
assert.equal(await channel.read(), null)
|
||||
throw new Error("channel was not aborted")
|
||||
})
|
||||
const session = await client.exec("still-here")
|
||||
await session.closeSend()
|
||||
await drain(() => session.read())
|
||||
assert.deepEqual(await session.wait(), { type: "exit", code: 0 })
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,535 @@
|
||||
import { Reader, Writer } from "./cursor.js"
|
||||
import type { Channel } from "./channel.js"
|
||||
|
||||
const MAX_PTY_CHARS = 10000
|
||||
const MAX_PTY_PIXELS = 1 << 20
|
||||
const MAX_PEER_REQUESTS = 4096
|
||||
|
||||
export const PTY_MODE = {
|
||||
TTY_OP_END: 0,
|
||||
VINTR: 1,
|
||||
VQUIT: 2,
|
||||
VERASE: 3,
|
||||
VKILL: 4,
|
||||
VEOF: 5,
|
||||
ISIG: 50,
|
||||
ICANON: 51,
|
||||
ECHO: 53,
|
||||
ECHOE: 54,
|
||||
ECHOK: 55,
|
||||
OPOST: 70,
|
||||
ONLCR: 72,
|
||||
TTY_OP_ISPEED: 128,
|
||||
TTY_OP_OSPEED: 129,
|
||||
} as const
|
||||
|
||||
export type WindowSize = {
|
||||
cols: number
|
||||
rows: number
|
||||
widthPx: number
|
||||
heightPx: number
|
||||
}
|
||||
|
||||
export type PtyOptions = Partial<WindowSize> & {
|
||||
term?: string
|
||||
modes?: Readonly<Record<number, number>>
|
||||
}
|
||||
|
||||
export type PtyRequest = WindowSize & {
|
||||
term: string
|
||||
modes: ReadonlyMap<number, number>
|
||||
}
|
||||
|
||||
export type SessionExit =
|
||||
| { type: "exit"; code: number }
|
||||
| { type: "signal"; signal: string; coreDumped: boolean; message: string }
|
||||
| { type: "none" }
|
||||
|
||||
export type SessionOptions = {
|
||||
env?: Readonly<Record<string, string>>
|
||||
requireEnv?: boolean
|
||||
pty?: PtyOptions | boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
type Resolver<T> = { resolve: (v: T) => void; reject: (e: unknown) => void }
|
||||
|
||||
function clamp(value: number, max: number): number {
|
||||
if (!Number.isFinite(value) || value < 0) return 0
|
||||
return Math.min(Math.floor(value), max)
|
||||
}
|
||||
|
||||
function encodeModes(modes: Readonly<Record<number, number>> | undefined): Uint8Array<ArrayBuffer> {
|
||||
const w = new Writer(32)
|
||||
for (const [op, value] of Object.entries(modes ?? {})) {
|
||||
const opcode = Number(op)
|
||||
if (opcode === PTY_MODE.TTY_OP_END) throw new Error("terminal mode opcode 0 is reserved")
|
||||
w.u8(opcode).u32(value)
|
||||
}
|
||||
return w.u8(PTY_MODE.TTY_OP_END).finish()
|
||||
}
|
||||
|
||||
function encodePty(opts: PtyOptions): Uint8Array<ArrayBuffer> {
|
||||
return new Writer()
|
||||
.string(opts.term ?? "xterm-256color")
|
||||
.u32(clamp(opts.cols ?? 80, MAX_PTY_CHARS))
|
||||
.u32(clamp(opts.rows ?? 24, MAX_PTY_CHARS))
|
||||
.u32(clamp(opts.widthPx ?? 0, MAX_PTY_PIXELS))
|
||||
.u32(clamp(opts.heightPx ?? 0, MAX_PTY_PIXELS))
|
||||
.string(encodeModes(opts.modes))
|
||||
.finish()
|
||||
}
|
||||
|
||||
function encodeWindowSize(size: Partial<WindowSize>): Uint8Array<ArrayBuffer> {
|
||||
return new Writer()
|
||||
.u32(clamp(size.cols ?? 80, MAX_PTY_CHARS))
|
||||
.u32(clamp(size.rows ?? 24, MAX_PTY_CHARS))
|
||||
.u32(clamp(size.widthPx ?? 0, MAX_PTY_PIXELS))
|
||||
.u32(clamp(size.heightPx ?? 0, MAX_PTY_PIXELS))
|
||||
.finish()
|
||||
}
|
||||
|
||||
function decodeWindowSize(r: Reader): WindowSize {
|
||||
return {
|
||||
cols: clamp(r.u32(), MAX_PTY_CHARS),
|
||||
rows: clamp(r.u32(), MAX_PTY_CHARS),
|
||||
widthPx: clamp(r.u32(), MAX_PTY_PIXELS),
|
||||
heightPx: clamp(r.u32(), MAX_PTY_PIXELS),
|
||||
}
|
||||
}
|
||||
|
||||
function decodePty(payload: Uint8Array<ArrayBuffer>): PtyRequest {
|
||||
const r = new Reader(payload)
|
||||
const term = r.utf8()
|
||||
const size = decodeWindowSize(r)
|
||||
const modes = new Map<number, number>()
|
||||
const raw = new Reader(r.string())
|
||||
for (;;) {
|
||||
let opcode: number
|
||||
try {
|
||||
opcode = raw.u8()
|
||||
if (opcode === PTY_MODE.TTY_OP_END) break
|
||||
modes.set(opcode, raw.u32())
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
return { term, ...size, modes }
|
||||
}
|
||||
|
||||
export class SSHSession {
|
||||
readonly #channel: Channel
|
||||
readonly #signal?: AbortSignal
|
||||
readonly #onAbort?: () => void
|
||||
#exit: SessionExit | null = null
|
||||
#settled = false
|
||||
#failure: unknown = null
|
||||
#waiters: Resolver<SessionExit>[] = []
|
||||
|
||||
constructor(channel: Channel, signal?: AbortSignal) {
|
||||
this.#channel = channel
|
||||
this.#signal = signal
|
||||
if (signal) {
|
||||
this.#onAbort = () => void this.abort(signal.reason)
|
||||
signal.addEventListener("abort", this.#onAbort, { once: true })
|
||||
}
|
||||
void this.#requestLoop()
|
||||
}
|
||||
|
||||
get channel(): Channel {
|
||||
return this.#channel
|
||||
}
|
||||
|
||||
async #requestLoop(): Promise<void> {
|
||||
let requests = 0
|
||||
for (;;) {
|
||||
let req
|
||||
try {
|
||||
req = await this.#channel.nextRequest()
|
||||
} catch {
|
||||
this.#finish()
|
||||
return
|
||||
}
|
||||
if (++requests > MAX_PEER_REQUESTS) {
|
||||
await this.#channel.abort(new Error("too many channel requests"))
|
||||
this.#finish()
|
||||
return
|
||||
}
|
||||
// Nothing awaits this loop, so a malformed payload from the peer must
|
||||
// never escape it as an unhandled rejection.
|
||||
try {
|
||||
if (req.name === "exit-status") {
|
||||
this.#resolve({ type: "exit", code: new Reader(req.payload).u32() })
|
||||
} else if (req.name === "exit-signal") {
|
||||
const r = new Reader(req.payload)
|
||||
this.#resolve({
|
||||
type: "signal",
|
||||
signal: r.utf8(),
|
||||
coreDumped: r.bool(),
|
||||
message: r.utf8(),
|
||||
})
|
||||
} else if (req.wantReply) {
|
||||
await this.#channel.reply(false).catch(() => {})
|
||||
}
|
||||
} catch (err) {
|
||||
await this.#channel.abort(err)
|
||||
this.#finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#resolve(exit: SessionExit): void {
|
||||
if (this.#settled) return
|
||||
this.#exit = exit
|
||||
this.#settled = true
|
||||
for (const w of this.#waiters) w.resolve(exit)
|
||||
this.#waiters = []
|
||||
this.#detach()
|
||||
}
|
||||
|
||||
// A channel that ends without an exit status is not an error: the peer may
|
||||
// have closed cleanly (`none`). Only a transport-level or aborted channel,
|
||||
// which records a failure, rejects.
|
||||
#finish(): void {
|
||||
if (this.#settled) return
|
||||
const failure = this.#channel.failure
|
||||
if (failure == null) {
|
||||
this.#resolve({ type: "none" })
|
||||
return
|
||||
}
|
||||
this.#settled = true
|
||||
this.#failure = failure
|
||||
for (const w of this.#waiters) w.reject(failure)
|
||||
this.#waiters = []
|
||||
this.#detach()
|
||||
}
|
||||
|
||||
#detach(): void {
|
||||
if (this.#signal && this.#onAbort) this.#signal.removeEventListener("abort", this.#onAbort)
|
||||
}
|
||||
|
||||
read(): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
return this.#channel.read()
|
||||
}
|
||||
|
||||
readStderr(): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
return this.#channel.readStderr()
|
||||
}
|
||||
|
||||
send(data: Uint8Array<ArrayBuffer>): Promise<void> {
|
||||
return this.#channel.send(data)
|
||||
}
|
||||
|
||||
closeSend(): Promise<void> {
|
||||
return this.#channel.closeSend()
|
||||
}
|
||||
|
||||
resize(size: Partial<WindowSize>): Promise<void> {
|
||||
return this.#channel.request("window-change", encodeWindowSize(size), false)
|
||||
}
|
||||
|
||||
signal(name: string): Promise<void> {
|
||||
return this.#channel.request("signal", new Writer().string(name).finish(), false)
|
||||
}
|
||||
|
||||
wait(): Promise<SessionExit> {
|
||||
if (this.#exit) return Promise.resolve(this.#exit)
|
||||
if (this.#failure != null) return Promise.reject(this.#failure)
|
||||
return new Promise((resolve, reject) => this.#waiters.push({ resolve, reject }))
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.#channel.close()
|
||||
}
|
||||
|
||||
abort(reason?: unknown): Promise<void> {
|
||||
return this.#channel.abort(reason ?? new Error("session aborted"))
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.#channel.closed
|
||||
}
|
||||
|
||||
get readEnded(): boolean {
|
||||
return this.#channel.readEnded
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionStartRequest = { name: string; payload: Uint8Array<ArrayBuffer> }
|
||||
|
||||
export async function startSession(
|
||||
channel: Channel,
|
||||
start: SessionStartRequest,
|
||||
opts: SessionOptions = {},
|
||||
): Promise<SSHSession> {
|
||||
const signal = opts.signal
|
||||
// Negotiation awaits replies from the peer, so the signal has to be live
|
||||
// before the first request rather than only once the session exists.
|
||||
const onAbort = signal ? () => void channel.abort(signal.reason) : undefined
|
||||
if (signal && onAbort) signal.addEventListener("abort", onAbort, { once: true })
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
for (const [name, value] of Object.entries(opts.env ?? {}))
|
||||
await channel.request(
|
||||
"env",
|
||||
new Writer().string(name).string(value).finish(),
|
||||
opts.requireEnv ?? false,
|
||||
)
|
||||
if (opts.pty)
|
||||
await channel.request("pty-req", encodePty(opts.pty === true ? {} : opts.pty), true)
|
||||
await channel.request(start.name, start.payload, true)
|
||||
} catch (err) {
|
||||
await channel.abort(err).catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
if (signal && onAbort) signal.removeEventListener("abort", onAbort)
|
||||
}
|
||||
return new SSHSession(channel, signal)
|
||||
}
|
||||
|
||||
export function execRequest(command: string): SessionStartRequest {
|
||||
return { name: "exec", payload: new Writer().string(command).finish() }
|
||||
}
|
||||
|
||||
export function shellRequest(): SessionStartRequest {
|
||||
return { name: "shell", payload: new Uint8Array(0) }
|
||||
}
|
||||
|
||||
export type SessionStart =
|
||||
| { type: "exec"; command: string }
|
||||
| { type: "shell" }
|
||||
| { type: "subsystem"; name: string }
|
||||
|
||||
export type ServerSession = {
|
||||
readonly channel: Channel
|
||||
readonly env: ReadonlyMap<string, string>
|
||||
readonly pty: PtyRequest | null
|
||||
read(): Promise<Uint8Array<ArrayBuffer> | null>
|
||||
send(data: Uint8Array<ArrayBuffer>): Promise<void>
|
||||
sendStderr(data: Uint8Array<ArrayBuffer>): Promise<void>
|
||||
exit(code: number): Promise<void>
|
||||
exitSignal(signal: string, opts?: { coreDumped?: boolean; message?: string }): Promise<void>
|
||||
}
|
||||
|
||||
export type SessionHandlers = {
|
||||
env?: (name: string, value: string) => boolean | Promise<boolean>
|
||||
pty?: (pty: PtyRequest) => boolean | Promise<boolean>
|
||||
authorize?: (start: SessionStart) => boolean | Promise<boolean>
|
||||
windowChange?: (size: WindowSize, session: ServerSession) => void
|
||||
signal?: (name: string, session: ServerSession) => void
|
||||
exec?: (command: string, session: ServerSession) => void | Promise<void>
|
||||
shell?: (session: ServerSession) => void | Promise<void>
|
||||
subsystem?: (name: string, session: ServerSession) => void | Promise<void>
|
||||
onError?: (error: unknown, session: ServerSession) => void
|
||||
}
|
||||
|
||||
export type SessionLimits = {
|
||||
maxEnv?: number
|
||||
maxEnvBytes?: number
|
||||
maxRequests?: number
|
||||
}
|
||||
|
||||
function startHandler(
|
||||
handlers: SessionHandlers,
|
||||
start: SessionStart,
|
||||
session: ServerSession,
|
||||
): (() => Promise<void>) | null {
|
||||
if (start.type === "exec")
|
||||
return handlers.exec ? async () => handlers.exec!(start.command, session) : null
|
||||
if (start.type === "subsystem")
|
||||
return handlers.subsystem ? async () => handlers.subsystem!(start.name, session) : null
|
||||
return handlers.shell ? async () => handlers.shell!(session) : null
|
||||
}
|
||||
|
||||
const DEFAULT_LIMITS: Required<SessionLimits> = {
|
||||
maxEnv: 32,
|
||||
maxEnvBytes: 8192,
|
||||
maxRequests: 4096,
|
||||
}
|
||||
|
||||
class ServerSessionState implements ServerSession {
|
||||
readonly channel: Channel
|
||||
readonly env = new Map<string, string>()
|
||||
pty: PtyRequest | null = null
|
||||
envBytes = 0
|
||||
exited = false
|
||||
|
||||
constructor(channel: Channel) {
|
||||
this.channel = channel
|
||||
}
|
||||
|
||||
read(): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
return this.channel.read()
|
||||
}
|
||||
|
||||
send(data: Uint8Array<ArrayBuffer>): Promise<void> {
|
||||
return this.channel.send(data)
|
||||
}
|
||||
|
||||
sendStderr(data: Uint8Array<ArrayBuffer>): Promise<void> {
|
||||
return this.channel.sendStderr(data)
|
||||
}
|
||||
|
||||
async exit(code: number): Promise<void> {
|
||||
if (this.exited || this.channel.closed) return
|
||||
this.exited = true
|
||||
await this.channel.request("exit-status", new Writer().u32(code >>> 0).finish(), false)
|
||||
}
|
||||
|
||||
async exitSignal(
|
||||
signal: string,
|
||||
opts: { coreDumped?: boolean; message?: string } = {},
|
||||
): Promise<void> {
|
||||
if (this.exited || this.channel.closed) return
|
||||
this.exited = true
|
||||
await this.channel.request(
|
||||
"exit-signal",
|
||||
new Writer()
|
||||
.string(signal)
|
||||
.bool(opts.coreDumped ?? false)
|
||||
.string(opts.message ?? "")
|
||||
.string("")
|
||||
.finish(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function serveSession(
|
||||
channel: Channel,
|
||||
handlers: SessionHandlers,
|
||||
limits: SessionLimits = {},
|
||||
): Promise<void> {
|
||||
const { maxEnv, maxEnvBytes, maxRequests } = { ...DEFAULT_LIMITS, ...limits }
|
||||
const state = new ServerSessionState(channel)
|
||||
let running: Promise<void> | null = null
|
||||
let requests = 0
|
||||
let markStarted = (): void => {}
|
||||
const started = new Promise<void>((resolve) => (markStarted = resolve))
|
||||
|
||||
const approve = async (decision: boolean | Promise<boolean>): Promise<boolean> => {
|
||||
try {
|
||||
return await decision
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// The loop outlives the start request so window-change and signal keep being
|
||||
// serviced while the command runs.
|
||||
const loop = (async (): Promise<void> => {
|
||||
for (;;) {
|
||||
let req
|
||||
try {
|
||||
req = await channel.nextRequest()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (++requests > maxRequests) {
|
||||
await channel.abort(new Error("too many channel requests"))
|
||||
return
|
||||
}
|
||||
// Callers run this loop fire-and-forget alongside the command, so a
|
||||
// malformed payload must fail only this request, never escape as an
|
||||
// unhandled rejection that takes the whole process down.
|
||||
try {
|
||||
switch (req.name) {
|
||||
case "env": {
|
||||
const r = new Reader(req.payload)
|
||||
const name = r.utf8()
|
||||
const value = r.utf8()
|
||||
const previous = state.env.get(name)
|
||||
const size = name.length + value.length - (previous ? name.length + previous.length : 0)
|
||||
const ok =
|
||||
!running &&
|
||||
(previous !== undefined || state.env.size < maxEnv) &&
|
||||
state.envBytes + size <= maxEnvBytes &&
|
||||
(await approve(handlers.env?.(name, value) ?? false))
|
||||
if (ok) {
|
||||
state.env.set(name, value)
|
||||
state.envBytes += size
|
||||
}
|
||||
if (req.wantReply) await channel.reply(ok)
|
||||
break
|
||||
}
|
||||
case "pty-req": {
|
||||
const pty = decodePty(req.payload)
|
||||
const ok = !running && (await approve(handlers.pty?.(pty) ?? false))
|
||||
if (ok) state.pty = pty
|
||||
if (req.wantReply) await channel.reply(ok)
|
||||
break
|
||||
}
|
||||
case "window-change": {
|
||||
const size = decodeWindowSize(new Reader(req.payload))
|
||||
if (state.pty) {
|
||||
state.pty = { ...state.pty, ...size }
|
||||
handlers.windowChange?.(size, state)
|
||||
}
|
||||
// RFC 4254 §6.7 never wants a reply here, but answer a non-conforming
|
||||
// peer rather than leaving it waiting.
|
||||
if (req.wantReply) await channel.reply(state.pty !== null)
|
||||
break
|
||||
}
|
||||
case "signal": {
|
||||
if (running) handlers.signal?.(new Reader(req.payload).utf8(), state)
|
||||
if (req.wantReply) await channel.reply(running !== null)
|
||||
break
|
||||
}
|
||||
case "exec":
|
||||
case "shell":
|
||||
case "subsystem": {
|
||||
const start: SessionStart =
|
||||
req.name === "exec"
|
||||
? { type: "exec", command: new Reader(req.payload).utf8() }
|
||||
: req.name === "subsystem"
|
||||
? { type: "subsystem", name: new Reader(req.payload).utf8() }
|
||||
: { type: "shell" }
|
||||
const run = startHandler(handlers, start, state)
|
||||
const ok =
|
||||
!running && run !== null && (await approve(handlers.authorize?.(start) ?? true))
|
||||
if (req.wantReply) await channel.reply(ok)
|
||||
if (!ok) break
|
||||
running = run!()
|
||||
markStarted()
|
||||
break
|
||||
}
|
||||
default:
|
||||
if (req.wantReply) await channel.reply(false)
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
if (req.wantReply) await channel.reply(false).catch(() => {})
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
await Promise.race([loop, started])
|
||||
|
||||
if (!running) {
|
||||
await loop
|
||||
await channel.close().catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
let failure: unknown = null
|
||||
try {
|
||||
await running
|
||||
} catch (err) {
|
||||
failure = err
|
||||
}
|
||||
await state.exit(failure == null ? 0 : 1).catch(() => {})
|
||||
await channel.closeSend().catch(() => {})
|
||||
await channel.close().catch(() => {})
|
||||
await loop
|
||||
// Callers run this fire-and-forget per accepted channel, so a failing
|
||||
// handler is reported through onError and never rejects: an unhandled
|
||||
// rejection here would take down every other session on the connection.
|
||||
if (failure != null) {
|
||||
try {
|
||||
handlers.onError?.(failure, state)
|
||||
} catch {
|
||||
/* a reporting hook must not escalate either */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,102 @@ async function readN(t: RawTransport, n: number): Promise<Uint8Array> {
|
||||
return out
|
||||
}
|
||||
|
||||
const dec = new TextDecoder()
|
||||
const enc = new TextEncoder()
|
||||
|
||||
async function drain(read: () => Promise<Uint8Array | null>): Promise<string> {
|
||||
let out = ""
|
||||
for (;;) {
|
||||
const chunk = await read()
|
||||
if (chunk === null) return out
|
||||
out += dec.decode(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
suite(
|
||||
"ssh sessions against a real OpenSSH server",
|
||||
{ skip: !configured && "set SSH_TEST_* env vars" },
|
||||
() => {
|
||||
test("exec captures stdout, stderr and the exit status separately", async () => {
|
||||
const connection = await connect()
|
||||
try {
|
||||
const result = await connection.run("echo out; echo err >&2; exit 7")
|
||||
assert.equal(dec.decode(result.stdout), "out\n")
|
||||
assert.equal(dec.decode(result.stderr), "err\n")
|
||||
assert.deepEqual(result.exit, { type: "exit", code: 7 })
|
||||
} finally {
|
||||
await connection.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("exec streams stdin through to the command", async () => {
|
||||
const connection = await connect()
|
||||
try {
|
||||
const session = await connection.exec("cat")
|
||||
await session.send(enc.encode("piped through sshd\n"))
|
||||
await session.closeSend()
|
||||
assert.equal(await drain(() => session.read()), "piped through sshd\n")
|
||||
assert.deepEqual(await session.wait(), { type: "exit", code: 0 })
|
||||
} finally {
|
||||
await connection.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("a killed command reports an exit signal", async () => {
|
||||
const connection = await connect()
|
||||
try {
|
||||
const result = await connection.run("exec sh -c 'kill -TERM $$'")
|
||||
assert.equal(result.exit.type, "signal")
|
||||
if (result.exit.type === "signal") assert.equal(result.exit.signal, "TERM")
|
||||
} finally {
|
||||
await connection.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("a pty session reports the terminal size and honours a resize", async () => {
|
||||
const connection = await connect()
|
||||
try {
|
||||
// The command blocks on `read` between the two reports, so the resize
|
||||
// cannot race ahead of the first one.
|
||||
const session = await connection.exec("sh -c 'stty size; read x; stty size'", {
|
||||
pty: { term: "xterm-256color", cols: 100, rows: 30 },
|
||||
})
|
||||
const readLine = async (): Promise<string> => {
|
||||
for (;;) {
|
||||
const chunk = await session.read()
|
||||
assert.ok(chunk, "session ended before reporting a size")
|
||||
const line = dec.decode(chunk).trim()
|
||||
if (line) return line
|
||||
}
|
||||
}
|
||||
assert.equal(await readLine(), "30 100")
|
||||
await session.resize({ cols: 132, rows: 43 })
|
||||
await session.send(enc.encode("\n"))
|
||||
assert.equal(await readLine(), "43 132")
|
||||
await drain(() => session.read())
|
||||
assert.deepEqual(await session.wait(), { type: "exit", code: 0 })
|
||||
} finally {
|
||||
await connection.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("several exec sessions run concurrently on one connection", async () => {
|
||||
const connection = await connect()
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
[1, 2, 3].map((n) => connection.run(`echo session-${n}; exit ${n}`)),
|
||||
)
|
||||
for (const [i, r] of results.entries()) {
|
||||
assert.equal(dec.decode(r.stdout), `session-${i + 1}\n`)
|
||||
assert.deepEqual(r.exit, { type: "exit", code: i + 1 })
|
||||
}
|
||||
} finally {
|
||||
await connection.close()
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
suite(
|
||||
"ssh forwarding against a real OpenSSH server",
|
||||
{ skip: !configured && "set SSH_TEST_* env vars (needs AllowTcpForwarding yes)" },
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# @webnet/state-transfer
|
||||
|
||||
Generic ownership-transfer contract for handing live objects across worker boundaries.
|
||||
|
||||
`StateTransferable<T>` is a single-method interface: `transferState(): Promise<T>` extracts the transferable state of an object and leaves the local object unusable afterwards. `isStateTransferable` is a runtime type guard for detecting the interface on an unknown value. The package has no dependencies and no implementations of its own; it exists so packages that need to move live state (sockets, sessions, worker-owned objects) between a worker and its host can agree on one contract without depending on each other.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { isStateTransferable, type StateTransferable } from "@webnet/state-transfer"
|
||||
|
||||
class Session implements StateTransferable<{ buffer: ArrayBuffer }> {
|
||||
async transferState() {
|
||||
return { buffer: new ArrayBuffer(0) }
|
||||
}
|
||||
}
|
||||
|
||||
async function handOff(x: unknown) {
|
||||
if (isStateTransferable(x)) {
|
||||
// x becomes unusable after this call
|
||||
return x.transferState()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/tsconnect-worker`](../tsconnect-worker) — uses `StateTransferable` to move IPN state across a worker boundary.
|
||||
- [`@webnet/smb2`](../smb2) — uses `StateTransferable` for connection handoff.
|
||||
@@ -0,0 +1,48 @@
|
||||
# @webnet/taildrive
|
||||
|
||||
Taildrive peer discovery, WebDAV client helpers, and a server-side bridge, built on `@webnet/webdav`.
|
||||
|
||||
Taildrive exposes a WebDAV endpoint (`/v0/drive`) over each Tailscale node's peerapi. On the client side, `listDrivePeersWithShares` lists an `IpnClient`'s Taildrive peers (via `IPN.listDrivePeers`) and probes each one over WebDAV, filtering out peers that are unreachable or export no shares; `createTaildriveClient` returns a `@webnet/webdav` `DAVClient` for a given peer, dialing through the IPN's `dial`/`dialTLS` methods rather than a real network socket. On the server side, `bridgeDriveHandler` adapts a `@webnet/http` `Handler` into the `RawDriveHandler` shape `IPN.serveDrive` expects, translating between the wasm bridge's request/response objects and `@webnet/http`'s `Context`.
|
||||
|
||||
## Entry points
|
||||
|
||||
| Entry point | Description |
|
||||
| -------------------------- | ---------------------------------------------------- |
|
||||
| `@webnet/taildrive` | Re-exports both `./client` and `./server`. |
|
||||
| `@webnet/taildrive/client` | `createTaildriveClient`, `listDrivePeersWithShares`. |
|
||||
| `@webnet/taildrive/server` | `bridgeDriveHandler`. |
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { listDrivePeersWithShares, createTaildriveClient } from "@webnet/taildrive/client"
|
||||
import type { IpnClient } from "@webnet/tsconnect"
|
||||
|
||||
async function browsePeerShares(ipn: IpnClient) {
|
||||
const peers = await listDrivePeersWithShares(ipn)
|
||||
for (const peer of peers) {
|
||||
const client = createTaildriveClient(ipn, peer)
|
||||
const { entries } = await client.statAndReaddir("/")
|
||||
console.log(
|
||||
peer.name,
|
||||
entries.map((e) => e.path),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
import { bridgeDriveHandler } from "@webnet/taildrive/server"
|
||||
import type { IpnClient } from "@webnet/tsconnect"
|
||||
import type { Handler } from "@webnet/http/server"
|
||||
|
||||
function serveTaildrive(ipn: IpnClient, davHandler: Handler) {
|
||||
ipn.serveDrive(bridgeDriveHandler(davHandler))
|
||||
}
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/tsconnect`](../tsconnect) — `IpnClient`, `IPN.dial`/`dialTLS`/`serveDrive`/`listDrivePeers` used by this package.
|
||||
- [`@webnet/webdav`](../webdav) — `DAVClient` and the WebDAV server `Handler` this package bridges to Taildrive.
|
||||
- [`@webnet/http`](../http) — the `Context`/`Handler` shape `bridgeDriveHandler` adapts.
|
||||
@@ -26,7 +26,7 @@
|
||||
"typecheck": "tsc --project tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@webnet/drive": "*",
|
||||
"@webnet/webdav": "*",
|
||||
"@webnet/http": "*",
|
||||
"@webnet/transport": "*",
|
||||
"@webnet/tsconnect": "*"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { suite, test } from "node:test"
|
||||
import { createDAVHandler } from "@webnet/drive"
|
||||
import { createDAVHandler } from "@webnet/webdav"
|
||||
import { Server, type Handler } from "@webnet/http/server"
|
||||
import type { IpnClient, IPNDrivePeer } from "@webnet/tsconnect"
|
||||
import { loopbackListener } from "@webnet/transport/loopback"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DAVClient } from "@webnet/drive/client"
|
||||
import { DAVClient } from "@webnet/webdav/client"
|
||||
import { PooledDialer } from "@webnet/http/client"
|
||||
import type { IpnClient, IPNDrivePeer } from "@webnet/tsconnect"
|
||||
import type { RawDialer, RawTransport } from "@webnet/transport"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# @webnet/tailshare
|
||||
|
||||
Tailshare is the flagship application of this repository: a browser app that joins a tailnet directly from the page and moves files across it.
|
||||
|
||||
Every other package here exists to be composed into something. Tailshare is that something. It is the reference consumer of the stack and the place where the repository's features are meant to become usable: connecting to Tailscale over WebAssembly, browsing remote filesystems over the protocol packages, and transferring files between them without a server in the middle. When a capability lands in a library package, Tailshare is where it is expected to surface.
|
||||
|
||||
It is a private workspace, not a published package.
|
||||
|
||||
## Current state
|
||||
|
||||
Tailshare is under active development and does not yet expose the whole stack. Today it provides:
|
||||
|
||||
- a Tailscale connection surface backed by `@webnet/tsconnect-worker`, with login, node and peer state, and exit node selection;
|
||||
- Taildrop send and receive against tailnet peers;
|
||||
- a Mantine application shell with routing, color scheme switching, and a tailnet drawer;
|
||||
- a placeholder files route.
|
||||
|
||||
## Intended scope
|
||||
|
||||
The application is intended to expose the features implemented across the repository, and those tracked in the open issues. The planned surface includes:
|
||||
|
||||
- a file browser over `@webnet/vfs` covering local, WebDAV, and Taildrive sources ([#86](https://gitea.codinget.me/webnet/webnet/issues/86));
|
||||
- copy and move between sources ([#87](https://gitea.codinget.me/webnet/webnet/issues/87));
|
||||
- text editing and media preview ([#88](https://gitea.codinget.me/webnet/webnet/issues/88));
|
||||
- serving its own VFS over WebDAV to the tailnet, Funnel, and Taildrive ([#90](https://gitea.codinget.me/webnet/webnet/issues/90));
|
||||
- a persistent, non-ephemeral node identity ([#91](https://gitea.codinget.me/webnet/webnet/issues/91));
|
||||
- WebRTC transport for accelerated peer transfers ([#92](https://gitea.codinget.me/webnet/webnet/issues/92));
|
||||
- static generation, a service worker, and installable PWA support ([#89](https://gitea.codinget.me/webnet/webnet/issues/89));
|
||||
- a Node server runtime with SSR and self-hosting modes ([#93](https://gitea.codinget.me/webnet/webnet/issues/93));
|
||||
- a designed application shell and navigation ([#128](https://gitea.codinget.me/webnet/webnet/issues/128)).
|
||||
|
||||
Treat this list as direction, not as a delivery schedule. The issue tracker is the source of truth.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Build the WASM artifact and TypeScript declarations first
|
||||
npm run build --workspace=packages/tsconnect
|
||||
|
||||
npm run dev --workspace=packages/tailshare
|
||||
```
|
||||
|
||||
`dev` runs the webpack dev server locally. `build` produces a production bundle.
|
||||
|
||||
Tailshare renders on the client today, but static generation ([#89](https://gitea.codinget.me/webnet/webnet/issues/89)) and SSR ([#93](https://gitea.codinget.me/webnet/webnet/issues/93)) are planned. New code that touches a browser-only global — `SharedWorker`, `crypto.subtle`, storage — should be guarded rather than read during render.
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/tsconnect`](../tsconnect) — the Tailscale WASM SDK it connects through
|
||||
- [`@webnet/tsconnect-worker`](../tsconnect-worker) — runs the IPN in a `SharedWorker` and syncs state via Redux
|
||||
- [`@webnet/tsconnect-react`](../tsconnect-react) — the React hooks and context it renders from
|
||||
- [`@webnet/vfs`](../vfs) — the async filesystem interface behind the file surfaces
|
||||
- [`@webnet/webdav`](../webdav) and [`@webnet/taildrive`](../taildrive) — the remote sources it is being built to browse
|
||||
- [`@webnet/test-app`](../test-app) — protocol testing surface, not the product
|
||||
@@ -13,7 +13,7 @@
|
||||
"@mantine/notifications": "^9.3.1",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@webnet/drive": "*",
|
||||
"@webnet/webdav": "*",
|
||||
"@webnet/http": "*",
|
||||
"@webnet/react": "*",
|
||||
"@webnet/tsconnect": "*",
|
||||
|
||||
@@ -37,13 +37,17 @@ function TailscaleQuickSettings({ onClose }: { onClose: () => void }) {
|
||||
const waitingFileCount = useIpnSelector(getWaitingFileCount)
|
||||
const dispatch = useTailshareDispatch()
|
||||
const ipn = use(IpnContext)
|
||||
const prepare = use(IpnPrepareContext)!
|
||||
const theme = useMantineTheme()
|
||||
|
||||
if (!ipn)
|
||||
return (
|
||||
<Button<typeof Link> mt="md" href="/tailscale" component={Link}>
|
||||
Tailscale settings
|
||||
</Button>
|
||||
<Stack gap="xs">
|
||||
{prepare.initializationFailure && <Text c="red">Tailscale failed to start.</Text>}
|
||||
<Button<typeof Link> href="/tailscale" component={Link}>
|
||||
Tailscale settings
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -166,8 +170,9 @@ export function TailscaleStateButton() {
|
||||
|
||||
const running = ipnState === "Running"
|
||||
const needsLogin = !running && ipnState === "NeedsLogin" && !!ipnLoginUrl
|
||||
const failed = !!prepare.initializationFailure
|
||||
const building = !needsLogin && !running && prepare.willBuild
|
||||
const notStarting = !building && !prepare.willBuild
|
||||
const notStarting = !failed && !building && !prepare.willBuild
|
||||
|
||||
let label: string
|
||||
let tooltip: ReactNode
|
||||
@@ -181,6 +186,10 @@ export function TailscaleStateButton() {
|
||||
label = "Login to Tailscale"
|
||||
tooltip = "Login to Tailscale"
|
||||
icon = <UserIcon />
|
||||
} else if (failed) {
|
||||
label = "Tailscale failed to start: open status"
|
||||
tooltip = "Tailscale failed to start"
|
||||
icon = <NetworkXIcon />
|
||||
} else if (building) {
|
||||
label = "Tailscale loading..."
|
||||
tooltip = "Tailscale loading..."
|
||||
@@ -205,8 +214,8 @@ export function TailscaleStateButton() {
|
||||
else if (notStarting) prepare.build()
|
||||
else open()
|
||||
}}
|
||||
variant={running || needsLogin ? "filled" : "default"}
|
||||
color={needsLogin ? "red" : undefined}
|
||||
variant={running || needsLogin || failed ? "filled" : "default"}
|
||||
color={needsLogin || failed ? "red" : undefined}
|
||||
size="xl"
|
||||
aria-label={label}
|
||||
>
|
||||
|
||||
@@ -28,7 +28,13 @@ import {
|
||||
} from "react"
|
||||
import { initIPN, InMemoryFileOps, InMemoryState } from "@webnet/tsconnect"
|
||||
import type { IpnClient } from "@webnet/tsconnect"
|
||||
import { useLocalStorage, useSharedWorkerAvailable } from "@webnet/react"
|
||||
import {
|
||||
serializeError,
|
||||
type SerializedError,
|
||||
useLocalStorage,
|
||||
useSecureContext,
|
||||
useSharedWorkerAvailable,
|
||||
} from "@webnet/react"
|
||||
import wasmUrl from "@webnet/tsconnect/main.wasm"
|
||||
import type { WorkerConfig } from "@webnet/tsconnect-worker"
|
||||
|
||||
@@ -50,6 +56,40 @@ export type TailshareConfig = {
|
||||
fileOps?: "memory" | "opfs"
|
||||
}
|
||||
|
||||
export type IpnInitializationFailure = {
|
||||
error: SerializedError
|
||||
mode: "worker-with-fallback" | "main-thread"
|
||||
workerError?: SerializedError
|
||||
}
|
||||
|
||||
function redactError(value: string, authKey?: string): string {
|
||||
return value
|
||||
.replaceAll(authKey ?? "\0", "[redacted]")
|
||||
.replace(/(authkey-[\w-]+|authKey[=:]\s*)\S+/gi, "$1[redacted]")
|
||||
}
|
||||
|
||||
function initializationFailure(
|
||||
error: unknown,
|
||||
mode: IpnInitializationFailure["mode"],
|
||||
authKey?: string,
|
||||
): IpnInitializationFailure {
|
||||
const errors =
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"errors" in error &&
|
||||
Array.isArray((error as { errors?: unknown }).errors)
|
||||
? (error as { errors: unknown[] }).errors
|
||||
: undefined
|
||||
if (errors?.length && mode === "worker-with-fallback") {
|
||||
return {
|
||||
error: serializeError(errors.at(-1), (value) => redactError(value, authKey)),
|
||||
mode,
|
||||
workerError: serializeError(errors[0], (value) => redactError(value, authKey)),
|
||||
}
|
||||
}
|
||||
return { error: serializeError(error, (value) => redactError(value, authKey)), mode }
|
||||
}
|
||||
|
||||
function parseConfig(raw: string): TailshareConfig {
|
||||
const cfg: TailshareConfig = { useWorker: true, fileOps: "memory" }
|
||||
try {
|
||||
@@ -74,6 +114,8 @@ export const IpnPrepareContext = createContext<{
|
||||
setConfig: (patch: Partial<TailshareConfig>) => void
|
||||
willBuild: boolean
|
||||
build: () => void
|
||||
initializationFailure: IpnInitializationFailure | null
|
||||
secureContext: boolean
|
||||
workerAvailable: boolean
|
||||
connectionMode: "worker" | "main-thread" | null
|
||||
} | null>(null)
|
||||
@@ -86,6 +128,7 @@ export function IpnProvider({ children }: { children: ReactNode }) {
|
||||
const localStore = storeRef.current
|
||||
|
||||
const workerAvailable = useSharedWorkerAvailable()
|
||||
const secureContext = useSecureContext()
|
||||
|
||||
const [configRaw, setConfigRaw] = useLocalStorage("tailshare:config")
|
||||
const config = useMemo(() => parseConfig(configRaw), [configRaw])
|
||||
@@ -97,11 +140,16 @@ export function IpnProvider({ children }: { children: ReactNode }) {
|
||||
)
|
||||
|
||||
const [willBuild, setWillBuild] = useState(false)
|
||||
const build = useCallback(() => setWillBuild(true), [])
|
||||
const [failure, setFailure] = useState<IpnInitializationFailure | null>(null)
|
||||
|
||||
// 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 build = useCallback(() => {
|
||||
if (!secureContext) return
|
||||
configAtBuildRef.current = config
|
||||
setFailure(null)
|
||||
setWillBuild(true)
|
||||
}, [config, secureContext])
|
||||
const configAtBuild = configAtBuildRef.current
|
||||
const buildConfig = configAtBuild ?? config
|
||||
|
||||
@@ -132,14 +180,28 @@ export function IpnProvider({ children }: { children: ReactNode }) {
|
||||
[buildConfig.hostname, buildConfig.controlURL, buildConfig.authKey, buildConfig.fileOps],
|
||||
)
|
||||
|
||||
const workerClient = useBuildIpnWorker(useWorker && willBuild ? buildWorker : null, workerConfig)
|
||||
const workerClient = useBuildIpnWorker(
|
||||
useWorker && willBuild ? buildWorker : null,
|
||||
workerConfig,
|
||||
null,
|
||||
(error) => {
|
||||
setFailure(initializationFailure(error, "worker-with-fallback", buildConfig.authKey))
|
||||
setWillBuild(false)
|
||||
},
|
||||
)
|
||||
|
||||
// ── Main-thread path ──────────────────────────────────────────────────────────
|
||||
const [ipnBuilder, setIpnBuilder] = useState<Awaited<ReturnType<typeof initIPN>> | null>(null)
|
||||
useEffect(() => {
|
||||
if (!willBuild || useWorker) return
|
||||
initIPN(wasmUrl).then((b) => setIpnBuilder(() => b))
|
||||
}, [willBuild, useWorker])
|
||||
initIPN(wasmUrl).then(
|
||||
(builder) => setIpnBuilder(() => builder),
|
||||
(error) => {
|
||||
setFailure(initializationFailure(error, "main-thread", buildConfig.authKey))
|
||||
setWillBuild(false)
|
||||
},
|
||||
)
|
||||
}, [willBuild, useWorker, buildConfig.authKey])
|
||||
|
||||
const builderParams = useMemo(() => {
|
||||
const params: Parameters<Exclude<typeof ipnBuilder, null>>[0] = {}
|
||||
@@ -155,6 +217,12 @@ export function IpnProvider({ children }: { children: ReactNode }) {
|
||||
localStore,
|
||||
!useWorker && willBuild ? ipnBuilder : null,
|
||||
builderParams,
|
||||
null,
|
||||
(error) => {
|
||||
setIpnBuilder(null)
|
||||
setFailure(initializationFailure(error, "main-thread", buildConfig.authKey))
|
||||
setWillBuild(false)
|
||||
},
|
||||
)
|
||||
|
||||
// ── Active handle + store ─────────────────────────────────────────────────────
|
||||
@@ -167,8 +235,28 @@ export function IpnProvider({ children }: { children: ReactNode }) {
|
||||
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],
|
||||
() => ({
|
||||
config,
|
||||
configAtBuild,
|
||||
setConfig,
|
||||
willBuild,
|
||||
build,
|
||||
initializationFailure: failure,
|
||||
secureContext,
|
||||
workerAvailable,
|
||||
connectionMode,
|
||||
}),
|
||||
[
|
||||
config,
|
||||
configAtBuild,
|
||||
setConfig,
|
||||
willBuild,
|
||||
build,
|
||||
failure,
|
||||
secureContext,
|
||||
workerAvailable,
|
||||
connectionMode,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ import "@mantine/core/styles.css"
|
||||
import "@mantine/notifications/styles.css"
|
||||
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { AppShell, MantineProvider } from "@mantine/core"
|
||||
import { Alert, AppShell, MantineProvider } from "@mantine/core"
|
||||
import { useDisclosure } from "@mantine/hooks"
|
||||
import { Header } from "./components/shell/Header"
|
||||
import { Navbar } from "./components/shell/Navbar"
|
||||
@@ -16,6 +16,7 @@ import { theme } from "./theme"
|
||||
import { Notifications } from "@mantine/notifications"
|
||||
import { TaildropNotifier } from "./components/shell/TaildropNotifier"
|
||||
import { TailnetDrawer } from "./components/tailnetDrawer/TailnetDrawer"
|
||||
import { useSecureContext } from "@webnet/react"
|
||||
|
||||
function AppStack({ store }: { store: TailshareStore }) {
|
||||
return (
|
||||
@@ -33,6 +34,7 @@ function AppStack({ store }: { store: TailshareStore }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const secureContext = useSecureContext()
|
||||
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure()
|
||||
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true)
|
||||
return (
|
||||
@@ -55,6 +57,11 @@ function App() {
|
||||
</AppShell.Header>
|
||||
<Navbar />
|
||||
<AppShell.Main>
|
||||
{!secureContext && (
|
||||
<Alert color="red" title="HTTPS required" mb="md">
|
||||
Tailshare requires HTTPS (or localhost) and will not function on this connection.
|
||||
</Alert>
|
||||
)}
|
||||
<Switch>
|
||||
{pages.map((page) => (
|
||||
<Route key={page.path} path={page.path}>
|
||||
|
||||
@@ -37,11 +37,20 @@ import { useDisclosure } from "@mantine/hooks"
|
||||
import clsx from "clsx"
|
||||
import type { IpnClient, IPNWaitingFile } from "@webnet/tsconnect"
|
||||
import { fmtSize, download } from "@webnet/utils"
|
||||
import { ErrorDetails } from "@webnet/react"
|
||||
import { setTailnetDrawerOpen } from "../store/slices/states"
|
||||
|
||||
function ConnectTailscale() {
|
||||
const prepare = use(IpnPrepareContext)!
|
||||
|
||||
if (!prepare.secureContext) {
|
||||
return (
|
||||
<Text c="red">
|
||||
Tailscale requires HTTPS (or localhost). Serve Tailshare from a secure context to enable it.
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Button disabled={prepare.willBuild} onClick={prepare.build}>
|
||||
@@ -52,6 +61,37 @@ function ConnectTailscale() {
|
||||
)
|
||||
}
|
||||
|
||||
function TailscaleInitializationFailure() {
|
||||
const prepare = use(IpnPrepareContext)!
|
||||
const failure = prepare.initializationFailure!
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text c="red" fw={500}>
|
||||
Tailscale could not start.
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
Attempted connection mode:{" "}
|
||||
{failure.mode === "worker-with-fallback"
|
||||
? "SharedWorker, then main-thread fallback"
|
||||
: "main thread"}
|
||||
.
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
Initialization failure: <Code>{failure.error.message}</Code>
|
||||
</Text>
|
||||
<ErrorDetails error={failure.error}>
|
||||
{failure.workerError && (
|
||||
<ErrorDetails error={failure.workerError} summary="SharedWorker failure" />
|
||||
)}
|
||||
</ErrorDetails>
|
||||
<Group>
|
||||
<Button onClick={prepare.build}>Retry</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
function TailscaleState() {
|
||||
const state = useIpnSelector(getIpnState)
|
||||
const loginUrl = useIpnSelector(getLoginUrl)
|
||||
@@ -350,7 +390,9 @@ export default function TailscaleRoute() {
|
||||
and an exit node.
|
||||
</Text>
|
||||
<Divider m={"md"} />
|
||||
{!ipn && !prepare.willBuild ? (
|
||||
{prepare.initializationFailure ? (
|
||||
<TailscaleInitializationFailure />
|
||||
) : !ipn && !prepare.willBuild ? (
|
||||
<ConnectTailscale />
|
||||
) : !ipn ? (
|
||||
<Group>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# @webnet/test-app
|
||||
|
||||
A manual and end-to-end test harness for the protocol packages.
|
||||
|
||||
It boots the tsconnect WASM IPN and exposes it, along with `@webnet/http`, `@webnet/websocket`, `@webnet/webdav`, `@webnet/ftp`, `@webnet/sftp`, `@webnet/ssh`, `@webnet/smb2`, `@webnet/utils`, and `@webnet/vfs`'s `MemoryVFS`/`FsaVFS`, as globals on `window` for interactive testing from the devtools console (dialing TCP/TLS, ICMP, listening, and exercising each protocol client/server against real peers). This is a private workspace in this repository, not a published package, and is a development and testing surface for the stack rather than the product application; it also serves as the target app for `@webnet/browser-test-utils`-driven browser integration tests.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
npm run dev --workspace=packages/test-app
|
||||
```
|
||||
|
||||
`dev` starts the Vite dev server bound to `localhost` only. `dev:host` (`WEBNET_DEV_HOST=1 vite`) additionally binds all interfaces and accepts forwarded hostnames, for use behind a trusted reverse proxy or on a Tailnet; see the root README's "Remote-host development" section before using it. `build` produces a production bundle and `preview` serves it locally.
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/tailshare`](../tailshare) — the repository's flagship tailnet file-sharing application
|
||||
- [`@webnet/example-app`](../example-app) — the other development/testing surface, covering the React integration
|
||||
- [`@webnet/browser-test-utils`](../browser-test-utils) — Playwright helpers that drive this app in integration tests
|
||||
@@ -10,7 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@webnet/drive": "*",
|
||||
"@webnet/webdav": "*",
|
||||
"@webnet/ftp": "*",
|
||||
"@webnet/http": "*",
|
||||
"@webnet/sftp": "*",
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as helpers from "@webnet/tsconnect/helpers"
|
||||
import * as http from "@webnet/http"
|
||||
import * as httpRouter from "@webnet/http/router"
|
||||
import * as websocket from "@webnet/websocket"
|
||||
import * as drive from "@webnet/drive"
|
||||
import * as webdav from "@webnet/webdav"
|
||||
import * as smb2 from "@webnet/smb2"
|
||||
import * as utils from "@webnet/utils"
|
||||
import * as ftp from "@webnet/ftp"
|
||||
@@ -24,7 +24,7 @@ declare global {
|
||||
httpRouter: typeof httpRouter
|
||||
helpers: typeof helpers
|
||||
websocket: typeof websocket
|
||||
drive: typeof drive
|
||||
webdav: typeof webdav
|
||||
smb2: typeof smb2
|
||||
utils: typeof utils
|
||||
ftp: typeof ftp
|
||||
@@ -59,7 +59,7 @@ window.loadCACerts = loadCACerts
|
||||
window.http = http
|
||||
window.helpers = helpers
|
||||
window.websocket = websocket
|
||||
window.drive = drive
|
||||
window.webdav = webdav
|
||||
window.smb2 = smb2
|
||||
window.utils = utils
|
||||
window.ftp = ftp
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# @webnet/transport
|
||||
|
||||
Transport abstractions and implementations for webnet.
|
||||
|
||||
The package defines `RawTransport`, `RawDialer`, and `RawListener`, the byte-stream interfaces every protocol package (`http`, `ssh`, `ftp`, `webdav`, `smb2`, ...) is built on: connect, accept, read a chunk, write a chunk, close, and optionally upgrade in place to TLS via `upgradeTls`. `Reader` and `Writer` are the read-only and write-only projections of `RawTransport`. Concrete implementations live in subpath exports: an in-memory loopback pair for tests, a Node `net`/`tls` socket wrapper, and a WebRTC data channel transport. `ReadBuffer` and `WriteBuffer` in the root export provide line- and size-based buffering on top of any `Reader`/`Writer`.
|
||||
|
||||
## Entry points
|
||||
|
||||
| Entry point | Description |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `@webnet/transport` | `RawTransport`, `RawDialer`, `RawListener`, `Reader`, `Writer`, `TlsUpgradeOptions` types. |
|
||||
| `@webnet/transport/node` | `NodeTransport`-backed `nodeDialer`, `NodeListener`, `nodeListen` for TCP/TLS sockets. |
|
||||
| `@webnet/transport/loopback` | `loopbackTransportPair`, `loopbackListener` for in-process, dependency-free transports. |
|
||||
| `@webnet/transport/webrtc` | `DataChannelTransport`, `openDataChannel`, `acceptDataChannel` for WebRTC data channels. |
|
||||
| `@webnet/transport/buffer` | `ReadBuffer`, `WriteBuffer`, `prependTransport` for buffering and CRLF line reads on top of a transport. |
|
||||
|
||||
`_internals` entry points are unstable and are not part of the public API.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { loopbackListener } from "@webnet/transport/loopback"
|
||||
import type { RawTransport } from "@webnet/transport"
|
||||
|
||||
const [listener, dialer] = loopbackListener()
|
||||
const acceptPromise = listener.accept()
|
||||
const client: RawTransport = await dialer.dial("localhost", 0)
|
||||
const server = await acceptPromise
|
||||
|
||||
await client.write(new TextEncoder().encode("hello"))
|
||||
const chunk = await server.read()
|
||||
if (chunk === null) {
|
||||
// The peer cleanly ended its byte stream. Later reads also return null.
|
||||
} else {
|
||||
console.log(new TextDecoder().decode(chunk))
|
||||
}
|
||||
```
|
||||
|
||||
`RawTransport.read()` resolves to `null` for clean end-of-stream and keeps returning `null` on
|
||||
later reads. Connection failures reject the promise, so callers can distinguish an orderly peer
|
||||
close from a broken transport without inspecting error messages.
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/http`](../http) — HTTP client/server built on `RawTransport`.
|
||||
- [`@webnet/ssh`](../ssh) — SSH transport layer built on `RawDialer`/`RawListener`.
|
||||
- [`@webnet/tsconnect`](../tsconnect) — dials Tailscale connections through this package's transport interfaces.
|
||||
@@ -14,7 +14,7 @@ function makeChunkedReader(chunks: (string | Uint8Array)[]): Reader {
|
||||
return i >= data.length
|
||||
},
|
||||
async read() {
|
||||
return data[i++] ?? new Uint8Array(0)
|
||||
return data[i++] ?? null
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ function makeReader(data: string | Uint8Array, chunkSize = Infinity): Reader {
|
||||
return offset >= bytes.length
|
||||
},
|
||||
async read() {
|
||||
if (offset >= bytes.length) return new Uint8Array(0)
|
||||
if (offset >= bytes.length) return null
|
||||
const end = isFinite(chunkSize) ? Math.min(offset + chunkSize, bytes.length) : bytes.length
|
||||
const chunk = bytes.slice(offset, end)
|
||||
offset = end
|
||||
@@ -115,6 +115,10 @@ suite("ReadBuffer", () => {
|
||||
const data = new Uint8Array([0x0d, 0x41]) // CR followed by 'A' (0x41)
|
||||
await assert.rejects(() => rb(data).readLine(), /0x41/)
|
||||
})
|
||||
|
||||
test("throws a deterministic error when EOF arrives before CRLF", async () => {
|
||||
await assert.rejects(() => rb("truncated").readLine(), /Unexpected end of stream/)
|
||||
})
|
||||
})
|
||||
|
||||
suite("len", () => {
|
||||
@@ -162,6 +166,23 @@ suite("ReadBuffer", () => {
|
||||
assert.ok(buf.len >= 10)
|
||||
})
|
||||
|
||||
test("read stops at EOF and records the ended state", async () => {
|
||||
const reader = makeReader("short")
|
||||
const buf = new ReadBuffer({
|
||||
get closed() {
|
||||
return false
|
||||
},
|
||||
read: reader.read.bind(reader),
|
||||
})
|
||||
await buf.read(10)
|
||||
assert.strictEqual(buf.len, 5)
|
||||
assert.strictEqual(buf.ended, false)
|
||||
buf.forward(5)
|
||||
assert.strictEqual(buf.ended, true)
|
||||
await buf.readOnce()
|
||||
assert.strictEqual(buf.ended, true)
|
||||
})
|
||||
|
||||
test("slice returns zero-length array for zero len", async () => {
|
||||
const buf = new ReadBuffer(makeReader("hello"))
|
||||
await buf.read(5)
|
||||
@@ -324,8 +345,7 @@ suite("prependTransport", () => {
|
||||
written.push(data)
|
||||
},
|
||||
async read() {
|
||||
if (offset >= chunks.length) throw new Error("end of stream")
|
||||
return chunks[offset++]
|
||||
return chunks[offset++] ?? null
|
||||
},
|
||||
get readEnded() {
|
||||
return offset >= chunks.length
|
||||
@@ -347,6 +367,13 @@ suite("prependTransport", () => {
|
||||
assert.strictEqual(dec.decode(await p.read()), "BASE")
|
||||
})
|
||||
|
||||
test("read() forwards repeated EOF after the prefix", async () => {
|
||||
const p = prependTransport(enc.encode("PRE"), makeBase())
|
||||
assert.strictEqual(dec.decode(await p.read()), "PRE")
|
||||
assert.strictEqual(await p.read(), null)
|
||||
assert.strictEqual(await p.read(), null)
|
||||
})
|
||||
|
||||
test("closed is false before prefix is sent", () => {
|
||||
const base = makeBase()
|
||||
const p = prependTransport(enc.encode("X"), base)
|
||||
|
||||
@@ -12,6 +12,7 @@ export class ReadBuffer {
|
||||
#offset: number
|
||||
#decoder: TextDecoder
|
||||
#reader: Reader
|
||||
#ended: boolean
|
||||
|
||||
#maxLineLength: number
|
||||
|
||||
@@ -20,6 +21,7 @@ export class ReadBuffer {
|
||||
this.#offset = 0
|
||||
this.#decoder = new TextDecoder()
|
||||
this.#reader = reader
|
||||
this.#ended = false
|
||||
|
||||
// needed to make typescript understand that it's definitely assigned
|
||||
this.#maxLineLength = 0
|
||||
@@ -37,24 +39,34 @@ export class ReadBuffer {
|
||||
return len
|
||||
}
|
||||
get ended(): boolean {
|
||||
return !this.len && this.#reader.closed
|
||||
return !this.len && (this.#ended || this.#reader.closed)
|
||||
}
|
||||
|
||||
async readOnce(): Promise<void> {
|
||||
this.#buffers.push(await this.#reader.read())
|
||||
if (this.#ended) return
|
||||
const chunk = await this.#reader.read()
|
||||
if (chunk === null) {
|
||||
this.#ended = true
|
||||
return
|
||||
}
|
||||
this.#buffers.push(chunk)
|
||||
}
|
||||
async read(size: number): Promise<void> {
|
||||
while (this.len < size && !this.#reader.closed) await this.readOnce()
|
||||
while (this.len < size && !this.#ended && !this.#reader.closed) await this.readOnce()
|
||||
}
|
||||
|
||||
async #readUntilByte(byte: number, maxLen: number): Promise<number> {
|
||||
if (!this.#buffers.length) await this.readOnce()
|
||||
if (!this.#buffers.length) throw new Error("Unexpected end of stream")
|
||||
let idx = this.#buffers[0].indexOf(byte, this.#offset)
|
||||
if (idx !== -1) return idx - this.#offset
|
||||
let len = this.#buffers[0].length - this.#offset
|
||||
let buf = 1
|
||||
while (len < maxLen) {
|
||||
if (this.#buffers.length <= buf) await this.readOnce()
|
||||
if (this.#buffers.length <= buf) {
|
||||
await this.readOnce()
|
||||
if (this.#buffers.length <= buf) throw new Error("Unexpected end of stream")
|
||||
}
|
||||
idx = this.#buffers[buf].indexOf(byte)
|
||||
if (idx !== -1) return len + idx
|
||||
len += this.#buffers[buf].length
|
||||
|
||||
@@ -94,12 +94,13 @@ suite("loopbackTransportPair", () => {
|
||||
await assert.rejects(() => a.write(enc.encode("x")), /Write closed/)
|
||||
})
|
||||
|
||||
test("pending read rejects when transport is fully closed", async () => {
|
||||
test("pending and subsequent reads return null after clean EOF", async () => {
|
||||
const [a, b] = loopbackTransportPair()
|
||||
const readPromise = b.read() // sets b.#callback
|
||||
a.close() // b.readClosed = true
|
||||
b.close() // b.writeClosed = true; b.closed = true → errcallback fires via setImmediate
|
||||
await assert.rejects(() => readPromise, /Socket ended/)
|
||||
assert.strictEqual(await readPromise, null)
|
||||
assert.strictEqual(await b.read(), null)
|
||||
})
|
||||
|
||||
test("concurrent read() throws broken invariant", async () => {
|
||||
|
||||
@@ -3,8 +3,7 @@ import { PairSync } from "../pair.js"
|
||||
|
||||
export class LoopbackTransportHalf implements RawTransport {
|
||||
#buffers: Uint8Array[] = []
|
||||
#callback: ((data: Uint8Array) => void) | null = null
|
||||
#errcallback: ((err: unknown) => void) | null = null
|
||||
#callback: ((data: Uint8Array | null) => void) | null = null
|
||||
#readClosed: boolean = false
|
||||
#writeClosed: boolean = false
|
||||
#whenClosedResolve: (() => void) | null = null
|
||||
@@ -24,7 +23,6 @@ export class LoopbackTransportHalf implements RawTransport {
|
||||
const callback = this.#callback
|
||||
if (callback) {
|
||||
this.#callback = null
|
||||
this.#errcallback = null
|
||||
callback(buf)
|
||||
} else {
|
||||
this.#buffers.push(buf)
|
||||
@@ -33,7 +31,7 @@ export class LoopbackTransportHalf implements RawTransport {
|
||||
_end(): void {
|
||||
this.#readClosed = true
|
||||
this.#maybeResolveWhenClosed()
|
||||
this.#maybeEnded("end event")
|
||||
this.#maybeEnded()
|
||||
}
|
||||
|
||||
#maybeResolveWhenClosed(): void {
|
||||
@@ -43,10 +41,13 @@ export class LoopbackTransportHalf implements RawTransport {
|
||||
}
|
||||
}
|
||||
|
||||
#maybeEnded(source: string): void {
|
||||
#maybeEnded(): void {
|
||||
setImmediate(() => {
|
||||
if (this.#readClosed && !this.#buffers.length && this.#callback)
|
||||
this.#errcallback?.(new Error("Socket ended: " + source))
|
||||
if (this.#readClosed && !this.#buffers.length && this.#callback) {
|
||||
const callback = this.#callback
|
||||
this.#callback = null
|
||||
callback(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,7 +55,7 @@ export class LoopbackTransportHalf implements RawTransport {
|
||||
this.#endFn()
|
||||
this.#writeClosed = true
|
||||
this.#maybeResolveWhenClosed()
|
||||
this.#maybeEnded("close()")
|
||||
this.#maybeEnded()
|
||||
}
|
||||
|
||||
get closed() {
|
||||
@@ -69,7 +70,7 @@ export class LoopbackTransportHalf implements RawTransport {
|
||||
return this.#whenClosed
|
||||
}
|
||||
|
||||
async read(): Promise<Uint8Array> {
|
||||
async read(): Promise<Uint8Array | null> {
|
||||
if (this.#buffers.length) {
|
||||
const buf = this.#buffers.shift()!
|
||||
return buf
|
||||
@@ -78,11 +79,10 @@ export class LoopbackTransportHalf implements RawTransport {
|
||||
throw new Error("Broken Invariant: two calls of read() at once")
|
||||
}
|
||||
if (this.#readClosed) {
|
||||
throw new Error("Socket ended")
|
||||
return null
|
||||
}
|
||||
return new Promise<Uint8Array>((ok, ko) => {
|
||||
return new Promise<Uint8Array | null>((ok) => {
|
||||
this.#callback = ok
|
||||
this.#errcallback = ko
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test, { suite } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import { createServer } from "node:net"
|
||||
import { createServer, Socket } from "node:net"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { NodeTransport, NodeDialer, NodeListener, nodeListen } from "./transport.js"
|
||||
|
||||
@@ -168,28 +168,43 @@ suite("NodeDialer / NodeTransport", () => {
|
||||
listener.close()
|
||||
})
|
||||
|
||||
test("socket error from RST propagates to pending read", async () => {
|
||||
const { listener, serverTransport, clientTransport } = await makeConnectedPair()
|
||||
// Set up a pending read on the client before destroying the server side
|
||||
const readPromise = clientTransport.read()
|
||||
// Destroy server socket — sends RST to client, which fires an error event on the client socket
|
||||
serverTransport.close()
|
||||
// The pending read should reject with an error (ECONNRESET or Socket ended)
|
||||
await assert.rejects(() => readPromise)
|
||||
clientTransport.close()
|
||||
listener.close()
|
||||
test("socket errors still reject a pending read", async () => {
|
||||
const socket = new Socket()
|
||||
const transport = new NodeTransport(socket)
|
||||
const readPromise = transport.read()
|
||||
const error = new Error("network failure")
|
||||
socket.emit("error", error)
|
||||
await assert.rejects(() => readPromise, error)
|
||||
await assert.rejects(() => transport.read(), error)
|
||||
transport.close()
|
||||
})
|
||||
|
||||
test("read() fast-fail: throws Socket ended when socket destroyed and #readError is null", async () => {
|
||||
// socket.destroy() → "close" event fires, setting #readClosed=true without #readError.
|
||||
// A subsequent read() call with no pending callback hits the fast-fail (lines 111-113):
|
||||
// throw this.#readError ?? new Error("Socket ended") → throws "Socket ended"
|
||||
test("a real TCP reset rejects pending and subsequent reads", async () => {
|
||||
const server = createServer()
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
const peerPromise = new Promise<Socket>((resolve) => server.once("connection", resolve))
|
||||
const port = (server.address() as { port: number }).port
|
||||
const transport = (await new NodeDialer().dial("127.0.0.1", port)) as NodeTransport
|
||||
const peer = await peerPromise
|
||||
const pending = transport.read()
|
||||
|
||||
peer.resetAndDestroy()
|
||||
|
||||
const isReset = (err: unknown) => (err as NodeJS.ErrnoException).code === "ECONNRESET"
|
||||
await assert.rejects(() => pending, isReset)
|
||||
await assert.rejects(() => transport.read(), isReset)
|
||||
transport.close()
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
})
|
||||
|
||||
test("clean close makes pending and subsequent reads return null", async () => {
|
||||
const { listener, serverTransport, clientTransport } = await makeConnectedPair()
|
||||
clientTransport.close()
|
||||
// Wait for the "close" event to fire and set #readClosed = true
|
||||
await clientTransport.whenClosed
|
||||
await assert.rejects(() => clientTransport.read(), /Socket ended/)
|
||||
const pending = clientTransport.read()
|
||||
serverTransport.halfClose()
|
||||
assert.strictEqual(await pending, null)
|
||||
assert.strictEqual(await clientTransport.read(), null)
|
||||
serverTransport.close()
|
||||
clientTransport.close()
|
||||
listener.close()
|
||||
})
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ export class NodeTransport implements RawTransport {
|
||||
#isTls: boolean
|
||||
#upgrading = false
|
||||
#buffers: Uint8Array[]
|
||||
#callback: ((data: Uint8Array) => void) | null
|
||||
#callback: ((data: Uint8Array | null) => void) | null
|
||||
#errcallback: ((err: unknown) => void) | null
|
||||
#readClosed: boolean
|
||||
#eofReturned: boolean
|
||||
#writeClosed: boolean
|
||||
#readError: unknown
|
||||
#whenClosedResolve: (() => void) | null
|
||||
@@ -28,6 +29,7 @@ export class NodeTransport implements RawTransport {
|
||||
this.#callback = null
|
||||
this.#errcallback = null
|
||||
this.#readClosed = false
|
||||
this.#eofReturned = false
|
||||
this.#writeClosed = false
|
||||
this.#readError = null
|
||||
|
||||
@@ -56,19 +58,23 @@ export class NodeTransport implements RawTransport {
|
||||
}
|
||||
this.#onEnd = () => {
|
||||
this.#readClosed = true
|
||||
this.#maybeEnded("end event")
|
||||
this.#maybeEnded()
|
||||
}
|
||||
this.#onClose = () => {
|
||||
this.#writeClosed = true
|
||||
this.#readClosed = true
|
||||
this.#whenClosedResolve?.()
|
||||
this.#whenClosedResolve = null
|
||||
this.#maybeEnded()
|
||||
}
|
||||
this.#onError = (err) => {
|
||||
this.#writeClosed = true
|
||||
this.#readClosed = true
|
||||
this.#readError = err
|
||||
this.#errcallback?.(err)
|
||||
const errcallback = this.#errcallback
|
||||
this.#callback = null
|
||||
this.#errcallback = null
|
||||
errcallback?.(err)
|
||||
this.#whenClosedResolve?.()
|
||||
this.#whenClosedResolve = null
|
||||
}
|
||||
@@ -91,23 +97,32 @@ export class NodeTransport implements RawTransport {
|
||||
socket.removeListener("error", this.#onError)
|
||||
}
|
||||
|
||||
#maybeEnded(source: string): void {
|
||||
#maybeEnded(): void {
|
||||
setImmediate(() => {
|
||||
if (this.#readClosed && !this.#buffers.length && this.#callback)
|
||||
this.#errcallback?.(new Error("Socket ended: " + source))
|
||||
if (this.#readClosed && !this.#buffers.length && this.#callback) {
|
||||
const callback = this.#callback
|
||||
const errcallback = this.#errcallback
|
||||
this.#callback = null
|
||||
this.#errcallback = null
|
||||
if (this.#readError) errcallback?.(this.#readError)
|
||||
else {
|
||||
this.#eofReturned = true
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
halfClose(): void {
|
||||
this.#socket.end()
|
||||
this.#writeClosed = true
|
||||
this.#maybeEnded("halfClose()")
|
||||
this.#maybeEnded()
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#socket.destroy()
|
||||
this.#writeClosed = true
|
||||
this.#maybeEnded("close()")
|
||||
this.#maybeEnded()
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
@@ -183,7 +198,8 @@ export class NodeTransport implements RawTransport {
|
||||
this.#isTls = true
|
||||
}
|
||||
|
||||
async read(): Promise<Uint8Array> {
|
||||
async read(): Promise<Uint8Array | null> {
|
||||
if (this.#eofReturned) return null
|
||||
if (this.#upgrading) {
|
||||
throw new Error("Cannot read() during a TLS upgrade")
|
||||
}
|
||||
@@ -195,9 +211,11 @@ export class NodeTransport implements RawTransport {
|
||||
throw new Error("Broken Invariant: two calls of read() at once")
|
||||
}
|
||||
if (this.#readClosed) {
|
||||
throw this.#readError ?? new Error("Socket ended")
|
||||
if (this.#readError) throw this.#readError
|
||||
this.#eofReturned = true
|
||||
return null
|
||||
}
|
||||
return new Promise<Uint8Array>((ok, ko) => {
|
||||
return new Promise<Uint8Array | null>((ok, ko) => {
|
||||
this.#callback = ok
|
||||
this.#errcallback = ko
|
||||
this.#socket.resume()
|
||||
|
||||
@@ -15,7 +15,12 @@ export interface RawTransport {
|
||||
close(): void | Promise<void>
|
||||
|
||||
write(data: Uint8Array): Promise<void | number>
|
||||
read(): Promise<Uint8Array>
|
||||
/**
|
||||
* Read the next available chunk, or `null` after the peer cleanly ends its
|
||||
* byte stream. Once `null` is returned, every subsequent read also returns
|
||||
* `null`. Transport failures reject the promise instead.
|
||||
*/
|
||||
read(): Promise<Uint8Array | null>
|
||||
|
||||
// optional features, should degrade gracefully if absent
|
||||
|
||||
|
||||
@@ -135,19 +135,18 @@ forBrowsers((ctx) => {
|
||||
const t2 = new DataChannelTransport(ch2)
|
||||
|
||||
ch1.close()
|
||||
pc1.close()
|
||||
|
||||
try {
|
||||
await t2.read()
|
||||
return false
|
||||
} catch {
|
||||
return true
|
||||
const first = await t2.read()
|
||||
const second = await t2.read()
|
||||
return first === null && second === null
|
||||
} finally {
|
||||
pc1.close()
|
||||
pc2.close()
|
||||
}
|
||||
}, `${server.url}/webrtc/transport.js`)
|
||||
|
||||
assert.ok(closedGracefully, "read() should throw when peer closes")
|
||||
assert.ok(closedGracefully, "read() should return null when peer closes")
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
|
||||
@@ -214,15 +214,6 @@ suite("DataChannelTransport", () => {
|
||||
await assert.rejects(() => ta.write(enc.encode("x")), /write closed/)
|
||||
})
|
||||
|
||||
test("pending read rejects when channel closes", async () => {
|
||||
const [a] = mockChannelPair()
|
||||
a.open()
|
||||
const ta = await acceptDataChannel(a)
|
||||
const readPromise = ta.read()
|
||||
a.close()
|
||||
await assert.rejects(() => readPromise, /DataChannel ended/)
|
||||
})
|
||||
|
||||
test("concurrent read() throws broken invariant", async () => {
|
||||
const [a, b] = mockChannelPair()
|
||||
a.open()
|
||||
@@ -253,12 +244,14 @@ suite("DataChannelTransport", () => {
|
||||
await assert.rejects(() => ta.read(), /gone/)
|
||||
})
|
||||
|
||||
test("read() after clean close throws generic ended error", async () => {
|
||||
test("clean close makes pending and subsequent reads return null", async () => {
|
||||
const [a] = mockChannelPair()
|
||||
a.open()
|
||||
const ta = await acceptDataChannel(a)
|
||||
const pending = ta.read()
|
||||
a.close()
|
||||
await assert.rejects(() => ta.read(), /DataChannel ended/)
|
||||
assert.strictEqual(await pending, null)
|
||||
assert.strictEqual(await ta.read(), null)
|
||||
})
|
||||
|
||||
test("error event with no error object uses generic message", async () => {
|
||||
|
||||
@@ -50,11 +50,12 @@ export class DataChannelTransport implements RawTransport {
|
||||
#channel: DataChannel
|
||||
#buffers: Uint8Array[] = []
|
||||
#bufferedBytes = 0
|
||||
#callback: ((data: Uint8Array) => void) | null = null
|
||||
#callback: ((data: Uint8Array | null) => void) | null = null
|
||||
#errcallback: ((err: unknown) => void) | null = null
|
||||
#drainCallback: (() => void) | null = null
|
||||
#drainErrCallback: ((err: unknown) => void) | null = null
|
||||
#readClosed = false
|
||||
#eofReturned = false
|
||||
#writeClosed = false
|
||||
#readError: unknown = null
|
||||
#receivePaused = false
|
||||
@@ -105,7 +106,7 @@ export class DataChannelTransport implements RawTransport {
|
||||
this.#drainCallback = null
|
||||
this.#drainErrCallback = null
|
||||
drainKo?.(new Error("DataChannel closed"))
|
||||
this.#maybeEnded("close event")
|
||||
this.#maybeEnded()
|
||||
}
|
||||
|
||||
this.#onError = (e) => {
|
||||
@@ -151,13 +152,14 @@ export class DataChannelTransport implements RawTransport {
|
||||
}
|
||||
}
|
||||
|
||||
#maybeEnded(source: string): void {
|
||||
#maybeEnded(): void {
|
||||
queueMicrotask(() => {
|
||||
if (this.#readClosed && !this.#buffers.length && this.#errcallback) {
|
||||
const ko = this.#errcallback
|
||||
if (this.#readClosed && !this.#buffers.length && this.#callback) {
|
||||
const callback = this.#callback
|
||||
this.#callback = null
|
||||
this.#errcallback = null
|
||||
ko(new Error("DataChannel ended: " + source))
|
||||
this.#eofReturned = true
|
||||
callback(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -174,7 +176,8 @@ export class DataChannelTransport implements RawTransport {
|
||||
return this.#whenClosed
|
||||
}
|
||||
|
||||
async read(): Promise<Uint8Array> {
|
||||
async read(): Promise<Uint8Array | null> {
|
||||
if (this.#eofReturned) return null
|
||||
if (this.#buffers.length) {
|
||||
const buf = this.#buffers.shift()!
|
||||
this.#bufferedBytes -= buf.byteLength
|
||||
@@ -187,9 +190,11 @@ export class DataChannelTransport implements RawTransport {
|
||||
throw new Error("Broken Invariant: two calls of read() at once")
|
||||
}
|
||||
if (this.#readClosed) {
|
||||
throw this.#readError ?? new Error("DataChannel ended")
|
||||
if (this.#readError) throw this.#readError
|
||||
this.#eofReturned = true
|
||||
return null
|
||||
}
|
||||
return new Promise<Uint8Array>((ok, ko) => {
|
||||
return new Promise<Uint8Array | null>((ok, ko) => {
|
||||
this.#callback = ok
|
||||
this.#errcallback = ko
|
||||
})
|
||||
@@ -209,7 +214,7 @@ export class DataChannelTransport implements RawTransport {
|
||||
close(): void {
|
||||
this.#writeClosed = true
|
||||
this.#channel.close()
|
||||
this.#maybeEnded("close()")
|
||||
this.#maybeEnded()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# @webnet/tsconnect-react
|
||||
|
||||
React hooks and context for `@webnet/tsconnect` IPN state and control.
|
||||
|
||||
`useBuildIpn` builds and runs an `IPN` directly in the main thread, dispatching its notifications into a `@webnet/tsconnect-redux` store via `runWithStore`. `useBuildIpnWorker` instead connects through `@webnet/tsconnect-worker`'s `connectWithFallback`, returning an `IpnClientHandle` and disconnecting it on unmount. `useIpnStore`, `useIpnSelector`, and `useIpnDispatch` are `react-redux` hooks pre-typed to `IpnStore`/`IpnState`/`IpnDispatch`; `createUseIpnStore`, `createUseIpnSelector`, and `createUseIpnDispatch` produce context-scoped variants for `IpnStoreProvider`/`createIpnStoreProvider`, for apps that need more than one IPN store on the page. `createIpnContext`/`createIpnStoreContext` create the underlying React contexts.
|
||||
|
||||
## Entry points
|
||||
|
||||
| Entry point | Description |
|
||||
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `@webnet/tsconnect-react` | `useBuildIpn`, `useBuildIpnWorker`, `useIpnStore`, `useIpnSelector`, `useIpnDispatch`, `createUseIpnStore`/`createUseIpnSelector`/`createUseIpnDispatch`, `IpnStoreProvider`, `createIpnStoreProvider`, `createIpnContext`, `createIpnStoreContext`. |
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { buildIpnStore } from "@webnet/tsconnect-redux"
|
||||
import { useBuildIpnWorker, useIpnSelector, IpnStoreProvider } from "@webnet/tsconnect-react"
|
||||
import { getIpnState } from "@webnet/tsconnect-redux"
|
||||
|
||||
const store = buildIpnStore()
|
||||
|
||||
function StatusLabel() {
|
||||
const state = useIpnSelector(getIpnState)
|
||||
return <span>{state}</span>
|
||||
}
|
||||
|
||||
function App() {
|
||||
useBuildIpnWorker(new URL("@webnet/tsconnect-worker/worker", import.meta.url), {
|
||||
wasmUrl: new URL("@webnet/tsconnect/main.wasm", import.meta.url).href,
|
||||
})
|
||||
return (
|
||||
<IpnStoreProvider store={store}>
|
||||
<StatusLabel />
|
||||
</IpnStoreProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/tsconnect-redux`](../tsconnect-redux) — the store, actions, and selectors these hooks read from.
|
||||
- [`@webnet/tsconnect-worker`](../tsconnect-worker) — `connectWithFallback`, used by `useBuildIpnWorker`.
|
||||
- [`@webnet/tsconnect`](../tsconnect) — the underlying `IPN` implementation.
|
||||
@@ -0,0 +1,34 @@
|
||||
# @webnet/tsconnect-redux
|
||||
|
||||
Redux Toolkit slice and bindings for `@webnet/tsconnect` IPN state.
|
||||
|
||||
`buildIpnStore` creates an RTK store combining slices for connection state, peers, exit-node selection, login URL, and inbound/outbound/waiting Taildrop file transfers. `runWithStore` wires an `IPN` instance's `run()` notify callbacks (`notifyState`, `notifyNetMap`, `notifyIncomingFiles`, ...) to dispatch the corresponding actions (`setState`, `setNetmap`, `setIncomingFiles`, ...), and wraps `ipn.deleteWaitingFile` so it also removes the entry from the store. `bindFileOpsToStore` mirrors a `ControlledFileOps` implementation's counters (file count, sizes, limits) into the store on every change. A large set of memoized selectors (`getIpnState`, `getPeers`, `getWaitingFiles`, `isIpnLockedOut`, `getIpnSuggestedExitNode`, ...) read from the resulting `IpnState`. This package has no UI framework dependency — `@webnet/tsconnect-react` layers React hooks and context on top of it, and `@webnet/tsconnect-worker` uses it to replicate state from a `SharedWorker` to its clients.
|
||||
|
||||
## Entry points
|
||||
|
||||
| Entry point | Description |
|
||||
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `@webnet/tsconnect-redux` | `buildIpnStore`, `runWithStore`, `bindFileOpsToStore`, slice actions (`setState`, `setNetmap`, `setExitNode`, `setLoginUrl`, `setIncomingFiles`, `setOutgoingFiles`, `deleteOutgoingFile`, `setWaitingFiles`, `deleteWaitingFile`, `setFileOpsState`), slice selectors (`selectIpnState`, `selectPeers`, `selectIncomingFiles`, `selectOutgoingFiles`, `selectWaitingFiles`, `selectFileOps`), derived selectors (`getIpnState`, `getPeers`, `getPeerByName`, `getWaitingFiles`, `getIncomingFiles`, `getOutgoingFiles`, `isIpnLockedOut`, `getIpnSuggestedExitNode`, `getLoginUrl`, `getExitNode`, ...), and the `IpnState`/`IpnAction`/`IpnDispatch`/`IpnStore`/`FileOpsState` types. |
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { initIPN } from "@webnet/tsconnect"
|
||||
import { buildIpnStore, runWithStore, getIpnState, getPeers } from "@webnet/tsconnect-redux"
|
||||
|
||||
const store = buildIpnStore()
|
||||
const buildIpn = await initIPN(wasmBytes)
|
||||
const ipn = buildIpn({ authKey: process.env.TS_AUTHKEY })
|
||||
|
||||
runWithStore(ipn, store)
|
||||
|
||||
store.subscribe(() => {
|
||||
console.log(getIpnState(store.getState()), getPeers(store.getState()))
|
||||
})
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/tsconnect`](../tsconnect) — the `IPN` type whose `run()` callbacks feed this store.
|
||||
- [`@webnet/tsconnect-react`](../tsconnect-react) — React hooks and context built on this store.
|
||||
- [`@webnet/tsconnect-worker`](../tsconnect-worker) — replicates this store's state from a `SharedWorker` to its clients.
|
||||
@@ -0,0 +1,43 @@
|
||||
# @webnet/tsconnect-worker
|
||||
|
||||
Runs `@webnet/tsconnect`'s IPN inside a `SharedWorker`, with a main-thread fallback.
|
||||
|
||||
`connectWithFallback` opens a `SharedWorker` running this package's `./worker` script, so a single IPN instance and its wasm module are shared across every tab, and returns an `IpnWorkerClient` that proxies `IpnClient` methods (`dial`, `listen`, `dialTLS`, `sendFile`, ...) to the worker over `MessagePort` RPC. `Conn`, `TCPListener`, and `PacketConn` values are represented client-side by `WorkerConn`, `WorkerTCPListener`, and `WorkerPacketConn`, each backed by a dedicated `MessageChannel`. IPN state is kept in sync via a `@webnet/tsconnect-redux` store: the worker dispatches actions over the port and the client replays them into its own `IpnWorkerClientStore`. When `SharedWorker` is unavailable (or `disableSharedWorker` is set, or the worker path throws), `connectWithFallback` falls back to `connectMainThread`, which runs the IPN directly in the calling context behind a Web Lock so only one tab owns it at a time. Both paths return an `IpnClientHandle`, so callers do not need to branch on which one was used. `sendTransfer`/`onTransfer`/`onAdopt` on the handle move live resources (open `Conn`s, listeners) between clients using `@webnet/state-transfer`; this is worker-only (`transferSupported` is `false` on the main-thread path).
|
||||
|
||||
## Entry points
|
||||
|
||||
| Entry point | Description |
|
||||
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `@webnet/tsconnect-worker` | `connectWithFallback`, `connectMainThread`, `IpnWorkerClient`, `IpnMainThreadHandle`, `IpnClientHandle`, `WorkerConfig`, and the client-side `WorkerConn`/`WorkerTCPListener`/`WorkerPacketConn`/`workerDialer`. |
|
||||
| `@webnet/tsconnect-worker/worker` | The compiled `SharedWorker` entry script; reference it by URL when constructing the worker, not by importing symbols from it. |
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { connectWithFallback } from "@webnet/tsconnect-worker"
|
||||
|
||||
const client = await connectWithFallback(
|
||||
new URL("@webnet/tsconnect-worker/worker", import.meta.url),
|
||||
{
|
||||
wasmUrl: new URL("@webnet/tsconnect/main.wasm", import.meta.url).href,
|
||||
stateStorage: "indexeddb",
|
||||
},
|
||||
)
|
||||
|
||||
client.run({
|
||||
notifyState: (state) => console.log("state:", state),
|
||||
})
|
||||
|
||||
const conn = await client.dial("tcp", "100.64.0.1:80")
|
||||
await conn.write(new TextEncoder().encode("GET / HTTP/1.0\r\n\r\n"))
|
||||
conn.close()
|
||||
|
||||
client.disconnect()
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`@webnet/tsconnect`](../tsconnect) — the `IPN` implementation this package runs inside the worker.
|
||||
- [`@webnet/tsconnect-redux`](../tsconnect-redux) — the Redux store used to synchronize IPN state between worker and client.
|
||||
- [`@webnet/tsconnect-react`](../tsconnect-react) — React hooks built on `connectWithFallback`.
|
||||
- [`@webnet/state-transfer`](../state-transfer) — the ownership-transfer contract behind `sendTransfer`/`onAdopt`.
|
||||
@@ -37,6 +37,8 @@ import type {
|
||||
DriveW2C,
|
||||
DriveC2W,
|
||||
WorkerConfig,
|
||||
WorkerConfigField,
|
||||
WorkerConnection,
|
||||
FileOpsLimits,
|
||||
TransferToken,
|
||||
} from "./protocol.js"
|
||||
@@ -97,6 +99,22 @@ function acquireLock(name: string): Promise<() => void> {
|
||||
})
|
||||
}
|
||||
|
||||
// Preserve cleanup-before-lock-release ordering through long WASM turns, while
|
||||
// still bounding how long a dead worker can stall local teardown.
|
||||
const workerAcknowledgementTimeoutMs = 5000
|
||||
|
||||
export class WorkerConfigMismatchError extends Error {
|
||||
readonly activeConfigIdentity: string
|
||||
readonly mismatchedFields: WorkerConfigField[]
|
||||
|
||||
constructor(activeConfigIdentity: string, mismatchedFields: WorkerConfigField[]) {
|
||||
super(`SharedWorker configuration mismatch: ${mismatchedFields.join(", ")}`)
|
||||
this.name = "WorkerConfigMismatchError"
|
||||
this.activeConfigIdentity = activeConfigIdentity
|
||||
this.mismatchedFields = mismatchedFields
|
||||
}
|
||||
}
|
||||
|
||||
// ── WorkerConn ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class WorkerConn implements RawTransport, StateTransferable<TransferToken> {
|
||||
@@ -106,6 +124,7 @@ export class WorkerConn implements RawTransport, StateTransferable<TransferToken
|
||||
#host?: TransferHost
|
||||
#port: MessagePort
|
||||
#closed = false
|
||||
#readEnded = false
|
||||
#isTls: boolean
|
||||
#upgrading = false
|
||||
#detached = false
|
||||
@@ -138,6 +157,10 @@ export class WorkerConn implements RawTransport, StateTransferable<TransferToken
|
||||
return this.#isTls
|
||||
}
|
||||
|
||||
get readEnded(): boolean {
|
||||
return this.#readEnded
|
||||
}
|
||||
|
||||
#onMessage(msg: ConnW2C): void {
|
||||
if (msg.type === "closed") {
|
||||
this.#closed = true
|
||||
@@ -148,7 +171,10 @@ export class WorkerConn implements RawTransport, StateTransferable<TransferToken
|
||||
if (!p) return
|
||||
this.#pending.delete(msg.id)
|
||||
if (msg.type === "data") p.resolve(msg.data)
|
||||
else if (msg.type === "readError") p.reject(new Error(msg.error))
|
||||
else if (msg.type === "eof") {
|
||||
this.#readEnded = true
|
||||
p.resolve(null)
|
||||
} else if (msg.type === "readError") p.reject(new Error(msg.error))
|
||||
else if (msg.type === "written") p.resolve(msg.n)
|
||||
else if (msg.type === "writeError") p.reject(new Error(msg.error))
|
||||
else if (msg.type === "upgraded") {
|
||||
@@ -174,12 +200,13 @@ export class WorkerConn implements RawTransport, StateTransferable<TransferToken
|
||||
return p
|
||||
}
|
||||
|
||||
read(): Promise<Uint8Array> {
|
||||
read(): Promise<Uint8Array | null> {
|
||||
if (this.#readEnded) return Promise.resolve(null)
|
||||
if (this.#detached) return Promise.reject(new Error("Conn.read: detached"))
|
||||
if (this.#closed) return Promise.reject(new Error("Conn.read: already closed"))
|
||||
if (this.#upgrading) return Promise.reject(new Error("Conn.read: TLS upgrade in progress"))
|
||||
const id = nextCallId++
|
||||
return this.#call<Uint8Array>({ type: "read", id }, id)
|
||||
return this.#call<Uint8Array | null>({ type: "read", id }, id)
|
||||
}
|
||||
|
||||
write(data: Uint8Array): Promise<number> {
|
||||
@@ -517,6 +544,8 @@ export interface IpnClientHandle extends IpnClient {
|
||||
readonly clientKey?: string
|
||||
/** Whether cross-client state transfer is available (false on the main-thread path). */
|
||||
readonly transferSupported: boolean
|
||||
/** SharedWorker handshake details, or undefined on the main-thread path. */
|
||||
readonly workerConnection?: WorkerConnection
|
||||
run(opts?: IPNRunOptions): void
|
||||
sendTransfer(
|
||||
targetKey: string,
|
||||
@@ -548,7 +577,7 @@ export interface IpnClientHandle extends IpnClient {
|
||||
* path should use `shutdown()` on `IpnMainThreadHandle` or send an explicit
|
||||
* stop message to the worker.
|
||||
*/
|
||||
disconnect(): void
|
||||
disconnect(): void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -562,9 +591,11 @@ export interface IpnClientHandle extends IpnClient {
|
||||
export class IpnWorkerClient implements IpnClientHandle, TransferHost {
|
||||
readonly store: IpnWorkerClientStore
|
||||
readonly clientKey?: string
|
||||
readonly workerConnection: WorkerConnection
|
||||
readonly #port: MessagePort
|
||||
#runOpts: IPNRunOptions | null = null
|
||||
#releaseLock: (() => void) | null = null
|
||||
#disconnectPromise: Promise<void> | null = null
|
||||
#pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>()
|
||||
#fileOps = false
|
||||
#state: IPNState = "NoState"
|
||||
@@ -572,10 +603,16 @@ export class IpnWorkerClient implements IpnClientHandle, TransferHost {
|
||||
#transferHandlers = new Map<string, (t: TransferEvent) => void>()
|
||||
#transferBuffer = new Map<string, Extract<W2C, { type: "transfer" }>[]>()
|
||||
|
||||
private constructor(port: MessagePort, store: IpnWorkerClientStore, clientKey?: string) {
|
||||
private constructor(
|
||||
port: MessagePort,
|
||||
store: IpnWorkerClientStore,
|
||||
workerConnection: WorkerConnection,
|
||||
clientKey?: string,
|
||||
) {
|
||||
this.#port = port
|
||||
this.store = store
|
||||
this.clientKey = clientKey
|
||||
this.workerConnection = workerConnection
|
||||
this.#state = store.getState().state.state
|
||||
port.onmessage = (e: MessageEvent) => this.#onMessage(e.data as W2C)
|
||||
}
|
||||
@@ -599,8 +636,8 @@ export class IpnWorkerClient implements IpnClientHandle, TransferHost {
|
||||
* Connect to a SharedWorker running the tsconnect-worker script.
|
||||
*
|
||||
* @param port The `SharedWorker.port` to communicate on.
|
||||
* @param config Worker and IPN configuration. Ignored if the worker is
|
||||
* already running (a second tab connecting to the same worker).
|
||||
* @param config Worker and IPN configuration. Must match the effective
|
||||
* configuration when attaching to an existing worker.
|
||||
*/
|
||||
static async connect(
|
||||
port: MessagePort,
|
||||
@@ -611,43 +648,67 @@ export class IpnWorkerClient implements IpnClientHandle, TransferHost {
|
||||
const lockReady = acquireLock(lockName)
|
||||
|
||||
let preloadedState: IpnState | undefined
|
||||
let workerConnection: WorkerConnection
|
||||
const bufferedActions: IpnAction[] = []
|
||||
const bufferedTransfers: Extract<W2C, { type: "transfer" }>[] = []
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
port.onmessage = (e: MessageEvent) => {
|
||||
const msg = e.data as W2C
|
||||
switch (msg.type) {
|
||||
case "ready":
|
||||
resolve()
|
||||
break
|
||||
case "initError":
|
||||
reject(new Error(msg.error))
|
||||
break
|
||||
case "preloadState":
|
||||
preloadedState = msg.state
|
||||
break
|
||||
case "action":
|
||||
bufferedActions.push(msg.action)
|
||||
break
|
||||
case "transfer":
|
||||
bufferedTransfers.push(msg)
|
||||
break
|
||||
try {
|
||||
workerConnection = await new Promise<WorkerConnection>((resolve, reject) => {
|
||||
port.onmessage = (e: MessageEvent) => {
|
||||
const msg = e.data as W2C
|
||||
switch (msg.type) {
|
||||
case "ready": {
|
||||
if (
|
||||
typeof msg.configIdentity !== "string" ||
|
||||
typeof msg.initialized !== "boolean" ||
|
||||
typeof msg.attachedDuringInitialization !== "boolean"
|
||||
) {
|
||||
reject(new Error("worker ready response omitted configuration details"))
|
||||
break
|
||||
}
|
||||
resolve({
|
||||
configIdentity: msg.configIdentity,
|
||||
initialized: msg.initialized,
|
||||
attachedDuringInitialization: msg.attachedDuringInitialization,
|
||||
})
|
||||
break
|
||||
}
|
||||
case "configMismatch":
|
||||
reject(new WorkerConfigMismatchError(msg.activeConfigIdentity, msg.mismatchedFields))
|
||||
break
|
||||
case "initError":
|
||||
reject(new Error(msg.error))
|
||||
break
|
||||
case "preloadState":
|
||||
preloadedState = msg.state
|
||||
break
|
||||
case "action":
|
||||
bufferedActions.push(msg.action)
|
||||
break
|
||||
case "transfer":
|
||||
bufferedTransfers.push(msg)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
port.start()
|
||||
port.postMessage({
|
||||
type: "hello",
|
||||
lockName,
|
||||
clientKey: opts?.clientKey,
|
||||
config,
|
||||
} satisfies C2W)
|
||||
})
|
||||
port.start()
|
||||
port.postMessage({
|
||||
type: "hello",
|
||||
lockName,
|
||||
clientKey: opts?.clientKey,
|
||||
config,
|
||||
} satisfies C2W)
|
||||
})
|
||||
} catch (err) {
|
||||
const releaseLock = await lockReady
|
||||
releaseLock()
|
||||
port.close()
|
||||
throw err
|
||||
}
|
||||
|
||||
const store = buildIpnStore(preloadedState)
|
||||
for (const action of bufferedActions) store.dispatch(action)
|
||||
|
||||
const client = new IpnWorkerClient(port, store, opts?.clientKey)
|
||||
const client = new IpnWorkerClient(port, store, workerConnection, opts?.clientKey)
|
||||
for (const msg of bufferedTransfers) client.#onMessage(msg)
|
||||
client.#releaseLock = await lockReady
|
||||
return client
|
||||
@@ -812,17 +873,54 @@ export class IpnWorkerClient implements IpnClientHandle, TransferHost {
|
||||
/** Release this client's hold on the worker without shutting the worker down.
|
||||
* The worker cleans up all resources opened by this client and shuts down
|
||||
* only if no other clients remain. Safe to call after {@link shutdown}. */
|
||||
disconnect(): void {
|
||||
disconnect(): Promise<void> {
|
||||
this.#disconnectPromise ??= this.#disconnect()
|
||||
return this.#disconnectPromise
|
||||
}
|
||||
|
||||
async #disconnect(): Promise<void> {
|
||||
this.#running = false
|
||||
this.#releaseLock?.()
|
||||
this.#releaseLock = null
|
||||
try {
|
||||
if (this.#releaseLock) {
|
||||
await this.#callWithTimeout("disconnectClient", []).catch(() => {})
|
||||
}
|
||||
} finally {
|
||||
this.#close(new Error("worker client disconnected"))
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.#running = false
|
||||
try {
|
||||
await this.#callWithTimeout("shutdown", [])
|
||||
} finally {
|
||||
this.#close(new Error("worker client shut down"))
|
||||
}
|
||||
}
|
||||
|
||||
async #callWithTimeout<T>(method: string, args: unknown[]): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
this.#call<T>(method, args),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error(`worker ${method} acknowledgement timed out`)),
|
||||
workerAcknowledgementTimeoutMs,
|
||||
)
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
if (timeout !== undefined) clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
#close(error: Error): void {
|
||||
this.#releaseLock?.()
|
||||
this.#releaseLock = null
|
||||
await this.#call("shutdown", [])
|
||||
this.#port.close()
|
||||
for (const pending of this.#pending.values()) pending.reject(error)
|
||||
this.#pending.clear()
|
||||
}
|
||||
|
||||
login(): void {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { suite, test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { comparableWorkerConfig, differentWorkerConfigFields } from "./config.js"
|
||||
|
||||
suite("worker configuration comparison", () => {
|
||||
test("normalizes effective defaults and ignores disabled FileOps settings", () => {
|
||||
const active = comparableWorkerConfig({ wasmUrl: "/main.wasm" })
|
||||
const requested = comparableWorkerConfig({
|
||||
wasmUrl: "/main.wasm",
|
||||
stateStorage: "indexeddb",
|
||||
stateDbName: "tsconnect-state",
|
||||
fileOps: false,
|
||||
fileOpsDir: "ignored",
|
||||
fileOpsMaxFiles: 1,
|
||||
})
|
||||
|
||||
assert.deepEqual(differentWorkerConfigFields(active, requested), [])
|
||||
})
|
||||
|
||||
test("compares only the addressed bytes of WASM views", () => {
|
||||
const active = comparableWorkerConfig({
|
||||
wasmUrl: new Uint8Array([0, 1, 2, 3]).subarray(1, 3),
|
||||
})
|
||||
const requested = comparableWorkerConfig({ wasmUrl: new Uint8Array([1, 2]).buffer })
|
||||
|
||||
assert.deepEqual(differentWorkerConfigFields(active, requested), [])
|
||||
})
|
||||
|
||||
test("reports mismatched field names without exposing their values", () => {
|
||||
const active = comparableWorkerConfig({
|
||||
wasmUrl: "/main.wasm",
|
||||
authKey: "secret-a",
|
||||
hostname: "alpha",
|
||||
})
|
||||
const requested = comparableWorkerConfig({
|
||||
wasmUrl: "/other.wasm",
|
||||
authKey: "secret-b",
|
||||
hostname: "beta",
|
||||
})
|
||||
|
||||
assert.deepEqual(differentWorkerConfigFields(active, requested), [
|
||||
"wasmUrl",
|
||||
"authKey",
|
||||
"hostname",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { WorkerConfig, WorkerConfigField } from "./protocol.js"
|
||||
|
||||
type ComparableWorkerConfig = {
|
||||
wasmUrl: string | Uint8Array
|
||||
authKey?: string
|
||||
controlURL?: string
|
||||
hostname?: string
|
||||
stateDbName?: string
|
||||
stateStorage: "indexeddb" | "memory"
|
||||
fileOps: boolean
|
||||
fileOpsDir?: string
|
||||
fileOpsMaxFiles?: number
|
||||
fileOpsMaxTotalSize?: number
|
||||
fileOpsMaxFileSize?: number
|
||||
}
|
||||
|
||||
function comparableWasmUrl(value: WorkerConfig["wasmUrl"]): string | Uint8Array {
|
||||
if (typeof value === "string") return value
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength))
|
||||
}
|
||||
return new Uint8Array(value.slice(0))
|
||||
}
|
||||
|
||||
export function comparableWorkerConfig(config: WorkerConfig): ComparableWorkerConfig {
|
||||
const stateStorage = config.stateStorage ?? "indexeddb"
|
||||
const fileOps = config.fileOps ?? false
|
||||
return {
|
||||
wasmUrl: comparableWasmUrl(config.wasmUrl),
|
||||
authKey: config.authKey,
|
||||
controlURL: config.controlURL,
|
||||
hostname: config.hostname,
|
||||
stateDbName:
|
||||
stateStorage === "indexeddb" ? (config.stateDbName ?? "tsconnect-state") : undefined,
|
||||
stateStorage,
|
||||
fileOps,
|
||||
fileOpsDir: fileOps ? config.fileOpsDir : undefined,
|
||||
fileOpsMaxFiles: fileOps ? config.fileOpsMaxFiles : undefined,
|
||||
fileOpsMaxTotalSize: fileOps ? config.fileOpsMaxTotalSize : undefined,
|
||||
fileOpsMaxFileSize: fileOps ? config.fileOpsMaxFileSize : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function wasmUrlsEqual(a: string | Uint8Array, b: string | Uint8Array): boolean {
|
||||
if (typeof a === "string" || typeof b === "string") return a === b
|
||||
return a.length === b.length && a.every((value, index) => value === b[index])
|
||||
}
|
||||
|
||||
export function differentWorkerConfigFields(
|
||||
active: ComparableWorkerConfig,
|
||||
requested: ComparableWorkerConfig,
|
||||
): WorkerConfigField[] {
|
||||
const fields: WorkerConfigField[] = []
|
||||
if (!wasmUrlsEqual(active.wasmUrl, requested.wasmUrl)) fields.push("wasmUrl")
|
||||
for (const field of [
|
||||
"authKey",
|
||||
"controlURL",
|
||||
"hostname",
|
||||
"stateDbName",
|
||||
"stateStorage",
|
||||
"fileOps",
|
||||
"fileOpsDir",
|
||||
"fileOpsMaxFiles",
|
||||
"fileOpsMaxTotalSize",
|
||||
"fileOpsMaxFileSize",
|
||||
] as const) {
|
||||
if (active[field] !== requested[field]) fields.push(field)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
export type { ComparableWorkerConfig }
|
||||
@@ -6,6 +6,8 @@ import { join, dirname } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { connectMainThread, IpnMainThreadHandle } from "./main-thread.js"
|
||||
import { connectWithFallback } from "./connect.js"
|
||||
import { buildIpnStore } from "@webnet/tsconnect-redux"
|
||||
import type { IPN } from "@webnet/tsconnect"
|
||||
import type { IpnClientHandle } from "./client.js"
|
||||
|
||||
const wasmPath = join(dirname(fileURLToPath(import.meta.url)), "../../tsconnect/dist/main.wasm")
|
||||
@@ -91,3 +93,23 @@ if (WASM_BUILT) {
|
||||
await h.shutdown()
|
||||
})
|
||||
}
|
||||
|
||||
test("main-thread disconnect suppresses shutdown failures without hiding them from shutdown", async () => {
|
||||
const shutdownError = new Error("Go runtime exited badly")
|
||||
let shutdownCalls = 0
|
||||
let releases = 0
|
||||
const ipn = {
|
||||
deleteWaitingFile: async () => {},
|
||||
run: () => {},
|
||||
shutdown: async () => {
|
||||
shutdownCalls++
|
||||
throw shutdownError
|
||||
},
|
||||
} as unknown as IPN
|
||||
const handle = new IpnMainThreadHandle(ipn, buildIpnStore(), undefined, () => releases++)
|
||||
|
||||
await handle.disconnect()
|
||||
await assert.rejects(handle.shutdown(), shutdownError)
|
||||
assert.equal(shutdownCalls, 1)
|
||||
assert.equal(releases, 1)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IpnWorkerClient } from "./client.js"
|
||||
import { IpnWorkerClient, WorkerConfigMismatchError } from "./client.js"
|
||||
import { connectMainThread } from "./main-thread.js"
|
||||
import type { IpnClientHandle } from "./client.js"
|
||||
import type { WorkerConfig } from "./protocol.js"
|
||||
@@ -40,8 +40,16 @@ export async function connectWithFallback(
|
||||
? workerUrlOrFactory()
|
||||
: new SharedWorker(workerUrlOrFactory, opts?.workerOptions)
|
||||
return await IpnWorkerClient.connect(sw.port, config, { clientKey: opts?.clientKey })
|
||||
} catch (err) {
|
||||
console.warn("tsconnect: SharedWorker path failed, falling back to main thread:", err)
|
||||
} catch (workerError) {
|
||||
if (workerError instanceof WorkerConfigMismatchError) throw workerError
|
||||
console.warn("tsconnect: SharedWorker path failed, falling back to main thread:", workerError)
|
||||
try {
|
||||
return await connectMainThread(config)
|
||||
} catch (mainThreadError) {
|
||||
const error = new Error("SharedWorker initialization and main-thread fallback both failed")
|
||||
;(error as Error & { errors: unknown[] }).errors = [workerError, mainThreadError]
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
WorkerTCPListener,
|
||||
WorkerPacketConn,
|
||||
workerDialer,
|
||||
WorkerConfigMismatchError,
|
||||
} from "./client.js"
|
||||
export type {
|
||||
IpnWorkerClientStore,
|
||||
@@ -15,4 +16,11 @@ export type {
|
||||
export { connectMainThread, IpnMainThreadHandle } from "./main-thread.js"
|
||||
export { connectWithFallback } from "./connect.js"
|
||||
export type { ConnectOptions } from "./connect.js"
|
||||
export type { WorkerConfig, TransferToken, ResourceKind, ResourceMeta } from "./protocol.js"
|
||||
export type {
|
||||
WorkerConfig,
|
||||
WorkerConfigField,
|
||||
WorkerConnection,
|
||||
TransferToken,
|
||||
ResourceKind,
|
||||
ResourceMeta,
|
||||
} from "./protocol.js"
|
||||
|
||||
@@ -31,11 +31,13 @@ export class IpnMainThreadHandle implements IpnClientHandle {
|
||||
readonly connectionMode = "main-thread" as const
|
||||
readonly transferSupported = false
|
||||
readonly clientKey = undefined
|
||||
readonly workerConnection = undefined
|
||||
readonly store: IpnWorkerClientStore
|
||||
#ipn: IPN
|
||||
#fsaFileOps: FsaFileOps | undefined
|
||||
#runOpts: IPNRunOptions | null = null
|
||||
#releaseLock: (() => void) | null = null
|
||||
#shutdownPromise: Promise<void> | null = null
|
||||
#fileOps = false
|
||||
#state: IPNState = "NoState"
|
||||
#running = false
|
||||
@@ -108,18 +110,21 @@ export class IpnMainThreadHandle implements IpnClientHandle {
|
||||
if (ofList.length) opts.notifyOutgoingFiles?.(ofList as IPNOutgoingFile[])
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
disconnect(): Promise<void> {
|
||||
return this.#stop().catch(() => {})
|
||||
}
|
||||
|
||||
#stop(): Promise<void> {
|
||||
this.#running = false
|
||||
this.#releaseLock?.()
|
||||
this.#releaseLock = null
|
||||
this.#ipn.shutdown().catch(() => {})
|
||||
this.#shutdownPromise ??= this.#ipn.shutdown().finally(() => {
|
||||
this.#releaseLock?.()
|
||||
this.#releaseLock = null
|
||||
})
|
||||
return this.#shutdownPromise
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.#running = false
|
||||
this.#releaseLock?.()
|
||||
this.#releaseLock = null
|
||||
await this.#ipn.shutdown()
|
||||
await this.#stop()
|
||||
}
|
||||
|
||||
login(): void {
|
||||
|
||||
@@ -23,6 +23,17 @@ export type WorkerConfig = {
|
||||
fileOpsMaxFileSize?: number
|
||||
}
|
||||
|
||||
export type WorkerConfigField = keyof WorkerConfig
|
||||
|
||||
export type WorkerConnection = {
|
||||
/** Opaque identity for the effective configuration. Stable for this worker's lifetime. */
|
||||
configIdentity: string
|
||||
/** Whether this client supplied the configuration that initialized the worker. */
|
||||
initialized: boolean
|
||||
/** Whether this client attached while that initialization was still pending. */
|
||||
attachedDuringInitialization: boolean
|
||||
}
|
||||
|
||||
export type FileOpsLimits = {
|
||||
maxFiles?: number
|
||||
maxTotalSize?: number
|
||||
@@ -49,7 +60,12 @@ export type C2W =
|
||||
// ── Main port: worker → client ──────────────────────────────────────────────
|
||||
|
||||
export type W2C =
|
||||
| { type: "ready" }
|
||||
| ({ type: "ready" } & WorkerConnection)
|
||||
| {
|
||||
type: "configMismatch"
|
||||
activeConfigIdentity: string
|
||||
mismatchedFields: WorkerConfigField[]
|
||||
}
|
||||
| { type: "initError"; error: string }
|
||||
| { type: "preloadState"; state: IpnState }
|
||||
| { type: "action"; action: IpnAction }
|
||||
@@ -87,6 +103,7 @@ export type ConnC2W =
|
||||
|
||||
export type ConnW2C =
|
||||
| { type: "data"; id: number; data: Uint8Array }
|
||||
| { type: "eof"; id: number }
|
||||
| { type: "readError"; id: number; error: string }
|
||||
| { type: "written"; id: number; n: number }
|
||||
| { type: "writeError"; id: number; error: string }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user