Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
outputs:
dist_tag: ${{ steps.dist-tag.outputs.dist_tag }}
env:
PACKAGE_DIRS: core cache cache-s3 metro expo-build-cache stim-cli ci server
PACKAGE_DIRS: core cache cache-s3 metro expo-build-cache stim-cli ci transport transport-tailscale server
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
Expand Down Expand Up @@ -194,6 +194,22 @@ jobs:
else
npm publish "$RUNNER_TEMP/tarballs/ci.tgz" --provenance --access public --tag "$DIST_TAG"
fi
- name: Publish @stim-cli/transport
run: |
version=$(node -p "require('$RUNNER_TEMP/tarballs/transport.package.json').version")
if npm view "@stim-cli/transport@$version" version >/dev/null 2>&1; then
echo "@stim-cli/transport@$version is already published"
else
npm publish "$RUNNER_TEMP/tarballs/transport.tgz" --provenance --access public --tag "$DIST_TAG"
fi
- name: Publish @stim-cli/transport-tailscale
run: |
version=$(node -p "require('$RUNNER_TEMP/tarballs/transport-tailscale.package.json').version")
if npm view "@stim-cli/transport-tailscale@$version" version >/dev/null 2>&1; then
echo "@stim-cli/transport-tailscale@$version is already published"
else
npm publish "$RUNNER_TEMP/tarballs/transport-tailscale.tgz" --provenance --access public --tag "$DIST_TAG"
fi
- name: Publish @stim-cli/server
run: |
version=$(node -p "require('$RUNNER_TEMP/tarballs/server.package.json').version")
Expand All @@ -215,7 +231,7 @@ jobs:
permissions:
contents: read
env:
PACKAGE_DIRS: core cache cache-s3 metro expo-build-cache stim-cli ci server
PACKAGE_DIRS: core cache cache-s3 metro expo-build-cache stim-cli ci transport transport-tailscale server
DIST_TAG: ${{ needs.publish.outputs.dist_tag }}
npm_config_prefer_online: 'true'
steps:
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ stim guide settings
- [`@stim-cli/core`](./packages/core) contains shared internal cache contracts.
- [`@stim-cli/server`](./packages/server) serves Stim status to paired
read-only clients, such as the phone app, over Tailscale.
- [`@stim-cli/transport`](./packages/transport) is the contract for the
transports that carry clients to stim-server, and
[`@stim-cli/transport-tailscale`](./packages/transport-tailscale) is its
Tailscale transport.

## Development

Expand Down
6 changes: 4 additions & 2 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ packages/cache @stim-cli/cache cache provider contra
packages/cache-s3 @stim-cli/cache-s3 optional S3-compatible cache provider
packages/expo-build-cache @stim-cli/expo-build-cache Expo build cache provider
packages/metro @stim-cli/metro shared Metro transform cache + log reporter
packages/transport @stim-cli/transport transport contract for stim-server
packages/transport-tailscale @stim-cli/transport-tailscale Tailscale transport for stim-server
packages/server @stim-cli/server stim-server, read-only status for paired clients
```

Expand Down Expand Up @@ -82,7 +84,7 @@ Every package name must already exist on npm, because trusted publishing can
only be configured for an existing package:

```bash
for p in @stim-cli/core @stim-cli/cache @stim-cli/cache-s3 @stim-cli/metro @stim-cli/expo-build-cache stim @stim-cli/ci @stim-cli/server; do
for p in @stim-cli/core @stim-cli/cache @stim-cli/cache-s3 @stim-cli/metro @stim-cli/expo-build-cache stim @stim-cli/ci @stim-cli/transport @stim-cli/transport-tailscale @stim-cli/server; do
npm view "$p" name >/dev/null || echo "$p: first publication required"
done
```
Expand Down Expand Up @@ -190,7 +192,7 @@ preparation, `git status --short` may show only the draft

```bash
out=$(mktemp -d)
for p in core cache cache-s3 metro expo-build-cache stim-cli ci server; do
for p in core cache cache-s3 metro expo-build-cache stim-cli ci transport transport-tailscale server; do
tgz=$(cd "packages/$p" && pnpm pack --pack-destination "$out" | tail -1)
echo "== $p ($(tar -tzf "$tgz" | wc -l | tr -d ' ') files)"
tar -tzf "$tgz" | grep -E 'README|LICENSE'
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/scripts/server-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const NODE_SHA256 = {
arm64: '6239d4cf92d864487ec8cd3615038f7b67e7f58b77b21cd2f09ea9fbd68065fe',
x64: '0ae5a24c24bb7d015cd816c5036b3f90f2945aa872fcf54e58da054753b3a299',
};
const PACKAGES = ['core', 'cache', 'metro', 'stim-cli', 'server'];
const PACKAGES = ['core', 'cache', 'metro', 'stim-cli', 'transport', 'transport-tailscale', 'server'];

const usage = 'usage: node scripts/server-runtime.mjs <destination> [--universal]';
const args = process.argv.slice(2);
Expand Down
2 changes: 2 additions & 0 deletions docs/release-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ pnpm --filter @stim-cli/cache-s3 publish --access public --tag <dist-tag> --otp
pnpm --filter @stim-cli/metro publish --access public --tag <dist-tag> --otp <code>
pnpm --filter @stim-cli/expo-build-cache publish --access public --tag <dist-tag> --otp <code>
pnpm --filter stim publish --access public --tag <dist-tag> --otp <code>
pnpm --filter @stim-cli/transport publish --access public --tag <dist-tag> --otp <code>
pnpm --filter @stim-cli/transport-tailscale publish --access public --tag <dist-tag> --otp <code>
pnpm --filter @stim-cli/server publish --access public --tag <dist-tag> --otp <code>
```

Expand Down
140 changes: 140 additions & 0 deletions docs/specs/2026-10-13-pluggable-transports-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Pluggable transports

Date: 2026-10-13. Status: accepted.

## Summary

stim-server reaches phones and other machines only through Tailscale. This
design splits that into a transport contract, `@stim-cli/transport`, and one
package per backend: `@stim-cli/transport-tailscale`, `@stim-cli/transport-local`,
`@stim-cli/transport-ngrok`, and `@stim-cli/transport-cloudflared`. It follows
the cache provider contract in `@stim-cli/cache`: a versioned module, a bag of
optional capabilities, a loader that never throws, and a contract test kit.

`stim` and `@stim-cli/server` depend on every first-party transport for now,
and Stim Desktop bundles them. They may become optional installs later.

## What Tailscale does today

Stim uses Tailscale for four separate things. The contract keeps them
separate, because the other backends provide only some of them.

| Role | Today | Contract capability |
| --------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| Exposure and TLS | `tailscale serve` on a tailnet-only HTTPS port | `expose`, `setup` |
| Peer identity | `tailscale whois` gives the node a device token is bound to | `identify` |
| Discovery and pinning | `tailscale status --json` lists peer machines; a name that moves to another node is refused | `discover`, `resolve` |
| Bind addresses | The server also listens on the Tailscale addresses; the hosted Metro gateway trusts WireGuard source addresses | `bindAddresses`, `traits.privateNetwork` |

The pairing core does not depend on the transport and does not change: the QR
payload, `hello` with a pairing token and then a device token, the token
registry, and grant and revoke.

## Decisions

- Transports are machine settings, not project settings. stim-server is one
process per machine and ships inside Stim Desktop, so transports resolve from
the server's own dependencies. A cache provider, by contrast, resolves from
the project that names it. `network.transports` is an ordered list, so one
server can answer a simulator on loopback and a phone on the tailnet.
- Stim adds its own peer identity, so every transport gets a second factor
beside the device token. At pairing the client sends a public key. Later
`hello` calls answer a server challenge with that key, and the server proves
the key the QR code names. A transport identity, such as a Tailscale node,
remains an extra check where the transport has one.
- Identity comes from the transport that owns the listener, never from request
headers. `tailscale serve`, ngrok, and cloudflared all proxy from loopback with
`X-Forwarded-For`, so the header cannot tell them apart.
- ngrok and cloudflared decrypt traffic at their edge. This reverses the
"Transport alternatives" note of the stim-server design: these backends ship,
but only when the user turns them on, and Desktop and the docs say that the
provider can read the traffic. Application-layer encryption may follow.
- Transports without `privateNetwork` cannot carry hosted Metro, whose gateway
trusts source addresses, until hosted Metro moves onto the authenticated
WebSocket.
- Metro reach (`metro.tunnel`) moves onto the same plugins, through
`expose({ purpose: 'metro' })`, after the server side is done.

## Contract

```ts
export const apiVersion = 1;
export const id: string;
export const optionsSchema: JSONSchema; // phase 3: x-stim annotations, rendered by Desktop
export function createTransport(input: {
env: NodeJS.ProcessEnv;
options: Record<string, unknown>;
}): Transport | Promise<Transport>;

interface Transport {
id: string;
traits: { privateNetwork: boolean; tls: 'end-to-end' | 'edge' | 'none'; peerIdentity: boolean };
bindAddresses?(): string[];
watch?(listener: () => void): () => void;
identify?(peer: { address: string; request: IncomingMessage }): Promise<TransportIdentity | null>;
close?(): void;
// Later phases:
status?(): Promise<TransportStatus>;
setup?(input: { port: number; action: string }): Promise<TransportStatus>;
expose?(input: { port: number; purpose: 'server' | 'metro' }): Promise<Exposure>;
discover?(): Promise<TransportPeer[]>;
resolve?(target: { machine: string; peerId: string }): Promise<Endpoint | string>;
}
```

Phase 1 ships the members above the comment. The others are added as optional
members while `apiVersion` stays 1, the way `@stim-cli/cache` added `files`.
In phase 1, `address` is still the `X-Forwarded-For` peer that `tailscale serve`
sets, and stim-server binds an identity to the transport that returned it. Phase 2
gives each listener a single transport, so only the transport that owns the
connection is asked.

`TransportStatus` is a list of checks, each with a state (`waiting`,
`working`, `ok`, `problem`), a title, an optional detail, and an optional
remedy (a command to copy, a URL to open, or a `setup` action). Desktop already
models pairing steps this way, so it renders any transport's checks without
transport-specific code.

## Apps

Stim Desktop is a Swift app and cannot load plugins, so the server serializes
what the apps need:

- `/health` gains `transports`, one entry per configured transport with its
state, endpoint, and traits. The `tailscale` and `route` fields stay while
older Desktop builds are in use.
- `transport.status`, `transport.setup`, and `transport.discover` replace
Desktop's own `tailscale status --json` reads and the `route.setup` call.
- Each transport's `optionsSchema` joins `settings.schema.json` under
`network.options.<id>`, so the existing settings editor renders it. Cache
providers get the same optional `optionsSchema`.
- Desktop shows the generic checks list by default. The Tailscale screens keep
their custom content (app links, app or CLI install, Funnel warning) as an
override keyed on the transport id.
- The QR payload moves to `v: 2` with an `endpoints` list, each entry naming its
transport, plus the server key. The mobile app tries the endpoints in order
and stops relying on the `.ts.net` suffix to decide whether to trust a host.
Error and onboarding copy becomes per transport.

## Credentials

Machine credentials store `nodeId` and `dnsName`. They become
`{ transport, peerId, address }`, and records without a transport are read as
`tailscale`. Machine names in settings accept an optional `<transport>:`
prefix; no prefix means `tailscale`.

## Phases

1. Add `@stim-cli/transport` and move Tailscale into
`@stim-cli/transport-tailscale`, with the server reaching it only through the
contract. No behavior change; the existing server, setup, registry, and
hosted relay tests are the check.
2. Add `@stim-cli/transport-local` (loopback, and LAN with a self-signed
certificate pinned by the QR code), the `v: 2` QR payload, and the key-based
peer identity.
3. Serve `/health.transports`, `transport.status`, `transport.setup`, and
`transport.discover`, and move Desktop and the mobile app onto them.
4. Add `@stim-cli/transport-ngrok` and `@stim-cli/transport-cloudflared` for
pairing and paired connections.
5. Move Metro reach onto the transports, support remote machines over
transports without discovery, and carry hosted Metro over the WebSocket.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
],
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/cache/tsconfig.json && tsc --noEmit -p packages/cache-s3/tsconfig.json && tsc --noEmit -p packages/stim-cli/tsconfig.json && tsc --noEmit -p packages/metro/tsconfig.json && tsc --noEmit -p packages/expo-build-cache/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ci/tsconfig.json && tsc --noEmit -p .github/actions/stim-ci/tsconfig.json",
"typecheck": "tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/cache/tsconfig.json && tsc --noEmit -p packages/cache-s3/tsconfig.json && tsc --noEmit -p packages/stim-cli/tsconfig.json && tsc --noEmit -p packages/metro/tsconfig.json && tsc --noEmit -p packages/expo-build-cache/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/transport/tsconfig.json && tsc --noEmit -p packages/transport-tailscale/tsconfig.json && tsc --noEmit -p packages/ci/tsconfig.json && tsc --noEmit -p .github/actions/stim-ci/tsconfig.json",
"lint": "oxlint . && node scripts/type-assertion-ratchet.mjs",
"lint:fix": "oxlint --fix .",
"format": "oxfmt .",
Expand Down
8 changes: 5 additions & 3 deletions packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1135,14 +1135,16 @@ the job to it. `--release <version>` takes an exact version from the public npm
registry (`https://registry.npmjs.org/`, never a registry `.npmrc` names), and
npm checks each package against the registry's integrity. `--from <dir>`
installs the `.tgz` packages in that directory, such as `pnpm pack` output of
the `@stim-cli/core`, `@stim-cli/cache`, `@stim-cli/metro`, `stim` and
`@stim-cli/server` packages of a checkout, and records each file's sha256.
the `@stim-cli/core`, `@stim-cli/cache`, `@stim-cli/metro`, `stim`,
`@stim-cli/transport`, `@stim-cli/transport-tailscale` and `@stim-cli/server`
packages of a checkout, and records each file's sha256.
Either way, `npm audit signatures` must verify the registry signature of every
package that came from the registry, and the provenance attestation of each one
that has it, or the update stops before it touches the job. Install scripts
never run. Each install goes to
`~/Library/Application Support/Stim/services/<label>/versions/`, in a directory
named by its version, Stim build and server code, and must start with the job's
named by its version, Stim build and server code, plus the package files for
`--from`, and must start with the job's
`node` before the switch. `update` then waits up to 30 minutes for `/health` to
report no offloaded build and no hosted session (a server older than `busy` in
`/health` restarts without waiting), rewrites only the server script in the
Expand Down
11 changes: 9 additions & 2 deletions packages/server/__tests__/install-from.compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const SERVER_DIR = fileURLToPath(new URL('..', import.meta.url));

let root: string;

async function packAtUnpublishedVersion(out: string): Promise<void> {
async function packAtUnpublishedVersion(out: string, change?: string): Promise<void> {
const packages = workspacePackages(SERVER_DIR);
if (!packages) throw new Error('This test runs from a Stim checkout.');
const names = new Set(packages.keys());
Expand All @@ -31,6 +31,7 @@ async function packAtUnpublishedVersion(out: string): Promise<void> {
}
}
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
if (manifest.name === change) writeFileSync(join(work, 'package', 'README.md'), 'changed\n');
execFileSync('/usr/bin/tar', ['-czf', join(out, file.name), '-C', work, 'package'], {
env: { ...process.env, COPYFILE_DISABLE: '1' },
});
Expand Down Expand Up @@ -67,5 +68,11 @@ describe('stim-server service update --from against real npm', () => {
expect(fromRegistry.length).toBeGreaterThan(0);
const report = execFileSync('npm', ['audit', 'signatures', '--prefix', installed.dir], { encoding: 'utf8' });
expect(report).toContain(`${fromRegistry.length} packages have verified registry signatures`);
}, 300_000);

const changed = join(root, 'changed');
mkdirSync(changed);
await packAtUnpublishedVersion(changed, '@stim-cli/transport-tailscale');
const reinstalled = await installServer(versions, { from: changed }, process.execPath, () => {});
expect(reinstalled.dir).not.toBe(installed.dir);
}, 600_000);
});
20 changes: 13 additions & 7 deletions packages/server/__tests__/loopback-only.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,19 @@ vi.mock('../src/environment.ts', () => ({
vi.mock('@stim-cli/core/toolchain', () => ({
discoverToolchain: (env: NodeJS.ProcessEnv) => ({ env: { ...env }, entries: [] }),
}));
vi.mock('../src/tailscale.ts', () => ({
findTailscale: () => null,
readTailscaleStatus: async () => ({ state: 'unavailable', reason: 'fixture' }),
}));
vi.mock('../src/tailscale-monitor.ts', () => ({
watchTailscale: () => ({ stop: () => {}, onChange: () => {} }),
}));
vi.mock('@stim-cli/transport-tailscale', async (original) => {
const actual = await original<typeof import('@stim-cli/transport-tailscale')>();
const snapshot = { binary: null, state: { state: 'unavailable' as const, reason: 'fixture' } };
return {
...actual,
createTransport: async ({ env }: { env: NodeJS.ProcessEnv }) =>
actual.tailscaleTransport({
env,
snapshot,
monitor: { current: () => snapshot, stop: () => {}, onChange: () => () => {} },
}),
};
});

async function serve(...flags: string[]): Promise<Record<string, unknown>> {
const home = mkdtempSync(join(tmpdir(), 'stim-server-loopback-'));
Expand Down
4 changes: 4 additions & 0 deletions packages/server/__tests__/machine-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ describe('client build packing', () => {
'@stim-cli/core',
'@stim-cli/metro',
'@stim-cli/server',
'@stim-cli/transport',
'@stim-cli/transport-tailscale',
'stim',
]);
expect(workspacePackages(join(SERVER, 'node_modules', '@stim-cli', 'server'))).toBeNull();
Expand Down Expand Up @@ -142,6 +144,8 @@ describe('asking a remote Mac to update', () => {
'stim-cli-core',
'stim-cli-metro',
'stim-cli-server',
'stim-cli-transport',
'stim-cli-transport-tailscale',
]);
for (const { name, size } of offered) {
const chunks = calls.filter((call) => call.method === 'server.update.chunk' && call.params.name === name);
Expand Down
4 changes: 2 additions & 2 deletions packages/server/__tests__/server-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ describe('server update requests', () => {
const entry = { name: 'stim.tgz', size: 1, sha256: sha('x') };
expect(parseUpdateStart({ packages: [entry, { ...entry, name: 'STIM.tgz' }] })).toContain('listed twice');
expect(parseUpdateStart({ packages: [{ ...entry, size: 64 * 1024 ** 2 + 1 }] })).toContain('64 MiB');
const many = Array.from({ length: 9 }, (_, index) => ({ ...entry, name: `p${index}.tgz` }));
expect(parseUpdateStart({ packages: many })).toContain('1 to 8');
const many = Array.from({ length: 17 }, (_, index) => ({ ...entry, name: `p${index}.tgz` }));
expect(parseUpdateStart({ packages: many })).toContain('1 to 16');
const large = Array.from({ length: 3 }, (_, index) => ({ ...entry, name: `p${index}.tgz`, size: 50 * 1024 ** 2 }));
expect(parseUpdateStart({ packages: large })).toContain('128 MiB');
});
Expand Down
Loading
Loading