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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/server/src/cli/pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
DEFAULT_TAILSCALE_SERVE_PORT,
ensureTailscaleServe,
readTailscaleStatus,
type TailscaleServeError,
} from "@t3tools/tailscale";
import * as Config from "effect/Config";
import * as Console from "effect/Console";
Expand Down Expand Up @@ -369,6 +370,7 @@ const awaitEnvironmentDescriptor = Effect.fn(function* (baseUrl: string) {
const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase")(
function* (input: { readonly target: DiscoveredPairTarget; readonly servePort: number }) {
const notes: Array<string> = [];
let replaceVerifiedHandler = false;
const status = yield* readTailscaleStatus.pipe(
Effect.mapError((cause) => new TailscaleUnavailableError({ cause })),
);
Expand Down Expand Up @@ -396,6 +398,7 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase"
if (input.target.state.devUrl === undefined) {
return { baseUrl, notes };
}
replaceVerifiedHandler = true;
}
if (existing._tag === "not-a-t3-server") {
return yield* new ServePortOccupiedError({ servePort: input.servePort });
Expand All @@ -408,10 +411,13 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase"
yield* ensureTailscaleServe({
localPort: localTarget.localPort,
servePort: input.servePort,
replaceVerifiedHandler,
...(localTarget.localHost !== undefined ? { localHost: localTarget.localHost } : {}),
}).pipe(
Effect.mapError(
(cause) => new TailscaleServeFailedError({ servePort: input.servePort, cause }),
Effect.mapError((cause: TailscaleServeError) =>
cause._tag === "TailscaleServePortOccupiedError"
? new ServePortOccupiedError({ servePort: input.servePort })
Comment thread
lastobelus marked this conversation as resolved.
: new TailscaleServeFailedError({ servePort: input.servePort, cause }),
),
);
notes.push(
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,11 @@ export const makeServerLayer = Layer.unwrap(
}),
(configured) =>
configured
? disableTailscaleServe({ servePort: configured.servePort }).pipe(
? disableTailscaleServe({
localPort: configured.localPort,
servePort: configured.servePort,
localHost: "127.0.0.1",
}).pipe(
Effect.tap(() =>
Effect.logInfo("Tailscale Serve disabled", {
servePort: configured.servePort,
Expand Down
10 changes: 6 additions & 4 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import "vite-plus/test/config";
import { defineConfig, type Connect, type Plugin } from "vite-plus";
import pkg from "./package.json" with { type: "json" };

import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy";
import { DEV_PROXIED_PATH_PREFIXES, resolveWebDevServerHost } from "@t3tools/shared/devProxy";

import { loadRepoEnv } from "../../scripts/lib/public-config";

Expand All @@ -28,7 +28,9 @@ const isSingleOriginDev = process.env.T3CODE_SINGLE_ORIGIN_DEV === "1";

const port = Number(process.env.PORT ?? 5733);
const explicitHost = process.env.HOST?.trim();
const host = explicitHost || "localhost";
const sharedBindHost = process.env.T3CODE_WEB_BIND_HOST?.trim();
const host = resolveWebDevServerHost({ explicitHost, sharedBindHost });
const explicitHmrHost = sharedBindHost ? undefined : explicitHost;
const configuredWsUrl = isSingleOriginDev ? undefined : process.env.VITE_WS_URL?.trim();
const configuredHttpUrl = isSingleOriginDev ? undefined : process.env.VITE_HTTP_URL?.trim();
const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";
Expand Down Expand Up @@ -247,11 +249,11 @@ export default defineConfig(() => {
// page origin, which is what makes HMR work over Tailscale/LAN instead of
// failing an attempt against the wrong machine's localhost first.
// (Vite 8 logs connection state via console.debug — enable "Verbose".)
...(explicitHost
...(explicitHmrHost
? {
hmr: {
protocol: "ws",
host: explicitHost,
host: explicitHmrHost,
clientPort: port,
},
}
Expand Down
7 changes: 6 additions & 1 deletion docs/internals/remote.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,12 @@ Worker itself. See [t3-connect.md](./t3-connect.md).

A T3-managed `tailscale serve` mapping exposes the server on the tailnet over HTTPS, and the
resulting private-network endpoints are advertised for pairing. Connection then follows the ordinary
bearer path.
bearer path. Serve configuration, rather than backend health, determines port ownership: T3 reuses
only an exact root handler for its loopback target, refuses to replace any other configured handler,
and checks that a handler still exactly matches the target before requesting removal. This lets other
applications keep additive handlers on separate HTTPS ports, including while their backends are
temporarily unavailable. Applications should not concurrently change the same HTTPS port because
the status check and removal command are separate Tailscale operations.

### Desktop-managed SSH access

Expand Down
2 changes: 1 addition & 1 deletion docs/user/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ If the server is only bound to loopback, the printed URL is not reachable from a
npx t3 pair --tailscale
```

This publishes the server over Tailscale Serve HTTPS (configuring the mapping if needed — it persists until you run `tailscale serve --https=443 off`) and pairs through the `https://machine.tailnet.ts.net/` URL. Use `--tailscale-serve-port` for a different HTTPS port, `--ttl` to change the token lifetime, and `--base-dir` to target a specific data directory.
This publishes the server over Tailscale Serve HTTPS (configuring the mapping if needed — it persists until you run `tailscale serve --https=443 off`) and pairs through the `https://machine.tailnet.ts.net/` URL. Use `--tailscale-serve-port` for a different HTTPS port, `--ttl` to change the token lifetime, and `--base-dir` to target a specific data directory. If that HTTPS port already has a different Serve handler, T3 leaves it unchanged and asks you to choose another port; an unavailable backend does not make an occupied port safe to reuse.
Comment thread
cursor[bot] marked this conversation as resolved.

If no server is running, `t3 pair` says so and points you at `npx t3 serve` or `npx t3 connect`.

Expand Down
39 changes: 39 additions & 0 deletions packages/shared/src/devProxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { assert, describe, it } from "@effect/vitest";

import { resolveWebDevServerHost, SHARED_DEV_LOOPBACK_HOST } from "./devProxy.ts";

describe("resolveWebDevServerHost", () => {
it("uses the same IPv4 loopback selected for Tailscale sharing", () => {
assert.equal(
resolveWebDevServerHost({
explicitHost: undefined,
sharedBindHost: SHARED_DEV_LOOPBACK_HOST,
}),
"127.0.0.1",
);
});

it("keeps ordinary local development and explicit desktop hosts unchanged", () => {
assert.equal(
resolveWebDevServerHost({ explicitHost: undefined, sharedBindHost: undefined }),
"localhost",
);
assert.equal(
resolveWebDevServerHost({
explicitHost: "192.0.2.10",
sharedBindHost: undefined,
}),
"192.0.2.10",
);
});

it("does not let an environment HOST override the shared proxy address", () => {
assert.equal(
resolveWebDevServerHost({
explicitHost: "localhost",
sharedBindHost: SHARED_DEV_LOOPBACK_HOST,
}),
"127.0.0.1",
);
});
});
9 changes: 9 additions & 0 deletions packages/shared/src/devProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@
*/
export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws"] as const;

export const SHARED_DEV_LOOPBACK_HOST = "127.0.0.1";

export function resolveWebDevServerHost(input: {
readonly explicitHost: string | undefined;
readonly sharedBindHost: string | undefined;
}): string {
return input.sharedBindHost || input.explicitHost || "localhost";
}

export function isDevProxiedPath(pathname: string): boolean {
return DEV_PROXIED_PATH_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
Expand Down
Loading
Loading