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...@@ -13,13 +13,17 @@ Use this skill for the web client. For iOS Simulator, Android Emulator, or physi
132. Choose a base directory that belongs only to the current worktree or test:132. Choose a base directory that belongs only to the current worktree or test:
14 - Use the repository's ignored `.t3` directory for reusable worktree-local state.14 - Use the repository's ignored `.t3` directory for reusable worktree-local state.
15 - Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path.15 - 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.
174. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output.174. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output.
1818
19Treat 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.19Treat 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
21The worktree-local default deliberately outranks an ambient `T3CODE_HOME`; do not pass the shared home through to a worktree dev server.21The 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
23The 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.27The 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
25## Preserve the environment while iterating29## Preserve the environment while iterating
AGENTS.md+3
...@@ -16,6 +16,9 @@...@@ -16,6 +16,9 @@
16## Dev Servers16## Dev Servers
1717
18- 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.18- 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
20## Package Roles23## Package Roles
2124
apps/server/src/auth/EnvironmentAuth.test.ts+15-1
...@@ -8,9 +8,13 @@ import * as ServerConfig from "../config.ts";...@@ -8,9 +8,13 @@ import * as ServerConfig from "../config.ts";
8import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";8import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
9import * as PairingGrantStore from "./PairingGrantStore.ts";9import * as PairingGrantStore from "./PairingGrantStore.ts";
10import * as EnvironmentAuth from "./EnvironmentAuth.ts";10import * as EnvironmentAuth from "./EnvironmentAuth.ts";
11import { resolveSessionCookieName } from "./utils.ts";
1112
12import * as ServerSecretStore from "./ServerSecretStore.ts";13import * 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
14const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>18const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
15 Layer.effect(19 Layer.effect(
16 ServerConfig.ServerConfig,20 ServerConfig.ServerConfig,
...@@ -19,6 +23,12 @@ const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Se...@@ -19,6 +23,12 @@ const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Se
19 return {23 return {
20 ...config,24 ...config,
21 ...overrides,25 ...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,
22 } satisfies ServerConfig.ServerConfig["Service"];32 } satisfies ServerConfig.ServerConfig["Service"];
23 }),33 }),
24 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" })));34 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" })));
...@@ -35,7 +45,11 @@ const makeCookieRequest = (...@@ -35,7 +45,11 @@ const makeCookieRequest = (
35): Parameters<EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]>[0] =>45): Parameters<EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]>[0] =>
36 ({46 ({
37 cookies: {47 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,
39 },53 },
40 headers: {},54 headers: {},
41 }) as unknown as Parameters<55 }) as unknown as Parameters<
apps/server/src/auth/EnvironmentAuth.ts+5
...@@ -438,6 +438,7 @@ export class EnvironmentAuth extends Context.Service<...@@ -438,6 +438,7 @@ export class EnvironmentAuth extends Context.Service<
438 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;438 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
439 readonly subject?: string;439 readonly subject?: string;
440 readonly proofKeyThumbprint?: string;440 readonly proofKeyThumbprint?: string;
441 readonly purpose?: "startup";
441 }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>;442 }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>;
442 readonly issuePairingCredential: (443 readonly issuePairingCredential: (
443 input?: AuthCreatePairingCredentialInput,444 input?: AuthCreatePairingCredentialInput,
...@@ -746,11 +747,13 @@ export const make = Effect.gen(function* () {...@@ -746,11 +747,13 @@ export const make = Effect.gen(function* () {
746 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;747 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
747 readonly subject: string;748 readonly subject: string;
748 readonly label?: string;749 readonly label?: string;
750 readonly purpose?: "startup";
749 }) =>751 }) =>
750 createPairingLink({752 createPairingLink({
751 scopes: input.scopes,753 scopes: input.scopes,
752 subject: input.subject,754 subject: input.subject,
753 ...(input.label ? { label: input.label } : {}),755 ...(input.label ? { label: input.label } : {}),
756 ...(input.purpose ? { purpose: input.purpose } : {}),
754 }).pipe(757 }).pipe(
755 Effect.map(758 Effect.map(
756 (issued) =>759 (issued) =>
...@@ -774,6 +777,7 @@ export const make = Effect.gen(function* () {...@@ -774,6 +777,7 @@ export const make = Effect.gen(function* () {
774 ...(input?.ttl ? { ttl: input.ttl } : {}),777 ...(input?.ttl ? { ttl: input.ttl } : {}),
775 ...(input?.label ? { label: input.label } : {}),778 ...(input?.label ? { label: input.label } : {}),
776 ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),779 ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
780 ...(input?.purpose ? { purpose: input.purpose } : {}),
777 });781 });
778 return {782 return {
779 id: issued.id,783 id: issued.id,
...@@ -872,6 +876,7 @@ export const make = Effect.gen(function* () {...@@ -872,6 +876,7 @@ export const make = Effect.gen(function* () {
872 issuePairingCredentialForSubject({876 issuePairingCredentialForSubject({
873 scopes: AuthAdministrativeScopes,877 scopes: AuthAdministrativeScopes,
874 subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,878 subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
879 purpose: "startup",
875 }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));880 }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));
876881
877 const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) =>882 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) => {...@@ -34,6 +34,9 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
3434
35 expect(descriptor.policy).toBe("desktop-managed-local");35 expect(descriptor.policy).toBe("desktop-managed-local");
36 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);36 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.
37 expect(descriptor.sessionCookieName).toBe("t3_session_3773");40 expect(descriptor.sessionCookieName).toBe("t3_session_3773");
38 }).pipe(41 }).pipe(
39 Effect.provide(42 Effect.provide(
...@@ -45,6 +48,22 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {...@@ -45,6 +48,22 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
45 ),48 ),
46 );49 );
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
48 it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () =>67 it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () =>
49 Effect.gen(function* () {68 Effect.gen(function* () {
50 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;69 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
...@@ -75,6 +94,24 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {...@@ -75,6 +94,24 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
75 makeEnvironmentAuthPolicyLayer({94 makeEnvironmentAuthPolicyLayer({
76 mode: "web",95 mode: "web",
77 host: "127.0.0.1",96 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"),
78 }),115 }),
79 ),116 ),
80 ),117 ),
apps/server/src/auth/EnvironmentAuthPolicy.ts+1
...@@ -41,6 +41,7 @@ export const make = Effect.gen(function* () {...@@ -41,6 +41,7 @@ export const make = Effect.gen(function* () {
41 sessionCookieName: resolveSessionCookieName({41 sessionCookieName: resolveSessionCookieName({
42 mode: config.mode,42 mode: config.mode,
43 port: config.port,43 port: config.port,
44 devUrl: config.devUrl,
44 }),45 }),
45 };46 };
4647
apps/server/src/auth/PairingGrantStore.ts+18-1
...@@ -202,6 +202,11 @@ export class PairingGrantStore extends Context.Service<...@@ -202,6 +202,11 @@ export class PairingGrantStore extends Context.Service<
202 readonly subject?: string;202 readonly subject?: string;
203 readonly label?: string;203 readonly label?: string;
204 readonly proofKeyThumbprint?: string;204 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";
205 }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;210 }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;
206 readonly listActive: () => Effect.Effect<211 readonly listActive: () => Effect.Effect<
207 ReadonlyArray<AuthPairingLink>,212 ReadonlyArray<AuthPairingLink>,
...@@ -243,6 +248,15 @@ const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);...@@ -243,6 +248,15 @@ const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);
243// window can still recover by re-bootstrapping rather than locking248// window can still recover by re-bootstrapping rather than locking
244// the user out of the backend.249// the user out of the backend.
245const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24);250const 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);
246const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";260const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
247const PAIRING_TOKEN_LENGTH = 12;261const PAIRING_TOKEN_LENGTH = 12;
248const PAIRING_TOKEN_REJECTION_LIMIT =262const PAIRING_TOKEN_REJECTION_LIMIT =
...@@ -371,7 +385,10 @@ export const make = Effect.gen(function* () {...@@ -371,7 +385,10 @@ export const make = Effect.gen(function* () {
371 ),385 ),
372 );386 );
373 const credential = yield* generatePairingToken;387 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);
375 const now = yield* DateTime.now;392 const now = yield* DateTime.now;
376 const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });393 const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
377 const issued: IssuedBootstrapCredential = {394 const issued: IssuedBootstrapCredential = {
apps/server/src/auth/SessionStore.ts+1
...@@ -470,6 +470,7 @@ export const make = Effect.gen(function* () {...@@ -470,6 +470,7 @@ export const make = Effect.gen(function* () {
470 const cookieName = resolveSessionCookieName({470 const cookieName = resolveSessionCookieName({
471 mode: serverConfig.mode,471 mode: serverConfig.mode,
472 port: serverConfig.port,472 port: serverConfig.port,
473 devUrl: serverConfig.devUrl,
473 });474 });
474475
475 const emitUpsert = (clientSession: AuthClientSession) =>476 const emitUpsert = (clientSession: AuthClientSession) =>
apps/server/src/auth/utils.ts+19-5
...@@ -10,15 +10,29 @@ import * as Result from "effect/Result";...@@ -10,15 +10,29 @@ import * as Result from "effect/Result";
1010
11const SESSION_COOKIE_NAME = "t3_session";11const 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 */
13export function resolveSessionCookieName(input: {28export function resolveSessionCookieName(input: {
14 readonly mode: "web" | "desktop";29 readonly mode: "web" | "desktop";
15 readonly port: number;30 readonly port: number;
31 readonly devUrl: URL | undefined;
16}): string {32}): string {
17 if (input.mode !== "desktop") {33 return input.devUrl === undefined && input.mode !== "desktop"
18 return SESSION_COOKIE_NAME;34 ? SESSION_COOKIE_NAME
19 }35 : `${SESSION_COOKIE_NAME}_${input.port}`;
20
21 return `${SESSION_COOKIE_NAME}_${input.port}`;
22}36}
2337
24export function base64UrlEncode(input: string | Uint8Array): string {38export function base64UrlEncode(input: string | Uint8Array): string {
apps/server/src/bin.test.ts+1
...@@ -84,6 +84,7 @@ const makeCliTestServerConfig = (baseDir: string) =>...@@ -84,6 +84,7 @@ const makeCliTestServerConfig = (baseDir: string) =>
84 ...derivedPaths,84 ...derivedPaths,
85 staticDir: undefined,85 staticDir: undefined,
86 devUrl: undefined,86 devUrl: undefined,
87 devAllowedOrigins: [],
87 noBrowser: true,88 noBrowser: true,
88 startupPresentation: "browser",89 startupPresentation: "browser",
89 desktopBootstrapToken: undefined,90 desktopBootstrapToken: undefined,
apps/server/src/cli/config.test.ts+4
...@@ -48,6 +48,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -48,6 +48,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
48 otlpMetricsUrl: undefined,48 otlpMetricsUrl: undefined,
49 otlpExportIntervalMs: 10_000,49 otlpExportIntervalMs: 10_000,
50 otlpServiceName: "t3-server",50 otlpServiceName: "t3-server",
51 devAllowedOrigins: [],
51 } as const;52 } as const;
5253
53 const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) {54 const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) {
...@@ -95,6 +96,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -95,6 +96,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
95 T3CODE_HOST: "0.0.0.0",96 T3CODE_HOST: "0.0.0.0",
96 T3CODE_HOME: baseDir,97 T3CODE_HOME: baseDir,
97 VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",98 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 ",
98 T3CODE_NO_BROWSER: "true",101 T3CODE_NO_BROWSER: "true",
99 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",102 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
100 T3CODE_LOG_WS_EVENTS: "true",103 T3CODE_LOG_WS_EVENTS: "true",
...@@ -117,6 +120,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -117,6 +120,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
117 host: "0.0.0.0",120 host: "0.0.0.0",
118 staticDir: undefined,121 staticDir: undefined,
119 devUrl: new URL("http://127.0.0.1:5173"),122 devUrl: new URL("http://127.0.0.1:5173"),
123 devAllowedOrigins: ["https://host.example.ts.net", "https://phone.example.ts.net"],
120 noBrowser: true,124 noBrowser: true,
121 startupPresentation: "browser",125 startupPresentation: "browser",
122 desktopBootstrapToken: undefined,126 desktopBootstrapToken: undefined,
apps/server/src/cli/config.ts+10
...@@ -106,6 +106,15 @@ const EnvServerConfig = Config.all({...@@ -106,6 +106,15 @@ const EnvServerConfig = Config.all({
106 host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),106 host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
107 t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),107 t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
108 devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),108 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 ),
109 noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe(118 noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe(
110 Config.option,119 Config.option,
111 Config.map(Option.getOrUndefined),120 Config.map(Option.getOrUndefined),
...@@ -363,6 +372,7 @@ export const resolveServerConfig = (...@@ -363,6 +372,7 @@ export const resolveServerConfig = (
363 host,372 host,
364 staticDir,373 staticDir,
365 devUrl,374 devUrl,
375 devAllowedOrigins: env.devAllowedOrigins,
366 noBrowser,376 noBrowser,
367 startupPresentation,377 startupPresentation,
368 desktopBootstrapToken,378 desktopBootstrapToken,
apps/server/src/config.ts+2
...@@ -72,6 +72,7 @@ export class ServerConfig extends Context.Service<...@@ -72,6 +72,7 @@ export class ServerConfig extends Context.Service<
72 readonly baseDir: string;72 readonly baseDir: string;
73 readonly staticDir: string | undefined;73 readonly staticDir: string | undefined;
74 readonly devUrl: URL | undefined;74 readonly devUrl: URL | undefined;
75 readonly devAllowedOrigins: ReadonlyArray<string>;
75 readonly noBrowser: boolean;76 readonly noBrowser: boolean;
76 readonly startupPresentation: StartupPresentation;77 readonly startupPresentation: StartupPresentation;
77 readonly desktopBootstrapToken: string | undefined;78 readonly desktopBootstrapToken: string | undefined;
...@@ -187,6 +188,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (...@@ -187,6 +188,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
187 desktopBootstrapToken: undefined,188 desktopBootstrapToken: undefined,
188 staticDir: undefined,189 staticDir: undefined,
189 devUrl,190 devUrl,
191 devAllowedOrigins: [],
190 noBrowser: false,192 noBrowser: false,
191 startupPresentation: "browser",193 startupPresentation: "browser",
192 });194 });
apps/server/src/environment/ServerEnvironment.test.ts+1
...@@ -43,6 +43,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {...@@ -43,6 +43,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
43 desktopBootstrapToken: undefined,43 desktopBootstrapToken: undefined,
44 staticDir: undefined,44 staticDir: undefined,
45 devUrl: undefined,45 devUrl: undefined,
46 devAllowedOrigins: [],
46 noBrowser: false,47 noBrowser: false,
47 startupPresentation: "browser",48 startupPresentation: "browser",
48 } satisfies ServerConfig.ServerConfig["Service"];49 } satisfies ServerConfig.ServerConfig["Service"];
apps/server/src/http.ts+14-1
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 AuthOrchestrationReadScope,4 AuthOrchestrationReadScope,
5 EnvironmentHttpApi,5 EnvironmentHttpApi,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { isDevProxiedPath } from "@t3tools/shared/devProxy";
7import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";8import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
8import * as Data from "effect/Data";9import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
...@@ -48,9 +49,17 @@ export const browserApiCorsLayer = Layer.unwrap(...@@ -48,9 +49,17 @@ export const browserApiCorsLayer = Layer.unwrap(
48 const devOrigin = config.devUrl?.origin;49 const devOrigin = config.devUrl?.origin;
49 // Dev uses credentialed requests from Vite or the Electron custom origin, so both must be50 // Dev uses credentialed requests from Vite or the Electron custom origin, so both must be
50 // explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin.51 // 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.
51 return HttpRouter.cors({57 return HttpRouter.cors({
52 ...(devOrigin58 ...(devOrigin
53 ? { allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS], credentials: true }59 ? {
60 allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS, ...config.devAllowedOrigins],
61 credentials: true,
62 }
54 : {}),63 : {}),
55 allowedMethods: browserApiCorsAllowedMethods,64 allowedMethods: browserApiCorsAllowedMethods,
56 allowedHeaders: browserApiCorsAllowedHeaders,65 allowedHeaders: browserApiCorsAllowedHeaders,
...@@ -216,6 +225,10 @@ export const staticAndDevRouteLayer = HttpRouter.add(...@@ -216,6 +225,10 @@ export const staticAndDevRouteLayer = HttpRouter.add(
216 }225 }
217226
218 const config = yield* ServerConfig.ServerConfig;227 const config = yield* ServerConfig.ServerConfig;
228 if (config.devUrl && isDevProxiedPath(url.value.pathname)) {
229 return HttpServerResponse.text("Not Found", { status: 404 });
230 }
231
219 if (config.devUrl && isLoopbackHostname(url.value.hostname)) {232 if (config.devUrl && isLoopbackHostname(url.value.hostname)) {
220 return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {233 return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {
221 status: 302,234 status: 302,
apps/server/src/server.test.ts+31
...@@ -380,6 +380,7 @@ const buildAppUnderTest = (options?: {...@@ -380,6 +380,7 @@ const buildAppUnderTest = (options?: {
380 ...derivedPaths,380 ...derivedPaths,
381 staticDir: undefined,381 staticDir: undefined,
382 devUrl,382 devUrl,
383 devAllowedOrigins: [],
383 noBrowser: true,384 noBrowser: true,
384 startupPresentation: "browser",385 startupPresentation: "browser",
385 desktopBootstrapToken: defaultDesktopBootstrapToken,386 desktopBootstrapToken: defaultDesktopBootstrapToken,
...@@ -1328,6 +1329,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1328,6 +1329,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1328 "bearer-access-token",1329 "bearer-access-token",
1329 "dpop-access-token",1330 "dpop-access-token",
1330 ]);1331 ]);
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.
1331 assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_"));1334 assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_"));
1332 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1335 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1333 );1336 );
...@@ -3246,6 +3249,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -3246,6 +3249,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
3246 }).pipe(Effect.provide(NodeHttpServer.layerTest)),3249 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3247 );3250 );
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
3249 for (const desktopOrigin of ["t3code://app", "t3code-dev://app"]) {3280 for (const desktopOrigin of ["t3code://app", "t3code-dev://app"]) {
3250 it.effect(`allows credentialed preflights from ${desktopOrigin} in development`, () =>3281 it.effect(`allows credentialed preflights from ${desktopOrigin} in development`, () =>
3251 Effect.gen(function* () {3282 Effect.gen(function* () {
apps/web/vite.config.ts+74-25
...@@ -7,14 +7,27 @@ import "vite-plus/test/config";...@@ -7,14 +7,27 @@ import "vite-plus/test/config";
7import { defineConfig } from "vite-plus";7import { defineConfig } from "vite-plus";
8import pkg from "./package.json" with { type: "json" };8import pkg from "./package.json" with { type: "json" };
99
10import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy";
11
10import { loadRepoEnv } from "../../scripts/lib/public-config";12import { loadRepoEnv } from "../../scripts/lib/public-config";
1113
12const repoEnv = loadRepoEnv();14const repoEnv = loadRepoEnv();
13Object.assign(process.env, repoEnv);15Object.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
15const port = Number(process.env.PORT ?? 5733);26const port = Number(process.env.PORT ?? 5733);
16const host = process.env.HOST?.trim() || "localhost";27const explicitHost = process.env.HOST?.trim();
17const configuredWsUrl = process.env.VITE_WS_URL?.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();
18const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";31const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";
19const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";32const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";
20const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || "";33const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || "";
...@@ -65,7 +78,20 @@ const unitTestProject = {...@@ -65,7 +78,20 @@ const unitTestProject = {
65 },78 },
66} satisfies TestProjectInlineConfiguration;79} 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.
69 if (!wsUrl) {95 if (!wsUrl) {
70 return undefined;96 return undefined;
71 }97 }
...@@ -86,7 +112,17 @@ function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined {...@@ -86,7 +112,17 @@ function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined {
86 }112 }
87}113}
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
91export default defineConfig(() => {127export default defineConfig(() => {
92 return {128 return {
...@@ -119,6 +155,10 @@ export default defineConfig(() => {...@@ -119,6 +155,10 @@ export default defineConfig(() => {
119 define: {155 define: {
120 // In dev mode, tell the web app where the WebSocket server lives156 // In dev mode, tell the web app where the WebSocket server lives
121 "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""),157 "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 ?? ""),
122 "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl),162 "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl),
123 "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey),163 "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey),
124 "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate),164 "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate),
...@@ -145,32 +185,41 @@ export default defineConfig(() => {...@@ -145,32 +185,41 @@ export default defineConfig(() => {
145 host,185 host,
146 port,186 port,
147 strictPort: true,187 strictPort: true,
188 allowedHosts,
148 ...(devProxyTarget189 ...(devProxyTarget
149 ? {190 ? {
150 proxy: {191 // One entry per shared prefix; the server's dev catch-all 404s the
151 "/.well-known": {192 // same list, so the two sides cannot drift. `/ws` is the app's own
152 target: devProxyTarget,193 // socket — Vite's HMR socket is matched separately and exactly
153 changeOrigin: true,194 // (path "/" plus a vite-hmr subprotocol), so the two upgrade
154 },195 // handlers don't collide.
155 "/api": {196 proxy: Object.fromEntries(
156 target: devProxyTarget,197 DEV_PROXIED_PATH_PREFIXES.map((prefix) => [
157 changeOrigin: true,198 prefix,
158 },199 {
159 "/attachments": {200 target: devProxyTarget,
160 target: devProxyTarget,201 changeOrigin: true,
161 changeOrigin: true,202 ...(prefix === "/ws" ? { ws: true } : {}),
162 },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,
163 },220 },
164 }221 }
165 : {}),222 : {}),
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 },
174 },223 },
175 build: {224 build: {
176 outDir: "dist",225 outDir: "dist",
docs/reference/scripts.md+8
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1# Scripts1# Scripts
22
3- `vp run dev` — Starts contracts, server, and web in watch mode.3- `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.
4- `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`.5- `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`.
5- `vp run dev:web` — Starts just the Vite dev server for the web app.6- `vp run dev:web` — Starts just the Vite dev server for the web app.
6- 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.7- 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 @@...@@ -24,6 +25,7 @@
24- Default build is unsigned/not notarized for local sharing.25- Default build is unsigned/not notarized for local sharing.
25- The DMG build uses `assets/prod/black-macos-1024.png` as the production app icon source.26- The DMG build uses `assets/prod/black-macos-1024.png` as the production app icon source.
26- Desktop production windows load the bundled UI from `t3code://app/index.html` (not a `127.0.0.1` document URL).27- Desktop production windows load the bundled UI from `t3code://app/index.html` (not a `127.0.0.1` document URL).
28
27- Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic.29- Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic.
28- Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch.30- Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch.
29- To keep staging files for debugging package contents, run: `vp run dist:desktop:dmg -- --keep-stage`31- To keep staging files for debugging package contents, run: `vp run dist:desktop:dmg -- --keep-stage`
...@@ -37,6 +39,12 @@...@@ -37,6 +39,12 @@
37- Azure authentication env vars are also required (for example service principal with secret):39- Azure authentication env vars are also required (for example service principal with secret):
38 `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`.40 `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
40## Running multiple dev instances48## Running multiple dev instances
4149
42Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together.50Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together.
package.json+1
...@@ -5,6 +5,7 @@...@@ -5,6 +5,7 @@
5 "scripts": {5 "scripts": {
6 "prepare": "effect-tsgo patch && vp config --no-agent",6 "prepare": "effect-tsgo patch && vp config --no-agent",
7 "dev": "node scripts/dev-runner.ts dev",7 "dev": "node scripts/dev-runner.ts dev",
8 "dev:share": "node scripts/dev-runner.ts dev --share",
8 "dev:server": "node scripts/dev-runner.ts dev:server",9 "dev:server": "node scripts/dev-runner.ts dev:server",
9 "dev:web": "node scripts/dev-runner.ts dev:web",10 "dev:web": "node scripts/dev-runner.ts dev:web",
10 "dev:marketing": "vp run --filter @t3tools/marketing dev",11 "dev:marketing": "vp run --filter @t3tools/marketing dev",
packages/shared/package.json+4
...@@ -198,6 +198,10 @@...@@ -198,6 +198,10 @@
198 "./devHome": {198 "./devHome": {
199 "types": "./src/devHome.ts",199 "types": "./src/devHome.ts",
200 "import": "./src/devHome.ts"200 "import": "./src/devHome.ts"
201 },
202 "./devProxy": {
203 "types": "./src/devProxy.ts",
204 "import": "./src/devProxy.ts"
201 }205 }
202 },206 },
203 "scripts": {207 "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 {...@@ -25,6 +25,44 @@ import {
25} from "./tailscale.ts";25} from "./tailscale.ts";
2626
27const encoder = new TextEncoder();27const 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}
28const tailscaleStatusJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100","fd7a:115c:a1e0::1","192.168.1.20"]}}`;66const tailscaleStatusJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100","fd7a:115c:a1e0::1","192.168.1.20"]}}`;
29const tailscaleStatusWithSingleIpJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.90.1.2"]}}`;67const tailscaleStatusWithSingleIpJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.90.1.2"]}}`;
3068
...@@ -194,6 +232,26 @@ describe("tailscale", () => {...@@ -194,6 +232,26 @@ describe("tailscale", () => {
194 assert.notProperty(error, "stderr");232 assert.notProperty(error, "stderr");
195 assert.notInclude(error.message, "tskey-auth-secret-token-value");233 assert.notInclude(error.message, "tskey-auth-secret-token-value");
196 assert.equal(error.message, "tailscale status exited with code 7.");234 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");
197 });255 });
198 });256 });
199257
...@@ -253,6 +311,10 @@ describe("tailscale", () => {...@@ -253,6 +311,10 @@ describe("tailscale", () => {
253 assert.notProperty(error, "command");311 assert.notProperty(error, "command");
254 assert.notProperty(error, "stderr");312 assert.notProperty(error, "stderr");
255 assert.notInclude(error.message, "tskey-auth-secret-token-value");313 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");
256 });318 });
257 });319 });
258320
packages/tailscale/src/tailscale.ts+43
...@@ -23,6 +23,37 @@ const TailscaleCommandContext = {...@@ -23,6 +23,37 @@ const TailscaleCommandContext = {
23 argumentCount: Schema.Number,23 argumentCount: Schema.Number,
24};24};
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
26export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass<TailscaleCommandSpawnError>()(57export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass<TailscaleCommandSpawnError>()(
27 "TailscaleCommandSpawnError",58 "TailscaleCommandSpawnError",
28 {59 {
...@@ -54,6 +85,12 @@ export class TailscaleCommandExitError extends Schema.TaggedErrorClass<Tailscale...@@ -54,6 +85,12 @@ export class TailscaleCommandExitError extends Schema.TaggedErrorClass<Tailscale
54 exitCode: Schema.Number,85 exitCode: Schema.Number,
55 stdoutLength: Schema.optional(Schema.Number),86 stdoutLength: Schema.optional(Schema.Number),
56 stderrLength: Schema.Number,87 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),
57 },94 },
58) {95) {
59 override get message(): string {96 override get message(): string {
...@@ -212,6 +249,9 @@ export const readTailscaleStatus = Effect.gen(function* () {...@@ -212,6 +249,9 @@ export const readTailscaleStatus = Effect.gen(function* () {
212 exitCode,249 exitCode,
213 stdoutLength: stdout.length,250 stdoutLength: stdout.length,
214 stderrLength: stderr.length,251 stderrLength: stderr.length,
252 ...(stderrDiagnosticOf(stderr) !== undefined
253 ? { stderrDiagnostic: stderrDiagnosticOf(stderr) }
254 : {}),
215 });255 });
216 }256 }
217 return yield* parseTailscaleStatus(stdout);257 return yield* parseTailscaleStatus(stdout);
...@@ -275,6 +315,9 @@ const runTailscaleCommand = (...@@ -275,6 +315,9 @@ const runTailscaleCommand = (
275 ...commandContext,315 ...commandContext,
276 exitCode,316 exitCode,
277 stderrLength: stderr.length,317 stderrLength: stderr.length,
318 ...(stderrDiagnosticOf(stderr) !== undefined
319 ? { stderrDiagnostic: stderrDiagnosticOf(stderr) }
320 : {}),
278 });321 });
279 }322 }
280 }).pipe(323 }).pipe(
pnpm-lock.yaml+3
...@@ -905,6 +905,9 @@ importers:...@@ -905,6 +905,9 @@ importers:
905 '@t3tools/shared':905 '@t3tools/shared':
906 specifier: workspace:*906 specifier: workspace:*
907 version: link:../packages/shared907 version: link:../packages/shared
908 '@t3tools/tailscale':
909 specifier: workspace:*
910 version: link:../packages/tailscale
908 effect:911 effect:
909 specifier: 4.0.0-beta.78912 specifier: 4.0.0-beta.78
910 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)913 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";...@@ -22,6 +22,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
22import {22import {
23 checkPortAvailabilityOnHosts,23 checkPortAvailabilityOnHosts,
24 createDevRunnerEnv,24 createDevRunnerEnv,
25 devPortProbeHosts,
25 findFirstAvailableOffset,26 findFirstAvailableOffset,
26 getDevRunnerModeArgs,27 getDevRunnerModeArgs,
27 resolveModePortOffsets,28 resolveModePortOffsets,
...@@ -66,6 +67,7 @@ const devServerInput = {...@@ -66,6 +67,7 @@ const devServerInput = {
66 port: 13_773,67 port: 13_773,
67 devUrl: undefined,68 devUrl: undefined,
68 dryRun: false,69 dryRun: false,
70 share: false,
69 runArgs: ["--inspect", "secret-token-value"],71 runArgs: ["--inspect", "secret-token-value"],
70} as const;72} as const;
7173
...@@ -344,8 +346,175 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -344,8 +346,175 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
344 });346 });
345347
346 assert.equal(env.T3CODE_PORT, "13773");348 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);
347 assert.equal(env.VITE_HTTP_URL, "http://localhost:13773");427 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");
349 }),518 }),
350 );519 );
351 });520 });
...@@ -444,6 +613,59 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -444,6 +613,59 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
444 );613 );
445 });614 });
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
447 describe("resolveModePortOffsets", () => {669 describe("resolveModePortOffsets", () => {
448 it.effect("uses a shared fallback offset for dev mode", () =>670 it.effect("uses a shared fallback offset for dev mode", () =>
449 Effect.gen(function* () {671 Effect.gen(function* () {
...@@ -577,6 +799,170 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -577,6 +799,170 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
577 });799 });
578 });800 });
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
580 it.effect("reports non-zero exits without manufacturing a cause", () => {966 it.effect("reports non-zero exits without manufacturing a cause", () => {
581 const spawnerLayer = Layer.succeed(967 const spawnerLayer = Layer.succeed(
582 ChildProcessSpawner.ChildProcessSpawner,968 ChildProcessSpawner.ChildProcessSpawner,
scripts/dev-runner.ts+255-30
...@@ -5,7 +5,7 @@ import * as NodeOS from "node:os";...@@ -5,7 +5,7 @@ import * as NodeOS from "node:os";
5import * as NodeRuntime from "@effect/platform-node/NodeRuntime";5import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
6import * as NodeServices from "@effect/platform-node/NodeServices";6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as NetService from "@t3tools/shared/Net";7import * as NetService from "@t3tools/shared/Net";
8import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";8import { resolveGitWorktreePath, resolveWorktreeT3Home } from "@t3tools/shared/devHome";
9import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess";9import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess";
10import { resolveSpawnCommand } from "@t3tools/shared/shell";10import { resolveSpawnCommand } from "@t3tools/shared/shell";
11import * as Config from "effect/Config";11import * as Config from "effect/Config";
...@@ -19,6 +19,7 @@ import * as Schema from "effect/Schema";...@@ -19,6 +19,7 @@ import * as Schema from "effect/Schema";
19import { Argument, Command, Flag } from "effect/unstable/cli";19import { Argument, Command, Flag } from "effect/unstable/cli";
20import { ChildProcess } from "effect/unstable/process";20import { ChildProcess } from "effect/unstable/process";
2121
22import { type DevShareError, shareDevServer, unshareDevServer } from "./lib/dev-share.ts";
22import { loadRepoEnv } from "./lib/public-config.ts";23import { loadRepoEnv } from "./lib/public-config.ts";
2324
24Object.assign(process.env, loadRepoEnv());25Object.assign(process.env, loadRepoEnv());
...@@ -28,7 +29,32 @@ const BASE_WEB_PORT = 5733;...@@ -28,7 +29,32 @@ const BASE_WEB_PORT = 5733;
28const MAX_HASH_OFFSET = 3000;29const MAX_HASH_OFFSET = 3000;
29const MAX_PORT = 65535;30const MAX_PORT = 65535;
30const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1";31const 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
33export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) =>59export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) =>
34 path.join(NodeOS.homedir(), ".t3"),60 path.join(NodeOS.homedir(), ".t3"),
...@@ -49,7 +75,15 @@ const MODE_ARGS = {...@@ -49,7 +75,15 @@ const MODE_ARGS = {
49} as const satisfies Record<string, ReadonlyArray<string>>;75} as const satisfies Record<string, ReadonlyArray<string>>;
5076
51type DevMode = keyof typeof MODE_ARGS;77type 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
54const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array<DevMode>;88const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array<DevMode>;
5589
...@@ -129,8 +163,21 @@ export class DevRunnerProcessExitError extends Schema.TaggedErrorClass<DevRunner...@@ -129,8 +163,21 @@ export class DevRunnerProcessExitError extends Schema.TaggedErrorClass<DevRunner
129 }163 }
130}164}
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
132export const DevRunnerError = Schema.Union([178export const DevRunnerError = Schema.Union([
133 DevRunnerConfigurationError,179 DevRunnerConfigurationError,
180 DevRunnerHostNotProxiableError,
134 DevRunnerInvalidPortOffsetError,181 DevRunnerInvalidPortOffsetError,
135 DevRunnerPortExhaustedError,182 DevRunnerPortExhaustedError,
136 DevRunnerProcessError,183 DevRunnerProcessError,
...@@ -167,6 +214,7 @@ const OffsetConfig = Config.all({...@@ -167,6 +214,7 @@ const OffsetConfig = Config.all({
167export function resolveOffset(config: {214export function resolveOffset(config: {
168 readonly portOffset: number | undefined;215 readonly portOffset: number | undefined;
169 readonly devInstance: string | undefined;216 readonly devInstance: string | undefined;
217 readonly worktreePath?: string | undefined;
170}): Effect.Effect<218}): Effect.Effect<
171 { readonly offset: number; readonly source: string },219 { readonly offset: number; readonly source: string },
172 DevRunnerInvalidPortOffsetError220 DevRunnerInvalidPortOffsetError
...@@ -188,19 +236,30 @@ export function resolveOffset(config: {...@@ -188,19 +236,30 @@ export function resolveOffset(config: {
188 }236 }
189237
190 const seed = config.devInstance?.trim();238 const seed = config.devInstance?.trim();
191 if (!seed) {239 if (seed) {
192 return Effect.succeed({ offset: 0, source: "default ports" });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}` });
193 }249 }
194250
195 if (/^\d+$/.test(seed)) {251 // Worktrees get ports derived from their path so each one is stable across
196 return Effect.succeed({252 // restarts and distinct from its siblings. Without this every worktree starts
197 offset: Number(seed),253 // at offset 0 and scan-collides onto whatever happens to be free that minute,
198 source: `numeric T3CODE_DEV_INSTANCE=${seed}`,254 // so ports move under you between runs — which breaks any URL you already
199 });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}` });
200 }260 }
201261
202 const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1;262 return Effect.succeed({ offset: 0, source: "default ports" });
203 return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` });
204}263}
205264
206function resolveBaseDir(baseDir: string | undefined): Effect.Effect<string, never, Path.Path> {265function resolveBaseDir(baseDir: string | undefined): Effect.Effect<string, never, Path.Path> {
...@@ -246,8 +305,8 @@ export function createDevRunnerEnv({...@@ -246,8 +305,8 @@ export function createDevRunnerEnv({
246 return Effect.gen(function* () {305 return Effect.gen(function* () {
247 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;306 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;
248 const webPort = BASE_WEB_PORT + webOffset;307 const webPort = BASE_WEB_PORT + webOffset;
249 // Precedence is resolved by the caller. An unset t3Home here genuinely308 // Precedence (--home-dir > worktree .t3 > ambient T3CODE_HOME) is resolved
250 // means "use the default" rather than inheriting an ambient value.309 // by the caller; an unset t3Home here genuinely means "use the default".
251 const configuredBaseDir = t3Home?.trim() || undefined;310 const configuredBaseDir = t3Home?.trim() || undefined;
252 const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir);311 const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir);
253 const isDesktopMode = mode === "dev:desktop";312 const isDesktopMode = mode === "dev:desktop";
...@@ -268,12 +327,41 @@ export function createDevRunnerEnv({...@@ -268,12 +327,41 @@ export function createDevRunnerEnv({
268327
269 if (!isDesktopMode) {328 if (!isDesktopMode) {
270 output.T3CODE_PORT = String(serverPort);329 output.T3CODE_PORT = String(serverPort);
271 output.VITE_HTTP_URL = `http://localhost:${serverPort}`;330 // HOST is Vite's own bind address, and the desktop branch below is the
272 output.VITE_WS_URL = `ws://localhost:${serverPort}`;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 }
273 } else {358 } else {
274 output.T3CODE_PORT = String(serverPort);359 output.T3CODE_PORT = String(serverPort);
275 output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`;360 output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`;
276 output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`;361 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;
277 delete output.T3CODE_MODE;365 delete output.T3CODE_MODE;
278 delete output.T3CODE_NO_BROWSER;366 delete output.T3CODE_NO_BROWSER;
279 delete output.T3CODE_HOST;367 delete output.T3CODE_HOST;
...@@ -344,13 +432,41 @@ export function checkPortAvailabilityOnHosts<R>(...@@ -344,13 +432,41 @@ export function checkPortAvailabilityOnHosts<R>(
344 });432 });
345}433}
346434
347const defaultCheckPortAvailability: PortAvailabilityCheck<NetService.NetService> = (port) =>435/**
348 Effect.gen(function* () {436 * Hosts to probe for a dev server bound to `configuredHost`.
349 const net = yield* NetService.NetService;437 *
350 return yield* checkPortAvailabilityOnHosts(port, DEV_PORT_PROBE_HOSTS, (candidatePort, host) =>438 * Loopback is always checked because the web server and the desktop renderer
351 net.canListenOnHost(candidatePort, host),439 * target reach it there. When `--host`/`T3CODE_HOST` moves the backend onto
352 );440 * another interface, that interface decides whether the bind actually
353 });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
355interface FindFirstAvailableOffsetInput<R = NetService.NetService> {471interface FindFirstAvailableOffsetInput<R = NetService.NetService> {
356 readonly startOffset: number;472 readonly startOffset: number;
...@@ -384,10 +500,10 @@ export function findFirstAvailableOffset<R = NetService.NetService>({...@@ -384,10 +500,10 @@ export function findFirstAvailableOffset<R = NetService.NetService>({
384500
385 const checks: Array<Effect.Effect<boolean, never, R>> = [];501 const checks: Array<Effect.Effect<boolean, never, R>> = [];
386 if (requireServerPort) {502 if (requireServerPort) {
387 checks.push(checkPort(serverPort));503 checks.push(checkPort(serverPort, "server"));
388 }504 }
389 if (requireWebPort) {505 if (requireWebPort) {
390 checks.push(checkPort(webPort));506 checks.push(checkPort(webPort, "web"));
391 }507 }
392508
393 if (checks.length === 0) {509 if (checks.length === 0) {
...@@ -483,6 +599,7 @@ interface DevRunnerCliInput {...@@ -483,6 +599,7 @@ interface DevRunnerCliInput {
483 readonly port: number | undefined;599 readonly port: number | undefined;
484 readonly devUrl: URL | undefined;600 readonly devUrl: URL | undefined;
485 readonly dryRun: boolean;601 readonly dryRun: boolean;
602 readonly share: boolean;
486 readonly runArgs: ReadonlyArray<string>;603 readonly runArgs: ReadonlyArray<string>;
487}604}
488605
...@@ -498,19 +615,41 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {...@@ -498,19 +615,41 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
498 ),615 ),
499 );616 );
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
503 const { serverOffset, webOffset } = yield* resolveModePortOffsets({639 const { serverOffset, webOffset } = yield* resolveModePortOffsets({
504 mode: input.mode,640 mode: input.mode,
505 startOffset: offset,641 startOffset: offset,
506 hasExplicitServerPort: input.port !== undefined,642 hasExplicitServerPort: input.port !== undefined,
507 hasExplicitDevUrl: input.devUrl !== undefined,643 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),
508 });647 });
509648
510 const hostEnvironment = yield* HostProcessEnvironment;649 const hostEnvironment = yield* HostProcessEnvironment;
511 // A worktree defaults to its own gitignored `.t3`. This deliberately650 // A dev server started inside a worktree defaults to that worktree's own
512 // outranks ambient T3CODE_HOME, which otherwise selects the installed651 // (gitignored) `.t3` — see @t3tools/shared/devHome for why this must
513 // app's live userdata database. An explicit --home-dir still wins.652 // outrank an ambient T3CODE_HOME. `--home-dir` still wins.
514 const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcessWorkingDirectory);653 const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcessWorkingDirectory);
515 // Trim before choosing: `--home-dir ""` is not a selection, and treating it654 // Trim before choosing: `--home-dir ""` is not a selection, and treating it
516 // as one would skip the worktree default and land on the shared home —655 // as one would skip the worktree default and land on the shared home —
...@@ -543,10 +682,90 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {...@@ -543,10 +682,90 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
543 `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`,682 `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`,
544 );683 );
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.
546 if (input.dryRun) {688 if (input.dryRun) {
547 return;689 return;
548 }690 }
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
550 const spawnCommand = yield* resolveSpawnCommand(769 const spawnCommand = yield* resolveSpawnCommand(
551 "vp",770 "vp",
552 [...MODE_ARGS[input.mode], ...input.runArgs],771 [...MODE_ARGS[input.mode], ...input.runArgs],
...@@ -606,7 +825,7 @@ const devRunnerCli = Command.make("dev-runner", {...@@ -606,7 +825,7 @@ const devRunnerCli = Command.make("dev-runner", {
606 ),825 ),
607 t3Home: Flag.string("home-dir").pipe(826 t3Home: Flag.string("home-dir").pipe(
608 Flag.withDescription(827 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.",
610 ),829 ),
611 Flag.optional,830 Flag.optional,
612 Flag.map(Option.getOrUndefined),831 Flag.map(Option.getOrUndefined),
...@@ -646,6 +865,12 @@ const devRunnerCli = Command.make("dev-runner", {...@@ -646,6 +865,12 @@ const devRunnerCli = Command.make("dev-runner", {
646 Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."),865 Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."),
647 Flag.withDefault(false),866 Flag.withDefault(false),
648 ),867 ),
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 ),
649 runArgs: Argument.string("run-arg").pipe(874 runArgs: Argument.string("run-arg").pipe(
650 Argument.withDescription("Additional Vite+ run args (pass after `--`)."),875 Argument.withDescription("Additional Vite+ run args (pass after `--`)."),
651 Argument.variadic(),876 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 @@...@@ -10,6 +10,7 @@
10 "@effect/platform-node": "catalog:",10 "@effect/platform-node": "catalog:",
11 "@t3tools/contracts": "workspace:*",11 "@t3tools/contracts": "workspace:*",
12 "@t3tools/shared": "workspace:*",12 "@t3tools/shared": "workspace:*",
13 "@t3tools/tailscale": "workspace:*",
13 "effect": "catalog:",14 "effect": "catalog:",
14 "pngjs": "7.0.0",15 "pngjs": "7.0.0",
15 "yaml": "catalog:"16 "yaml": "catalog:"