Skip to content

refactor: move Tailscale behind a transport contract - #3685

Merged
janicduplessis merged 4 commits into
mainfrom
feat/pluggable-transports
Oct 13, 2026
Merged

janicduplessis merged 4 commits into
mainfrom
feat/pluggable-transports

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Description

stim-server reaches phones and other machines only through Tailscale, and server.ts calls whois, serveRoute, setupServeRoute and readRawTailscaleStatus directly. There is no place to plug in another transport. This is phase 1 of the design, which is also in this PR. Later phases add local, ngrok and cloudflared transports, a key-based peer identity and the generic status surface for Desktop and the mobile app.

Solution

  • @stim-cli/transport is a contract-only package, in the same style as @stim-cli/cache: a module exports apiVersion, id and createTransport(). loadTransport() never throws: a missing module, a wrong apiVersion, a failing factory, a capability that is not a function or a 10 s timeout all come back as unavailable, and a transport created after the timeout is closed. The caller passes the import function, so bundled first-party transports use a plain import() and tests can mock them.
  • @stim-cli/transport-tailscale holds tailscale.ts and tailscale-monitor.ts, moved with git mv and otherwise left as they were, plus planServe. service-plist.ts re-exports planServe so setup.ts and service.ts keep their imports.
  • stim-server loads the Tailscale transport through the loader. server.ts gets peer identity (identify) and its extra listen addresses (bindAddresses, watch) only through the contract. The server listens on the bind addresses of every transport that has them. Only an identity returned by the Tailscale transport maps to the existing on-disk { kind: 'tailnet', nodeId, ... } record, so devices.json and the other registries do not change.
  • Three things still go to the Tailscale transport directly, through a typed extension: the legacy /health tailscale and route fields, route.setup, and the raw status that pins paired machines in the hosted relay and machine updates. They move onto generic status, setup and resolve in phases 2 and 3. The CLI side (core/state/tailnet.ts, offload/) is untouched here.
  • ServerOptions still accepts tailscale, tailscaleState and tailscaleMonitor and builds the transport from them, so the existing server tests run as before.

Packaging changes that come with two new published packages:

  • the server's dependencies
  • the Desktop server bundle's package list (apps/desktop/scripts/server-runtime.mjs)
  • the release workflow's PACKAGE_DIRS and two publish steps before @stim-cli/server
  • release-prep, runtime-floor and the RELEASE.md and recovery docs

Before the next release tag, @stim-cli/transport and @stim-cli/transport-tailscale need placeholder publications and trusted publishers. Follow "First publication of a NEW package" in docs/release-recovery.md. Without them, the publish job fails after the earlier packages have published.

Machine updates offer the two new packages to remote Macs from a checkout, so the expected package lists in machine-update.test.ts grow. That makes 7 packages. With the agent driver packages from #3686 it would be 9, past the receiver's old limit of 8, so MAX_PACKAGES in server-update.ts goes to 16. Remote Macs on older releases still accept at most 8 in a checkout update; release updates are unaffected.

service update --from named the install directory by version, Stim build and a digest of the server's own dist. Once the Tailscale code lives in another package, an upload that changes only that package would reuse the old directory and report the update as already running. The directory name now also includes a digest of the uploaded package files. Release installs keep their old names.

Test plan

  • The existing server suites pass unchanged. That covers the fake tailscale binary in server.test.ts, the x-forwarded-for peer cases, setup*.test.ts, registry.test.ts and hosted-relay*.test.ts. Three tests changed only because they mocked module paths that moved: setup-endpoint.test.ts now stubs the transport's identify, and shutdown.test.ts and loopback-only.test.ts stub createTransport.
  • New loader tests in packages/transport/__tests__/load.test.ts: a module that does not load, a different apiVersion, no factory, a throwing factory, a non-transport result, a capability that is not a function, a load that never settles, and a factory that finishes after the timeout.
  • server.test.ts: the server listens on the bind addresses of a transport without watch.
  • install-from.compat.test.ts (real npm): reinstalling with only @stim-cli/transport-tailscale changed goes to a new directory.
  • Real Tailscale: I started the built dist/stim-server.mjs --loopback-only with a temporary STIM_HOME on a Mac with Tailscale running. /health returned 200 with tailscale: { state: 'running', dnsName: '<mac>.ts.net' } and route: { state: 'missing', port: 7444 }, read through the loaded transport.
  • Full pnpm test: 8912 passed. Format, lint, build, typecheck, knip, rpc:check and test:runtime are clean.

Refs #3681

Add @stim-cli/transport, the contract and loader for stim-server
transports, and @stim-cli/transport-tailscale, which holds the Tailscale
driver moved out of packages/server. stim-server loads the Tailscale
transport through the loader and reaches peer identity and its extra
listen addresses only through the contract. The /health fields,
route.setup and paired machine pinning still read the Tailscale
transport directly until the generic status and resolve capabilities
land.

No behavior change. Refs #3681.
@janicduplessis janicduplessis added area: server packages/server area: desktop apps/desktop area: docs docs/ and root documents area: repo .github/, scripts/, test/, packaging/, patches/, root config labels Oct 13, 2026
Listen on the bind addresses of every transport, not only of those that
report changes, and give the Tailscale transport bind addresses only when
it has a monitor, as before. The loader refuses a capability that is not
a function and closes a transport that a factory creates after the load
timed out. stim-server binds an identity to the transport that answered.

A service install from package files is keyed by those files too, so an
update that changes only a dependency package installs it. Align the
design with the phase 1 contract and list the new packages in the
READMEs.

Refs #3681.
The server's workspace closure grows with the transport and agent driver
packages, past the old limit of 8. Refs #3681.
@janicduplessis janicduplessis added review: ai-approved A fresh AI review found nothing left to fix review: needs-human Holds merge until a maintainer reviews the lines named in the Human review: comment labels Oct 13, 2026
@janicduplessis

Copy link
Copy Markdown
Collaborator Author

Human review: reviewed at b2e5e99

  • .github/workflows/release.yml:52 adds transport and transport-tailscale to the packages the publish job (environment release, id-token: write) builds, packs and checks.
  • .github/workflows/release.yml:197-212 adds two npm publish --provenance steps for @stim-cli/transport and @stim-cli/transport-tailscale in that job. Both packages need placeholder publications and trusted publishers before the next tag, or the job fails after the earlier packages publish.

@janicduplessis
janicduplessis marked this pull request as ready for review October 13, 2026 15:06
@janicduplessis
janicduplessis added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit 1039c16 Oct 13, 2026
37 of 42 checks passed
@janicduplessis
janicduplessis deleted the feat/pluggable-transports branch October 13, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: desktop apps/desktop area: docs docs/ and root documents area: repo .github/, scripts/, test/, packaging/, patches/, root config area: server packages/server review: ai-approved A fresh AI review found nothing left to fix review: needs-human Holds merge until a maintainer reviews the lines named in the Human review: comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant