authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-26 19:48:09-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-26 19:48:09-07:00
log23b55022175e69938514934f65c5a607d38f1e47
tree95fa44a061e32d7caf27b54160e5d84b96970a2c
parenta17cbc3b4022b824cda9b199e4c53a8c9a608923
signature Signed by PGP key B5690EEEBB952194

feat(dev): Make t3 code dev instances shareable over Tailscale (#4556)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

29 files changed, 1417 insertions(+), 65 deletions(-)

.agents/skills/test-t3-app/SKILL.md+5-1
......@@ -13,13 +13,17 @@ Use this skill for the web client. For iOS Simulator, Android Emulator, or physi
13132. Choose a base directory that belongs only to the current worktree or test:
1414 - Use the repository's ignored `.t3` directory for reusable worktree-local state.
1515 - Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path.
163. Start the full web stack with `vp run dev`. In a linked worktree it defaults to that worktree's gitignored `.t3`; pass `--home-dir <base-dir>` only when the test needs a different isolated directory.
163. Start the full web stack with `vp run dev`. Add `--share` when the user needs to open it from another tailnet device. In a linked worktree it defaults to that worktree's gitignored `.t3`; pass `--home-dir <base-dir>` only when the test needs a different isolated directory.
17174. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output.
1818
1919Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership.
2020
2121The worktree-local default deliberately outranks an ambient `T3CODE_HOME`; do not pass the shared home through to a worktree dev server.
2222
23Ports are derived from the worktree path but can shift when occupied. Always read the actual values from the `[dev-runner]` line.
24
25Shared browser dev is single-origin: Vite proxies the backend paths, so never set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`.
26
2327The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it.
2428
2529## Preserve the environment while iterating
AGENTS.md+3
......@@ -16,6 +16,9 @@
1616## Dev Servers
1717
1818- In a linked git worktree, dev state defaults to that worktree's gitignored `.t3`. This deliberately outranks an ambient `T3CODE_HOME`, which could otherwise select the installed app's live `~/.t3/userdata` database. An explicit `--home-dir` still wins.
19- Start the web stack with `vp run dev`. Add `--share` when someone needs to open it from another device on the tailnet.
20- Browser dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`.
21- Worktree paths supply stable preferred port offsets. Read the actual server and web ports from the `[dev-runner]` line because occupied ports can still shift them.
1922
2023## Package Roles
2124
apps/server/src/auth/EnvironmentAuth.test.ts+15-1
......@@ -8,9 +8,13 @@ import * as ServerConfig from "../config.ts";
88import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
99import * as PairingGrantStore from "./PairingGrantStore.ts";
1010import * as EnvironmentAuth from "./EnvironmentAuth.ts";
11import { resolveSessionCookieName } from "./utils.ts";
1112
1213import * as ServerSecretStore from "./ServerSecretStore.ts";
1314
15/** Pinned so dev-mode cookie tests can assert the port-scoped name. */
16const TEST_SERVER_PORT = 13_773;
17
1418const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
1519 Layer.effect(
1620 ServerConfig.ServerConfig,
......@@ -19,6 +23,12 @@ const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Se
1923 return {
2024 ...config,
2125 ...overrides,
26 // Last, so the port cannot be overridden out from under
27 // makeCookieRequest — which builds the cookie name from this constant.
28 // An override that changed it would leave the server reading
29 // t3_session_<other> while every request still sent t3_session_13773,
30 // and the tests would fail for a reason unrelated to what they assert.
31 port: TEST_SERVER_PORT,
2232 } satisfies ServerConfig.ServerConfig["Service"];
2333 }),
2434 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" })));
......@@ -35,7 +45,11 @@ const makeCookieRequest = (
3545): Parameters<EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]>[0] =>
3646 ({
3747 cookies: {
38 t3_session: sessionToken,
48 // Derived, not hardcoded: the name is port-scoped so concurrent servers
49 // on one hostname don't share a cookie. Mode and devUrl mirror
50 // ServerConfig.layerTest, so this resolves to whatever the server reads.
51 [resolveSessionCookieName({ mode: "web", port: TEST_SERVER_PORT, devUrl: undefined })]:
52 sessionToken,
3953 },
4054 headers: {},
4155 }) as unknown as Parameters<
apps/server/src/auth/EnvironmentAuth.ts+5
......@@ -438,6 +438,7 @@ export class EnvironmentAuth extends Context.Service<
438438 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
439439 readonly subject?: string;
440440 readonly proofKeyThumbprint?: string;
441 readonly purpose?: "startup";
441442 }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>;
442443 readonly issuePairingCredential: (
443444 input?: AuthCreatePairingCredentialInput,
......@@ -746,11 +747,13 @@ export const make = Effect.gen(function* () {
746747 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
747748 readonly subject: string;
748749 readonly label?: string;
750 readonly purpose?: "startup";
749751 }) =>
750752 createPairingLink({
751753 scopes: input.scopes,
752754 subject: input.subject,
753755 ...(input.label ? { label: input.label } : {}),
756 ...(input.purpose ? { purpose: input.purpose } : {}),
754757 }).pipe(
755758 Effect.map(
756759 (issued) =>
......@@ -774,6 +777,7 @@ export const make = Effect.gen(function* () {
774777 ...(input?.ttl ? { ttl: input.ttl } : {}),
775778 ...(input?.label ? { label: input.label } : {}),
776779 ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
780 ...(input?.purpose ? { purpose: input.purpose } : {}),
777781 });
778782 return {
779783 id: issued.id,
......@@ -872,6 +876,7 @@ export const make = Effect.gen(function* () {
872876 issuePairingCredentialForSubject({
873877 scopes: AuthAdministrativeScopes,
874878 subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
879 purpose: "startup",
875880 }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));
876881
877882 const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) =>
apps/server/src/auth/EnvironmentAuthPolicy.test.ts+37
......@@ -34,6 +34,9 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
3434
3535 expect(descriptor.policy).toBe("desktop-managed-local");
3636 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);
37 // Packaged desktop has no devUrl, but still needs the port scope: it
38 // scans upward from 3773 for a free port and binds 127.0.0.1, so a second
39 // instance shares this one's hostname on a different port.
3740 expect(descriptor.sessionCookieName).toBe("t3_session_3773");
3841 }).pipe(
3942 Effect.provide(
......@@ -45,6 +48,22 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
4548 ),
4649 );
4750
51 it.effect("keeps desktop cookies port-scoped on the port a second instance lands on", () =>
52 Effect.gen(function* () {
53 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
54 const descriptor = yield* policy.getDescriptor();
55
56 expect(descriptor.sessionCookieName).toBe("t3_session_3774");
57 }).pipe(
58 Effect.provide(
59 makeEnvironmentAuthPolicyLayer({
60 mode: "desktop",
61 port: 3774,
62 }),
63 ),
64 ),
65 );
66
4867 it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () =>
4968 Effect.gen(function* () {
5069 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
......@@ -75,6 +94,24 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
7594 makeEnvironmentAuthPolicyLayer({
7695 mode: "web",
7796 host: "127.0.0.1",
97 port: 13773,
98 }),
99 ),
100 ),
101 );
102
103 it.effect("scopes web session cookies by port only in development", () =>
104 Effect.gen(function* () {
105 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
106 const descriptor = yield* policy.getDescriptor();
107
108 expect(descriptor.sessionCookieName).toBe("t3_session_13773");
109 }).pipe(
110 Effect.provide(
111 makeEnvironmentAuthPolicyLayer({
112 mode: "web",
113 port: 13773,
114 devUrl: new URL("http://127.0.0.1:5733"),
78115 }),
79116 ),
80117 ),
apps/server/src/auth/EnvironmentAuthPolicy.ts+1
......@@ -41,6 +41,7 @@ export const make = Effect.gen(function* () {
4141 sessionCookieName: resolveSessionCookieName({
4242 mode: config.mode,
4343 port: config.port,
44 devUrl: config.devUrl,
4445 }),
4546 };
4647
apps/server/src/auth/PairingGrantStore.ts+18-1
......@@ -202,6 +202,11 @@ export class PairingGrantStore extends Context.Service<
202202 readonly subject?: string;
203203 readonly label?: string;
204204 readonly proofKeyThumbprint?: string;
205 /**
206 * "startup" marks the credential the server mints for itself at boot,
207 * which gets the long dev TTL when a dev URL is configured.
208 */
209 readonly purpose?: "startup";
205210 }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;
206211 readonly listActive: () => Effect.Effect<
207212 ReadonlyArray<AuthPairingLink>,
......@@ -243,6 +248,15 @@ const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);
243248// window can still recover by re-bootstrapping rather than locking
244249// the user out of the backend.
245250const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24);
251// A dev server's startup token is read off a log by whoever (or whatever) is
252// driving the session, often minutes later — after a `node --watch` restart, a
253// detour into another task, or a hand-off to the person actually doing the
254// testing. Five minutes turns that into a restart-the-server loop for no
255// security benefit: the token only unlocks a local dev backend, and its holder
256// could read the log anyway. Same reasoning (and duration) as the desktop
257// bootstrap grant above. Only applies when a dev URL is configured; user-issued
258// pairing links and real servers keep the 5-minute default.
259const DEV_STARTUP_TTL_HOURS = Duration.hours(24);
246260const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
247261const PAIRING_TOKEN_LENGTH = 12;
248262const PAIRING_TOKEN_REJECTION_LIMIT =
......@@ -371,7 +385,10 @@ export const make = Effect.gen(function* () {
371385 ),
372386 );
373387 const credential = yield* generatePairingToken;
374 const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
388 const isDevStartupToken = config.devUrl !== undefined && input?.purpose === "startup";
389 const ttl =
390 input?.ttl ??
391 (isDevStartupToken ? DEV_STARTUP_TTL_HOURS : DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES);
375392 const now = yield* DateTime.now;
376393 const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
377394 const issued: IssuedBootstrapCredential = {
apps/server/src/auth/SessionStore.ts+1
......@@ -470,6 +470,7 @@ export const make = Effect.gen(function* () {
470470 const cookieName = resolveSessionCookieName({
471471 mode: serverConfig.mode,
472472 port: serverConfig.port,
473 devUrl: serverConfig.devUrl,
473474 });
474475
475476 const emitUpsert = (clientSession: AuthClientSession) =>
apps/server/src/auth/utils.ts+19-5
......@@ -10,15 +10,29 @@ import * as Result from "effect/Result";
1010
1111const SESSION_COOKIE_NAME = "t3_session";
1212
13/**
14 * Cookies are scoped by host but *not* by port, so any two servers that can be
15 * live on one hostname at once need separate names — otherwise the second
16 * clobbers the first's session and both sides see "Invalid session token
17 * signature" until someone clears cookies by hand.
18 *
19 * Two populations qualify, for the same reason but from different causes:
20 *
21 * - **Dev servers** (`devUrl` set), which run several at a time across worktrees.
22 * - **Desktop**, which scans upward from 3773 for a free port and binds
23 * 127.0.0.1, so a second instance lands on a different port and the same host.
24 *
25 * Hosted deployments keep the stable production name: their public port can
26 * change between releases, and scoping it would log every user out.
27 */
1328export function resolveSessionCookieName(input: {
1429 readonly mode: "web" | "desktop";
1530 readonly port: number;
31 readonly devUrl: URL | undefined;
1632}): string {
17 if (input.mode !== "desktop") {
18 return SESSION_COOKIE_NAME;
19 }
20
21 return `${SESSION_COOKIE_NAME}_${input.port}`;
33 return input.devUrl === undefined && input.mode !== "desktop"
34 ? SESSION_COOKIE_NAME
35 : `${SESSION_COOKIE_NAME}_${input.port}`;
2236}
2337
2438export function base64UrlEncode(input: string | Uint8Array): string {
apps/server/src/bin.test.ts+1
......@@ -84,6 +84,7 @@ const makeCliTestServerConfig = (baseDir: string) =>
8484 ...derivedPaths,
8585 staticDir: undefined,
8686 devUrl: undefined,
87 devAllowedOrigins: [],
8788 noBrowser: true,
8889 startupPresentation: "browser",
8990 desktopBootstrapToken: undefined,
apps/server/src/cli/config.test.ts+4
......@@ -48,6 +48,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
4848 otlpMetricsUrl: undefined,
4949 otlpExportIntervalMs: 10_000,
5050 otlpServiceName: "t3-server",
51 devAllowedOrigins: [],
5152 } as const;
5253
5354 const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) {
......@@ -95,6 +96,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
9596 T3CODE_HOST: "0.0.0.0",
9697 T3CODE_HOME: baseDir,
9798 VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
99 T3CODE_DEV_ALLOWED_ORIGINS:
100 "https://host.example.ts.net, https://phone.example.ts.net ",
98101 T3CODE_NO_BROWSER: "true",
99102 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
100103 T3CODE_LOG_WS_EVENTS: "true",
......@@ -117,6 +120,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
117120 host: "0.0.0.0",
118121 staticDir: undefined,
119122 devUrl: new URL("http://127.0.0.1:5173"),
123 devAllowedOrigins: ["https://host.example.ts.net", "https://phone.example.ts.net"],
120124 noBrowser: true,
121125 startupPresentation: "browser",
122126 desktopBootstrapToken: undefined,
apps/server/src/cli/config.ts+10
......@@ -106,6 +106,15 @@ const EnvServerConfig = Config.all({
106106 host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
107107 t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
108108 devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
109 devAllowedOrigins: Config.string("T3CODE_DEV_ALLOWED_ORIGINS").pipe(
110 Config.withDefault(""),
111 Config.map((value) =>
112 value
113 .split(",")
114 .map((entry) => entry.trim())
115 .filter((entry) => entry.length > 0),
116 ),
117 ),
109118 noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe(
110119 Config.option,
111120 Config.map(Option.getOrUndefined),
......@@ -363,6 +372,7 @@ export const resolveServerConfig = (
363372 host,
364373 staticDir,
365374 devUrl,
375 devAllowedOrigins: env.devAllowedOrigins,
366376 noBrowser,
367377 startupPresentation,
368378 desktopBootstrapToken,
apps/server/src/config.ts+2
......@@ -72,6 +72,7 @@ export class ServerConfig extends Context.Service<
7272 readonly baseDir: string;
7373 readonly staticDir: string | undefined;
7474 readonly devUrl: URL | undefined;
75 readonly devAllowedOrigins: ReadonlyArray<string>;
7576 readonly noBrowser: boolean;
7677 readonly startupPresentation: StartupPresentation;
7778 readonly desktopBootstrapToken: string | undefined;
......@@ -187,6 +188,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
187188 desktopBootstrapToken: undefined,
188189 staticDir: undefined,
189190 devUrl,
191 devAllowedOrigins: [],
190192 noBrowser: false,
191193 startupPresentation: "browser",
192194 });
apps/server/src/environment/ServerEnvironment.test.ts+1
......@@ -43,6 +43,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
4343 desktopBootstrapToken: undefined,
4444 staticDir: undefined,
4545 devUrl: undefined,
46 devAllowedOrigins: [],
4647 noBrowser: false,
4748 startupPresentation: "browser",
4849 } satisfies ServerConfig.ServerConfig["Service"];
apps/server/src/http.ts+14-1
......@@ -4,6 +4,7 @@ import {
44 AuthOrchestrationReadScope,
55 EnvironmentHttpApi,
66} from "@t3tools/contracts";
7import { isDevProxiedPath } from "@t3tools/shared/devProxy";
78import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
89import * as Data from "effect/Data";
910import * as Effect from "effect/Effect";
......@@ -48,9 +49,17 @@ export const browserApiCorsLayer = Layer.unwrap(
4849 const devOrigin = config.devUrl?.origin;
4950 // Dev uses credentialed requests from Vite or the Electron custom origin, so both must be
5051 // explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin.
52 //
53 // T3CODE_DEV_ALLOWED_ORIGINS covers dev servers reached from a second
54 // origin — a tailnet name, a LAN IP, a phone. Browser dev normally proxies
55 // through Vite and is same-origin (no preflight at all), so this is a
56 // safety net for the desktop renderer and any direct-to-backend caller.
5157 return HttpRouter.cors({
5258 ...(devOrigin
53 ? { allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS], credentials: true }
59 ? {
60 allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS, ...config.devAllowedOrigins],
61 credentials: true,
62 }
5463 : {}),
5564 allowedMethods: browserApiCorsAllowedMethods,
5665 allowedHeaders: browserApiCorsAllowedHeaders,
......@@ -216,6 +225,10 @@ export const staticAndDevRouteLayer = HttpRouter.add(
216225 }
217226
218227 const config = yield* ServerConfig.ServerConfig;
228 if (config.devUrl && isDevProxiedPath(url.value.pathname)) {
229 return HttpServerResponse.text("Not Found", { status: 404 });
230 }
231
219232 if (config.devUrl && isLoopbackHostname(url.value.hostname)) {
220233 return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {
221234 status: 302,
apps/server/src/server.test.ts+31
......@@ -380,6 +380,7 @@ const buildAppUnderTest = (options?: {
380380 ...derivedPaths,
381381 staticDir: undefined,
382382 devUrl,
383 devAllowedOrigins: [],
383384 noBrowser: true,
384385 startupPresentation: "browser",
385386 desktopBootstrapToken: defaultDesktopBootstrapToken,
......@@ -1328,6 +1329,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
13281329 "bearer-access-token",
13291330 "dpop-access-token",
13301331 ]);
1332 // Desktop, so port-scoped: instances scan for a free port and share
1333 // 127.0.0.1, and cookies are not scoped by port.
13311334 assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_"));
13321335 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
13331336 );
......@@ -3246,6 +3249,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
32463249 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
32473250 );
32483251
3252 it.effect("allows configured development origins through ServerConfig", () =>
3253 Effect.gen(function* () {
3254 const tailnetOrigin = "https://host.example.ts.net";
3255 yield* buildAppUnderTest({
3256 config: {
3257 devUrl: new URL(crossOriginClientOrigin),
3258 devAllowedOrigins: [tailnetOrigin],
3259 },
3260 });
3261
3262 const sessionUrl = yield* getHttpServerUrl("/api/auth/session");
3263 const response = yield* fetchEffect(sessionUrl, {
3264 method: "OPTIONS",
3265 headers: {
3266 origin: tailnetOrigin,
3267 "access-control-request-method": "GET",
3268 "access-control-request-headers": "content-type",
3269 },
3270 });
3271
3272 assert.equal(response.status, 204);
3273 assertBrowserApiCorsPreflightHeaders(response.headers, {
3274 origin: tailnetOrigin,
3275 credentials: true,
3276 });
3277 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3278 );
3279
32493280 for (const desktopOrigin of ["t3code://app", "t3code-dev://app"]) {
32503281 it.effect(`allows credentialed preflights from ${desktopOrigin} in development`, () =>
32513282 Effect.gen(function* () {
apps/web/vite.config.ts+74-25
......@@ -7,14 +7,27 @@ import "vite-plus/test/config";
77import { defineConfig } from "vite-plus";
88import pkg from "./package.json" with { type: "json" };
99
10import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy";
11
1012import { loadRepoEnv } from "../../scripts/lib/public-config";
1113
1214const repoEnv = loadRepoEnv();
1315Object.assign(process.env, repoEnv);
1416
17// Single-origin dev is signalled positively, because it cannot be inferred
18// from the absence of VITE_HTTP_URL/VITE_WS_URL: the runner deletes those keys
19// but `loadRepoEnv` merges `.env`/`.env.local` *underneath* the process env, so
20// a developer with either URL in their `.env` gets it back here. Baking it then
21// pins the client to localhost and breaks every non-localhost origin — the
22// exact failure single-origin mode exists to prevent, and an invisible one
23// since the page still loads.
24const isSingleOriginDev = process.env.T3CODE_SINGLE_ORIGIN_DEV === "1";
25
1526const port = Number(process.env.PORT ?? 5733);
16const host = process.env.HOST?.trim() || "localhost";
17const configuredWsUrl = process.env.VITE_WS_URL?.trim();
27const explicitHost = process.env.HOST?.trim();
28const host = explicitHost || "localhost";
29const configuredWsUrl = isSingleOriginDev ? undefined : process.env.VITE_WS_URL?.trim();
30const configuredHttpUrl = isSingleOriginDev ? undefined : process.env.VITE_HTTP_URL?.trim();
1831const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";
1932const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";
2033const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || "";
......@@ -65,7 +78,20 @@ const unitTestProject = {
6578 },
6679} satisfies TestProjectInlineConfiguration;
6780
68function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined {
81function resolveDevProxyTarget(
82 backendPort: string | undefined,
83 wsUrl: string | undefined,
84): string | undefined {
85 // Browser dev is single-origin: the backend port is proxied through this
86 // server so the app works from any origin (localhost, tailnet, LAN, phone).
87 // T3CODE_PORT is set by scripts/dev-runner.ts for every non-desktop mode.
88 const port = Number(backendPort?.trim());
89 if (Number.isInteger(port) && port > 0) {
90 return `http://localhost:${port}/`;
91 }
92
93 // dev:desktop still points the renderer straight at the backend, so fall
94 // back to deriving the target from the explicit websocket URL.
6995 if (!wsUrl) {
7096 return undefined;
7197 }
......@@ -86,7 +112,17 @@ function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined {
86112 }
87113}
88114
89const devProxyTarget = resolveDevProxyTarget(configuredWsUrl);
115const devProxyTarget = resolveDevProxyTarget(process.env.T3CODE_PORT, configuredWsUrl);
116
117// Vite rejects requests whose Host header isn't localhost, which blocks sharing
118// a dev server over Tailscale/LAN. Tailnet names are safe to allow wholesale:
119// the DNS is controlled by tailscale, so they can't be rebound by an attacker.
120// Anything else (ngrok, a LAN IP alias) goes through the env var.
121const configuredAllowedHosts = (process.env.T3CODE_DEV_ALLOWED_HOSTS ?? "")
122 .split(",")
123 .map((entry) => entry.trim())
124 .filter((entry) => entry.length > 0);
125const allowedHosts = [".ts.net", ...configuredAllowedHosts];
90126
91127export default defineConfig(() => {
92128 return {
......@@ -119,6 +155,10 @@ export default defineConfig(() => {
119155 define: {
120156 // In dev mode, tell the web app where the WebSocket server lives
121157 "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""),
158 // Pinned explicitly rather than left to Vite's automatic VITE_ exposure:
159 // under single-origin dev this must stay empty even when a `.env`
160 // supplies it, so the client falls back to window.location.origin.
161 "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""),
122162 "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl),
123163 "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey),
124164 "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate),
......@@ -145,32 +185,41 @@ export default defineConfig(() => {
145185 host,
146186 port,
147187 strictPort: true,
188 allowedHosts,
148189 ...(devProxyTarget
149190 ? {
150 proxy: {
151 "/.well-known": {
152 target: devProxyTarget,
153 changeOrigin: true,
154 },
155 "/api": {
156 target: devProxyTarget,
157 changeOrigin: true,
158 },
159 "/attachments": {
160 target: devProxyTarget,
161 changeOrigin: true,
162 },
191 // One entry per shared prefix; the server's dev catch-all 404s the
192 // same list, so the two sides cannot drift. `/ws` is the app's own
193 // socket — Vite's HMR socket is matched separately and exactly
194 // (path "/" plus a vite-hmr subprotocol), so the two upgrade
195 // handlers don't collide.
196 proxy: Object.fromEntries(
197 DEV_PROXIED_PATH_PREFIXES.map((prefix) => [
198 prefix,
199 {
200 target: devProxyTarget,
201 changeOrigin: true,
202 ...(prefix === "/ws" ? { ws: true } : {}),
203 },
204 ]),
205 ),
206 }
207 : {}),
208 // Electron's BrowserWindow needs the HMR socket pinned to an explicit
209 // host to connect reliably; dev:desktop is the only mode that sets HOST.
210 // Everywhere else, leaving this unset lets the client derive it from the
211 // page origin, which is what makes HMR work over Tailscale/LAN instead of
212 // failing an attempt against the wrong machine's localhost first.
213 // (Vite 8 logs connection state via console.debug — enable "Verbose".)
214 ...(explicitHost
215 ? {
216 hmr: {
217 protocol: "ws",
218 host: explicitHost,
219 clientPort: port,
163220 },
164221 }
165222 : {}),
166 hmr: {
167 // Explicit config so Vite's HMR WebSocket connects reliably
168 // inside Electron's BrowserWindow. Vite 8 uses console.debug for
169 // connection logs — enable "Verbose" in DevTools to see them.
170 protocol: "ws",
171 host,
172 clientPort: port,
173 },
174223 },
175224 build: {
176225 outDir: "dist",
docs/reference/scripts.md+8
......@@ -1,6 +1,7 @@
11# Scripts
22
33- `vp run dev` — Starts contracts, server, and web in watch mode.
4- `vp run dev --share` — Also publishes the web port over HTTPS on this machine's tailnet. The startup pairing URL is built against the shared origin, and the mapping is removed on exit.
45- `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`.
56- `vp run dev:web` — Starts just the Vite dev server for the web app.
67- Dev commands run from a linked **git worktree** default to that worktree's gitignored `.t3`, even when `T3CODE_HOME` is set, storing state in `<worktree>/.t3/userdata`. Pass `--home-dir <path>` to choose another isolated directory explicitly. Submodules are not worktrees and keep the normal precedence.
......@@ -24,6 +25,7 @@
2425- Default build is unsigned/not notarized for local sharing.
2526- The DMG build uses `assets/prod/black-macos-1024.png` as the production app icon source.
2627- Desktop production windows load the bundled UI from `t3code://app/index.html` (not a `127.0.0.1` document URL).
28
2729- Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic.
2830- Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch.
2931- To keep staging files for debugging package contents, run: `vp run dist:desktop:dmg -- --keep-stage`
......@@ -37,6 +39,12 @@
3739- Azure authentication env vars are also required (for example service principal with secret):
3840 `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`.
3941
42## Browser development
43
44`dev` and `dev:web` leave `VITE_HTTP_URL` and `VITE_WS_URL` unset so the browser resolves the backend from `window.location.origin`. Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the server, allowing the same bundle to work from localhost or a tailnet hostname.
45
46Worktrees derive a preferred port offset from their path. The runner shifts both ports together when either is occupied, so treat the `[dev-runner]` output as authoritative.
47
4048## Running multiple dev instances
4149
4250Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together.
package.json+1
......@@ -5,6 +5,7 @@
55 "scripts": {
66 "prepare": "effect-tsgo patch && vp config --no-agent",
77 "dev": "node scripts/dev-runner.ts dev",
8 "dev:share": "node scripts/dev-runner.ts dev --share",
89 "dev:server": "node scripts/dev-runner.ts dev:server",
910 "dev:web": "node scripts/dev-runner.ts dev:web",
1011 "dev:marketing": "vp run --filter @t3tools/marketing dev",
packages/shared/package.json+4
......@@ -198,6 +198,10 @@
198198 "./devHome": {
199199 "types": "./src/devHome.ts",
200200 "import": "./src/devHome.ts"
201 },
202 "./devProxy": {
203 "types": "./src/devProxy.ts",
204 "import": "./src/devProxy.ts"
201205 }
202206 },
203207 "scripts": {
packages/shared/src/devProxy.ts created+17
......@@ -0,0 +1,17 @@
1/**
2 * Backend paths the web dev server proxies in single-origin browser dev.
3 *
4 * Two consumers must agree on this list: the Vite proxy map
5 * (apps/web/vite.config.ts) that forwards these to the backend, and the
6 * server's dev catch-all (apps/server/src/http.ts) that 404s them instead of
7 * redirecting back to Vite. Drift is silent and nasty in both directions — a
8 * prefix only Vite knows gets answered with index.html; a prefix only the
9 * server knows redirect-loops through the proxy.
10 */
11export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws"] as const;
12
13export function isDevProxiedPath(pathname: string): boolean {
14 return DEV_PROXIED_PATH_PREFIXES.some(
15 (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
16 );
17}
packages/tailscale/src/tailscale.test.ts+62
......@@ -25,6 +25,44 @@ import {
2525} from "./tailscale.ts";
2626
2727const encoder = new TextEncoder();
28
29/**
30 * Asserts nothing reachable from `error` contains `secret`. Recurses through
31 * nested objects, arrays, and `cause` chains rather than checking only
32 * top-level strings: a leak one level down (say, a wrapped cause carrying raw
33 * stderr) is just as visible in a log, and a shallow check would pass it.
34 *
35 * Walks values instead of serializing so it holds for fields added later, and
36 * tracks visited objects so a cyclic cause chain terminates.
37 */
38function assertCarriesNoSecret(error: object, secret: string): void {
39 const seen = new WeakSet<object>();
40
41 const walk = (value: unknown, path: string): void => {
42 if (typeof value === "string") {
43 assert.notInclude(value, secret, `${path} leaked stderr`);
44 return;
45 }
46 if (typeof value !== "object" || value === null || seen.has(value)) {
47 return;
48 }
49 seen.add(value);
50
51 if (Array.isArray(value)) {
52 value.forEach((entry, index) => walk(entry, `${path}[${String(index)}]`));
53 return;
54 }
55 // `message` and `cause` are getters on Error subclasses, so they are not
56 // own enumerable properties and Object.entries alone would skip them.
57 walk((value as { message?: unknown }).message, `${path}.message`);
58 walk((value as { cause?: unknown }).cause, `${path}.cause`);
59 for (const [key, nested] of Object.entries(value)) {
60 walk(nested, `${path}.${key}`);
61 }
62 };
63
64 walk(error, "error");
65}
2866const tailscaleStatusJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100","fd7a:115c:a1e0::1","192.168.1.20"]}}`;
2967const tailscaleStatusWithSingleIpJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.90.1.2"]}}`;
3068
......@@ -194,6 +232,26 @@ describe("tailscale", () => {
194232 assert.notProperty(error, "stderr");
195233 assert.notInclude(error.message, "tskey-auth-secret-token-value");
196234 assert.equal(error.message, "tailscale status exited with code 7.");
235 assert.equal(error.stderrDiagnostic, "not-logged-in");
236 assertCarriesNoSecret(error, "tskey-auth-secret-token-value");
237 });
238 });
239
240 it.effect("classifies unrecognized stderr without quoting it", () => {
241 const layer = mockSpawnerLayer(() => ({
242 code: 3,
243 stderr: "something novel went wrong for node fluffy-badger tskey-auth-secret-token-value",
244 }));
245
246 return Effect.gen(function* () {
247 const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer));
248
249 assert.instanceOf(error, TailscaleCommandExitError);
250 // Unmatched stderr degrades to "unknown" rather than passing text
251 // through — that fallback is what keeps novel output from leaking.
252 assert.equal(error.stderrDiagnostic, "unknown");
253 assertCarriesNoSecret(error, "tskey-auth-secret-token-value");
254 assertCarriesNoSecret(error, "fluffy-badger");
197255 });
198256 });
199257
......@@ -253,6 +311,10 @@ describe("tailscale", () => {
253311 assert.notProperty(error, "command");
254312 assert.notProperty(error, "stderr");
255313 assert.notInclude(error.message, "tskey-auth-secret-token-value");
314 // The diagnostic classifies the failure without quoting stderr, so the
315 // key cannot reach a log through it either.
316 assert.equal(error.stderrDiagnostic, "permission-denied");
317 assertCarriesNoSecret(error, "tskey-auth-secret-token-value");
256318 });
257319 });
258320
packages/tailscale/src/tailscale.ts+43
......@@ -23,6 +23,37 @@ const TailscaleCommandContext = {
2323 argumentCount: Schema.Number,
2424};
2525
26/**
27 * Failure kinds we can name without quoting the CLI. Anything unrecognized
28 * becomes "unknown" rather than falling back to raw text — stderr can contain
29 * auth keys (`tskey-…`) and node names, and these labels are logged.
30 */
31export const TailscaleStderrDiagnostic = Schema.Literals([
32 "no-existing-handler",
33 "not-logged-in",
34 "permission-denied",
35 "unknown",
36]);
37export type TailscaleStderrDiagnostic = typeof TailscaleStderrDiagnostic.Type;
38
39// Matched against stderr, most specific first. Patterns are deliberately short
40// and anchored on tailscale's own wording.
41const STDERR_DIAGNOSTIC_PATTERNS: ReadonlyArray<
42 readonly [RegExp, Exclude<TailscaleStderrDiagnostic, "unknown">]
43> = [
44 [/handler does not exist/i, "no-existing-handler"],
45 [/not logged in|logged out|needs? login/i, "not-logged-in"],
46 [/permission denied|access denied|must be root|operation not permitted/i, "permission-denied"],
47];
48
49/** Classifies stderr into a safe label, dropping the text itself. */
50export const stderrDiagnosticOf = (stderr: string): TailscaleStderrDiagnostic | undefined => {
51 if (stderr.trim().length === 0) {
52 return undefined;
53 }
54 return STDERR_DIAGNOSTIC_PATTERNS.find(([pattern]) => pattern.test(stderr))?.[1] ?? "unknown";
55};
56
2657export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass<TailscaleCommandSpawnError>()(
2758 "TailscaleCommandSpawnError",
2859 {
......@@ -54,6 +85,12 @@ export class TailscaleCommandExitError extends Schema.TaggedErrorClass<Tailscale
5485 exitCode: Schema.Number,
5586 stdoutLength: Schema.optional(Schema.Number),
5687 stderrLength: Schema.Number,
88 // A classified diagnostic, never raw CLI output. `tailscale` prints auth
89 // keys and node identifiers into stderr, and this field is surfaced in
90 // dev-runner logs — so it carries only a known-safe label from the closed
91 // set below. Callers that need to recognize a specific failure (e.g.
92 // `serve off` on a port with no mapping) match on the label.
93 stderrDiagnostic: Schema.optional(TailscaleStderrDiagnostic),
5794 },
5895) {
5996 override get message(): string {
......@@ -212,6 +249,9 @@ export const readTailscaleStatus = Effect.gen(function* () {
212249 exitCode,
213250 stdoutLength: stdout.length,
214251 stderrLength: stderr.length,
252 ...(stderrDiagnosticOf(stderr) !== undefined
253 ? { stderrDiagnostic: stderrDiagnosticOf(stderr) }
254 : {}),
215255 });
216256 }
217257 return yield* parseTailscaleStatus(stdout);
......@@ -275,6 +315,9 @@ const runTailscaleCommand = (
275315 ...commandContext,
276316 exitCode,
277317 stderrLength: stderr.length,
318 ...(stderrDiagnosticOf(stderr) !== undefined
319 ? { stderrDiagnostic: stderrDiagnosticOf(stderr) }
320 : {}),
278321 });
279322 }
280323 }).pipe(
pnpm-lock.yaml+3
......@@ -905,6 +905,9 @@ importers:
905905 '@t3tools/shared':
906906 specifier: workspace:*
907907 version: link:../packages/shared
908 '@t3tools/tailscale':
909 specifier: workspace:*
910 version: link:../packages/tailscale
908911 effect:
909912 specifier: 4.0.0-beta.78
910913 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)
scripts/dev-runner.test.ts+387-1
......@@ -22,6 +22,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
2222import {
2323 checkPortAvailabilityOnHosts,
2424 createDevRunnerEnv,
25 devPortProbeHosts,
2526 findFirstAvailableOffset,
2627 getDevRunnerModeArgs,
2728 resolveModePortOffsets,
......@@ -66,6 +67,7 @@ const devServerInput = {
6667 port: 13_773,
6768 devUrl: undefined,
6869 dryRun: false,
70 share: false,
6971 runArgs: ["--inspect", "secret-token-value"],
7072} as const;
7173
......@@ -344,8 +346,175 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
344346 });
345347
346348 assert.equal(env.T3CODE_PORT, "13773");
349 assert.equal(env.PORT, "5733");
350 }),
351 );
352
353 // Browser dev is single-origin: Vite proxies the backend, and the client
354 // resolves it from window.location.origin. Baking a localhost URL here is
355 // what breaks sharing a dev server to another device.
356 for (const mode of ["dev", "dev:web"] as const) {
357 it.effect(`leaves the client backend URLs unset in ${mode} mode`, () =>
358 Effect.gen(function* () {
359 const env = yield* createDevRunnerEnv({
360 mode,
361 baseEnv: {
362 VITE_HTTP_URL: "http://localhost:1234",
363 VITE_WS_URL: "ws://localhost:1234",
364 },
365 serverOffset: 0,
366 webOffset: 0,
367 t3Home: undefined,
368 browser: undefined,
369 autoBootstrapProjectFromCwd: undefined,
370 logWebSocketEvents: undefined,
371 host: undefined,
372 port: undefined,
373 devUrl: undefined,
374 });
375
376 assert.equal(env.VITE_HTTP_URL, undefined);
377 assert.equal(env.VITE_WS_URL, undefined);
378 assert.equal(env.T3CODE_PORT, "13773");
379 // Deleting the keys is not sufficient — vite.config.ts merges
380 // `.env`/`.env.local` underneath this env and would revive them, so
381 // the intent has to be stated positively.
382 assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, "1");
383 }),
384 );
385 }
386
387 // Desktop pins the renderer at loopback deliberately; an ambient marker
388 // must not make Vite discard those URLs.
389 it.effect("clears the single-origin marker in dev:desktop mode", () =>
390 Effect.gen(function* () {
391 const env = yield* createDevRunnerEnv({
392 mode: "dev:desktop",
393 baseEnv: { T3CODE_SINGLE_ORIGIN_DEV: "1" },
394 serverOffset: 0,
395 webOffset: 0,
396 t3Home: undefined,
397 browser: undefined,
398 autoBootstrapProjectFromCwd: undefined,
399 logWebSocketEvents: undefined,
400 host: undefined,
401 port: undefined,
402 devUrl: undefined,
403 });
404
405 assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, undefined);
406 assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:13773");
407 }),
408 );
409
410 it.effect("clears the single-origin marker in dev:server mode", () =>
411 Effect.gen(function* () {
412 const env = yield* createDevRunnerEnv({
413 mode: "dev:server",
414 baseEnv: { T3CODE_SINGLE_ORIGIN_DEV: "1" },
415 serverOffset: 0,
416 webOffset: 0,
417 t3Home: undefined,
418 browser: undefined,
419 autoBootstrapProjectFromCwd: undefined,
420 logWebSocketEvents: undefined,
421 host: undefined,
422 port: undefined,
423 devUrl: undefined,
424 });
425
426 assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, undefined);
347427 assert.equal(env.VITE_HTTP_URL, "http://localhost:13773");
348 assert.equal(env.VITE_WS_URL, "ws://localhost:13773");
428 }),
429 );
430
431 // HOST is Vite's bind address and gates the HMR pin in vite.config.ts. An
432 // inherited one would survive into browser dev and point HMR at the wrong
433 // interface — invisible over a shared origin, since the page still loads.
434 for (const mode of ["dev", "dev:web"] as const) {
435 it.effect(`drops an inherited HOST in ${mode} mode`, () =>
436 Effect.gen(function* () {
437 const env = yield* createDevRunnerEnv({
438 mode,
439 baseEnv: { HOST: "0.0.0.0" },
440 serverOffset: 0,
441 webOffset: 0,
442 t3Home: undefined,
443 browser: undefined,
444 autoBootstrapProjectFromCwd: undefined,
445 logWebSocketEvents: undefined,
446 host: undefined,
447 port: undefined,
448 devUrl: undefined,
449 });
450
451 assert.equal(env.HOST, undefined);
452 }),
453 );
454 }
455
456 // --host configures the *backend* (T3CODE_HOST). It must not become Vite's
457 // bind address by way of an inherited HOST that happens to agree with it.
458 it.effect("drops an inherited HOST even when --host is given", () =>
459 Effect.gen(function* () {
460 const env = yield* createDevRunnerEnv({
461 mode: "dev",
462 baseEnv: { HOST: "0.0.0.0" },
463 serverOffset: 0,
464 webOffset: 0,
465 t3Home: undefined,
466 browser: undefined,
467 autoBootstrapProjectFromCwd: undefined,
468 logWebSocketEvents: undefined,
469 host: "0.0.0.0",
470 port: undefined,
471 devUrl: undefined,
472 });
473
474 assert.equal(env.HOST, undefined);
475 assert.equal(env.T3CODE_HOST, "0.0.0.0");
476 }),
477 );
478
479 // Desktop sets HOST itself, so the clearing must not reach it.
480 it.effect("still pins HOST for dev:desktop", () =>
481 Effect.gen(function* () {
482 const env = yield* createDevRunnerEnv({
483 mode: "dev:desktop",
484 baseEnv: { HOST: "0.0.0.0" },
485 serverOffset: 0,
486 webOffset: 0,
487 t3Home: undefined,
488 browser: undefined,
489 autoBootstrapProjectFromCwd: undefined,
490 logWebSocketEvents: undefined,
491 host: undefined,
492 port: undefined,
493 devUrl: undefined,
494 });
495
496 assert.equal(env.HOST, "127.0.0.1");
497 }),
498 );
499
500 it.effect("keeps explicit backend URLs for the desktop renderer", () =>
501 Effect.gen(function* () {
502 const env = yield* createDevRunnerEnv({
503 mode: "dev:desktop",
504 baseEnv: {},
505 serverOffset: 0,
506 webOffset: 0,
507 t3Home: undefined,
508 browser: undefined,
509 autoBootstrapProjectFromCwd: undefined,
510 logWebSocketEvents: undefined,
511 host: undefined,
512 port: undefined,
513 devUrl: undefined,
514 });
515
516 assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:13773");
517 assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:13773");
349518 }),
350519 );
351520 });
......@@ -444,6 +613,59 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
444613 );
445614 });
446615
616 describe("devPortProbeHosts", () => {
617 it.effect("probes loopback only when no bind host is configured", () =>
618 Effect.sync(() => {
619 assert.deepStrictEqual(devPortProbeHosts(undefined), ["127.0.0.1", "::1"]);
620 assert.deepStrictEqual(devPortProbeHosts(" "), ["127.0.0.1", "::1"]);
621 }),
622 );
623
624 // A port free on loopback can be taken on the interface the server will
625 // actually bind, so --host/T3CODE_HOST has to be probed as well.
626 it.effect("adds a non-loopback bind host to the probe list", () =>
627 Effect.sync(() => {
628 assert.deepStrictEqual(devPortProbeHosts("0.0.0.0"), ["127.0.0.1", "::1", "0.0.0.0"]);
629 assert.deepStrictEqual(devPortProbeHosts("192.168.1.10"), [
630 "127.0.0.1",
631 "::1",
632 "192.168.1.10",
633 ]);
634 }),
635 );
636
637 it.effect("does not probe loopback twice when it is the configured host", () =>
638 Effect.sync(() => {
639 assert.deepStrictEqual(devPortProbeHosts("127.0.0.1"), ["127.0.0.1", "::1"]);
640 }),
641 );
642
643 // Only the backend honours --host/T3CODE_HOST. Vite reads HOST (set for
644 // desktop only), so judging the web port against the backend's interface
645 // would reject ports for a server that never binds there.
646 it.effect("passes the port role so only the server port sees the bind host", () =>
647 Effect.gen(function* () {
648 const probed: Array<{ port: number; role: string | undefined }> = [];
649
650 yield* resolveModePortOffsets({
651 mode: "dev",
652 startOffset: 0,
653 hasExplicitServerPort: false,
654 hasExplicitDevUrl: false,
655 checkPortAvailability: (port, role) => {
656 probed.push({ port, role });
657 return Effect.succeed(true);
658 },
659 });
660
661 assert.deepStrictEqual(probed, [
662 { port: 13_773, role: "server" },
663 { port: 5733, role: "web" },
664 ]);
665 }),
666 );
667 });
668
447669 describe("resolveModePortOffsets", () => {
448670 it.effect("uses a shared fallback offset for dev mode", () =>
449671 Effect.gen(function* () {
......@@ -577,6 +799,170 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
577799 });
578800 });
579801
802 // `tailscale serve` config outlives the process, so a dry run that shared
803 // would replace and then tear down whatever mapping the port already had.
804 // Base-dir precedence (--home-dir > worktree .t3 > ambient T3CODE_HOME)
805 // lives in runDevRunnerWithInput; the env builder must not consult the
806 // ambient variable on its own, or it would silently outrank the worktree
807 // default and land dev state on the user's real database.
808 it.effect("ignores an ambient T3CODE_HOME when no home is resolved", () =>
809 Effect.gen(function* () {
810 const env = yield* createDevRunnerEnv({
811 mode: "dev",
812 baseEnv: { T3CODE_HOME: "/home/user/.t3" },
813 serverOffset: 0,
814 webOffset: 0,
815 t3Home: undefined,
816 browser: undefined,
817 autoBootstrapProjectFromCwd: undefined,
818 logWebSocketEvents: undefined,
819 host: undefined,
820 port: undefined,
821 devUrl: undefined,
822 });
823
824 assert.equal(env.T3CODE_HOME, undefined);
825 }),
826 );
827
828 // Sharing dev:desktop would publish a URL whose renderer dials the
829 // visitor's own loopback, and would clobber the VITE_DEV_SERVER_URL that
830 // Electron loads from. It must decline, not half-work.
831 it.effect("declines to share for dev:desktop and still starts the stack", () => {
832 let spawnCount = 0;
833 const spawnerLayer = Layer.succeed(
834 ChildProcessSpawner.ChildProcessSpawner,
835 ChildProcessSpawner.make(() => {
836 spawnCount += 1;
837 return Effect.succeed(mockProcess(0));
838 }),
839 );
840
841 return Effect.gen(function* () {
842 yield* runDevRunnerWithInput({
843 ...devServerInput,
844 mode: "dev:desktop",
845 port: undefined,
846 share: true,
847 }).pipe(
848 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
849 Effect.provideService(HostProcessPlatform, "linux"),
850 );
851
852 assert.equal(spawnCount, 1);
853 });
854 });
855
856 // Single-origin browser dev proxies the backend at localhost, so a backend
857 // bound only to a specific interface breaks every proxied request in a way
858 // that looks like a broken server. Reject the combination up front.
859 it.effect("rejects a specific non-loopback --host for browser dev modes", () => {
860 const spawnerLayer = Layer.succeed(
861 ChildProcessSpawner.ChildProcessSpawner,
862 ChildProcessSpawner.make(() => Effect.succeed(mockProcess(0))),
863 );
864
865 return Effect.gen(function* () {
866 const error = yield* runDevRunnerWithInput({
867 ...devServerInput,
868 mode: "dev",
869 port: undefined,
870 host: "192.168.1.10",
871 }).pipe(
872 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
873 Effect.provideService(HostProcessPlatform, "linux"),
874 Effect.flip,
875 );
876
877 if (error._tag !== "DevRunnerHostNotProxiableError") {
878 assert.fail(`Unexpected error: ${error._tag}`);
879 }
880 assert.equal(error.mode, "dev");
881 assert.equal(error.host, "192.168.1.10");
882 assert.include(error.message, "0.0.0.0");
883 assert.include(error.message, "--share");
884 });
885 });
886
887 // Wildcards keep loopback answering, so the proxy target stays valid and
888 // the combination must keep working — it is the documented way to serve a
889 // LAN interface and the browser proxy at once.
890 it.effect("still spawns the stack for a wildcard --host in dev mode", () => {
891 let spawnCount = 0;
892 const spawnerLayer = Layer.succeed(
893 ChildProcessSpawner.ChildProcessSpawner,
894 ChildProcessSpawner.make(() => {
895 spawnCount += 1;
896 return Effect.succeed(mockProcess(0));
897 }),
898 );
899
900 return Effect.gen(function* () {
901 yield* runDevRunnerWithInput({
902 ...devServerInput,
903 mode: "dev",
904 port: undefined,
905 host: "0.0.0.0",
906 }).pipe(
907 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
908 Effect.provideService(HostProcessPlatform, "linux"),
909 );
910
911 assert.equal(spawnCount, 1);
912 });
913 });
914
915 // dev:server does not proxy — the client talks to the backend directly —
916 // so a specific interface bind stays legitimate there.
917 it.effect("keeps a specific --host working for dev:server", () => {
918 let spawnCount = 0;
919 const spawnerLayer = Layer.succeed(
920 ChildProcessSpawner.ChildProcessSpawner,
921 ChildProcessSpawner.make(() => {
922 spawnCount += 1;
923 return Effect.succeed(mockProcess(0));
924 }),
925 );
926
927 return Effect.gen(function* () {
928 yield* runDevRunnerWithInput({
929 ...devServerInput,
930 host: "192.168.1.10",
931 }).pipe(
932 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
933 Effect.provideService(HostProcessPlatform, "linux"),
934 );
935
936 assert.equal(spawnCount, 1);
937 });
938 });
939
940 it.effect("spawns nothing when --dry-run is combined with --share", () => {
941 let spawnCount = 0;
942 const spawnerLayer = Layer.succeed(
943 ChildProcessSpawner.ChildProcessSpawner,
944 ChildProcessSpawner.make(() => {
945 spawnCount += 1;
946 return Effect.succeed(mockProcess(0));
947 }),
948 );
949
950 return Effect.gen(function* () {
951 yield* runDevRunnerWithInput({
952 ...devServerInput,
953 mode: "dev",
954 port: undefined,
955 dryRun: true,
956 share: true,
957 }).pipe(
958 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
959 Effect.provideService(HostProcessPlatform, "linux"),
960 );
961
962 assert.equal(spawnCount, 0);
963 });
964 });
965
580966 it.effect("reports non-zero exits without manufacturing a cause", () => {
581967 const spawnerLayer = Layer.succeed(
582968 ChildProcessSpawner.ChildProcessSpawner,
scripts/dev-runner.ts+255-30
......@@ -5,7 +5,7 @@ import * as NodeOS from "node:os";
55import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
66import * as NodeServices from "@effect/platform-node/NodeServices";
77import * as NetService from "@t3tools/shared/Net";
8import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";
8import { resolveGitWorktreePath, resolveWorktreeT3Home } from "@t3tools/shared/devHome";
99import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess";
1010import { resolveSpawnCommand } from "@t3tools/shared/shell";
1111import * as Config from "effect/Config";
......@@ -19,6 +19,7 @@ import * as Schema from "effect/Schema";
1919import { Argument, Command, Flag } from "effect/unstable/cli";
2020import { ChildProcess } from "effect/unstable/process";
2121
22import { type DevShareError, shareDevServer, unshareDevServer } from "./lib/dev-share.ts";
2223import { loadRepoEnv } from "./lib/public-config.ts";
2324
2425Object.assign(process.env, loadRepoEnv());
......@@ -28,7 +29,32 @@ const BASE_WEB_PORT = 5733;
2829const MAX_HASH_OFFSET = 3000;
2930const MAX_PORT = 65535;
3031const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1";
31const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const;
32// Dev servers bind loopback, so loopback is the only interface whose
33// availability decides whether we can use a port. Probing wildcards too made
34// the runner walk away from a perfectly free port whenever something else held
35// the same number on another interface — `tailscale serve` does exactly that,
36// which silently moved the ports out from under a URL that had just been shared.
37const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "::1"] as const;
38
39/**
40 * Bind hosts on which a backend still answers `http://localhost:<port>`, which
41 * is where single-origin browser dev proxies to. Loopback and the wildcards
42 * qualify; a specific interface (e.g. a LAN IP) does not — the OS binds only
43 * that address and the proxy target goes dark.
44 */
45export function isProxiableBindHost(host: string): boolean {
46 const normalized = host.trim();
47 return (
48 normalized === "" ||
49 normalized === "localhost" ||
50 normalized === "127.0.0.1" ||
51 normalized === "::1" ||
52 normalized === "[::1]" ||
53 normalized === "0.0.0.0" ||
54 normalized === "::" ||
55 normalized === "[::]"
56 );
57}
3258
3359export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) =>
3460 path.join(NodeOS.homedir(), ".t3"),
......@@ -49,7 +75,15 @@ const MODE_ARGS = {
4975} as const satisfies Record<string, ReadonlyArray<string>>;
5076
5177type DevMode = keyof typeof MODE_ARGS;
52type PortAvailabilityCheck<R = never> = (port: number) => Effect.Effect<boolean, never, R>;
78/**
79 * `role` matters because only the backend honours `--host`/`T3CODE_HOST`; the
80 * web port is always loopback. Passed explicitly rather than inferred from the
81 * port number, which stops distinguishing them under a large port offset.
82 */
83type PortAvailabilityCheck<R = never> = (
84 port: number,
85 role?: "server" | "web",
86) => Effect.Effect<boolean, never, R>;
5387
5488const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array<DevMode>;
5589
......@@ -129,8 +163,21 @@ export class DevRunnerProcessExitError extends Schema.TaggedErrorClass<DevRunner
129163 }
130164}
131165
166export class DevRunnerHostNotProxiableError extends Schema.TaggedErrorClass<DevRunnerHostNotProxiableError>()(
167 "DevRunnerHostNotProxiableError",
168 {
169 mode: Schema.Literals(["dev", "dev:web"]),
170 host: Schema.String,
171 },
172) {
173 override get message(): string {
174 return `--host ${this.host} cannot be combined with ${this.mode}: single-origin browser dev proxies the backend at localhost, and a backend bound only to ${this.host} leaves localhost unanswered, so every proxied request fails. Use a wildcard (0.0.0.0 or ::) to serve that interface and loopback together, or --share for remote access.`;
175 }
176}
177
132178export const DevRunnerError = Schema.Union([
133179 DevRunnerConfigurationError,
180 DevRunnerHostNotProxiableError,
134181 DevRunnerInvalidPortOffsetError,
135182 DevRunnerPortExhaustedError,
136183 DevRunnerProcessError,
......@@ -167,6 +214,7 @@ const OffsetConfig = Config.all({
167214export function resolveOffset(config: {
168215 readonly portOffset: number | undefined;
169216 readonly devInstance: string | undefined;
217 readonly worktreePath?: string | undefined;
170218}): Effect.Effect<
171219 { readonly offset: number; readonly source: string },
172220 DevRunnerInvalidPortOffsetError
......@@ -188,19 +236,30 @@ export function resolveOffset(config: {
188236 }
189237
190238 const seed = config.devInstance?.trim();
191 if (!seed) {
192 return Effect.succeed({ offset: 0, source: "default ports" });
239 if (seed) {
240 if (/^\d+$/.test(seed)) {
241 return Effect.succeed({
242 offset: Number(seed),
243 source: `numeric T3CODE_DEV_INSTANCE=${seed}`,
244 });
245 }
246
247 const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1;
248 return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` });
193249 }
194250
195 if (/^\d+$/.test(seed)) {
196 return Effect.succeed({
197 offset: Number(seed),
198 source: `numeric T3CODE_DEV_INSTANCE=${seed}`,
199 });
251 // Worktrees get ports derived from their path so each one is stable across
252 // restarts and distinct from its siblings. Without this every worktree starts
253 // at offset 0 and scan-collides onto whatever happens to be free that minute,
254 // so ports move under you between runs — which breaks any URL you already
255 // shared. The main checkout keeps the documented 5733/13773.
256 const worktreePath = config.worktreePath?.trim();
257 if (worktreePath) {
258 const offset = ((Hash.string(worktreePath) >>> 0) % MAX_HASH_OFFSET) + 1;
259 return Effect.succeed({ offset, source: `worktree ${worktreePath}` });
200260 }
201261
202 const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1;
203 return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` });
262 return Effect.succeed({ offset: 0, source: "default ports" });
204263}
205264
206265function resolveBaseDir(baseDir: string | undefined): Effect.Effect<string, never, Path.Path> {
......@@ -246,8 +305,8 @@ export function createDevRunnerEnv({
246305 return Effect.gen(function* () {
247306 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;
248307 const webPort = BASE_WEB_PORT + webOffset;
249 // Precedence is resolved by the caller. An unset t3Home here genuinely
250 // means "use the default" rather than inheriting an ambient value.
308 // Precedence (--home-dir > worktree .t3 > ambient T3CODE_HOME) is resolved
309 // by the caller; an unset t3Home here genuinely means "use the default".
251310 const configuredBaseDir = t3Home?.trim() || undefined;
252311 const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir);
253312 const isDesktopMode = mode === "dev:desktop";
......@@ -268,12 +327,41 @@ export function createDevRunnerEnv({
268327
269328 if (!isDesktopMode) {
270329 output.T3CODE_PORT = String(serverPort);
271 output.VITE_HTTP_URL = `http://localhost:${serverPort}`;
272 output.VITE_WS_URL = `ws://localhost:${serverPort}`;
330 // HOST is Vite's own bind address, and the desktop branch below is the
331 // only place we set it. An inherited one (an exported HOST, a container,
332 // a `HOST=0.0.0.0 npm start` habit) would otherwise reach Vite and pin
333 // its HMR socket to that address — see the `explicitHost` gate in
334 // apps/web/vite.config.ts. Over a shared origin that is invisible: the
335 // page loads and only HMR quietly dials the wrong machine.
336 delete output.HOST;
337 if (mode === "dev" || mode === "dev:web") {
338 // Browser dev is single-origin: everything (including /ws) is proxied
339 // through Vite, so the client must resolve its backend from
340 // window.location.origin rather than a baked-in localhost URL. See
341 // resolveConfiguredPrimaryTarget in apps/web/src/environments/primary/target.ts
342 // — it only defers to the origin when both of these are absent. Baking
343 // localhost here is what breaks any non-localhost origin (tailnet, LAN,
344 // phone): the remote browser dials its own machine.
345 delete output.VITE_HTTP_URL;
346 delete output.VITE_WS_URL;
347 // Deleting is not enough on its own: vite.config.ts calls loadRepoEnv,
348 // which merges `.env`/`.env.local` *under* this env, so a developer
349 // with either URL in their `.env` would get it back and silently lose
350 // single-origin mode. This states the intent positively so Vite can
351 // ignore those values rather than infer from their absence.
352 output.T3CODE_SINGLE_ORIGIN_DEV = "1";
353 } else {
354 output.VITE_HTTP_URL = `http://localhost:${serverPort}`;
355 output.VITE_WS_URL = `ws://localhost:${serverPort}`;
356 delete output.T3CODE_SINGLE_ORIGIN_DEV;
357 }
273358 } else {
274359 output.T3CODE_PORT = String(serverPort);
275360 output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`;
276361 output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`;
362 // Desktop pins the renderer to loopback on purpose; an ambient marker
363 // must not make Vite drop those URLs.
364 delete output.T3CODE_SINGLE_ORIGIN_DEV;
277365 delete output.T3CODE_MODE;
278366 delete output.T3CODE_NO_BROWSER;
279367 delete output.T3CODE_HOST;
......@@ -344,13 +432,41 @@ export function checkPortAvailabilityOnHosts<R>(
344432 });
345433}
346434
347const defaultCheckPortAvailability: PortAvailabilityCheck<NetService.NetService> = (port) =>
348 Effect.gen(function* () {
349 const net = yield* NetService.NetService;
350 return yield* checkPortAvailabilityOnHosts(port, DEV_PORT_PROBE_HOSTS, (candidatePort, host) =>
351 net.canListenOnHost(candidatePort, host),
352 );
353 });
435/**
436 * Hosts to probe for a dev server bound to `configuredHost`.
437 *
438 * Loopback is always checked because the web server and the desktop renderer
439 * target reach it there. When `--host`/`T3CODE_HOST` moves the backend onto
440 * another interface, that interface decides whether the bind actually
441 * succeeds — probing only loopback would hand back a port that is free here
442 * and taken there, and the server would fail to start.
443 *
444 * `configuredHost` applies to the *backend* only. Vite takes its bind address
445 * from `HOST`, which the runner sets for desktop alone, so the web port stays
446 * on loopback and must not be judged against the backend's interface —
447 * a port free on loopback but busy on that interface would otherwise be
448 * rejected for a server that was never going to bind there.
449 */
450export function devPortProbeHosts(configuredHost: string | undefined): ReadonlyArray<string> {
451 const host = configuredHost?.trim();
452 if (!host || DEV_PORT_PROBE_HOSTS.includes(host as (typeof DEV_PORT_PROBE_HOSTS)[number])) {
453 return DEV_PORT_PROBE_HOSTS;
454 }
455 return [...DEV_PORT_PROBE_HOSTS, host];
456}
457
458const makeDefaultCheckPortAvailability =
459 (configuredHost: string | undefined): PortAvailabilityCheck<NetService.NetService> =>
460 (port, role) =>
461 Effect.gen(function* () {
462 const net = yield* NetService.NetService;
463 const hosts = role === "web" ? DEV_PORT_PROBE_HOSTS : devPortProbeHosts(configuredHost);
464 return yield* checkPortAvailabilityOnHosts(port, hosts, (candidatePort, host) =>
465 net.canListenOnHost(candidatePort, host),
466 );
467 });
468
469const defaultCheckPortAvailability = makeDefaultCheckPortAvailability(undefined);
354470
355471interface FindFirstAvailableOffsetInput<R = NetService.NetService> {
356472 readonly startOffset: number;
......@@ -384,10 +500,10 @@ export function findFirstAvailableOffset<R = NetService.NetService>({
384500
385501 const checks: Array<Effect.Effect<boolean, never, R>> = [];
386502 if (requireServerPort) {
387 checks.push(checkPort(serverPort));
503 checks.push(checkPort(serverPort, "server"));
388504 }
389505 if (requireWebPort) {
390 checks.push(checkPort(webPort));
506 checks.push(checkPort(webPort, "web"));
391507 }
392508
393509 if (checks.length === 0) {
......@@ -483,6 +599,7 @@ interface DevRunnerCliInput {
483599 readonly port: number | undefined;
484600 readonly devUrl: URL | undefined;
485601 readonly dryRun: boolean;
602 readonly share: boolean;
486603 readonly runArgs: ReadonlyArray<string>;
487604}
488605
......@@ -498,19 +615,41 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
498615 ),
499616 );
500617
501 const { offset, source } = yield* resolveOffset({ portOffset, devInstance });
618 // Single-origin browser dev proxies the backend at localhost. A wildcard
619 // bind still answers there; a specific non-loopback interface does not,
620 // which breaks every proxied request in a way that reads as "server is
621 // broken" rather than "flag combination is unsupported". Reject it up
622 // front instead. (dev:server and dev:desktop don't proxy — untouched.)
623 if (
624 (input.mode === "dev" || input.mode === "dev:web") &&
625 input.host !== undefined &&
626 !isProxiableBindHost(input.host)
627 ) {
628 return yield* new DevRunnerHostNotProxiableError({ mode: input.mode, host: input.host });
629 }
630
631 const worktreePath = yield* resolveGitWorktreePath(yield* HostProcessWorkingDirectory);
632
633 const { offset, source } = yield* resolveOffset({
634 portOffset,
635 devInstance,
636 worktreePath,
637 });
502638
503639 const { serverOffset, webOffset } = yield* resolveModePortOffsets({
504640 mode: input.mode,
505641 startOffset: offset,
506642 hasExplicitServerPort: input.port !== undefined,
507643 hasExplicitDevUrl: input.devUrl !== undefined,
644 // A non-loopback bind host decides whether the backend can actually take
645 // the port, so it has to be probed alongside loopback.
646 checkPortAvailability: makeDefaultCheckPortAvailability(input.host),
508647 });
509648
510649 const hostEnvironment = yield* HostProcessEnvironment;
511 // A worktree defaults to its own gitignored `.t3`. This deliberately
512 // outranks ambient T3CODE_HOME, which otherwise selects the installed
513 // app's live userdata database. An explicit --home-dir still wins.
650 // A dev server started inside a worktree defaults to that worktree's own
651 // (gitignored) `.t3` — see @t3tools/shared/devHome for why this must
652 // outrank an ambient T3CODE_HOME. `--home-dir` still wins.
514653 const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcessWorkingDirectory);
515654 // Trim before choosing: `--home-dir ""` is not a selection, and treating it
516655 // as one would skip the worktree default and land on the shared home —
......@@ -543,10 +682,90 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
543682 `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`,
544683 );
545684
685 // Before the share block: --dry-run only resolves and prints. Sharing would
686 // replace, then tear down, whatever mapping the port already had — a
687 // surprising side effect from a command documented as inert.
546688 if (input.dryRun) {
547689 return;
548690 }
549691
692 const sharedWebPort = BASE_WEB_PORT + webOffset;
693 if (input.share) {
694 if (input.mode === "dev:server") {
695 yield* Effect.logInfo("[dev-runner] --share has no effect for dev:server (no web server).");
696 } else if (input.mode === "dev:desktop") {
697 // Desktop is not single-origin: the renderer gets VITE_HTTP_URL and
698 // VITE_WS_URL baked to loopback, so a tailnet visitor would load the UI
699 // and then watch it dial its own 127.0.0.1 for the backend. Worse,
700 // sharing would overwrite VITE_DEV_SERVER_URL, which is the origin
701 // Electron itself loads the renderer from. Refuse rather than hand out
702 // a URL that is broken in a way the user cannot see.
703 yield* Effect.logWarning(
704 "[dev-runner] --share is not supported for dev:desktop (the renderer is pinned to loopback). Use `dev`, which runs the whole browser stack.",
705 );
706 } else {
707 // acquireRelease, not share-then-addFinalizer: the mapping outlives this
708 // process (and reboots), so the cleanup has to be registered atomically
709 // with creating it. An interrupt landing in between would otherwise
710 // leave a mapping pointing at a port nothing is listening on.
711 //
712 // Deliberately no ownership tracking beyond that: if a second runner
713 // takes this port during a fast restart, the first's exit can briefly
714 // tear down the new mapping — visible (the URL stops working) and fixed
715 // by re-running --share. A lease protocol closing that window existed
716 // and was removed as more machinery than a dev convenience warrants.
717 //
718 // A tailnet that isn't up shouldn't stop the dev server from starting —
719 // warn, and carry on serving locally.
720 const shared = yield* Effect.acquireRelease(
721 shareDevServer({ webPort: sharedWebPort }),
722 () =>
723 // Serve config outlives this process, so a cleanup that did not
724 // take leaves a tailnet URL pointing at a port nothing serves.
725 unshareDevServer(sharedWebPort).pipe(
726 Effect.flatMap((result) =>
727 result.cleared
728 ? Effect.void
729 : Effect.logWarning(
730 `[dev-runner] could not remove the tailnet mapping for port ${String(sharedWebPort)}${
731 result.explanation ? `: ${result.explanation}` : ""
732 }. Remove it with \`tailscale serve --https=${String(sharedWebPort)} off\`.`,
733 ),
734 ),
735 ),
736 ).pipe(
737 Effect.tapError((error: DevShareError) =>
738 Effect.logWarning(
739 `[dev-runner] could not share on the tailnet: ${error.message}${
740 error.hint ? ` — ${error.hint}` : ""
741 }`,
742 ),
743 ),
744 Effect.option,
745 Effect.map(Option.getOrUndefined),
746 );
747
748 if (shared) {
749 // The app is reached from the tailnet origin. Vite already allows
750 // *.ts.net hosts; the backend needs the origin for credentialed
751 // requests that bypass the proxy (desktop renderer, direct calls).
752 env.T3CODE_DEV_ALLOWED_ORIGINS = [
753 env.T3CODE_DEV_ALLOWED_ORIGINS,
754 new URL(shared.url).origin,
755 ]
756 .filter((entry) => entry && entry.length > 0)
757 .join(",");
758 // The server builds its pairing URL from this, so the URL printed at
759 // startup is already the shareable one — no rewriting by hand. An
760 // explicit --dev-url still wins.
761 if (input.devUrl === undefined) {
762 env.VITE_DEV_SERVER_URL = shared.url;
763 }
764 yield* Effect.logInfo(`[dev-runner] shared on tailnet: ${shared.url}`);
765 }
766 }
767 }
768
550769 const spawnCommand = yield* resolveSpawnCommand(
551770 "vp",
552771 [...MODE_ARGS[input.mode], ...input.runArgs],
......@@ -606,7 +825,7 @@ const devRunnerCli = Command.make("dev-runner", {
606825 ),
607826 t3Home: Flag.string("home-dir").pipe(
608827 Flag.withDescription(
609 "Explicit T3 Code data directory; runtime state is stored under userdata. Inside a git worktree this defaults to that worktree's own .t3.",
828 "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME). Inside a git worktree this defaults to that worktree's own .t3 so dev state stays off the shared home.",
610829 ),
611830 Flag.optional,
612831 Flag.map(Option.getOrUndefined),
......@@ -646,6 +865,12 @@ const devRunnerCli = Command.make("dev-runner", {
646865 Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."),
647866 Flag.withDefault(false),
648867 ),
868 share: Flag.boolean("share").pipe(
869 Flag.withDescription(
870 "Publish the web dev server on this machine's tailnet over HTTPS (via `tailscale serve`) and print the pairing URL for it. Removed again on exit.",
871 ),
872 Flag.withDefault(false),
873 ),
649874 runArgs: Argument.string("run-arg").pipe(
650875 Argument.withDescription("Additional Vite+ run args (pass after `--`)."),
651876 Argument.variadic(),
scripts/lib/dev-share.test.ts created+178
......@@ -0,0 +1,178 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Sink from "effect/Sink";
5import * as Stream from "effect/Stream";
6import { ChildProcessSpawner } from "effect/unstable/process";
7
8import {
9 type DevShareError,
10 DevServeFailedError,
11 shareDevServer,
12 unshareDevServer,
13} from "./dev-share.ts";
14
15const TAILNET_STATUS = JSON.stringify({ Self: { DNSName: "host.example.ts.net." } });
16const NO_HANDLER_STDERR = "error: failed to remove web serve: handler does not exist";
17
18interface CallResult {
19 readonly exitCode: number;
20 readonly stderr?: string;
21}
22
23const encode = (value: string) => Stream.make(new TextEncoder().encode(value));
24
25/**
26 * Answers `tailscale status --json` with a valid tailnet name, and lets each
27 * test set the outcome of the `off` (pre-clear) and `serve` calls separately —
28 * they are the same subcommand and are told apart by the trailing `off`.
29 */
30const spawnerLayer = (input: { readonly off?: CallResult; readonly serve?: CallResult }) =>
31 Layer.succeed(
32 ChildProcessSpawner.ChildProcessSpawner,
33 ChildProcessSpawner.make((command) => {
34 const args = "args" in command ? (command.args as ReadonlyArray<string>) : [];
35 const result: CallResult = args.includes("status")
36 ? { exitCode: 0 }
37 : args.includes("off")
38 ? (input.off ?? { exitCode: 0 })
39 : (input.serve ?? { exitCode: 0 });
40
41 return Effect.succeed(
42 ChildProcessSpawner.makeHandle({
43 pid: ChildProcessSpawner.ProcessId(1),
44 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)),
45 isRunning: Effect.succeed(false),
46 kill: () => Effect.void,
47 unref: Effect.succeed(Effect.void),
48 stdin: Sink.drain,
49 stdout: args.includes("status") ? encode(TAILNET_STATUS) : Stream.empty,
50 stderr: result.stderr ? encode(result.stderr) : Stream.empty,
51 all: Stream.empty,
52 getInputFd: () => Sink.drain,
53 getOutputFd: () => Stream.empty,
54 }),
55 );
56 }),
57 );
58
59describe("unshareDevServer", () => {
60 it.effect("treats a removed mapping as cleared", () =>
61 Effect.gen(function* () {
62 const result = yield* unshareDevServer(5788).pipe(
63 Effect.provide(spawnerLayer({ off: { exitCode: 0 } })),
64 );
65 assert.isTrue(result.cleared);
66 }),
67 );
68
69 // `tailscale serve … off` exits 1 when the port had no mapping, which is the
70 // normal first-share case — the port is clear, so this must not be an error.
71 it.effect("treats a missing handler as cleared", () =>
72 Effect.gen(function* () {
73 const result = yield* unshareDevServer(5788).pipe(
74 Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: NO_HANDLER_STDERR } })),
75 );
76 assert.isTrue(result.cleared);
77 }),
78 );
79
80 it.effect("reports a genuine removal failure as not cleared", () =>
81 Effect.gen(function* () {
82 const result = yield* unshareDevServer(5788).pipe(
83 Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: "permission denied" } })),
84 );
85 assert.isFalse(result.cleared);
86 assert.include(result.explanation, "permission denied");
87 // Structured, so a wrapping error can keep the real chain.
88 assert.equal(result.cause?._tag, "TailscaleCommandExitError");
89 }),
90 );
91});
92
93describe("shareDevServer", () => {
94 it.effect("returns the tailnet URL for the same port", () =>
95 Effect.gen(function* () {
96 const shared = yield* shareDevServer({ webPort: 5788 }).pipe(
97 Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: NO_HANDLER_STDERR } })),
98 );
99
100 assert.equal(shared.host, "host.example.ts.net");
101 assert.equal(shared.url, "https://host.example.ts.net:5788/");
102 }),
103 );
104
105 // The stale-mapping clear runs before serve, so a failure here leaves the
106 // port serving nothing. Saying only "serve failed" would let an operator
107 // assume their previous mapping survived.
108 it.effect("reports that the prior mapping was cleared when serve fails", () =>
109 Effect.gen(function* () {
110 const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe(
111 Effect.provide(
112 spawnerLayer({
113 off: { exitCode: 0 },
114 serve: { exitCode: 1, stderr: "port already in use" },
115 }),
116 ),
117 Effect.flip,
118 );
119
120 assert.instanceOf(error, DevServeFailedError);
121 assert.equal(error.stage, "serve");
122 assert.equal(error.webPort, 5788);
123 // The underlying failure is preserved rather than flattened to a string.
124 assert.equal(
125 (error.cause as { _tag?: string } | undefined)?._tag,
126 "TailscaleCommandExitError",
127 );
128 // Unclassifiable stderr is never quoted (it can carry auth keys), so the
129 // message points at the command instead of echoing the CLI.
130 assert.notInclude(error.message, "port already in use");
131 assert.include(error.message, "run the command by hand");
132 assert.include(error.message, "no longer served");
133 assert.include(error.message, "5788");
134 }),
135 );
136
137 // A recognized failure gets our own wording for it — enough to act on
138 // without passing CLI text through.
139 it.effect("explains a recognized serve failure without quoting stderr", () =>
140 Effect.gen(function* () {
141 const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe(
142 Effect.provide(
143 spawnerLayer({
144 off: { exitCode: 0 },
145 serve: {
146 exitCode: 1,
147 stderr: "permission denied for tskey-auth-secret-token-value",
148 },
149 }),
150 ),
151 Effect.flip,
152 );
153
154 assert.instanceOf(error, DevServeFailedError);
155 assert.equal(error.stage, "serve");
156 assert.include(error.message, "permission denied");
157 assert.include(error.message, "elevated privileges");
158 assert.notInclude(error.message, "tskey-auth-secret-token-value");
159 }),
160 );
161
162 // Serving over routes we could not remove yields a URL that loads but whose
163 // /ws and /api quietly point at a dead backend.
164 it.effect("refuses to serve when the existing mapping could not be cleared", () =>
165 Effect.gen(function* () {
166 const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe(
167 Effect.provide(spawnerLayer({ off: { exitCode: 1, stderr: "permission denied" } })),
168 Effect.flip,
169 );
170
171 assert.instanceOf(error, DevServeFailedError);
172 // A distinct stage: the prior mapping survived, so nothing was replaced.
173 assert.equal(error.stage, "clear-existing");
174 assert.include(error.message, "could not clear the existing mapping");
175 assert.include(error.message, "permission denied");
176 }),
177 );
178});
scripts/lib/dev-share.ts created+217
......@@ -0,0 +1,217 @@
1/**
2 * Shares a running dev server on the local tailnet via `tailscale serve`, so it
3 * can be opened from a phone, another laptop, or by whoever is reviewing the
4 * work.
5 *
6 * Thin wrapper over `@t3tools/tailscale` (the same client the server's own
7 * `--tailscale-serve` uses). What it adds is dev-share semantics: replacing a
8 * stale mapping left by a killed run, and refusing to serve over routes it
9 * could not remove.
10 *
11 * Because browser dev is single-origin (Vite proxies the backend — see
12 * `resolveDevProxyTarget` in apps/web/vite.config.ts), one proxy rule covering
13 * the web port is enough; the backend needs no mapping of its own.
14 */
15
16import {
17 buildTailscaleHttpsBaseUrl,
18 disableTailscaleServe,
19 ensureTailscaleServe,
20 readTailscaleStatus,
21 type TailscaleCommandError,
22 type TailscaleStderrDiagnostic,
23} from "@t3tools/tailscale";
24import * as Effect from "effect/Effect";
25import * as Schema from "effect/Schema";
26import type { ChildProcessSpawner } from "effect/unstable/process";
27
28/**
29 * Human-readable gloss for each diagnostic. Deliberately our own words rather
30 * than the CLI's: tailscale prints auth keys and node names into stderr, and
31 * this string is logged.
32 */
33const DIAGNOSTIC_EXPLANATIONS: Record<TailscaleStderrDiagnostic, string | undefined> = {
34 "no-existing-handler": "no mapping existed for that port",
35 "not-logged-in": "this machine is not logged into a tailnet — run `tailscale up`",
36 "permission-denied": "permission denied — `tailscale serve` may need elevated privileges",
37 unknown: undefined,
38};
39
40/**
41 * Our own wording for why a tailscale command failed, derived from the
42 * classified diagnostic. Never the CLI's text — see `stderrDiagnosticOf`.
43 */
44const explainCommandFailure = (error: TailscaleCommandError): string | undefined =>
45 error._tag === "TailscaleCommandExitError" && error.stderrDiagnostic !== undefined
46 ? (DIAGNOSTIC_EXPLANATIONS[error.stderrDiagnostic] ?? "run the command by hand to see why")
47 : undefined;
48
49/**
50 * Three distinct failures, three classes: each has its own caller-visible
51 * message and its own remedy, and `shareDevServer` chooses between them
52 * structurally. A single error with a `reason` discriminator would encode that
53 * distinction twice and put a lookup table in the `message` getter.
54 *
55 * Each wraps a real underlying failure and so keeps it as `cause`; the message
56 * is derived only from the structural fields, never from `cause.message`.
57 */
58export class TailscaleUnavailableError extends Schema.TaggedErrorClass<TailscaleUnavailableError>()(
59 "TailscaleUnavailableError",
60 { cause: Schema.Defect() },
61) {
62 override get message(): string {
63 return "could not talk to tailscale";
64 }
65
66 get hint(): string {
67 return "Is Tailscale installed and tailscaled running? Try `tailscale status` — or drop --share and open the printed localhost URL.";
68 }
69}
70
71/** No underlying failure: the status read succeeded and simply had no name. */
72export class TailnetNameMissingError extends Schema.TaggedErrorClass<TailnetNameMissingError>()(
73 "TailnetNameMissingError",
74 {},
75) {
76 override get message(): string {
77 return "this machine has no tailnet DNS name";
78 }
79
80 get hint(): string {
81 return "Run `tailscale up` and make sure MagicDNS is enabled.";
82 }
83}
84
85/**
86 * `stage` is a genuine multi-value discriminator: both stages share the same
87 * semantics (a `tailscale serve` invocation failed for this port) and differ
88 * only in which one, which the message states plainly.
89 */
90export class DevServeFailedError extends Schema.TaggedErrorClass<DevServeFailedError>()(
91 "DevServeFailedError",
92 {
93 stage: Schema.Literals(["clear-existing", "serve"]),
94 webPort: Schema.Number,
95 explanation: Schema.optional(Schema.String),
96 cause: Schema.optional(Schema.Defect()),
97 },
98) {
99 override get message(): string {
100 const port = String(this.webPort);
101 const base =
102 this.stage === "clear-existing"
103 ? `could not clear the existing mapping for port ${port}. Run \`tailscale serve --https=${port} off\` and retry`
104 : `could not serve port ${port} on the tailnet (it is no longer served; any previous mapping for it was cleared before this attempt)`;
105 return this.explanation ? `${base}: ${this.explanation}` : base;
106 }
107
108 get hint(): undefined {
109 return undefined;
110 }
111}
112
113export const DevShareError = Schema.Union([
114 TailscaleUnavailableError,
115 TailnetNameMissingError,
116 DevServeFailedError,
117]);
118export type DevShareError = typeof DevShareError.Type;
119export const isDevShareError = Schema.is(DevShareError);
120
121/**
122 * Removes any mapping for `webPort`, reporting whether the port is now clear.
123 *
124 * Runs uninterruptibly: this is called from a finalizer on the way out of an
125 * interrupted program, and cancelling the cleanup subprocess would leave
126 * exactly the stale mapping it exists to remove.
127 */
128export const unshareDevServer = (
129 webPort: number,
130): Effect.Effect<
131 {
132 readonly cleared: boolean;
133 readonly explanation?: string | undefined;
134 // Kept structured so a caller wrapping this can preserve the real error
135 // chain rather than a flattened string.
136 readonly cause?: TailscaleCommandError | undefined;
137 },
138 never,
139 ChildProcessSpawner.ChildProcessSpawner
140> =>
141 disableTailscaleServe({ servePort: webPort }).pipe(
142 Effect.as({ cleared: true } as const),
143 Effect.catch((error: TailscaleCommandError) =>
144 Effect.succeed(
145 // "Nothing was mapped" leaves the port clear either way.
146 error._tag === "TailscaleCommandExitError" &&
147 error.stderrDiagnostic === "no-existing-handler"
148 ? ({ cleared: true } as const)
149 : ({
150 cleared: false,
151 ...(explainCommandFailure(error) !== undefined
152 ? { explanation: explainCommandFailure(error) }
153 : {}),
154 cause: error,
155 } as const),
156 ),
157 ),
158 Effect.uninterruptible,
159 );
160
161export interface DevShareResult {
162 readonly url: string;
163 readonly host: string;
164}
165
166/**
167 * Publishes `webPort` on the tailnet at the same port number and returns the
168 * resulting HTTPS URL. Idempotent: re-running replaces any existing mapping.
169 */
170export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (input: {
171 readonly webPort: number;
172}) {
173 const status = yield* readTailscaleStatus.pipe(
174 Effect.mapError((error) => new TailscaleUnavailableError({ cause: error })),
175 );
176 if (status.magicDnsName === null) {
177 return yield* new TailnetNameMissingError();
178 }
179
180 // Clear any mapping left behind by a run that was killed before its finalizer
181 // could fire. Serve config survives both the process and a reboot, and a
182 // stale entry may carry path routes we no longer want — older versions mapped
183 // /ws, /api and friends to a separate backend port, and serving "/" alone
184 // would leave those pointing at a port nothing is listening on.
185 const cleared = yield* unshareDevServer(input.webPort);
186 if (!cleared.cleared) {
187 // Serving over routes we failed to remove would hand out a URL that is
188 // broken in a way the user cannot see: the page loads while /ws and /api
189 // silently resolve to a dead backend. Better to refuse and say why.
190 return yield* new DevServeFailedError({
191 stage: "clear-existing",
192 webPort: input.webPort,
193 ...(cleared.explanation !== undefined ? { explanation: cleared.explanation } : {}),
194 ...(cleared.cause !== undefined ? { cause: cleared.cause } : {}),
195 });
196 }
197
198 yield* ensureTailscaleServe({ localPort: input.webPort, servePort: input.webPort }).pipe(
199 Effect.mapError((error) => {
200 const explanation = explainCommandFailure(error);
201 return new DevServeFailedError({
202 stage: "serve",
203 webPort: input.webPort,
204 ...(explanation !== undefined ? { explanation } : {}),
205 cause: error,
206 });
207 }),
208 );
209
210 return {
211 url: buildTailscaleHttpsBaseUrl({
212 magicDnsName: status.magicDnsName,
213 servePort: input.webPort,
214 }),
215 host: status.magicDnsName,
216 } satisfies DevShareResult;
217});
scripts/package.json+1
......@@ -10,6 +10,7 @@
1010 "@effect/platform-node": "catalog:",
1111 "@t3tools/contracts": "workspace:*",
1212 "@t3tools/shared": "workspace:*",
13 "@t3tools/tailscale": "workspace:*",
1314 "effect": "catalog:",
1415 "pngjs": "7.0.0",
1516 "yaml": "catalog:"