diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts
index 841962f0dc8e..2225a1f20b97 100644
--- a/apps/server/src/cli/config.ts
+++ b/apps/server/src/cli/config.ts
@@ -131,6 +131,14 @@ const EnvServerConfig = Config.all({
host: Config.String("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
t3Home: Config.String("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
devUrl: Config.URL("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
+ connectDevShare: Config.String("T3CODE_DEV_SHARE").pipe(
+ Config.withDefault(""),
+ Config.map((value) => value === "connect"),
+ ),
+ connectAuthorizationHome: Config.String("T3CODE_CONNECT_AUTHORIZATION_HOME").pipe(
+ Config.option,
+ Config.map(Option.getOrUndefined),
+ ),
devAllowedOrigins: Config.String("T3CODE_DEV_ALLOWED_ORIGINS").pipe(
Config.withDefault(""),
Config.map((value) =>
@@ -464,6 +472,10 @@ export const resolveServerConfig = (
devUrl,
...(devAuthToken === undefined ? {} : { devAuthToken }),
devAllowedOrigins: env.devAllowedOrigins,
+ ...(env.connectDevShare ? { connectDevShare: true } : {}),
+ ...(env.connectAuthorizationHome
+ ? { connectAuthorizationHome: env.connectAuthorizationHome }
+ : {}),
noBrowser,
startupPresentation,
desktopBootstrapToken,
diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts
index 83d384097ecc..d83a67327185 100644
--- a/apps/server/src/cli/connect.ts
+++ b/apps/server/src/cli/connect.ts
@@ -434,7 +434,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* (run: (origin: string) => Effect.Effect {
+ it.effect("refuses missing, invalid and unreachable connect endpoints before minting", () =>
+ withDescriptorServer((origin) =>
+ Effect.gen(function* () {
+ const baseDir = yield* Effect.acquireRelease(
+ Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-connect-"))),
+ (path) => Effect.sync(() => NodeFS.rmSync(path, { recursive: true, force: true })),
+ );
+ const stateDir = NodePath.join(baseDir, "userdata");
+ yield* persistServerRuntimeState({
+ path: NodePath.join(stateDir, "server-runtime.json"),
+ state: yield* makePersistedServerRuntimeState({
+ config: { host: "127.0.0.1", devUrl: undefined },
+ port: Number(new URL(origin).port),
+ }),
+ });
+ const secretsDir = NodePath.join(stateDir, "secrets");
+ NodeFS.mkdirSync(secretsDir, { recursive: true });
+ for (const [endpoint, expectedMessage] of [
+ [undefined, "No saved Connect endpoint"],
+ ["not a url", "saved Connect endpoint is invalid"],
+ [origin, "saved Connect endpoint is invalid"],
+ ["https://user:password@example.com", "saved Connect endpoint is invalid"],
+ ["https://example.com/path", "saved Connect endpoint is invalid"],
+ ["https://127.0.0.1:1", "not reaching this environment"],
+ ] as const) {
+ if (endpoint !== undefined) {
+ NodeFS.writeFileSync(
+ NodePath.join(secretsDir, `${CLOUD_ENDPOINT_HTTP_ORIGIN}.bin`),
+ endpoint,
+ );
+ }
+ const error = yield* provideCliTestLayers(
+ runCli(["pair", "--base-dir", baseDir, "--connect"]).pipe(Effect.flip),
+ );
+ const rendered = String(
+ typeof error === "object" && error !== null && "cause" in error ? error.cause : error,
+ );
+ assert.include(rendered, expectedMessage);
+ assert.isFalse(NodeFS.existsSync(NodePath.join(stateDir, "state.sqlite")));
+ }
+ }),
+ ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+ );
+
it.effect("mints a token and prints a QR pairing URL for a live server", () =>
withDescriptorServer((origin) =>
Effect.gen(function* () {
diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts
index 63e7de96c232..c3125e7b684b 100644
--- a/apps/server/src/cli/pair.ts
+++ b/apps/server/src/cli/pair.ts
@@ -7,7 +7,8 @@
* public environment descriptor. Inside a linked git worktree the worktree's
* own `.t3` is checked first (matching dev-runner precedence); otherwise the
* shared T3 home. `--tailscale` publishes the server over Tailscale Serve
- * HTTPS and pairs through the tailnet URL instead.
+ * HTTPS and pairs through the tailnet URL instead. `--connect` uses the
+ * environment's existing managed Connect endpoint after checking it is live.
*/
import {
AuthStandardClientScopes,
@@ -36,6 +37,8 @@ import { Command, Flag, GlobalFlag } from "effect/cli";
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
+import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
+import { CLOUD_ENDPOINT_HTTP_ORIGIN } from "../cloud/config.ts";
import * as ServerConfig from "../config.ts";
import { resolveBaseDir } from "../os-jank.ts";
import {
@@ -55,6 +58,7 @@ import { baseDirFlag, DurationFromString } from "./config.ts";
const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment";
const PAIR_PROBE_TIMEOUT = Duration.millis(2_500);
+const decodeConnectOrigin = Schema.decodeUnknownOption(Schema.URLFromString);
// Tailscale provisions an HTTPS certificate on the first request to a fresh
// serve mapping, which can take a few seconds.
const TAILSCALE_PROBE_ATTEMPTS = 5;
@@ -81,6 +85,23 @@ export class NoRunningServerError extends Schema.TaggedError()(
+ "ConnectPairingError",
+ { reason: Schema.Literals(["missing", "invalid", "unreachable", "conflict"]) },
+) {
+ override get message(): string {
+ return {
+ missing:
+ "No saved Connect endpoint for this environment. Run `t3 connect link`, or restart its `vp run dev --share` server.",
+ invalid:
+ "The saved Connect endpoint is invalid. Restart this environment to refresh its link.",
+ unreachable:
+ "The saved Connect endpoint is not reaching this environment. Restart its Connect share and retry once the public endpoint is ready.",
+ conflict: "Choose either --connect or --tailscale.",
+ }[this.reason];
+ }
+}
+
// Each tailscale failure gets its own class (same reasoning as
// scripts/lib/dev-share.ts): distinct caller-visible message, distinct remedy.
export class TailscaleUnavailableError extends Schema.TaggedError()(
@@ -128,7 +149,7 @@ export class ServePortOccupiedError extends Schema.TaggedError
state.devUrl ?? resolveHeadlessConnectionString(state.host, state.port);
@@ -227,6 +248,34 @@ const probeEnvironmentDescriptor = (
return { _tag: "descriptor", descriptor } as const;
}).pipe(Effect.catch((outcome) => Effect.succeed(outcome)));
+const resolveConnectPairingBaseUrl = Effect.fn("pair.resolveConnectPairingBaseUrl")(function* (
+ environmentId: ExecutionEnvironmentDescriptor["environmentId"],
+) {
+ const secrets = yield* ServerSecretStore.ServerSecretStore;
+ const stored = yield* secrets.get(CLOUD_ENDPOINT_HTTP_ORIGIN);
+ if (Option.isNone(stored)) {
+ return yield* new ConnectPairingError({ reason: "missing" });
+ }
+ const parsed = decodeConnectOrigin(new TextDecoder().decode(stored.value));
+ if (
+ Option.isNone(parsed) ||
+ parsed.value.protocol !== "https:" ||
+ parsed.value.username !== "" ||
+ parsed.value.password !== "" ||
+ parsed.value.pathname !== "/" ||
+ parsed.value.search !== "" ||
+ parsed.value.hash !== ""
+ ) {
+ return yield* new ConnectPairingError({ reason: "invalid" });
+ }
+ const baseUrl = parsed.value.origin;
+ const probe = yield* probeEnvironmentDescriptor(baseUrl);
+ if (probe._tag !== "descriptor" || probe.descriptor.environmentId !== environmentId) {
+ return yield* new ConnectPairingError({ reason: "unreachable" });
+ }
+ return baseUrl;
+});
+
interface DiscoveredPairTarget {
readonly baseDir: string;
readonly variant: PairStateVariant;
@@ -477,6 +526,10 @@ export const pairCommand = Command.make("pair", {
baseDir: baseDirFlag,
ttl: ttlFlag,
label: labelFlag,
+ connect: Flag.Boolean("connect").pipe(
+ Flag.withDescription("Pair through this environment's existing T3 Connect endpoint."),
+ Flag.withDefault(false),
+ ),
tailscale: tailscaleFlag,
tailscaleServePort: tailscaleServePortFlag,
}).pipe(
@@ -485,16 +538,24 @@ export const pairCommand = Command.make("pair", {
),
Command.withHandler((flags) =>
Effect.gen(function* () {
+ if (flags.connect && flags.tailscale) {
+ return yield* new ConnectPairingError({ reason: "conflict" });
+ }
const cliLogLevel = yield* GlobalFlag.LogLevel;
// Default to Warn so storage/migration chatter cannot bury the QR code;
// an explicit --log-level still wins.
const logLevel = Option.getOrElse(cliLogLevel, () => "Warn" as const);
const target = yield* discoverPairTarget(Option.getOrUndefined(flags.baseDir));
+ const config = yield* makePairServerConfig({ target, logLevel });
const notes: Array = [];
let pairingBaseUrl: string;
- if (flags.tailscale) {
+ if (flags.connect) {
+ pairingBaseUrl = yield* resolveConnectPairingBaseUrl(target.descriptor.environmentId).pipe(
+ Effect.provide(ServerSecretStore.layer.pipe(Layer.provide(ServerConfig.layer(config)))),
+ );
+ } else if (flags.tailscale) {
const resolved = yield* resolveTailscalePairingBase({
target,
servePort: flags.tailscaleServePort,
@@ -515,7 +576,6 @@ export const pairCommand = Command.make("pair", {
}
}
- const config = yield* makePairServerConfig({ target, logLevel });
const issued = yield* mintPairingLink({ config, ttl: flags.ttl, label: flags.label });
const pairingUrl = buildPairingUrl(pairingBaseUrl, issued.credential);
diff --git a/apps/server/src/cloud/CliState.ts b/apps/server/src/cloud/CliState.ts
index dc77609ccc92..82b8425a1be9 100644
--- a/apps/server/src/cloud/CliState.ts
+++ b/apps/server/src/cloud/CliState.ts
@@ -4,6 +4,7 @@ import * as Option from "effect/Option";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import {
CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
+ CLOUD_ENDPOINT_HTTP_ORIGIN,
CLOUD_ENDPOINT_RUNTIME_CONFIG,
CLOUD_LINKED_USER_ID,
CLOUD_MINT_PUBLIC_KEY,
@@ -54,6 +55,7 @@ export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(f
);
} else {
yield* secrets.remove(CLOUD_CLI_DESIRED_LINK_SECRET);
+ yield* secrets.remove(CLOUD_ENDPOINT_HTTP_ORIGIN);
}
});
@@ -68,6 +70,7 @@ export const clearPersistedCloudLink = Effect.gen(function* () {
secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET),
secrets.remove(CLOUD_MINT_PUBLIC_KEY),
secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG),
+ secrets.remove(CLOUD_ENDPOINT_HTTP_ORIGIN),
secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN),
secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET),
],
diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts
index c0fb59026a76..ae7393b951e3 100644
--- a/apps/server/src/cloud/CliTokenManager.test.ts
+++ b/apps/server/src/cloud/CliTokenManager.test.ts
@@ -5,9 +5,11 @@ import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Base64Url from "effect/encoding/Base64Url";
+import * as FileSystem from "effect/FileSystem";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
+import * as Path from "effect/Path";
import * as Queue from "effect/Queue";
import * as Schema from "effect/Schema";
import * as Terminal from "effect/Terminal";
@@ -15,6 +17,9 @@ import * as TestClock from "effect/testing/TestClock";
import * as HttpClient from "effect/http/HttpClient";
import * as HttpClientResponse from "effect/http/HttpClientResponse";
+import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
+import * as ServerConfig from "../config.ts";
+import * as ExternalLauncher from "../process/externalLauncher.ts";
import * as CliTokenManager from "./CliTokenManager.ts";
// pk_test_
@@ -323,3 +328,106 @@ it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) =>
}),
);
});
+
+it.layer(NodeServices.layer)("CliTokenManager.layerWithSharedAuthorization", (it) => {
+ it.effect.each([
+ {
+ name: "prefers the stored home over the dev-share home",
+ stored: true,
+ devShare: true,
+ configured: true,
+ expected: "stored",
+ },
+ {
+ name: "uses the stored home outside dev sharing",
+ stored: true,
+ devShare: false,
+ configured: true,
+ expected: "stored",
+ },
+ {
+ name: "uses the configured home for dev sharing",
+ stored: false,
+ devShare: true,
+ configured: true,
+ expected: "configured",
+ },
+ {
+ name: "ignores the configured home outside dev sharing",
+ stored: false,
+ devShare: false,
+ configured: true,
+ expected: "local",
+ },
+ {
+ name: "uses local credentials without an authorization home",
+ stored: false,
+ devShare: true,
+ configured: false,
+ expected: "local",
+ },
+ ])("$name", (scenario) =>
+ Effect.gen(function* () {
+ const config = yield* ServerConfig.ServerConfig;
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-auth-priority-" });
+ const storedHome = path.join(directory, "stored");
+ const configuredHome = path.join(directory, "configured");
+ const localConfig = ServerConfig.make({
+ ...config,
+ secretsDir: path.join(directory, "local"),
+ connectDevShare: scenario.devShare,
+ ...(scenario.configured ? { connectAuthorizationHome: configuredHome } : {}),
+ });
+ for (const [identity, secretsDir] of [
+ ["stored", path.join(storedHome, "userdata", "secrets")],
+ ["configured", path.join(configuredHome, "userdata", "secrets")],
+ ["local", localConfig.secretsDir],
+ ] as const) {
+ yield* Effect.gen(function* () {
+ const manager = yield* CliTokenManager.CloudCliTokenManager;
+ yield* manager.store({
+ accessToken: identity,
+ refreshToken: identity,
+ expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
+ });
+ }).pipe(
+ Effect.provide(
+ CliTokenManager.layer.pipe(
+ Layer.provide(ServerSecretStore.layer),
+ Layer.provide(ServerConfig.layer({ ...localConfig, secretsDir })),
+ ),
+ ),
+ );
+ }
+ yield* Effect.gen(function* () {
+ const secrets = yield* ServerSecretStore.ServerSecretStore;
+ if (scenario.stored) {
+ yield* secrets.set(
+ CliTokenManager.CLOUD_CLI_AUTHORIZATION_HOME_SECRET,
+ new TextEncoder().encode(storedHome),
+ );
+ }
+ const token = yield* Effect.gen(function* () {
+ const manager = yield* CliTokenManager.CloudCliTokenManager;
+ return yield* manager.getExisting;
+ }).pipe(Effect.provide(CliTokenManager.layerWithSharedAuthorization));
+ assert.isTrue(Option.isSome(token));
+ assert.equal(Option.getOrThrow(token).accessToken, scenario.expected);
+ }).pipe(
+ Effect.provide(
+ ServerSecretStore.layer.pipe(Layer.provideMerge(ServerConfig.layer(localConfig))),
+ ),
+ );
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ ServerConfig.layerTest(process.cwd(), { prefix: "t3-cli-auth-config-" }),
+ ExternalLauncher.layer,
+ layerDeviceFlow({ requests: [], tokenReplies: [] }),
+ ),
+ ),
+ ),
+ );
+});
diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts
index 475f9aa9caee..2982cdcf7bc7 100644
--- a/apps/server/src/cloud/CliTokenManager.ts
+++ b/apps/server/src/cloud/CliTokenManager.ts
@@ -13,6 +13,7 @@ import * as Effect from "effect/Effect";
import * as Base64Url from "effect/encoding/Base64Url";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
+import * as Path from "effect/Path";
import * as Queue from "effect/Queue";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
@@ -28,6 +29,7 @@ import * as HttpServerResponse from "effect/http/HttpServerResponse";
import { buildConnectAuthorizeRequestUrl } from "@t3tools/shared/connectAuth";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
+import * as ServerConfig from "../config.ts";
import * as ExternalLauncher from "../process/externalLauncher.ts";
import {
cloudCliOAuthConfig,
@@ -37,6 +39,7 @@ import {
import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts";
const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token";
+export const CLOUD_CLI_AUTHORIZATION_HOME_SECRET = "cloud-cli-authorization-home";
const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10);
const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5));
const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
@@ -583,3 +586,33 @@ export const make = Effect.gen(function* () {
});
export const layer = Layer.effect(CloudCliTokenManager, make);
+
+// A dev environment retains its own link state while account commands use
+// the same login that originally authorized its shared endpoint.
+export const layerWithSharedAuthorization = Layer.unwrap(
+ Effect.gen(function* () {
+ const config = yield* ServerConfig.ServerConfig;
+ const localSecrets = yield* ServerSecretStore.ServerSecretStore;
+ const storedHome = yield* localSecrets.get(CLOUD_CLI_AUTHORIZATION_HOME_SECRET);
+ const authorizationHome =
+ (Option.isSome(storedHome) ? new TextDecoder().decode(storedHome.value).trim() : undefined) ||
+ (config.connectDevShare ? config.connectAuthorizationHome : undefined);
+ const path = yield* Path.Path;
+ return layer.pipe(
+ Layer.provide(
+ Layer.fresh(ServerSecretStore.layer).pipe(
+ Layer.provide(
+ ServerConfig.layer(
+ ServerConfig.make({
+ ...config,
+ secretsDir: authorizationHome
+ ? path.join(authorizationHome, "userdata", "secrets")
+ : config.secretsDir,
+ }),
+ ),
+ ),
+ ),
+ ),
+ );
+ }),
+);
diff --git a/apps/server/src/cloud/CloudLink.lifecycle.test.ts b/apps/server/src/cloud/CloudLink.lifecycle.test.ts
index dd2115692778..7a2ffa60fbe2 100644
--- a/apps/server/src/cloud/CloudLink.lifecycle.test.ts
+++ b/apps/server/src/cloud/CloudLink.lifecycle.test.ts
@@ -33,6 +33,7 @@ import * as CliTokenManager from "./CliTokenManager.ts";
import { RelayManagedEndpointRecoveryRegistrationRequest } from "@t3tools/contracts/relay";
import {
CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
+ CLOUD_ENDPOINT_HTTP_ORIGIN,
CLOUD_ENDPOINT_RUNTIME_CONFIG,
CLOUD_LINKED_USER_ID,
decodeConfirmedOrigin,
@@ -357,6 +358,25 @@ describe("releaseManagedTunnelOnShutdown", () => {
[CLOUD_CLI_DESIRED_LINK_SECRET, "managed"],
] as const;
+ it.effect("clears the managed public HTTP origin when unlinking", () => {
+ const { store, values } = makeMemorySecretStore([
+ ...managedLinkSecrets,
+ [CLOUD_ENDPOINT_HTTP_ORIGIN, "https://environment.example.test"],
+ ]);
+ const applyConfigCalls: Array = [];
+ const requests: Array = [];
+ return Effect.gen(function* () {
+ const link = yield* CloudLink.CloudLink;
+ expect(yield* link.unlink()).toMatchObject({
+ ok: true,
+ endpointRuntimeStatus: { status: "disabled" },
+ });
+ expect(values.has(CLOUD_ENDPOINT_HTTP_ORIGIN)).toBe(false);
+ expect(values.has(CLOUD_CLI_DESIRED_LINK_SECRET)).toBe(false);
+ expect(applyConfigCalls).toEqual([null]);
+ }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
+ });
+
it.effect("does not recreate a link that was unlinked while startup registration retried", () => {
const { store, values } = makeMemorySecretStore(managedLinkSecrets);
const applyConfigCalls: Array = [];
diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts
index 57904596b5f1..b4ddfb900b59 100644
--- a/apps/server/src/cloud/CloudLink.ts
+++ b/apps/server/src/cloud/CloudLink.ts
@@ -83,6 +83,7 @@ import {
import * as CliTokenManager from "./CliTokenManager.ts";
import {
CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
+ CLOUD_ENDPOINT_HTTP_ORIGIN,
CLOUD_ENDPOINT_RUNTIME_CONFIG,
CLOUD_LINKED_USER_ID,
CLOUD_MINT_PUBLIC_KEY,
@@ -817,6 +818,7 @@ const make = Effect.gen(function* () {
}
yield* endpointRuntime.applyConfig(null);
yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN);
+ yield* secrets.remove(CLOUD_ENDPOINT_HTTP_ORIGIN);
yield* secrets.set(RELAY_URL_SECRET, stringToBytes(payload.relayUrl));
yield* secrets.set(
@@ -944,6 +946,11 @@ const make = Effect.gen(function* () {
confirmedOrigin: parsedOrigin.origin,
},
);
+ if (managedTunnelsEnabled) {
+ yield* secrets.set(CLOUD_ENDPOINT_HTTP_ORIGIN, stringToBytes(link.endpoint.httpBaseUrl));
+ } else {
+ yield* secrets.remove(CLOUD_ENDPOINT_HTTP_ORIGIN);
+ }
// Callers decide on managed tunnel recovery from the mode this link
// actually used, not from a value read before the relay round trip.
return mode;
diff --git a/apps/server/src/cloud/config.ts b/apps/server/src/cloud/config.ts
index 910f0820282f..54a46b40f78a 100644
--- a/apps/server/src/cloud/config.ts
+++ b/apps/server/src/cloud/config.ts
@@ -10,6 +10,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key";
export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config";
+export const CLOUD_ENDPOINT_HTTP_ORIGIN = "cloud-endpoint-http-origin";
export const CLOUD_ENDPOINT_CONFIRMED_ORIGIN = "cloud-endpoint-confirmed-origin";
export const CLOUD_LINKED_USER_ID = "cloud-linked-user-id";
export const RELAY_URL_SECRET = "cloud-relay-url";
diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
index 91a1775bdf22..2968b2a0df07 100644
--- a/apps/server/src/config.ts
+++ b/apps/server/src/config.ts
@@ -89,6 +89,8 @@ export class ServerConfig extends Context.Service<
readonly baseDir: string;
readonly staticDir: string | undefined;
readonly devUrl: URL | undefined;
+ readonly connectDevShare?: boolean;
+ readonly connectAuthorizationHome?: string;
readonly devAuthToken?: Redacted.Redacted | undefined;
readonly devAllowedOrigins: ReadonlyArray;
readonly noBrowser: boolean;
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 1294e6bdcf32..58f29d5154ed 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -10,7 +10,11 @@ import * as NodeHttp from "node:http";
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import * as NodeServices from "@effect/platform-node/NodeServices";
-import { EnvironmentHttpApi, type RepositoryIdentity } from "@t3tools/contracts";
+import {
+ EnvironmentHttpApi,
+ ExecutionEnvironmentDescriptor,
+ type RepositoryIdentity,
+} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Duration from "effect/Duration";
import * as Deferred from "effect/Deferred";
@@ -18,8 +22,15 @@ import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Layer from "effect/Layer";
import * as Stream from "effect/Stream";
+import * as Schema from "effect/Schema";
import * as Schedule from "effect/Schedule";
-import { FetchHttpClient, HttpRouter, HttpServer } from "effect/http";
+import {
+ FetchHttpClient,
+ HttpClient,
+ HttpClientResponse,
+ HttpRouter,
+ HttpServer,
+} from "effect/http";
import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
@@ -138,6 +149,7 @@ import {
retryManagedTunnelRegistration,
} from "./cloud/managedTunnelStartup.ts";
import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
+import * as CloudConfig from "./cloud/config.ts";
import * as CloudCliState from "./cloud/CliState.ts";
import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts";
@@ -618,7 +630,7 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(
Layer.provideMerge(ServerSecretStore.layer),
Layer.provideMerge(
Layer.mergeAll(
- CloudCliTokenManager.layer.pipe(
+ CloudCliTokenManager.layerWithSharedAuthorization.pipe(
Layer.provide(ServerSecretStore.layer),
Layer.provide(ExternalLauncher.layer),
),
@@ -692,9 +704,29 @@ const layerMakeRoutes = Layer.mergeAll(
Layer.provide(ServerHttp.layerHttpCompression),
);
+class ConnectDevShareError extends Schema.TaggedError()(
+ "ConnectDevShareError",
+ { reason: Schema.Literals(["configuration", "environment-mismatch"]) },
+) {
+ override get message(): string {
+ return this.reason === "configuration"
+ ? "Connect dev sharing requires T3 Connect public configuration and a local HTTP Vite origin. Copy .env.example to .env to configure a source build."
+ : "Connect endpoint belongs to a different environment.";
+ }
+}
+
const layerMakeServer = Layer.unwrap(
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
+ if (
+ config.connectDevShare &&
+ (!hasCloudPublicConfig ||
+ !config.devUrl ||
+ config.devUrl.protocol !== "http:" ||
+ !["localhost", "127.0.0.1"].includes(config.devUrl.hostname))
+ ) {
+ return yield* new ConnectDevShareError({ reason: "configuration" });
+ }
const activation = yield* Deferred.make();
const awaitActivation = Deferred.await(activation);
const layerActivation = Layer.succeed(ServerActivation.ServerActivation, awaitActivation);
@@ -831,10 +863,69 @@ const layerMakeServer = Layer.unwrap(
if (!cleanupBeforeActivation) {
yield* Effect.addFinalizer(() => releaseManagedTunnel);
}
+ if (config.devUrl && !config.connectDevShare) return;
const server = yield* HttpServer.HttpServer;
const address = server.address;
if (typeof address === "string" || !("port" in address)) return;
- const localOrigin = `http://127.0.0.1:${address.port}`;
+ const localOrigin =
+ config.connectDevShare && config.devUrl
+ ? config.devUrl.origin
+ : `http://127.0.0.1:${address.port}`;
+ if (config.connectDevShare) {
+ yield* Effect.gen(function* () {
+ const secrets = yield* ServerSecretStore.ServerSecretStore;
+ if (config.connectAuthorizationHome) {
+ yield* secrets.set(
+ CloudCliTokenManager.CLOUD_CLI_AUTHORIZATION_HOME_SECRET,
+ new TextEncoder().encode(config.connectAuthorizationHome),
+ );
+ }
+ const relayClient = yield* RelayClient.RelayClient;
+ yield* relayClient.install;
+ yield* cloudLink.reconcileDesiredLink(localOrigin).pipe(
+ Effect.retry({
+ while: CloudLink.shouldRetryCloudLink,
+ schedule: Schedule.exponential("1 second").pipe(
+ Schedule.upTo({ duration: "10 minutes" }),
+ ),
+ }),
+ );
+ const storedOrigin = yield* secrets.get(CloudConfig.CLOUD_ENDPOINT_HTTP_ORIGIN);
+ if (Option.isNone(storedOrigin))
+ return yield* new ConnectDevShareError({ reason: "configuration" });
+ const publicOrigin = new TextDecoder().decode(storedOrigin.value);
+ const environment = yield* ServerEnvironment.ServerEnvironment;
+ const expected = yield* environment.getDescriptor;
+ const client = yield* HttpClient.HttpClient;
+ yield* client.get(new URL("/.well-known/t3/environment", publicOrigin)).pipe(
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
+ Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)),
+ Effect.filterOrFail(
+ (actual) => actual.environmentId === expected.environmentId,
+ () => new ConnectDevShareError({ reason: "environment-mismatch" }),
+ ),
+ Effect.timeout("5 seconds"),
+ Effect.retry({
+ schedule: Schedule.spaced("1 second"),
+ while: (error) => !Schema.is(ConnectDevShareError)(error),
+ }),
+ Effect.timeout("2 minutes"),
+ );
+ const auth = yield* EnvironmentAuth.EnvironmentAuth;
+ const pairingUrl = yield* auth.issueStartupPairingUrl(publicOrigin);
+ yield* Effect.logInfo(`[dev-runner] pairingUrl: ${pairingUrl}`);
+ }).pipe(
+ Effect.tapError((error) =>
+ Effect.logWarning(
+ Schema.is(CloudLink.CloudLinkAuthorizationMissingError)(error) ||
+ Schema.is(ConnectDevShareError)(error)
+ ? `T3 Connect dev sharing failed. ${error.message}`
+ : "T3 Connect dev sharing failed. Check the Connect configuration and retry.",
+ { errorTag: error._tag },
+ ),
+ ),
+ );
+ }
const endpointRuntime = yield* CloudManagedEndpointRuntime.CloudManagedEndpointRuntime;
const recoveryLock = yield* Semaphore.make(1);
let lastRecoveryAtMillis = 0;
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 1d710f273cd0..5d305843b82f 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -9,7 +9,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, isDevProxiedPath } from "@t3tools/shared/devProxy";
import { loadRepoEnv } from "../../scripts/lib/public-config";
import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses";
@@ -32,8 +32,14 @@ const explicitHost = process.env.HOST?.trim();
const host = explicitHost || "localhost";
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() || "";
-const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";
+// Shared dev uses direct pairing; Clerk's hosted sign-in is not configured
+// for each managed environment hostname.
+const configuredRelayUrl =
+ process.env.T3CODE_DEV_SHARE === "connect" ? "" : repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";
+const configuredClerkPublishableKey =
+ process.env.T3CODE_DEV_SHARE === "connect"
+ ? ""
+ : repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";
const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || "";
const configuredClerkCliOAuthClientId = repoEnv.VITE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || "";
const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || "";
@@ -63,6 +69,11 @@ const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase();
// round trip per import level in unbundled dev); T3CODE_BUNDLED_DEV=0 opts out.
const bundledDevEnv = process.env.T3CODE_BUNDLED_DEV?.trim().toLowerCase();
const bundledDev = bundledDevEnv === "1" || bundledDevEnv === "true";
+const connectDevShare = process.env.T3CODE_DEV_SHARE === "connect";
+
+if (connectDevShare && (!bundledDev || !["localhost", "127.0.0.1"].includes(host))) {
+ throw new Error("Connect dev sharing requires bundled dev and a loopback listener.");
+}
const buildSourcemap: boolean | "hidden" =
sourcemapEnv === "0" || sourcemapEnv === "false"
@@ -143,6 +154,114 @@ function devCompressionPlugin(): Plugin {
};
}
+function connectDevSharePlugin(): Plugin {
+ const publicFiles = new Set([
+ "/apple-touch-icon.png",
+ "/favicon-16x16.png",
+ "/favicon-32x32.png",
+ "/favicon.ico",
+ "/manifest.webmanifest",
+ ]);
+ return {
+ name: "t3code:connect-dev-share",
+ apply: "serve",
+ configureServer(server) {
+ // Bundled dev needs only these two inbound events. Vite's other custom
+ // handlers trust their payloads and must not receive public socket input.
+ server.ws.on("connection", (socket) => {
+ const listeners = socket.listeners("message");
+ socket.removeAllListeners("message");
+ let registered = false;
+ socket.on("message", (raw, isBinary) => {
+ if (isBinary || !Buffer.isBuffer(raw) || raw.length > 4096) return socket.terminate();
+ let message: unknown;
+ try {
+ message = JSON.parse(raw.toString());
+ } catch {
+ return socket.terminate();
+ }
+ if (
+ typeof message === "object" &&
+ message !== null &&
+ "type" in message &&
+ message.type === "ping"
+ )
+ return;
+ if (
+ typeof message !== "object" ||
+ message === null ||
+ !("type" in message) ||
+ message.type !== "custom" ||
+ !("event" in message) ||
+ !("data" in message) ||
+ typeof message.data !== "object" ||
+ message.data === null
+ )
+ return socket.terminate();
+ if (
+ message.event === "vite:client-connected" &&
+ !registered &&
+ "clientId" in message.data &&
+ typeof message.data.clientId === "string" &&
+ message.data.clientId.length > 0
+ )
+ registered = true;
+ else if (
+ message.event !== "vite:bundled-dev:reload-needed" ||
+ !registered ||
+ !("reason" in message.data) ||
+ typeof message.data.reason !== "string"
+ )
+ return socket.terminate();
+ for (const listener of listeners) listener.call(socket, raw, isBinary);
+ });
+ });
+ server.middlewares.use((request, response, next) => {
+ const [pathname = "/", query] = (request.url ?? "/").split("?", 2);
+ if (isDevProxiedPath(pathname)) return next();
+ response.setHeader("Cache-Control", "no-cache");
+ response.setHeader("Cloudflare-CDN-Cache-Control", "no-store");
+ const params = new URLSearchParams(query);
+ const safePath =
+ pathname.startsWith("/") &&
+ !/[%#\\]/u.test(pathname) &&
+ !pathname.includes("/.") &&
+ !pathname.endsWith(".map") &&
+ !/^\/(?:src|node_modules|@|__)/u.test(pathname) &&
+ !["raw", "url", "import", "direct", "sourcemap"].some((key) => params.has(key));
+ if (safePath && (request.method === "GET" || request.method === "HEAD")) {
+ // Only Vite's emitted files are public, never its source loader or
+ // compiler endpoints. HMR patches use the same in-memory file store.
+ const emitted = server.environments.client?.bundledDev?.memoryFiles.has(
+ pathname.slice(1),
+ );
+ const assetQuery = [...params].every(
+ ([key, value]) => key === "t" && /^\d+$/u.test(value),
+ );
+ if ((emitted || publicFiles.has(pathname)) && assetQuery) return next();
+ if (pathname === "/index.html" || /^\/[a-zA-Z0-9_/-]*$/u.test(pathname)) {
+ // Every navigation renders this app, even if another HTML file
+ // exists in the workspace. Pairing queries stay in the browser.
+ request.url = "/index.html";
+ return next();
+ }
+ }
+ response.writeHead(404).end();
+ });
+ server.httpServer?.prependListener("upgrade", (request, socket) => {
+ const pathname = request.url?.split("?", 1)[0];
+ const protocol = request.headers["sec-websocket-protocol"];
+ if (
+ !isDevProxiedPath(pathname ?? "/") &&
+ !(pathname === "/" && (protocol === "vite-hmr" || protocol === "vite-ping"))
+ ) {
+ socket.destroy();
+ }
+ });
+ },
+ };
+}
+
// Vite rejects requests whose Host header isn't localhost, which blocks sharing
// a dev server over Tailscale/LAN. Tailnet names are safe to allow wholesale:
// the DNS is controlled by tailscale, so they can't be rebound by an attacker.
@@ -157,6 +276,7 @@ export default defineConfig(() => {
return {
assetsInclude: ["**/*.wasm"],
plugins: [
+ ...(connectDevShare ? [connectDevSharePlugin()] : []),
devCompressionPlugin(),
thirdPartyLicensesPlugin({
bundleName: "web",
@@ -225,10 +345,14 @@ export default defineConfig(() => {
bundledDev,
},
server: {
+ // The environment server owns credentialed API CORS, including Electron origins.
+ ...(connectDevShare ? { cors: { preflightContinue: true } } : {}),
host,
port,
strictPort: true,
- allowedHosts,
+ // The guarded public surface is intentional; the managed hostname is
+ // allocated after Vite starts and backend routes retain their own auth.
+ allowedHosts: connectDevShare ? (true as const) : allowedHosts,
// Transform the whole module graph at server start instead of on the
// first request. Without this, a cold worktree discovers and transforms
// modules one import-level at a time while the browser waits — which
@@ -262,7 +386,7 @@ 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
+ ...(explicitHost && !connectDevShare
? {
hmr: {
protocol: "ws",
@@ -278,6 +402,24 @@ export default defineConfig(() => {
devSourcemap: buildSourcemap !== false,
},
build: {
+ // Compile split chunks at startup instead of exposing Vite's arbitrary
+ // module compilation endpoint. Clients still load routes on demand.
+ ...(connectDevShare
+ ? {
+ rolldownOptions: {
+ experimental: { devMode: { lazy: false } },
+ output: {
+ codeSplitting: {
+ groups: [
+ { name: "refresh", test: /react-refresh/, priority: 30 },
+ { name: "icons", test: /[\\/]lucide-react[\\/]/, priority: 20 },
+ { name: "shared", minShareCount: 2, includeDependenciesRecursively: true },
+ ],
+ },
+ },
+ },
+ }
+ : {}),
outDir: "dist",
emptyOutDir: true,
manifest: true,
diff --git a/docs/operations/development.md b/docs/operations/development.md
index b7e1b257d0b2..9fa7920a3ea7 100644
--- a/docs/operations/development.md
+++ b/docs/operations/development.md
@@ -37,15 +37,24 @@ different preference when needed.
### Sharing and remote debugging
-`vp run dev --share` publishes the web port over the machine's tailnet and prints a pairing URL
-for that origin. Give the tester the complete URL, including its token. The dev runner removes
-its mapping on exit.
+`vp run dev --share` shares through T3 Connect. Copy `.env.example` to `.env` for a source build,
+then run `node --env-file=.env apps/server/src/bin.ts connect login --base-dir ~/.t3` from the
+repository root once. The runner reuses that account login while keeping each worktree's
+environment and runtime state separate. Give the tester the complete printed pairing URL,
+including its token; `node --env-file=.env apps/server/src/bin.ts pair --connect` creates another.
+
+Each worktree uses a normal Connect environment and counts toward the account's tunnel limit.
+Shutdown releases its tunnel but keeps its registration and hostname for the next run.
+Deregister an unused worktree from T3 Connect to remove it. A killed process can leave its
+tunnel allocated until restart or deregistration. `--share-via tailscale` selects the existing
+tailnet sharing path instead.
Leave `VITE_HTTP_URL` and `VITE_WS_URL` unset. Vite proxies the backend through the browser's
origin so the same build works over localhost and remote connections.
Shared runs enable bundled dev to avoid a network round trip for each import level.
-`T3CODE_BUNDLED_DEV=0` opts out when debugging bundler differences. Two reload traps matter
+Connect sharing requires bundled dev; `T3CODE_BUNDLED_DEV=0` opts out for other runs when
+debugging bundler differences. Two reload traps matter
when changing this setup:
- The web entry must dynamically import the app so React refresh initializes before application
diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts
index c8363c181976..8f52a9e944a1 100644
--- a/scripts/dev-runner.test.ts
+++ b/scripts/dev-runner.test.ts
@@ -912,11 +912,15 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
// lives in runDevRunnerWithInput; the env builder must not consult the
// ambient variable on its own, or it would silently outrank the worktree
// default and land dev state on the user's real database.
- it.effect("ignores an ambient T3CODE_HOME when no home is resolved", () =>
+ it.effect("clears inherited home and sharing state when neither is requested", () =>
Effect.gen(function* () {
const env = yield* createDevRunnerEnv({
mode: "dev",
- baseEnv: { T3CODE_HOME: "/home/user/.t3" },
+ baseEnv: {
+ T3CODE_HOME: "/home/user/.t3",
+ T3CODE_DEV_SHARE: "connect",
+ T3CODE_CONNECT_AUTHORIZATION_HOME: "/another/environment",
+ },
serverOffset: 0,
webOffset: 0,
t3Home: undefined,
@@ -929,13 +933,15 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
});
assert.equal(env.T3CODE_HOME, undefined);
+ assert.equal(env.T3CODE_DEV_SHARE, undefined);
+ assert.equal(env.T3CODE_CONNECT_AUTHORIZATION_HOME, undefined);
}),
);
// Sharing dev:desktop would publish a URL whose renderer dials the
// visitor's own loopback, and would clobber the VITE_DEV_SERVER_URL that
// Electron loads from. It must decline, not half-work.
- it.effect("declines to share for dev:desktop and still starts the stack", () => {
+ it.effect("declines tailscale sharing for dev:desktop and still starts the stack", () => {
let spawnCount = 0;
const spawnerLayer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
@@ -951,6 +957,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
mode: "dev:desktop",
port: undefined,
share: true,
+ shareVia: "tailscale",
}).pipe(
Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
Effect.provideService(HostProcessPlatform, "linux"),
@@ -1048,7 +1055,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
// per-module waterfall pays a tailnet round trip per import level. The
// runner defaults bundled dev on for the spawned stack, but only
// defaults: an explicit T3CODE_BUNDLED_DEV (even "0") must pass through.
- describe("--share bundled dev default", () => {
+ describe("--share-via tailscale bundled dev default", () => {
const shareSpawnedEnv = (input: { readonly ambientBundledDev: string | undefined }) =>
Effect.gen(function* () {
let captured: Record | undefined;
@@ -1095,6 +1102,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
mode: "dev",
port: undefined,
share: true,
+ shareVia: "tailscale",
}).pipe(
Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
Effect.provideService(HostProcessPlatform, "linux"),
@@ -1153,7 +1161,54 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
);
});
- it.effect("spawns nothing when --dry-run is combined with --share", () => {
+ it.effect("shares through connect with stable account auth and isolated runtime state", () => {
+ const commands: string[] = [];
+ let captured: NodeJS.ProcessEnv | undefined;
+ const spawnerLayer = Layer.succeed(
+ ChildProcessSpawner.ChildProcessSpawner,
+ ChildProcessSpawner.make((command) => {
+ const spawned = command as {
+ readonly command: string;
+ readonly options?: { readonly env?: NodeJS.ProcessEnv };
+ };
+ commands.push(spawned.command);
+ captured = spawned.options?.env;
+ return Effect.succeed(mockProcess(0));
+ }),
+ );
+
+ return Effect.gen(function* () {
+ for (const shareVia of [undefined, "connect"] as const) {
+ yield* runDevRunnerWithInput({
+ ...devServerInput,
+ mode: "dev",
+ share: true,
+ ...(shareVia ? { shareVia } : {}),
+ }).pipe(
+ Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
+ Effect.provideService(HostProcessPlatform, "linux"),
+ Effect.provideService(HostProcessEnvironment, {
+ T3CODE_HOME: "/another/runtime",
+ T3CODE_CONNECT_AUTHORIZATION_HOME: "/another/account",
+ T3CODE_BUNDLED_DEV: "0",
+ }),
+ );
+ assert.equal(captured?.T3CODE_HOME, NodePath.resolve(devServerInput.t3Home));
+ assert.equal(
+ captured?.T3CODE_CONNECT_AUTHORIZATION_HOME,
+ NodePath.join(NodeOS.homedir(), ".t3"),
+ );
+ assert.equal(captured?.T3CODE_DEV_SHARE, "connect");
+ assert.equal(captured?.T3CODE_BUNDLED_DEV, "1");
+ assert.equal(captured?.T3CODE_SINGLE_ORIGIN_DEV, "1");
+ assert.equal(captured?.VITE_HTTP_URL, undefined);
+ assert.equal(captured?.VITE_WS_URL, undefined);
+ }
+ assert.deepStrictEqual(commands, ["vp", "vp"]);
+ });
+ });
+
+ it.effect("rejects unsupported connect configurations before spawning", () => {
let spawnCount = 0;
const spawnerLayer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
@@ -1164,16 +1219,64 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
);
return Effect.gen(function* () {
- yield* runDevRunnerWithInput({
- ...devServerInput,
- mode: "dev",
- port: undefined,
- dryRun: true,
- share: true,
- }).pipe(
- Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
- Effect.provideService(HostProcessPlatform, "linux"),
- );
+ for (const invalid of [
+ { mode: "dev:server" },
+ { mode: "dev:web" },
+ { mode: "dev:desktop" },
+ { mode: "dev", devUrl: new URL("https://preview.example.com") },
+ { mode: "dev", host: "0.0.0.0" },
+ { mode: "dev", host: "::" },
+ { mode: "dev", host: "::1" },
+ ] as const) {
+ const error = yield* runDevRunnerWithInput({
+ ...devServerInput,
+ ...invalid,
+ share: true,
+ }).pipe(
+ Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
+ Effect.provideService(HostProcessPlatform, "linux"),
+ Effect.flip,
+ );
+ assert.equal(error._tag, "DevRunnerConnectShareUnsupportedError");
+ if (error._tag === "DevRunnerConnectShareUnsupportedError") {
+ assert.equal(error.mode, invalid.mode);
+ assert.equal(
+ error.reason,
+ invalid.mode !== "dev" ? "mode" : "devUrl" in invalid ? "dev-url" : "host",
+ );
+ assert.include(error.message, "dev mode");
+ assert.include(error.message, "--dev-url");
+ assert.include(error.message, "loopback host");
+ }
+ }
+ assert.equal(spawnCount, 0);
+ });
+ });
+
+ it.effect("spawns nothing when --dry-run is combined with either sharing transport", () => {
+ let spawnCount = 0;
+ const spawnerLayer = Layer.succeed(
+ ChildProcessSpawner.ChildProcessSpawner,
+ ChildProcessSpawner.make(() => {
+ spawnCount += 1;
+ return Effect.succeed(mockProcess(0));
+ }),
+ );
+
+ return Effect.gen(function* () {
+ for (const shareVia of [undefined, "connect", "tailscale"] as const) {
+ yield* runDevRunnerWithInput({
+ ...devServerInput,
+ mode: "dev",
+ port: undefined,
+ dryRun: true,
+ share: true,
+ ...(shareVia ? { shareVia } : {}),
+ }).pipe(
+ Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
+ Effect.provideService(HostProcessPlatform, "linux"),
+ );
+ }
assert.equal(spawnCount, 0);
});
diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts
index f026e2507e59..1282e025b75f 100644
--- a/scripts/dev-runner.ts
+++ b/scripts/dev-runner.ts
@@ -118,6 +118,15 @@ export class DevRunnerConfigurationError extends Schema.TaggedError()(
+ "DevRunnerConnectShareUnsupportedError",
+ { mode: Schema.String, reason: Schema.Literals(["mode", "dev-url", "host"]) },
+) {
+ override get message(): string {
+ return "T3 Connect sharing requires dev mode, no --dev-url override, and a loopback host (localhost or 127.0.0.1).";
+ }
+}
+
export class DevRunnerInvalidPortOffsetError extends Schema.TaggedError()(
"DevRunnerInvalidPortOffsetError",
{
@@ -336,6 +345,8 @@ export function createDevRunnerEnv({
// (serviceLauncherClient.ts resolveStartup).
delete output.T3_SERVICE_LAUNCHER_CONTEXT;
delete output.T3_BOOT_SERVICE_UNIT;
+ delete output.T3CODE_DEV_SHARE;
+ delete output.T3CODE_CONNECT_AUTHORIZATION_HOME;
if (!isDesktopMode) {
output.T3CODE_PORT = String(serverPort);
@@ -617,11 +628,13 @@ interface DevRunnerCliInput {
readonly devUrl: URL | undefined;
readonly dryRun: boolean;
readonly share: boolean;
+ readonly shareVia?: "connect" | "tailscale";
readonly runArgs: ReadonlyArray;
}
export function runDevRunnerWithInput(input: DevRunnerCliInput) {
return Effect.gen(function* () {
+ const host = input.host?.trim() || undefined;
const { portOffset, devInstance } = yield* OffsetConfig.pipe(
Effect.mapError(
(cause) =>
@@ -639,10 +652,10 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
// front instead. (dev:server and dev:desktop don't proxy — untouched.)
if (
(input.mode === "dev" || input.mode === "dev:web") &&
- input.host !== undefined &&
- !isProxiableBindHost(input.host)
+ host !== undefined &&
+ !isProxiableBindHost(host)
) {
- return yield* new DevRunnerHostNotProxiableError({ mode: input.mode, host: input.host });
+ return yield* new DevRunnerHostNotProxiableError({ mode: input.mode, host });
}
const worktreePath = yield* resolveGitWorktreePath(yield* HostProcessWorkingDirectory);
@@ -660,7 +673,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
hasExplicitDevUrl: input.devUrl !== undefined,
// A non-loopback bind host decides whether the backend can actually take
// the port, so it has to be probed alongside loopback.
- checkPortAvailability: makeDefaultCheckPortAvailability(input.host),
+ checkPortAvailability: makeDefaultCheckPortAvailability(host),
});
const hostEnvironment = yield* HostProcessEnvironment;
@@ -684,7 +697,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
browser: input.browser,
autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd,
logWebSocketEvents: input.logWebSocketEvents,
- host: input.host,
+ host,
port: input.port,
devUrl: input.devUrl,
});
@@ -694,6 +707,26 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
? ` selectedOffset(server=${serverOffset},web=${webOffset})`
: "";
const baseDir = env.T3CODE_HOME ?? (yield* DEFAULT_T3_HOME);
+ const connectShare = input.share && input.shareVia !== "tailscale";
+ if (
+ connectShare &&
+ (input.mode !== "dev" ||
+ input.devUrl !== undefined ||
+ (host !== undefined && !["localhost", "127.0.0.1"].includes(host)))
+ ) {
+ return yield* new DevRunnerConnectShareUnsupportedError({
+ mode: input.mode,
+ reason: input.mode !== "dev" ? "mode" : input.devUrl !== undefined ? "dev-url" : "host",
+ });
+ }
+ if (connectShare) {
+ env.T3CODE_DEV_SHARE = "connect";
+ env.T3CODE_CONNECT_AUTHORIZATION_HOME = yield* DEFAULT_T3_HOME;
+ env.T3CODE_BUNDLED_DEV = "1";
+ yield* Effect.logInfo(
+ "[dev-runner] sharing through T3 Connect using this worktree's saved environment",
+ );
+ }
yield* Effect.logInfo(
`[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`,
@@ -707,7 +740,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
}
const sharedWebPort = BASE_WEB_PORT + webOffset;
- if (input.share) {
+ if (input.share && !connectShare) {
if (input.mode === "dev:server") {
yield* Effect.logInfo("[dev-runner] --share has no effect for dev:server (no web server).");
} else if (input.mode === "dev:desktop") {
@@ -893,10 +926,14 @@ const devRunnerCli = Command.make("dev-runner", {
),
share: Flag.Boolean("share").pipe(
Flag.withDescription(
- "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.",
+ "Share through T3 Connect and print a pairing URL. Reuses this worktree's saved environment; run `t3 connect login` once first.",
),
Flag.withDefault(false),
),
+ shareVia: Flag.Literals("share-via", ["connect", "tailscale"]).pipe(
+ Flag.withDescription("Transport for --share (defaults to T3 Connect)."),
+ Flag.withDefault("connect"),
+ ),
runArgs: Argument.String("run-arg").pipe(
Argument.withDescription("Additional Vite+ run args (pass after `--`)."),
Argument.variadic(),