From e6a520eb25e6fdb15496540eddc109a56156765a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 4 Sep 2026 22:10:37 +0000 Subject: [PATCH 01/14] feat(dev): simplify sharing through existing t3 connect environments --- apps/server/src/cli/config.ts | 12 ++ apps/server/src/cli/connect.ts | 2 +- apps/server/src/cli/pair.test.ts | 45 +++++++ apps/server/src/cli/pair.ts | 67 +++++++++- apps/server/src/cloud/CliState.ts | 3 + apps/server/src/cloud/CliTokenManager.ts | 33 +++++ .../src/cloud/ManagedEndpointRuntime.test.ts | 41 ++++++ .../src/cloud/ManagedEndpointRuntime.ts | 23 ++-- apps/server/src/cloud/config.ts | 1 + apps/server/src/cloud/http.ts | 12 +- apps/server/src/config.ts | 2 + apps/server/src/server.ts | 104 +++++++++++++-- apps/web/vite.config.ts | 100 +++++++++++++- docs/operations/development.md | 16 ++- docs/user/remote-access.md | 4 + scripts/dev-runner.test.ts | 123 +++++++++++++++--- scripts/dev-runner.ts | 42 +++++- 17 files changed, 576 insertions(+), 54 deletions(-) diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index f739a4e2f22c..6234bb4cb6b7 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -106,6 +106,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) => @@ -374,6 +382,10 @@ export const resolveServerConfig = ( staticDir, devUrl, 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 25cfb18f3402..14ab8e85ab58 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -446,7 +446,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 d40e0d97e484..45d512d57d64 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, @@ -39,6 +40,8 @@ import { } from "effect/unstable/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 { @@ -58,6 +61,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; @@ -84,6 +88,11 @@ export class NoRunningServerError extends Schema.TaggedErrorClass()( + "ConnectPairingError", + { message: Schema.String }, +) {} + // 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.TaggedErrorClass()( @@ -131,7 +140,7 @@ export class ServePortOccupiedError extends Schema.TaggedErrorClass state.devUrl ?? resolveHeadlessConnectionString(state.host, state.port); @@ -230,6 +239,43 @@ 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({ + message: + "No saved Connect endpoint for this environment. Run `t3 connect link`, or restart its `vp run dev --share` server.", + }); + } + 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({ + message: + "The saved Connect endpoint is invalid. Restart this environment to refresh its link.", + }); + } + const baseUrl = parsed.value.origin; + const probe = yield* probeEnvironmentDescriptor(baseUrl); + if (probe._tag !== "descriptor" || probe.descriptor.environmentId !== environmentId) { + return yield* new ConnectPairingError({ + message: + "The saved Connect endpoint is not reaching this environment. Restart its Connect share and retry once the public endpoint is ready.", + }); + } + return baseUrl; +}); + interface DiscoveredPairTarget { readonly baseDir: string; readonly variant: PairStateVariant; @@ -475,6 +521,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( @@ -483,16 +533,26 @@ export const pairCommand = Command.make("pair", { ), Command.withHandler((flags) => Effect.gen(function* () { + if (flags.connect && flags.tailscale) { + return yield* new ConnectPairingError({ + message: "Choose either --connect or --tailscale.", + }); + } 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, @@ -513,7 +573,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 9af9a032f856..56d8636e82ce 100644 --- a/apps/server/src/cloud/CliState.ts +++ b/apps/server/src/cloud/CliState.ts @@ -3,6 +3,7 @@ import * as Option from "effect/Option"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { + CLOUD_ENDPOINT_HTTP_ORIGIN, CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINKED_USER_ID, CLOUD_MINT_PUBLIC_KEY, @@ -53,6 +54,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); } }); @@ -67,6 +69,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(PUBLISH_AGENT_ACTIVITY_SECRET), ], { concurrency: "unbounded" }, diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index c4443a7301cb..3c083a4c8f63 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 Encoding from "effect/Encoding"; 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"; @@ -31,6 +32,7 @@ import { } 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, @@ -40,6 +42,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 boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; @@ -492,3 +495,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/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index ba2cf5c5ac05..a2fb1ee02641 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -15,6 +15,7 @@ import * as RelayClient from "@t3tools/shared/relayClient"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; +import { CLOUD_ENDPOINT_RUNTIME_CONFIG } from "./config.ts"; const relayClientAvailableLayer = Layer.succeed( RelayClient.RelayClient, @@ -82,6 +83,46 @@ function makeHandle(input: { } describe("CloudManagedEndpointRuntime", () => { + it.effect( + "keeps a saved tunnel offline until explicitly applied when restoration is disabled", + () => + Effect.gen(function* () { + let spawnCount = 0; + const saved = { providerKind: "cloudflare_tunnel", connectorToken: "saved-token" } as const; + const spawner = ChildProcessSpawner.make(() => + Effect.sync(() => { + spawnCount += 1; + return makeHandle({ pid: 100, onKill: () => undefined }); + }), + ); + const context = yield* Layer.build( + ManagedEndpointRuntime.layerWithoutRestoring.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + relayClientAvailableLayer, + Layer.mock(ServerSecretStore.ServerSecretStore)({ + get: (name) => + Effect.succeed( + name === CLOUD_ENDPOINT_RUNTIME_CONFIG + ? Option.some(new TextEncoder().encode(JSON.stringify(saved))) + : Option.none(), + ), + }), + ), + ), + ), + ); + const runtime = yield* Effect.service( + ManagedEndpointRuntime.CloudManagedEndpointRuntime, + ).pipe(Effect.provide(context)); + expect(spawnCount).toBe(0); + expect((yield* runtime.applyConfig(saved)).status).toBe("running"); + expect(spawnCount).toBe(1); + expect(yield* runtime.applyConfig(null)).toEqual({ status: "disabled" }); + }), + ); + it("classifies Cloudflare connection and warning output", () => { expect( ManagedEndpointRuntime.classifyRelayClientOutput( diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 564d7346d036..4d0ac5adaafc 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -111,7 +111,9 @@ const stopConnector = (connector: ActiveConnector | null) => ) : Effect.void; -export const make = Effect.gen(function* () { +const makeRuntime = Effect.fn("CloudManagedEndpointRuntime.make")(function* ( + restorePersisted: boolean, +) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const relayClient = yield* RelayClient.RelayClient; const activeRef = yield* Ref.make(null); @@ -358,16 +360,21 @@ export const make = Effect.gen(function* () { applyConfig, }); - const initialConfig = yield* readRuntimeConfig.pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to read managed endpoint runtime config", { cause }).pipe( - Effect.as(null), + if (restorePersisted) { + const initialConfig = yield* readRuntimeConfig.pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to read managed endpoint runtime config", { cause }).pipe( + Effect.as(null), + ), ), - ), - ); - yield* runtime.applyConfig(initialConfig); + ); + yield* runtime.applyConfig(initialConfig); + } yield* Effect.addFinalizer(() => runtime.applyConfig(null)); return runtime; }); +export const make = makeRuntime(true); export const layer = Layer.effect(CloudManagedEndpointRuntime, make); +// Local dev can retain a saved link without publishing its unguarded Vite origin. +export const layerWithoutRestoring = Layer.effect(CloudManagedEndpointRuntime, makeRuntime(false)); diff --git a/apps/server/src/cloud/config.ts b/apps/server/src/cloud/config.ts index 2eff693f61e6..bc2f2ad1bc77 100644 --- a/apps/server/src/cloud/config.ts +++ b/apps/server/src/cloud/config.ts @@ -7,6 +7,7 @@ import type * 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_LINKED_USER_ID = "cloud-linked-user-id"; export const RELAY_URL_SECRET = "cloud-relay-url"; export const RELAY_ISSUER_SECRET = "cloud-relay-issuer"; diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index e0d458b4b97c..0befb4630f7e 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -67,6 +67,7 @@ import { serviceStateHasPendingUpdate, } from "./serviceProtocol.ts"; import { + CLOUD_ENDPOINT_HTTP_ORIGIN, CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINKED_USER_ID, CLOUD_MINT_PUBLIC_KEY, @@ -603,7 +604,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi schema: RelayEnvironmentLinkResponse, }); yield* setCliDesiredCloudLink(true, mode); - return yield* applyCloudRelayConfig(dependencies, { + const result = yield* applyCloudRelayConfig(dependencies, { relayUrl, relayIssuer: link.relayIssuer, cloudUserId: link.cloudUserId, @@ -611,6 +612,15 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi cloudMintPublicKey: link.cloudMintPublicKey, endpointRuntime: link.endpointRuntime, }); + if (managedTunnelsEnabled) { + yield* dependencies.secrets.set( + CLOUD_ENDPOINT_HTTP_ORIGIN, + stringToBytes(link.endpoint.httpBaseUrl), + ); + } else { + yield* dependencies.secrets.remove(CLOUD_ENDPOINT_HTTP_ORIGIN); + } + return { ...result, endpoint: link.endpoint }; }, Effect.catchIf( ServerSecretStore.isSecretStoreError, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 42df3814b070..8903a5de9ac2 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -77,6 +77,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 devAllowedOrigins: ReadonlyArray; readonly noBrowser: boolean; readonly startupPresentation: StartupPresentation; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c0be0c444573..b8fe1b702f42 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,12 +1,23 @@ -import { EnvironmentHttpApi, ProviderDriverKind } from "@t3tools/contracts"; +import { + EnvironmentHttpApi, + ExecutionEnvironmentDescriptor, + ProviderDriverKind, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; +import { + FetchHttpClient, + HttpClient, + HttpClientResponse, + HttpRouter, + HttpServer, +} from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; @@ -405,10 +416,14 @@ const AuthLayerLive = EnvironmentAuth.layer.pipe( const CloudManagedEndpointRuntimeLive = Layer.mergeAll( RelayClientLive, - CloudManagedEndpointRuntime.layer.pipe( - Layer.provide(ServerSecretStore.layer), - Layer.provide(RelayClientLive), - ), + Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return config.devUrl + ? CloudManagedEndpointRuntime.layerWithoutRestoring + : CloudManagedEndpointRuntime.layer; + }), + ).pipe(Layer.provide(ServerSecretStore.layer), Layer.provide(RelayClientLive)), ); const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( @@ -499,7 +514,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( Layer.mergeAll( - CloudCliTokenManager.layer.pipe( + CloudCliTokenManager.layerWithSharedAuthorization.pipe( Layer.provide(ServerSecretStore.layer), Layer.provide(ExternalLauncher.layer), ), @@ -557,9 +572,28 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(httpCompressionLayer), ); +class ConnectDevShareError extends Schema.TaggedErrorClass()( + "ConnectDevShareError", + { + message: Schema.String, + }, +) {} + export const makeServerLayer = 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({ + message: + "Connect dev sharing requires T3 Connect public configuration and a local HTTP Vite origin. Copy .env.example to .env to configure a source build.", + }); + } const activation = yield* Deferred.make(); const awaitActivation = Deferred.await(activation); const activationLayer = Layer.succeed(ServerActivation, awaitActivation); @@ -669,6 +703,10 @@ export const makeServerLayer = Layer.unwrap( yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); return; } + if (config.devUrl && !config.connectDevShare) { + yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); + return; + } const releaseManagedTunnel = releaseManagedTunnelOnShutdown().pipe( Effect.timeout("10 seconds"), Effect.tap((released) => @@ -697,6 +735,18 @@ export const makeServerLayer = Layer.unwrap( if (!cleanupBeforeActivation) { yield* Effect.addFinalizer(() => releaseManagedTunnel); } + if (config.connectDevShare) { + if (config.connectAuthorizationHome) { + const secrets = yield* ServerSecretStore.ServerSecretStore; + yield* secrets.set( + CloudCliTokenManager.CLOUD_CLI_AUTHORIZATION_HOME_SECRET, + new TextEncoder().encode(config.connectAuthorizationHome), + ); + } + const relayClient = yield* RelayClient.RelayClient; + yield* relayClient.install; + yield* CloudCliState.setCliDesiredCloudLink(true); + } if (!(yield* CloudCliState.readCliDesiredCloudLink)) return; const server = yield* HttpServer.HttpServer; const address = server.address; @@ -707,7 +757,11 @@ export const makeServerLayer = Layer.unwrap( // covers anything this sleep used to hedge against. Every // millisecond here is dead time on the path to remote // reachability after a restart. - yield* reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`).pipe( + const origin = + config.connectDevShare && config.devUrl + ? config.devUrl.origin + : `http://127.0.0.1:${address.port}`; + yield* reconcileDesiredCloudLink(origin).pipe( Effect.retry({ while: shouldRetryCloudLink, schedule: Schedule.exponential("1 second").pipe( @@ -717,7 +771,39 @@ export const makeServerLayer = Layer.unwrap( Schedule.upTo({ duration: "10 minutes" }), ), }), - Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")), + Effect.tap((link) => + Effect.gen(function* () { + yield* Effect.logInfo("T3 Connect desired link reconciled on startup"); + if (config.connectDevShare) { + const environment = yield* ServerEnvironment.ServerEnvironment; + const expected = yield* environment.getDescriptor; + const client = yield* HttpClient.HttpClient; + yield* client + .get(new URL("/.well-known/t3/environment", link.endpoint.httpBaseUrl)) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap( + HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor), + ), + Effect.filterOrFail( + (actual) => actual.environmentId === expected.environmentId, + () => + new ConnectDevShareError({ + message: "Connect endpoint belongs to a different environment.", + }), + ), + Effect.timeout("5 seconds"), + Effect.retry({ schedule: Schedule.spaced("1 second") }), + Effect.timeout("2 minutes"), + ); + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const pairingUrl = yield* auth.issueStartupPairingUrl( + link.endpoint.httpBaseUrl, + ); + yield* Effect.logInfo(`[dev-runner] pairingUrl: ${pairingUrl}`); + } + }), + ), Effect.catch((cause) => Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { message: cause.message, diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 702c18840983..31ff0d26835e 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 { tailwindPlugins } from "./vite/tailwind"; @@ -31,8 +31,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() || ""; @@ -62,6 +68,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" @@ -142,6 +153,64 @@ 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) { + 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 ( + pathname !== "/ws" && + !(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. @@ -156,6 +225,7 @@ export default defineConfig(() => { return { assetsInclude: ["**/*.wasm"], plugins: [ + ...(connectDevShare ? [connectDevSharePlugin()] : []), devCompressionPlugin(), // Route components load as split chunks so settings, pull-request, and // usage code stay out of the cold-start payload; the router prefetches @@ -218,7 +288,9 @@ export default defineConfig(() => { 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 @@ -251,7 +323,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", @@ -267,6 +339,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: false }, + ], + }, + }, + }, + } + : {}), outDir: "dist", emptyOutDir: true, manifest: true, diff --git a/docs/operations/development.md b/docs/operations/development.md index dec3e8681fd3..90900e0125cd 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -35,15 +35,23 @@ 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. Configure a source build with `.env.example` +and run `t3 connect login --base-dir ~/.t3` 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; `t3 pair --connect` creates another one. + +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/docs/user/remote-access.md b/docs/user/remote-access.md index fa1b2a73df54..14c840bf35ab 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -24,6 +24,10 @@ On your other device, sign in to the same T3 Connect account and choose the environment. Over SSH, the CLI prints a browser link and accepts the returned authorization code, so you do not need to forward an OAuth callback port. +For a server linked through the CLI, `npx t3 pair --connect` creates a fresh pairing link +through its managed endpoint. The command verifies that the endpoint reaches this environment +before issuing a token. Tailscale is not required. + ## Pair over a LAN or private network Use direct pairing when the other device can reach the host's network address. diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 9b4f44475d95..894dbde49e5b 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -879,11 +879,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, @@ -896,13 +900,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, @@ -918,6 +924,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"), @@ -1015,7 +1022,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; @@ -1062,6 +1069,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"), @@ -1120,7 +1128,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, @@ -1131,16 +1186,54 @@ 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, "DevRunnerConfigurationError"); + } + 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 d426cc7829b1..cb75f5d38463 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -347,6 +347,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); @@ -627,11 +629,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) => @@ -649,10 +653,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); @@ -670,7 +674,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; @@ -694,7 +698,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, }); @@ -704,6 +708,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 DevRunnerConfigurationError({ + configKeys: ["--share", "--dev-url"], + cause: "Connect sharing requires the complete dev stack and its local Vite origin.", + }); + } + 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}`, @@ -717,7 +741,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") { @@ -902,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.choice("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(), From 33da629afc004e6393f7cc6719a6df8d484c03ee Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 4 Sep 2026 22:18:44 +0000 Subject: [PATCH 02/14] fix(dev): clarify connect setup and structured failures --- apps/server/src/cli/pair.ts | 35 +++++++++++++++++----------------- apps/server/src/server.ts | 22 ++++++++++----------- docs/operations/development.md | 9 +++++---- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 45d512d57d64..63c2d5c0ce9f 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -90,8 +90,20 @@ export class NoRunningServerError extends Schema.TaggedErrorClass()( "ConnectPairingError", - { message: Schema.String }, -) {} + { 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. @@ -245,10 +257,7 @@ const resolveConnectPairingBaseUrl = Effect.fn("pair.resolveConnectPairingBaseUr const secrets = yield* ServerSecretStore.ServerSecretStore; const stored = yield* secrets.get(CLOUD_ENDPOINT_HTTP_ORIGIN); if (Option.isNone(stored)) { - return yield* new ConnectPairingError({ - message: - "No saved Connect endpoint for this environment. Run `t3 connect link`, or restart its `vp run dev --share` server.", - }); + return yield* new ConnectPairingError({ reason: "missing" }); } const parsed = decodeConnectOrigin(new TextDecoder().decode(stored.value)); if ( @@ -260,18 +269,12 @@ const resolveConnectPairingBaseUrl = Effect.fn("pair.resolveConnectPairingBaseUr parsed.value.search !== "" || parsed.value.hash !== "" ) { - return yield* new ConnectPairingError({ - message: - "The saved Connect endpoint is invalid. Restart this environment to refresh its link.", - }); + 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({ - message: - "The saved Connect endpoint is not reaching this environment. Restart its Connect share and retry once the public endpoint is ready.", - }); + return yield* new ConnectPairingError({ reason: "unreachable" }); } return baseUrl; }); @@ -534,9 +537,7 @@ export const pairCommand = Command.make("pair", { Command.withHandler((flags) => Effect.gen(function* () { if (flags.connect && flags.tailscale) { - return yield* new ConnectPairingError({ - message: "Choose either --connect or --tailscale.", - }); + return yield* new ConnectPairingError({ reason: "conflict" }); } const cliLogLevel = yield* GlobalFlag.LogLevel; // Default to Warn so storage/migration chatter cannot bury the QR code; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b8fe1b702f42..740fbe8ae2eb 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -574,10 +574,14 @@ export const makeRoutesLayer = Layer.mergeAll( class ConnectDevShareError extends Schema.TaggedErrorClass()( "ConnectDevShareError", - { - message: Schema.String, - }, -) {} + { 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."; + } +} export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { @@ -589,10 +593,7 @@ export const makeServerLayer = Layer.unwrap( config.devUrl.protocol !== "http:" || !["localhost", "127.0.0.1"].includes(config.devUrl.hostname)) ) { - return yield* new ConnectDevShareError({ - message: - "Connect dev sharing requires T3 Connect public configuration and a local HTTP Vite origin. Copy .env.example to .env to configure a source build.", - }); + return yield* new ConnectDevShareError({ reason: "configuration" }); } const activation = yield* Deferred.make(); const awaitActivation = Deferred.await(activation); @@ -787,10 +788,7 @@ export const makeServerLayer = Layer.unwrap( ), Effect.filterOrFail( (actual) => actual.environmentId === expected.environmentId, - () => - new ConnectDevShareError({ - message: "Connect endpoint belongs to a different environment.", - }), + () => new ConnectDevShareError({ reason: "environment-mismatch" }), ), Effect.timeout("5 seconds"), Effect.retry({ schedule: Schedule.spaced("1 second") }), diff --git a/docs/operations/development.md b/docs/operations/development.md index 90900e0125cd..4ce5388381c4 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -35,10 +35,11 @@ different preference when needed. ### Sharing and remote debugging -`vp run dev --share` shares through T3 Connect. Configure a source build with `.env.example` -and run `t3 connect login --base-dir ~/.t3` 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; `t3 pair --connect` creates another one. +`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. From 091ec0db67b0ebc79102b3fb0ae1a05a331b850b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 4 Sep 2026 22:25:31 +0000 Subject: [PATCH 03/14] fix(dev): release stale connect tunnels after private restarts --- apps/server/src/server.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 740fbe8ae2eb..b755f8791cba 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -704,10 +704,6 @@ export const makeServerLayer = Layer.unwrap( yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); return; } - if (config.devUrl && !config.connectDevShare) { - yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); - return; - } const releaseManagedTunnel = releaseManagedTunnelOnShutdown().pipe( Effect.timeout("10 seconds"), Effect.tap((released) => @@ -736,6 +732,7 @@ export const makeServerLayer = Layer.unwrap( if (!cleanupBeforeActivation) { yield* Effect.addFinalizer(() => releaseManagedTunnel); } + if (config.devUrl && !config.connectDevShare) return; if (config.connectDevShare) { if (config.connectAuthorizationHome) { const secrets = yield* ServerSecretStore.ServerSecretStore; From 2056b821c0d6a4773fab67451b96ef13e5639cb5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 4 Sep 2026 23:43:17 +0000 Subject: [PATCH 04/14] feat(preview): forward remote browsers through existing connect tunnels --- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/preview.ts | 11 + apps/desktop/src/preload.ts | 2 + apps/desktop/src/preview/Manager.ts | 14 + apps/desktop/src/preview/PortForward.ts | 258 ++++++++++++++++++ apps/server/src/preview/forward.ts | 220 +++++++++++++++ apps/server/src/preview/recordings.ts | 75 +++++ apps/server/src/server.test.ts | 130 +++++++++ apps/server/src/server.ts | 4 + apps/web/src/browser/HostedBrowserWebview.tsx | 56 +++- .../web/src/browser/browserEnvironmentHttp.ts | 40 +++ apps/web/src/browser/browserPortForward.ts | 65 +++++ apps/web/src/browser/browserRecording.test.ts | 15 +- apps/web/src/browser/browserRecording.ts | 32 +++ apps/web/src/browser/browserTargetResolver.ts | 17 ++ .../preview/PreviewAutomationHosts.tsx | 16 +- .../src/components/preview/PreviewView.tsx | 49 +++- .../components/preview/usePreviewBridge.ts | 22 ++ apps/web/vite.config.ts | 7 +- docs/user/remote-access.md | 9 + packages/contracts/src/ipc.ts | 3 + 21 files changed, 1022 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/src/preview/PortForward.ts create mode 100644 apps/server/src/preview/forward.ts create mode 100644 apps/server/src/preview/recordings.ts create mode 100644 apps/web/src/browser/browserEnvironmentHttp.ts create mode 100644 apps/web/src/browser/browserPortForward.ts diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 81b50d165d24..8d68fca873d7 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -51,6 +51,7 @@ export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview"; export const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate"; +export const PREVIEW_PORT_FORWARD_CHANNEL = "desktop:preview-port-forward"; export const PREVIEW_GO_BACK_CHANNEL = "desktop:preview-go-back"; export const PREVIEW_GO_FORWARD_CHANNEL = "desktop:preview-go-forward"; export const PREVIEW_REFRESH_CHANNEL = "desktop:preview-refresh"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 5fb7eff99fc6..e7b4b38d1eab 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -99,6 +99,16 @@ export const navigate = DesktopIpc.makeIpcMethod({ }), }); +export const ensurePortForward = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_PORT_FORWARD_CHANNEL, + payload: Schema.Struct({ key: Schema.String, websocketUrl: Schema.String }), + result: Schema.Number, + handler: Effect.fn("desktop.ipc.preview.ensurePortForward")(function* ({ key, websocketUrl }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.ensurePortForward(key, websocketUrl); + }), +}); + const tabMethod = ( channel: string, name: string, @@ -468,6 +478,7 @@ export const saveRecording = DesktopIpc.makeIpcMethod({ }); export const methods = [ + ensurePortForward, createTab, closeTab, registerWebview, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 685a9b1204db..f31a6e5dffc8 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -210,6 +210,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, { tabId, webContentsId }), navigate: (tabId, url) => ipcRenderer.invoke(IpcChannels.PREVIEW_NAVIGATE_CHANNEL, { tabId, url }), + ensurePortForward: (key, websocketUrl) => + ipcRenderer.invoke(IpcChannels.PREVIEW_PORT_FORWARD_CHANNEL, { key, websocketUrl }), goBack: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_BACK_CHANNEL, { tabId }), goForward: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, { tabId }), refresh: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_REFRESH_CHANNEL, { tabId }), diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 324b92034f36..02a215c01bf9 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -54,6 +54,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; import * as BrowserSession from "./BrowserSession.ts"; +import { createPreviewPortForwards } from "./PortForward.ts"; import { ANNOTATION_CAPTURED_CHANNEL, ANNOTATION_THEME_CHANNEL, @@ -592,6 +593,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const resolvedArtifactDirectory = path.resolve(artifactDirectory); + const portForwards = createPreviewPortForwards(); + yield* Effect.addFinalizer(() => Effect.sync(() => portForwards.close())); + const ensurePortForward = (key: string, websocketUrl: string) => + Effect.tryPromise({ + try: () => portForwards.ensure(key, websocketUrl), + catch: (cause) => new PreviewOperationError({ operation: "ensurePortForward", cause }), + }); const playwrightInstallExpression = yield* Effect.cached( playwrightInjectedRuntimeInstallExpression(), ); @@ -4141,6 +4149,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return { automationClick, + ensurePortForward, automationEvaluate, automationPress, automationScroll, @@ -4498,6 +4507,10 @@ export class PreviewManager extends Context.Service< webContentsId: number, ) => Effect.Effect; readonly navigate: (tabId: string, url: string) => Effect.Effect; + readonly ensurePortForward: ( + key: string, + websocketUrl: string, + ) => Effect.Effect; readonly goBack: (tabId: string) => Effect.Effect; readonly goForward: (tabId: string) => Effect.Effect; readonly refresh: (tabId: string) => Effect.Effect; @@ -4615,6 +4628,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { closeTab: operations.closeTab, registerWebview: operations.registerWebview, navigate: operations.navigate, + ensurePortForward: operations.ensurePortForward, goBack: operations.goBack, goForward: operations.goForward, refresh: operations.refresh, diff --git a/apps/desktop/src/preview/PortForward.ts b/apps/desktop/src/preview/PortForward.ts new file mode 100644 index 000000000000..2da42f094e8d --- /dev/null +++ b/apps/desktop/src/preview/PortForward.ts @@ -0,0 +1,258 @@ +// @effect-diagnostics globalTimers:off -- This Node socket adapter owns cancellable connection deadlines outside the Effect runtime. +import * as NodeNet from "node:net"; +import * as NodeTimers from "node:timers"; + +const WINDOW_BYTES = 256 * 1024; +const FRAME_BYTES = 64 * 1024; +const MAX_STREAMS = 32; +const MAX_FORWARDS = 32; + +interface Stream { + socket: NodeNet.Socket; + connected: boolean; + remoteEnded: boolean; + sendCredit: number; + receiveCredit: number; + timeout: ReturnType; +} + +interface Forward { + ensure(websocketUrl: string): Promise; + idle(): boolean; + close(): void; +} + +function createForward(): Forward { + let websocket: WebSocket | null = null; + const streams = new Map(); + const sockets = new Set(); + const server = NodeNet.createServer({ allowHalfOpen: true }); + let nextId = 1; + let closed = true; + let disposed = false; + let ready: Promise | null = null; + let rejectReady: ((reason: Error) => void) | null = null; + let connectTimeout: ReturnType | undefined; + + const close = (error = new Error("Preview forwarding connection closed.")) => { + if (closed) return; + closed = true; + NodeTimers.clearTimeout(connectTimeout); + rejectReady?.(error); + ready = null; + for (const stream of streams.values()) { + NodeTimers.clearTimeout(stream.timeout); + stream.socket.destroy(); + } + streams.clear(); + for (const socket of sockets) socket.destroy(); + sockets.clear(); + websocket?.close(); + websocket = null; + }; + + const send = (id: number, opcode: number, payload?: Uint8Array) => { + if (closed || !websocket) return; + const frame = Buffer.allocUnsafe(5 + (payload?.byteLength ?? 0)); + frame.writeUInt32BE(id, 0); + frame[4] = opcode; + if (payload) frame.set(payload, 5); + try { + websocket.send(frame); + } catch { + close(); + } + }; + const remove = (id: number, notify: boolean) => { + const stream = streams.get(id); + if (!stream) return; + streams.delete(id); + NodeTimers.clearTimeout(stream.timeout); + if (notify) send(id, 2); + stream.socket.destroy(); + }; + const pump = (id: number, stream: Stream) => { + // Read only the available credit. Node retains the bounded unread TCP buffer. + while (stream.connected && stream.sendCredit > 0 && streams.get(id) === stream) { + const size = Math.min(FRAME_BYTES, stream.sendCredit, stream.socket.readableLength); + if (size === 0) break; + const chunk: Buffer | null = stream.socket.read(size); + if (!chunk) break; + stream.sendCredit -= chunk.length; + send(id, 1, chunk); + } + }; + + server.on("connection", (socket) => { + if ( + closed || + websocket?.readyState !== WebSocket.OPEN || + sockets.size >= MAX_STREAMS || + nextId > 0xffffffff + ) { + socket.destroy(); + return; + } + socket.setNoDelay(true); + sockets.add(socket); + const id = nextId++; + const stream: Stream = { + socket, + connected: false, + remoteEnded: false, + sendCredit: WINDOW_BYTES, + receiveCredit: WINDOW_BYTES, + timeout: NodeTimers.setTimeout(() => remove(id, true), 15_000), + }; + streams.set(id, stream); + socket.on("readable", () => pump(id, stream)); + socket.on("error", () => remove(id, true)); + socket.on("close", () => { + sockets.delete(socket); + remove(id, true); + }); + socket.on("end", () => { + if (stream.connected && streams.get(id) === stream) send(id, 4); + }); + send(id, 0); + }); + server.on("error", (error) => close(error)); + const handleMessage = (event: MessageEvent) => { + if (!(event.data instanceof ArrayBuffer) || event.data.byteLength < 5) { + close(new Error("Invalid preview forwarding frame.")); + return; + } + const frame = Buffer.from(event.data); + const id = frame.readUInt32BE(0); + const opcode = frame[4]; + const payload = frame.subarray(5); + if ( + id === 0 || + !( + ((opcode === 0 || opcode === 2 || opcode === 4) && payload.length === 0) || + (opcode === 1 && payload.length > 0 && payload.length <= FRAME_BYTES) || + (opcode === 3 && payload.length === 4) + ) + ) { + close(new Error("Invalid preview forwarding frame.")); + return; + } + const stream = streams.get(id); + // A local socket can close while its peer still has frames in flight. + if (!stream) return; + if (opcode === 0 && !stream.connected) { + stream.connected = true; + NodeTimers.clearTimeout(stream.timeout); + pump(id, stream); + if (stream.socket.readableEnded) send(id, 4); + } else if ( + opcode === 1 && + stream.connected && + !stream.remoteEnded && + payload.length <= stream.receiveCredit + ) { + stream.receiveCredit -= payload.length; + stream.socket.write(payload, (error) => { + if (error || streams.get(id) !== stream) return; + stream.receiveCredit += payload.length; + const ack = Buffer.allocUnsafe(4); + ack.writeUInt32BE(payload.length); + send(id, 3, ack); + }); + } else if (opcode === 2) { + streams.delete(id); + NodeTimers.clearTimeout(stream.timeout); + stream.socket.end(); + stream.socket.setTimeout(15_000, () => stream.socket.destroy()); + } else if (opcode === 4 && stream.connected && !stream.remoteEnded) { + stream.remoteEnded = true; + stream.socket.end(); + } else if (opcode === 3 && stream.connected) { + const consumed = payload.readUInt32BE(0); + if (consumed === 0 || consumed > WINDOW_BYTES - stream.sendCredit) { + close(new Error("Invalid preview forwarding credit.")); + return; + } + stream.sendCredit += consumed; + pump(id, stream); + } else { + close(new Error("Unexpected preview forwarding frame.")); + } + }; + + return { + ensure(websocketUrl) { + if (disposed) return Promise.reject(new Error("Preview forwarding listener closed.")); + if (ready) return ready; + const peer = new WebSocket(websocketUrl); + peer.binaryType = "arraybuffer"; + websocket = peer; + closed = false; + ready = new Promise((resolve, reject) => { + rejectReady = reject; + connectTimeout = NodeTimers.setTimeout( + () => close(new Error("Preview forwarding connection timed out.")), + 15_000, + ); + peer.addEventListener("open", () => { + if (closed || websocket !== peer) return; + const finish = () => { + if (disposed) server.close(); + if (closed || websocket !== peer) return; + const address = server.address(); + if (!address || typeof address === "string") { + close(new Error("Preview forwarding listener did not get a port.")); + return; + } + NodeTimers.clearTimeout(connectTimeout); + resolve(address.port); + }; + // Retain the listener across relay disconnects so browser history and + // cookies keep the same origin when a fresh ticket reconnects it. + if (server.listening) finish(); + else server.listen(0, "127.0.0.1", finish); + }); + peer.addEventListener("close", () => { + if (websocket === peer) close(); + }); + peer.addEventListener("error", () => { + if (websocket === peer) close(); + }); + peer.addEventListener("message", (event: MessageEvent) => { + if (websocket === peer) handleMessage(event); + }); + }); + return ready; + }, + idle: () => (closed || server.listening) && sockets.size === 0, + close: () => { + disposed = true; + close(); + server.close(); + }, + }; +} + +/** Reuse one loopback listener and one multiplexed connection per preview target. */ +export function createPreviewPortForwards() { + const forwards = new Map(); + return { + async ensure(key: string, websocketUrl: string): Promise { + const existing = forwards.get(key); + if (existing) return existing.ensure(websocketUrl); + if (forwards.size >= MAX_FORWARDS) { + const idle = [...forwards].find(([, forward]) => forward.idle()); + if (!idle) throw new Error("Too many active preview forwarding connections."); + idle[1].close(); + forwards.delete(idle[0]); + } + const forward = createForward(); + forwards.set(key, forward); + return forward.ensure(websocketUrl); + }, + close(): void { + for (const forward of forwards.values()) forward.close(); + forwards.clear(); + }, + }; +} diff --git a/apps/server/src/preview/forward.ts b/apps/server/src/preview/forward.ts new file mode 100644 index 000000000000..79f3e25b8182 --- /dev/null +++ b/apps/server/src/preview/forward.ts @@ -0,0 +1,220 @@ +// @effect-diagnostics nodeBuiltinImport:off - This route bridges authenticated WebSockets to loopback TCP. +import * as NodeNet from "node:net"; +import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import * as Socket from "effect/unstable/socket/Socket"; +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; + +const WINDOW = 256 * 1024; +const MAX_DATA = 64 * 1024; +const MAX_STREAMS = 32; + +interface ForwardStream { + socket: NodeNet.Socket; + connected: boolean; + ended: boolean; + sendCredit: number; + receiveCredit: number; +} + +// One authenticated connection carries the browser's HTTP, HTTPS and HMR sockets. +// Credits bound buffering independently of either TCP peer's reading speed. +const forward = (websocket: Socket.Socket, port: number) => + Effect.gen(function* () { + const streams = new Map(); + const sockets = new Set(); + let closed = false; + let ws: globalThis.WebSocket | undefined; + const send = (id: number, op: number, payload?: Uint8Array) => { + if (closed || !ws) return; + if (ws.bufferedAmount > MAX_STREAMS * WINDOW * 2) { + stop(); + return; + } + const frame = Buffer.allocUnsafe(5 + (payload?.byteLength ?? 0)); + frame.writeUInt32BE(id); + frame[4] = op; + if (payload) frame.set(payload, 5); + try { + ws.send(frame); + } catch { + stop(); + } + }; + const stop = () => { + closed = true; + for (const socket of sockets) socket.destroy(); + sockets.clear(); + streams.clear(); + ws?.close(1002, "Invalid preview forwarding frame"); + }; + const close = (id: number, graceful = false) => { + const stream = streams.get(id); + if (!stream) return; + streams.delete(id); + if (graceful) { + stream.socket.setTimeout(5000, () => stream.socket.destroy()); + stream.socket.destroySoon(); + } else stream.socket.destroy(); + send(id, 2); + }; + const flush = (id: number, stream: ForwardStream) => { + while (stream.connected && stream.sendCredit > 0 && streams.get(id) === stream) { + const count = Math.min(stream.socket.readableLength, stream.sendCredit, MAX_DATA); + if (count === 0) break; + const data: Buffer | null = stream.socket.read(count); + if (!data) break; + stream.sendCredit -= data.length; + send(id, 1, data); + } + }; + const open = (id: number) => { + if (streams.has(id) || sockets.size >= MAX_STREAMS) { + stop(); + return; + } + const stream: ForwardStream = { + socket: new NodeNet.Socket(), + connected: false, + ended: false, + sendCredit: WINDOW, + receiveCredit: WINDOW, + }; + streams.set(id, stream); + const connect = (host: string) => { + const socket = NodeNet.createConnection({ + host, + port, + allowHalfOpen: true, + }); + sockets.add(socket); + stream.socket = socket; + socket.setTimeout(5000, () => close(id)); + socket.once("connect", () => { + if (streams.get(id) !== stream) return; + stream.connected = true; + socket.setTimeout(0); + socket.setNoDelay(true); + send(id, 0); + flush(id, stream); + }); + socket.on("readable", () => flush(id, stream)); + socket.once("error", () => { + if (!stream.connected && host === "127.0.0.1" && streams.get(id) === stream) { + connect("::1"); + } else close(id); + }); + socket.once("end", () => send(id, 4)); + socket.once("close", () => { + sockets.delete(socket); + if (stream.socket === socket) close(id); + }); + }; + connect("127.0.0.1"); + }; + yield* websocket + .runRaw((data) => + Effect.gen(function* () { + // Socket.runRaw supplies its native WebSocket to message handlers. + ws ??= Option.getOrThrow(yield* Effect.serviceOption(Socket.WebSocket)); + if (closed) return; + if (typeof data === "string" || data.byteLength < 5 || data.byteLength > MAX_DATA + 5) { + stop(); + return; + } + const frame = Buffer.from(data.buffer, data.byteOffset, data.byteLength); + const id = frame.readUInt32BE(0); + const op = frame[4]; + const payload = frame.subarray(5); + if ( + id === 0 || + op === undefined || + op > 4 || + ((op === 0 || op === 2 || op === 4) && payload.length !== 0) || + (op === 1 && payload.length === 0) || + (op === 3 && payload.length !== 4) + ) { + stop(); + return; + } + if (op === 0) { + open(id); + return; + } + const stream = streams.get(id); + // A peer can close while data or acknowledgements are already in flight. + if (!stream) return; + if (op === 2) { + close(id, true); + } else if (op === 4) { + if (!stream.connected || stream.ended) { + stop(); + return; + } + stream.ended = true; + stream.socket.end(); + } else if (op === 1) { + if (!stream.connected || stream.ended || payload.length > stream.receiveCredit) { + stop(); + return; + } + stream.receiveCredit -= payload.length; + stream.socket.write(payload, (error) => { + if (error || streams.get(id) !== stream) return; + stream.receiveCredit += payload.length; + const credit = Buffer.allocUnsafe(4); + credit.writeUInt32BE(payload.length); + send(id, 3, credit); + }); + } else { + const credit = payload.readUInt32BE(0); + if (credit === 0 || credit > WINDOW - stream.sendCredit) { + stop(); + return; + } + stream.sendCredit += credit; + flush(id, stream); + } + }), + ) + .pipe( + Effect.ensuring( + Effect.sync(() => { + closed = true; + for (const socket of sockets) socket.destroy(); + sockets.clear(); + streams.clear(); + }), + ), + Effect.catch(() => Effect.void), + ); + return HttpServerResponse.empty(); + }); + +export const previewForwardRouteLayer = HttpRouter.add( + "GET", + "/api/preview/forward", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const session = yield* auth.authenticateWebSocketUpgrade(request); + if (!session.scopes.includes(AuthOrchestrationOperateScope)) { + return HttpServerResponse.empty({ status: 403 }); + } + const rawPort = new URL(request.url, "http://localhost").searchParams.get("port") ?? ""; + const port = Number(rawPort); + if (!/^\d+$/.test(rawPort) || !Number.isInteger(port) || port < 1 || port > 65535) { + return HttpServerResponse.empty({ status: 400 }); + } + return yield* forward(yield* request.upgrade, port); + }).pipe( + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => + Effect.succeed(HttpServerResponse.empty({ status: 401 })), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, () => + Effect.succeed(HttpServerResponse.empty({ status: 500 })), + ), + ), +); diff --git a/apps/server/src/preview/recordings.ts b/apps/server/src/preview/recordings.ts new file mode 100644 index 000000000000..379140148530 --- /dev/null +++ b/apps/server/src/preview/recordings.ts @@ -0,0 +1,75 @@ +// @effect-diagnostics nodeBuiltinImport:off - Recording filenames use cryptographically random IDs. +import * as NodeCrypto from "node:crypto"; +import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerConfig from "../config.ts"; + +const MAX_RECORDING_BYTES = 64 * 1024 * 1024; + +export const previewRecordingRouteLayer = HttpRouter.add( + "POST", + "/api/preview/recordings", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const session = yield* auth.authenticateHttpRequest(request); + if (!session.scopes.includes(AuthOrchestrationOperateScope)) { + return HttpServerResponse.empty({ status: 403 }); + } + const mimeType = request.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase(); + if (mimeType !== "video/webm" && mimeType !== "video/mp4") { + return HttpServerResponse.empty({ status: 415 }); + } + const sizeHeader = request.headers["content-length"]; + const size = sizeHeader === undefined ? undefined : Number(sizeHeader); + if ( + size !== undefined && + (!Number.isSafeInteger(size) || size < 1 || size > MAX_RECORDING_BYTES) + ) { + return HttpServerResponse.empty({ status: 413 }); + } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const directory = path.join(config.stateDir, "browser-artifacts"); + const extension = mimeType === "video/mp4" ? "mp4" : "webm"; + const target = path.join( + directory, + `browser-recording-${NodeCrypto.randomUUID()}.${extension}`, + ); + const temporary = `${target}.part`; + // Keep ingress alive until the response is sent when rejecting an oversized stream. + const bodyPull = yield* Stream.toPull(request.stream); + let received = 0; + return yield* Effect.gen(function* () { + yield* fs.makeDirectory(directory, { recursive: true }); + yield* Stream.run( + Stream.fromPull(Effect.succeed(bodyPull)).pipe( + Stream.takeWhile((chunk) => { + received += chunk.byteLength; + return received <= MAX_RECORDING_BYTES; + }), + ), + fs.sink(temporary), + ); + if (received > MAX_RECORDING_BYTES) return HttpServerResponse.empty({ status: 413 }); + if (received === 0 || (size !== undefined && received !== size)) { + return HttpServerResponse.empty({ status: 400 }); + } + yield* fs.rename(temporary, target); + return HttpServerResponse.jsonUnsafe({ path: target }); + }).pipe(Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.ignore))); + }).pipe( + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => + Effect.succeed(HttpServerResponse.empty({ status: 401 })), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, () => + Effect.succeed(HttpServerResponse.empty({ status: 500 })), + ), + ), +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 92def1774e74..4b6d49f75871 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2,6 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeCrypto from "node:crypto"; +import * as NodeNet from "node:net"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { @@ -1634,6 +1635,135 @@ const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe( ); it.layer(NodeServices.layer)("server router seam", (it) => { + for (const host of ["127.0.0.1", "::1"]) { + it.effect( + `forwards preview TCP on ${host} with credit replenishment and request half-close`, + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const response = Buffer.alloc(1024 * 1024, 71); + const upstream = NodeNet.createServer({ allowHalfOpen: true }, (socket) => { + const request: Buffer[] = []; + socket.on("data", (data: Buffer) => request.push(data)); + socket.on("end", () => { + if (Buffer.concat(request).toString() === "preview request") socket.end(response); + else socket.destroy(); + }); + }); + yield* Effect.addFinalizer(() => Effect.sync(() => upstream.close())); + yield* Effect.promise( + () => new Promise((resolve) => upstream.listen(0, host, resolve)), + ); + const address = upstream.address() as NodeNet.AddressInfo; + const { cookie, url } = parseSessionCookieFromWsUrl( + yield* getWsServerUrl(`/api/preview/forward?port=${address.port}`), + ); + const received = yield* Effect.promise( + () => + new Promise((resolve, reject) => { + const socket = new NodeSocket.NodeWS.WebSocket(url, { + headers: { cookie: cookie! }, + }); + const chunks: Buffer[] = []; + const send = (op: number, payload = Buffer.alloc(0)) => { + const frame = Buffer.alloc(5 + payload.length); + frame.writeUInt32BE(1); + frame[4] = op; + payload.copy(frame, 5); + socket.send(frame); + }; + socket.once("error", reject); + socket.once("open", () => send(0)); + socket.on("message", (data) => { + const frame = Buffer.from(data as Buffer); + if (frame[4] === 0) { + send(1, Buffer.from("preview request")); + send(4); + } else if (frame[4] === 1) { + chunks.push(frame.subarray(5)); + const credit = Buffer.alloc(4); + credit.writeUInt32BE(frame.length - 5); + send(3, credit); + } else if (frame[4] === 4) { + resolve(Buffer.concat(chunks)); + socket.close(); + } + }); + socket.once("close", () => + reject(new Error("preview closed before response ended")), + ); + }), + ); + assert.deepEqual(received, response); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + } + + it.effect("rejects preview forwarding without authentication and invalid ports", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const unauthorized = yield* HttpClient.get("/api/preview/forward?port=3000"); + assert.equal(unauthorized.status, 401); + const invalid = yield* HttpClient.get("/api/preview/forward?port=65536", { + headers: { cookie: yield* getAuthenticatedSessionCookieHeader() }, + }); + assert.equal(invalid.status, 400); + const { cookie, url } = parseSessionCookieFromWsUrl( + yield* getWsServerUrl("/api/preview/forward?port=3000"), + ); + const code = yield* Effect.promise( + () => + new Promise((resolve, reject) => { + const socket = new NodeSocket.NodeWS.WebSocket(url, { headers: { cookie: cookie! } }); + socket.once("error", reject); + socket.once("open", () => socket.send(Buffer.from([0]))); + socket.once("close", resolve); + }), + ); + assert.equal(code, 1002); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("stores authenticated preview recordings in the owning environment", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const bytes = new Uint8Array([26, 69, 223, 163, 1, 2, 3]); + const unauthorized = yield* HttpClient.post("/api/preview/recordings", { + body: HttpBody.uint8Array(bytes, "video/webm"), + }); + assert.equal(unauthorized.status, 401); + const cookie = yield* getAuthenticatedSessionCookieHeader(); + const invalid = yield* HttpClient.post("/api/preview/recordings", { + headers: { cookie }, + body: HttpBody.uint8Array(bytes, "text/plain"), + }); + assert.equal(invalid.status, 415); + const uploaded = yield* HttpClient.post("/api/preview/recordings", { + headers: { cookie }, + body: HttpBody.uint8Array(bytes, "video/webm;codecs=vp9"), + }); + assert.equal(uploaded.status, 200); + const result = (yield* uploaded.json) as { path: string }; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + assert.equal(path.basename(path.dirname(result.path)), "browser-artifacts"); + assert.isTrue(result.path.endsWith(".webm")); + assert.deepEqual(yield* fs.readFile(result.path), bytes); + const chunk = new Uint8Array(1024 * 1024); + const oversized = yield* HttpClient.post("/api/preview/recordings", { + headers: { cookie }, + body: HttpBody.stream( + Stream.fromIterable(Array.from({ length: 65 }, () => chunk)), + "video/webm", + ), + }); + assert.equal(oversized.status, 413); + assert.deepEqual(yield* fs.readDirectory(path.dirname(result.path)), [ + path.basename(result.path), + ]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("parks HTTP ingress until command readiness", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b755f8791cba..cd901a2b57b0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -35,6 +35,8 @@ import { import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; +import { previewForwardRouteLayer } from "./preview/forward.ts"; +import { previewRecordingRouteLayer } from "./preview/recordings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; @@ -559,6 +561,8 @@ export const makeRoutesLayer = Layer.mergeAll( attachmentUploadRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, + previewForwardRouteLayer, + previewRecordingRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), ).pipe( diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 0f01960ce52b..01bef2dd8d5b 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -5,6 +5,8 @@ import { useShallow } from "zustand/react/shallow"; import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; +import { toastManager } from "~/components/ui/toast"; +import { resolveForwardedBrowserTarget } from "./browserPortForward"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { cn, isMacPlatform } from "~/lib/utils"; @@ -67,7 +69,28 @@ export function HostedBrowserWebview(props: { profileId, } = props; const config = usePreviewWebviewConfig(threadRef.environmentId, profileId); - const [initialSrc] = useState(() => initialUrl ?? "about:blank"); + const [mountUrl] = useState(initialUrl); + const [initialSrc, setInitialSrc] = useState(mountUrl ? null : "about:blank"); + useEffect(() => { + if (!mountUrl) return; + let disposed = false; + void resolveForwardedBrowserTarget(threadRef.environmentId, { kind: "url", url: mountUrl }) + .then((url) => { + if (!disposed) setInitialSrc(url); + }) + .catch((error: unknown) => { + if (disposed) return; + setInitialSrc("about:blank"); + toastManager.add({ + title: "Preview connection failed", + type: "error", + description: error instanceof Error ? error.message : "Could not connect to the preview.", + }); + }); + return () => { + disposed = true; + }; + }, [mountUrl, threadRef.environmentId]); const tabLeaseRef = useRef(null); const wrapperRef = useRef(null); const webviewRef = useRef(null); @@ -148,8 +171,29 @@ export function HostedBrowserWebview(props: { recoveryTimeout = setTimeout(() => { recoveryTimeout = null; if (!disposed) { - setRecoverySrc(latestUrlRef.current ?? initialSrc); - setWebviewGeneration((generation) => generation + 1); + const requestedUrl = latestUrlRef.current ?? initialSrc; + void ( + requestedUrl && requestedUrl !== "about:blank" + ? resolveForwardedBrowserTarget(threadRef.environmentId, { + kind: "url", + url: requestedUrl, + }) + : Promise.resolve("about:blank") + ) + .then((url) => { + if (disposed) return; + setRecoverySrc(url); + setWebviewGeneration((generation) => generation + 1); + }) + .catch((error: unknown) => { + if (disposed) return; + toastManager.add({ + title: "Preview connection failed", + type: "error", + description: + error instanceof Error ? error.message : "Could not recover the preview.", + }); + }); } }, recovery.delayMs); }; @@ -164,7 +208,7 @@ export function HostedBrowserWebview(props: { webview.removeEventListener("dom-ready", register); webview.removeEventListener("render-process-gone", recoverGuest); }; - }, [config, initialSrc, runtimeTabId, webviewGeneration]); + }, [config, initialSrc, runtimeTabId, webviewGeneration, threadRef.environmentId]); const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; @@ -249,7 +293,7 @@ export function HostedBrowserWebview(props: { wrapper.scrollTo({ left: 0, top: 0 }); }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]); - if (!config) return null; + if (!config || !initialSrc) return null; const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive; const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ @@ -293,7 +337,7 @@ export function HostedBrowserWebview(props: { // boolean, but react-dom drops boolean values for unrecognized attributes, // so the literal string has to be spread past the type. {...({ allowpopups: "true" } as unknown as { readonly allowpopups?: boolean })} - src={webviewGeneration === 0 ? initialSrc : recoverySrc} + src={webviewGeneration === 0 ? initialSrc : (recoverySrc ?? initialSrc)} partition={config.partition} webpreferences={config.webPreferences} {...(config.preloadUrl ? { preload: config.preloadUrl } : {})} diff --git a/apps/web/src/browser/browserEnvironmentHttp.ts b/apps/web/src/browser/browserEnvironmentHttp.ts new file mode 100644 index 000000000000..ad1446525bc0 --- /dev/null +++ b/apps/web/src/browser/browserEnvironmentHttp.ts @@ -0,0 +1,40 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import * as Effect from "effect/Effect"; + +import { runtime } from "~/lib/runtime"; +import { readPreparedConnection } from "~/state/session"; + +export async function previewEnvironmentPost( + environmentId: EnvironmentId, + path: string, + body?: Blob, +) { + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); + const url = new URL(path, connection.httpBaseUrl).toString(); + const headers: Record = {}; + const authorization = connection.httpAuthorization; + if (authorization?._tag === "Bearer") { + headers.authorization = `Bearer ${authorization.token}`; + } else if (authorization?._tag === "Dpop") { + headers.authorization = `DPoP ${authorization.accessToken}`; + headers.dpop = await runtime.runPromise( + Effect.gen(function* () { + const signer = yield* ManagedRelay.ManagedRelayDpopSigner; + return yield* signer.createProof({ + method: "POST", + url, + accessToken: authorization.accessToken, + }); + }), + ); + } + return fetch(url, { + method: "POST", + headers, + ...(body ? { body } : {}), + credentials: authorization === null ? "include" : "omit", + signal: AbortSignal.timeout(body ? 120_000 : 15_000), + }); +} diff --git a/apps/web/src/browser/browserPortForward.ts b/apps/web/src/browser/browserPortForward.ts new file mode 100644 index 000000000000..756b845d37a3 --- /dev/null +++ b/apps/web/src/browser/browserPortForward.ts @@ -0,0 +1,65 @@ +import type { BrowserNavigationTarget, EnvironmentId } from "@t3tools/contracts"; +import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; + +import { previewEnvironmentPost } from "./browserEnvironmentHttp"; +import { readPreparedConnection } from "~/state/session"; +import { previewBridge } from "~/components/preview/previewBridge"; +import { + isLocalLoopbackHost, + isPrivateNetworkHost, + resolveBrowserNavigationTarget, + rememberForwardedOrigin, + restoreForwardedBrowserUrl, +} from "./browserTargetResolver"; + +/** Keep app traffic on a separate loopback origin while reusing the environment's tunnel. */ +export async function resolveForwardedBrowserTarget( + environmentId: EnvironmentId, + target: BrowserNavigationTarget, +): Promise { + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); + const environmentUrl = new URL(connection.httpBaseUrl); + const requested = + target.kind === "url" + ? new URL(restoreForwardedBrowserUrl(environmentId, normalizePreviewUrl(target.url))) + : new URL( + target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`, + `${target.protocol ?? "http"}://localhost:${target.port}`, + ); + const environmentPort = + target.kind === "environment-port" || + isLoopbackHost(requested.hostname) || + isLocalLoopbackHost(requested.hostname) || + (requested.hostname === environmentUrl.hostname && requested.port !== environmentUrl.port); + if (!environmentPort || isPrivateNetworkHost(environmentUrl.hostname)) { + return resolveBrowserNavigationTarget(environmentId, target).resolvedUrl; + } + if (!previewBridge?.ensurePortForward) { + throw new Error("Update the desktop app to preview remote ports through T3 Connect."); + } + const port = Number(requested.port || (requested.protocol === "https:" ? 443 : 80)); + const response = await previewEnvironmentPost(environmentId, "/api/auth/websocket-ticket"); + if (!response.ok) throw new Error(`Preview authorization failed (${response.status}).`); + const ticket: unknown = await response.json(); + if ( + typeof ticket !== "object" || + ticket === null || + !("ticket" in ticket) || + typeof ticket.ticket !== "string" + ) + throw new Error("Invalid preview authorization response."); + const socketUrl = new URL("/api/preview/forward", connection.httpBaseUrl); + socketUrl.protocol = socketUrl.protocol === "https:" ? "wss:" : "ws:"; + socketUrl.searchParams.set("port", String(port)); + socketUrl.searchParams.set("wsTicket", ticket.ticket); + const localPort = await previewBridge.ensurePortForward( + `${environmentId}:${environmentUrl.origin}:${port}`, + socketUrl.toString(), + ); + const requestedOrigin = requested.origin; + requested.hostname = "127.0.0.1"; + requested.port = String(localPort); + rememberForwardedOrigin(environmentId, requested.origin, requestedOrigin); + return requested.toString(); +} diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts index 49145f314e98..e5251f2f3588 100644 --- a/apps/web/src/browser/browserRecording.test.ts +++ b/apps/web/src/browser/browserRecording.test.ts @@ -55,6 +55,16 @@ vi.mock("~/components/preview/previewBridge", () => ({ }, })); +vi.mock("./browserEnvironmentHttp", () => ({ + previewEnvironmentPost: vi.fn( + async () => new Response(JSON.stringify({ path: "/remote/recording.webm" })), + ), +})); + +vi.mock("~/state/session", () => ({ + readPreparedConnection: vi.fn(() => ({ target: { _tag: "BearerConnectionTarget" } })), +})); + vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: { set: registrySet }, })); @@ -517,7 +527,10 @@ describe("browser recording", () => { ).rejects.toBeInstanceOf(BrowserRecordingConflictError); expect(startScreencast).toHaveBeenCalledTimes(1); - await stopBrowserRecording(runtimeTabId); + await expect(stopBrowserRecording(runtimeTabId)).resolves.toMatchObject({ + path: "/tmp/recording-test.webm", + environmentPath: "/remote/recording.webm", + }); }); it("does not report success for a second start while the first is still starting", async () => { diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 73bc2708ddf6..80f1cd4a0ec9 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -7,8 +7,10 @@ import { Atom } from "effect/unstable/reactivity"; import { previewBridge } from "~/components/preview/previewBridge"; import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings"; import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { readPreparedConnection } from "~/state/session"; import { acquireBrowserSurfaceActivity } from "./browserSurfaceStore"; +import { previewEnvironmentPost } from "./browserEnvironmentHttp"; export class BrowserRecordingUnavailableError extends Schema.TaggedErrorClass()( "BrowserRecordingUnavailableError", @@ -691,6 +693,36 @@ const finalizeBrowserRecording = async ( mimeType, new Uint8Array(await blob.arrayBuffer()), ); + if ( + recording.threadRef && + readPreparedConnection(recording.threadRef.environmentId)?.target._tag !== + "PrimaryConnectionTarget" + ) { + if (blob.size > 64 * 1024 * 1024) { + throw new Error( + "Recording saved on this desktop. Remote recordings must be under 64 MiB.", + ); + } + const response = await previewEnvironmentPost( + recording.threadRef.environmentId, + "/api/preview/recordings", + blob, + ); + if (!response.ok) + throw new Error( + `Recording saved on this desktop, but transfer failed (${response.status}).`, + ); + const uploaded: unknown = await response.json(); + if ( + typeof uploaded !== "object" || + uploaded === null || + !("path" in uploaded) || + typeof uploaded.path !== "string" + ) { + throw new Error("Invalid recording transfer response."); + } + artifact.environmentPath = uploaded.path; + } result = { _tag: "Success", artifact }; } catch (cause) { throw new BrowserRecordingOperationError({ diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index c06c60b5f740..b441458a3a41 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -7,6 +7,23 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; import { readPreparedConnection } from "~/state/session"; +const forwardedOrigins = new Map(); + +export function rememberForwardedOrigin( + environmentId: EnvironmentId, + localOrigin: string, + requestedOrigin: string, +) { + forwardedOrigins.set(`${environmentId}:${localOrigin}`, requestedOrigin); +} + +export function restoreForwardedBrowserUrl(environmentId: EnvironmentId, rawUrl: string): string { + const url = new URL(rawUrl); + const origin = forwardedOrigins.get(`${environmentId}:${url.origin}`); + if (!origin) return rawUrl; + return `${origin}${url.pathname}${url.search}${url.hash}`; +} + export const normalizeHostname = (host: string): string => host .toLowerCase() diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 54c2e1d9cf68..663b1fdebdfb 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -30,7 +30,7 @@ import { updatePreviewServerSnapshot, } from "~/previewStateStore"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; -import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; +import { resolveForwardedBrowserTarget } from "~/browser/browserPortForward"; import { readActiveBrowserRecordingTargets, startBrowserRecording, @@ -392,10 +392,10 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) case "open": { const input = request.input as PreviewAutomationOpenInput; const resolvedInputUrl = input.url - ? resolveBrowserNavigationTarget(environmentId, { + ? await resolveForwardedBrowserTarget(environmentId, { kind: "url", url: input.url, - }).resolvedUrl + }) : undefined; let activeTabId = resolvePreviewAutomationOpenTab( state, @@ -525,14 +525,14 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) case "navigate": { const ready = await requireReadyTab(); const input = request.input as PreviewAutomationNavigateInput; - const resolution = resolveBrowserNavigationTarget( + const resolvedUrl = await resolveForwardedBrowserTarget( environmentId, input.target ?? { kind: "url", url: input.url!, }, ); - await ready.bridge.navigate(ready.runtimeTabId, resolution.resolvedUrl); + await ready.bridge.navigate(ready.runtimeTabId, resolvedUrl); await waitForNavigationReadiness( threadRef, request.requestId, @@ -717,7 +717,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }), ); } - return { ...artifact, tabId: stopTabId }; + return { + ...artifact, + path: artifact.environmentPath ?? artifact.path, + tabId: stopTabId, + }; } } } catch (cause) { diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 640690854e53..2a5400c2c54b 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -28,6 +28,7 @@ import { useThreadPreviewState, } from "~/previewStateStore"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; +import { resolveForwardedBrowserTarget } from "~/browser/browserPortForward"; import { useEnvironmentHttpBaseUrl } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -181,7 +182,11 @@ export function PreviewView({ async (resolvedUrl: string) => { if (runtimeTabId && previewBridge) { // The bridge mirrors the resolved URL back to the server. - await previewBridge.navigate(runtimeTabId, resolvedUrl); + const forwardedUrl = await resolveForwardedBrowserTarget(threadRef.environmentId, { + kind: "url", + url: resolvedUrl, + }); + await previewBridge.navigate(runtimeTabId, forwardedUrl); rememberPreviewUrl(threadRef, resolvedUrl); return true; } @@ -198,8 +203,12 @@ export function PreviewView({ if (await navigateToResolvedUrl(normalized)) { recordVisitForThread(threadRef, normalized); } - } catch { - // Server-side `failed` event renders the unreachable view. + } catch (error) { + toastManager.add({ + title: "Preview connection failed", + type: "error", + description: error instanceof Error ? error.message : "Could not open the preview.", + }); } }, [navigateToResolvedUrl, threadRef], @@ -212,16 +221,30 @@ export function PreviewView({ if (await navigateToResolvedUrl(resolved)) { recordVisitForThread(threadRef, next); } - } catch { - // Server-side `failed` event renders the unreachable view. + } catch (error) { + toastManager.add({ + title: "Preview connection failed", + type: "error", + description: error instanceof Error ? error.message : "Could not open the preview.", + }); } }, [navigateToResolvedUrl, threadRef], ); const handleRefresh = useCallback(() => { - if (previewBridge && runtimeTabId) void previewBridge.refresh(runtimeTabId); - }, [runtimeTabId]); + const bridge = previewBridge; + if (!url || !runtimeTabId || !bridge) return; + void resolveForwardedBrowserTarget(threadRef.environmentId, { kind: "url", url }) + .then((resolvedUrl) => bridge.navigate(runtimeTabId, resolvedUrl)) + .catch((error: unknown) => + toastManager.add({ + title: "Preview connection failed", + type: "error", + description: error instanceof Error ? error.message : "Could not refresh the preview.", + }), + ); + }, [runtimeTabId, threadRef.environmentId, url]); const handleZoomIn = useCallback(() => { if (previewBridge && runtimeTabId) void previewBridge.zoomIn(runtimeTabId); @@ -292,8 +315,16 @@ export function PreviewView({ const handleOpenInBrowser = useCallback(() => { if (!localApi || !url) return; - void localApi.shell.openExternal(url).catch(() => undefined); - }, [url]); + void resolveForwardedBrowserTarget(threadRef.environmentId, { kind: "url", url }) + .then((resolvedUrl) => localApi.shell.openExternal(resolvedUrl)) + .catch((error: unknown) => + toastManager.add({ + title: "Preview connection failed", + type: "error", + description: error instanceof Error ? error.message : "Could not open the browser.", + }), + ); + }, [url, threadRef.environmentId]); const handlePictureInPicture = useCallback(() => { if (!tabId) return; diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts index 58b918ad819e..0c321985d50b 100644 --- a/apps/web/src/components/preview/usePreviewBridge.ts +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -16,6 +16,7 @@ import { useFaviconProjectRefForThread, } from "~/browserFaviconStore"; import { useBrowserPointerStore } from "~/browser/browserPointerStore"; +import { restoreForwardedBrowserUrl } from "~/browser/browserTargetResolver"; import { applyPreviewDesktopState, type DesktopPreviewOverlay } from "~/previewStateStore"; import { previewEnvironment } from "~/state/preview"; import { usePreparedConnection } from "~/state/session"; @@ -64,6 +65,27 @@ export function usePreviewBridge(input: { const handleStateChange = useEffectEvent( (changedTabId: string, state: DesktopPreviewTabState): void => { if (changedTabId !== runtimeTabId) return; + if (state.navStatus.kind !== "Idle") { + state = { + ...state, + navStatus: { + ...state.navStatus, + url: restoreForwardedBrowserUrl(stableThreadRef.environmentId, state.navStatus.url), + }, + }; + } + if (state.favicon) { + state = { + ...state, + favicon: { + ...state.favicon, + pageUrl: restoreForwardedBrowserUrl( + stableThreadRef.environmentId, + state.favicon.pageUrl, + ), + }, + }; + } if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) { clearBrowserPointer(runtimeTabId); } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 31ff0d26835e..132f9b3233b2 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -202,6 +202,7 @@ function connectDevSharePlugin(): Plugin { const protocol = request.headers["sec-websocket-protocol"]; if ( pathname !== "/ws" && + pathname !== "/api/preview/forward" && !(pathname === "/" && (protocol === "vite-hmr" || protocol === "vite-ping")) ) { socket.destroy(); @@ -285,6 +286,8 @@ export default defineConfig(() => { bundledDev, }, server: { + // The environment server owns credentialed API CORS, including Electron origins. + ...(connectDevShare ? { cors: { preflightContinue: true } } : {}), host, port, strictPort: true, @@ -311,7 +314,7 @@ export default defineConfig(() => { { target: devProxyTarget, changeOrigin: true, - ...(prefix === "/ws" ? { ws: true } : {}), + ...(prefix === "/ws" || prefix === "/api" ? { ws: true } : {}), }, ]), ), @@ -350,7 +353,7 @@ export default defineConfig(() => { groups: [ { name: "refresh", test: /react-refresh/, priority: 30 }, { name: "icons", test: /[\\/]lucide-react[\\/]/, priority: 20 }, - { name: "shared", minShareCount: 2, includeDependenciesRecursively: false }, + { name: "shared", minShareCount: 2, includeDependenciesRecursively: true }, ], }, }, diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 14c840bf35ab..a11c28fa433e 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -28,6 +28,15 @@ For a server linked through the CLI, `npx t3 pair --connect` creates a fresh pai through its managed endpoint. The command verifies that the endpoint reaches this environment before issuing a token. Tailscale is not required. +In the desktop app's Browser panel, select a local server from the connected +environment or enter its localhost URL. Preview tabs reuse that environment's +connection and do not consume additional T3 Connect tunnel slots. Both the +desktop app and the environment server must support remote previews. + +Browser recordings are saved on the desktop and copied to the connected +environment so its agent can read them. Remote recording transfers support files +up to 64 MiB. + ## Pair over a LAN or private network Use direct pairing when the other device can reach the host's network address. diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 798d5a777d5d..f385282bb123 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -707,6 +707,7 @@ export interface DesktopPreviewRecordingArtifact { id: string; tabId: string; path: string; + environmentPath?: string; mimeType: string; sizeBytes: number; createdAt: string; @@ -717,6 +718,7 @@ export const DesktopPreviewRecordingArtifactSchema: Schema.Codec Promise; createTab: (tabId: string, defaults?: DesktopPreviewTabDefaults) => Promise; closeTab: (tabId: string) => Promise; registerWebview: (tabId: string, webContentsId: number) => Promise; From 6656aa115f0f796d987dfce9456bdf5cc504f6fe Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 4 Sep 2026 23:49:14 +0000 Subject: [PATCH 05/14] fix(preview): preserve forwarded URL credentials and origins --- apps/web/src/browser/browserPortForward.ts | 2 +- .../src/browser/browserTargetResolver.test.ts | 17 +++++++++++++++++ apps/web/src/browser/browserTargetResolver.ts | 5 ++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/web/src/browser/browserPortForward.ts b/apps/web/src/browser/browserPortForward.ts index 756b845d37a3..f74fd31fbd37 100644 --- a/apps/web/src/browser/browserPortForward.ts +++ b/apps/web/src/browser/browserPortForward.ts @@ -54,7 +54,7 @@ export async function resolveForwardedBrowserTarget( socketUrl.searchParams.set("port", String(port)); socketUrl.searchParams.set("wsTicket", ticket.ticket); const localPort = await previewBridge.ensurePortForward( - `${environmentId}:${environmentUrl.origin}:${port}`, + `${environmentId}:${environmentUrl.origin}:${requested.origin}:${port}`, socketUrl.toString(), ); const requestedOrigin = requested.origin; diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index c2b3432402ed..4d0f8e6db8dc 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -8,6 +8,23 @@ vi.mock("~/state/session", () => ({ readPreparedConnection })); describe("browser target resolver", () => { beforeEach(() => readPreparedConnection.mockReset()); + it("restores forwarded URLs with credentials and their original hostname", async () => { + const { rememberForwardedOrigin, restoreForwardedBrowserUrl } = + await import("./browserTargetResolver"); + const environmentId = EnvironmentId.make("forwarded-environment"); + rememberForwardedOrigin(environmentId, "http://127.0.0.1:41001", "http://localhost:5173"); + rememberForwardedOrigin(environmentId, "http://127.0.0.1:41002", "http://127.0.0.1:5173"); + expect( + restoreForwardedBrowserUrl( + environmentId, + "http://user:p%40ss@127.0.0.1:41001/dashboard?q=1#section", + ), + ).toBe("http://user:p%40ss@localhost:5173/dashboard?q=1#section"); + expect(restoreForwardedBrowserUrl(environmentId, "http://127.0.0.1:41002/")).toBe( + "http://127.0.0.1:5173/", + ); + }); + it("maps environment ports onto a private network host", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index b441458a3a41..18ea63a95e0f 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -21,7 +21,10 @@ export function restoreForwardedBrowserUrl(environmentId: EnvironmentId, rawUrl: const url = new URL(rawUrl); const origin = forwardedOrigins.get(`${environmentId}:${url.origin}`); if (!origin) return rawUrl; - return `${origin}${url.pathname}${url.search}${url.hash}`; + const restored = new URL(`${origin}${url.pathname}${url.search}${url.hash}`); + restored.username = url.username; + restored.password = url.password; + return restored.toString(); } export const normalizeHostname = (host: string): string => From 0b2bacfb2a1bbcacec4382f27aa053fc6941bfa0 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 4 Sep 2026 23:57:56 +0000 Subject: [PATCH 06/14] fix(preview): keep forwarded targets on their requested loopback port --- apps/web/src/browser/browserPortForward.ts | 5 +- .../src/browser/browserTargetResolver.test.ts | 64 ++++++++++++++----- apps/web/src/browser/browserTargetResolver.ts | 4 +- 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/apps/web/src/browser/browserPortForward.ts b/apps/web/src/browser/browserPortForward.ts index f74fd31fbd37..77f6d2b7ddd4 100644 --- a/apps/web/src/browser/browserPortForward.ts +++ b/apps/web/src/browser/browserPortForward.ts @@ -24,8 +24,7 @@ export async function resolveForwardedBrowserTarget( target.kind === "url" ? new URL(restoreForwardedBrowserUrl(environmentId, normalizePreviewUrl(target.url))) : new URL( - target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`, - `${target.protocol ?? "http"}://localhost:${target.port}`, + `${target.protocol ?? "http"}://localhost:${target.port}${target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`}`, ); const environmentPort = target.kind === "environment-port" || @@ -58,7 +57,7 @@ export async function resolveForwardedBrowserTarget( socketUrl.toString(), ); const requestedOrigin = requested.origin; - requested.hostname = "127.0.0.1"; + if (requested.hostname !== "localhost") requested.hostname = "127.0.0.1"; requested.port = String(localPort); rememberForwardedOrigin(environmentId, requested.origin, requestedOrigin); return requested.toString(); diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index 4d0f8e6db8dc..15746369c67e 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -4,10 +4,35 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const readPreparedConnection = vi.fn(); vi.mock("~/state/session", () => ({ readPreparedConnection })); +vi.mock("~/components/preview/previewBridge", () => ({ + previewBridge: { ensurePortForward: vi.fn(async () => 41003) }, +})); +vi.mock("./browserEnvironmentHttp", () => ({ + previewEnvironmentPost: vi.fn(async () => Response.json({ ticket: "preview-ticket" })), +})); describe("browser target resolver", () => { beforeEach(() => readPreparedConnection.mockReset()); + it("preserves localhost TLS names, credentials, and environment-relative paths when forwarding", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.example.test" }); + const { resolveForwardedBrowserTarget } = await import("./browserPortForward"); + const environmentId = EnvironmentId.make("forwarded-targets"); + expect( + await resolveForwardedBrowserTarget(environmentId, { + kind: "url", + url: "https://user:pass@localhost:5173/dashboard", + }), + ).toBe("https://user:pass@localhost:41003/dashboard"); + expect( + await resolveForwardedBrowserTarget(environmentId, { + kind: "environment-port", + port: 5173, + path: "//example.test/x", + }), + ).toBe("http://localhost:41003//example.test/x"); + }); + it("restores forwarded URLs with credentials and their original hostname", async () => { const { rememberForwardedOrigin, restoreForwardedBrowserUrl } = await import("./browserTargetResolver"); @@ -25,21 +50,30 @@ describe("browser target resolver", () => { ); }); - it("maps environment ports onto a private network host", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "environment-port", - port: 5173, - path: "/dashboard", - }), - ).toEqual({ - requestedUrl: "http://localhost:5173/dashboard", - resolvedUrl: "http://192.168.1.25:5173/dashboard", - resolutionKind: "direct-private-network", - environmentId: "environment-1", - }); + it.each(["/dashboard", "//example.test/x"])( + "maps environment paths %s onto a private network host", + async (path) => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + path, + }), + ).toEqual({ + requestedUrl: `http://localhost:5173${path}`, + resolvedUrl: `http://192.168.1.25:5173${path}`, + resolutionKind: "direct-private-network", + environmentId: "environment-1", + }); + }, + ); + + it("recognizes IPv4-mapped loopback without treating mapped public hosts as local", async () => { + const { isLocalLoopbackHost } = await import("./browserTargetResolver"); + expect(isLocalLoopbackHost(new URL("http://[::ffff:127.0.0.1]:5173").hostname)).toBe(true); + expect(isLocalLoopbackHost(new URL("http://[::ffff:8.8.8.8]:5173").hostname)).toBe(false); }); it("preserves explicit loopback URL navigation for a remote Tailscale environment", async () => { diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 18ea63a95e0f..93efed64c269 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -107,7 +107,7 @@ const isSpecialPurposeIpv4Address = (parts: readonly number[]): boolean => export const isLocalLoopbackHost = (host: string): boolean => { const normalized = normalizeHostname(host); if (normalized === "localhost" || normalized === "::1") return true; - return parseIpv4Address(normalized)?.[0] === 127; + return (parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized))?.[0] === 127; }; export const isPrivateNetworkHost = (host: string): boolean => { @@ -207,7 +207,7 @@ const resolveEnvironmentPortTarget = ( : normalizedEnvironmentHost; const resolved = sourceUrl ? new URL(sourceUrl) - : new URL(path, `${protocol}://${resolvedHost}:${target.port}`); + : new URL(`${protocol}://${resolvedHost}:${target.port}${path}`); if (sourceUrl) { resolved.hostname = resolvedHost; resolved.port = String(target.port); From 3868c867516a63589702475d887491009fd87003 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 5 Sep 2026 01:30:25 +0000 Subject: [PATCH 07/14] fix(connect): address independent preview review findings --- .../src/mcp/PreviewAutomationBroker.test.ts | 28 +++++++++++ .../server/src/mcp/PreviewAutomationBroker.ts | 3 +- .../preview/PreviewAutomationHosts.tsx | 5 +- apps/web/vite.config.ts | 50 +++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 3bc0fd71308e..4261e5c917b8 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -19,6 +19,7 @@ import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -207,6 +208,33 @@ it.effect("tracks the tab returned by a targeted recording stop", () => ), ); +it.effect("waits for recording transfer beyond the normal request deadline", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const received = yield* Deferred.make(); + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => Deferred.succeed(received, request)).pipe( + Effect.forkScoped, + ); + const pending = yield* broker + .invoke({ scope, operation: "recordingStop", input: {} }) + .pipe(Effect.forkScoped); + const request = yield* Deferred.await(received); + yield* TestClock.adjust(120_000); + const artifact = { id: "recording-1", path: "/environment/browser-artifacts/recording.webm" }; + yield* broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: artifact, + }); + expect(yield* Fiber.join(pending)).toEqual(artifact); + }), + ), +); + it.effect("does not let a no-tab response suppress an earlier tab decision", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 3e9bfaac26ff..01b3977fee9a 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -426,7 +426,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* ( input: Parameters[0], ): Effect.fn.Return { - const timeoutMs = input.timeoutMs ?? 15_000; + // Recording stop includes the remote upload, which can take up to two minutes. + const timeoutMs = input.timeoutMs ?? (input.operation === "recordingStop" ? 150_000 : 15_000); const deferred = yield* Deferred.make(); const route = yield* SynchronizedRef.modify(state, (current) => { const assignments = new Map( diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 663b1fdebdfb..5f76341ba178 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -31,6 +31,7 @@ import { } from "~/previewStateStore"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { resolveForwardedBrowserTarget } from "~/browser/browserPortForward"; +import { restoreForwardedBrowserUrl } from "~/browser/browserTargetResolver"; import { readActiveBrowserRecordingTargets, startBrowserRecording, @@ -412,7 +413,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) environmentId, input: { threadId: request.threadId, - ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), + ...(resolvedInputUrl + ? { url: restoreForwardedBrowserUrl(environmentId, resolvedInputUrl) } + : {}), // An agent that didn't state a size gets the user's // configured default, same as a hand-opened tab. viewport: browserDefaultOpenViewport(await resolveBrowserDefaults()), diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 132f9b3233b2..8eb1cad83a0c 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -165,6 +165,56 @@ function connectDevSharePlugin(): Plugin { 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(); From 32363e82ee60eb43136e5892b9efc882bbe4f171 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 5 Sep 2026 05:04:29 +0000 Subject: [PATCH 08/14] fix(preview): preserve existing forwards when capacity is reached --- apps/desktop/src/preview/PortForward.ts | 9 +++------ apps/server/src/preview/forward.ts | 6 +++++- apps/server/src/server.test.ts | 13 +++++++++---- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/preview/PortForward.ts b/apps/desktop/src/preview/PortForward.ts index 2da42f094e8d..86681050cc70 100644 --- a/apps/desktop/src/preview/PortForward.ts +++ b/apps/desktop/src/preview/PortForward.ts @@ -18,7 +18,6 @@ interface Stream { interface Forward { ensure(websocketUrl: string): Promise; - idle(): boolean; close(): void; } @@ -224,7 +223,6 @@ function createForward(): Forward { }); return ready; }, - idle: () => (closed || server.listening) && sockets.size === 0, close: () => { disposed = true; close(); @@ -241,10 +239,9 @@ export function createPreviewPortForwards() { const existing = forwards.get(key); if (existing) return existing.ensure(websocketUrl); if (forwards.size >= MAX_FORWARDS) { - const idle = [...forwards].find(([, forward]) => forward.idle()); - if (!idle) throw new Error("Too many active preview forwarding connections."); - idle[1].close(); - forwards.delete(idle[0]); + throw new Error( + "Too many preview targets. Restart the desktop app to clear unused forwards.", + ); } const forward = createForward(); forwards.set(key, forward); diff --git a/apps/server/src/preview/forward.ts b/apps/server/src/preview/forward.ts index 79f3e25b8182..8772513b0970 100644 --- a/apps/server/src/preview/forward.ts +++ b/apps/server/src/preview/forward.ts @@ -71,10 +71,14 @@ const forward = (websocket: Socket.Socket, port: number) => } }; const open = (id: number) => { - if (streams.has(id) || sockets.size >= MAX_STREAMS) { + if (streams.has(id)) { stop(); return; } + if (sockets.size >= MAX_STREAMS) { + send(id, 2); + return; + } const stream: ForwardStream = { socket: new NodeNet.Socket(), connected: false, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index eec1fb8f3d96..61e8eb9499bb 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1637,7 +1637,7 @@ const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe( it.layer(NodeServices.layer)("server router seam", (it) => { for (const host of ["127.0.0.1", "::1"]) { it.effect( - `forwards preview TCP on ${host} with credit replenishment and request half-close`, + `forwards preview TCP on ${host} through stream overflow, credit replenishment and request half-close`, () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -1665,18 +1665,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { headers: { cookie: cookie! }, }); const chunks: Buffer[] = []; - const send = (op: number, payload = Buffer.alloc(0)) => { + let opened = 0; + const send = (op: number, payload = Buffer.alloc(0), id = 1) => { const frame = Buffer.alloc(5 + payload.length); - frame.writeUInt32BE(1); + frame.writeUInt32BE(id); frame[4] = op; payload.copy(frame, 5); socket.send(frame); }; socket.once("error", reject); - socket.once("open", () => send(0)); + socket.once("open", () => { + for (let id = 1; id <= 32; id++) send(0, Buffer.alloc(0), id); + }); socket.on("message", (data) => { const frame = Buffer.from(data as Buffer); if (frame[4] === 0) { + if (++opened === 32) send(0, Buffer.alloc(0), 33); + } else if (frame[4] === 2 && frame.readUInt32BE(0) === 33) { send(1, Buffer.from("preview request")); send(4); } else if (frame[4] === 1) { From d51d1e83ca1c85899e9eea857f9a800a7902fbd4 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 5 Sep 2026 05:14:23 +0000 Subject: [PATCH 09/14] fix(preview): retry recovery and discard stale navigations --- apps/web/src/browser/HostedBrowserWebview.tsx | 1 + .../components/preview/PreviewView.test.tsx | 43 +++++++++++++++++++ .../src/components/preview/PreviewView.tsx | 28 +++++++----- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 01bef2dd8d5b..5140f1947854 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -193,6 +193,7 @@ export function HostedBrowserWebview(props: { description: error instanceof Error ? error.message : "Could not recover the preview.", }); + recoverGuest(); }); } }, recovery.delayMs); diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index bb20ee362376..2763b3c2d4a7 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -13,6 +13,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ navigate: vi.fn(async (_tabId: string, _url: string): Promise => undefined), + ensurePortForward: vi.fn<() => Promise>(), rememberPreviewUrl: vi.fn(), readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })), submittedUrl: null as ((url: string) => void) | null, @@ -205,6 +206,7 @@ vi.mock("~/components/ui/toast", () => ({ vi.mock("./previewBridge", () => ({ previewBridge: { navigate: mocks.navigate, + ensurePortForward: mocks.ensurePortForward, pickElement: mocks.pickElement, pictureInPicture: { open: mocks.openPictureInPicture, @@ -440,6 +442,47 @@ describe("PreviewView navigation", () => { }); }); + it("keeps the latest submission when remote forwards resolve out of order", async () => { + const document = installTestDom(); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(document.createElement("div") as unknown as Element); + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://test.t3coderelay.com" }); + mocks.ensurePortForward.mockReset(); + mocks.ensurePortForward.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ ticket: "test-ticket" })), + ); + try { + await act(() => + root.render(), + ); + await act(async () => { + mocks.submittedUrl?.("localhost:3000/older"); + mocks.submittedUrl?.("localhost:4000/newer"); + }); + expect(mocks.ensurePortForward).toHaveBeenCalledTimes(2); + await act(async () => { + second.resolve(44000); + }); + await act(async () => { + first.resolve(43000); + }); + expect(mocks.navigate.mock.calls).toEqual([ + [TEST_RUNTIME_TAB_ID, "http://localhost:44000/newer"], + ]); + expect(mocks.recordVisitForThread.mock.calls).toEqual([ + [TEST_THREAD_REF, "http://localhost:4000/newer"], + ]); + } finally { + await act(() => root.unmount()); + mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://172.25.85.75:3773" }); + vi.unstubAllGlobals(); + } + }); + it("maps an empty-state localhost server onto the WSL host", async () => { mocks.showEmptyState = true; renderToStaticMarkup( diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 2a5400c2c54b..99a9f4fe1c7b 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -102,6 +102,7 @@ export function PreviewView({ const activeRecordingTabIds = useActiveBrowserRecordingTabIds(); const pickActiveRef = useRef(false); const isMountedRef = useRef(true); + const navigationRequestRef = useRef(0); // Kept in sync so the title effect can depend on the stable thread key // instead of the thread object, which is recreated on every update. const threadRefRef = useRef(threadRef); @@ -136,6 +137,12 @@ export function PreviewView({ const runtimeTabId = tabId ? previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId) : null; + useEffect( + () => () => { + navigationRequestRef.current += 1; + }, + [runtimeTabId], + ); const recordingRuntimeTabId = tabId && runtimeTabId ? activeRecordingTabIds.has(runtimeTabId) @@ -180,13 +187,16 @@ export function PreviewView({ const navigateToResolvedUrl = useCallback( async (resolvedUrl: string) => { + const request = ++navigationRequestRef.current; if (runtimeTabId && previewBridge) { // The bridge mirrors the resolved URL back to the server. const forwardedUrl = await resolveForwardedBrowserTarget(threadRef.environmentId, { kind: "url", url: resolvedUrl, }); + if (request !== navigationRequestRef.current) return false; await previewBridge.navigate(runtimeTabId, forwardedUrl); + if (request !== navigationRequestRef.current) return false; rememberPreviewUrl(threadRef, resolvedUrl); return true; } @@ -235,16 +245,14 @@ export function PreviewView({ const handleRefresh = useCallback(() => { const bridge = previewBridge; if (!url || !runtimeTabId || !bridge) return; - void resolveForwardedBrowserTarget(threadRef.environmentId, { kind: "url", url }) - .then((resolvedUrl) => bridge.navigate(runtimeTabId, resolvedUrl)) - .catch((error: unknown) => - toastManager.add({ - title: "Preview connection failed", - type: "error", - description: error instanceof Error ? error.message : "Could not refresh the preview.", - }), - ); - }, [runtimeTabId, threadRef.environmentId, url]); + void navigateToResolvedUrl(url).catch((error: unknown) => + toastManager.add({ + title: "Preview connection failed", + type: "error", + description: error instanceof Error ? error.message : "Could not refresh the preview.", + }), + ); + }, [navigateToResolvedUrl, runtimeTabId, url]); const handleZoomIn = useCallback(() => { if (previewBridge && runtimeTabId) void previewBridge.zoomIn(runtimeTabId); From be5d6f47c2cd317707fd3160019de8e6ccf8cb84 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 5 Sep 2026 05:24:12 +0000 Subject: [PATCH 10/14] fix(preview): reuse connect request credential renewal --- .../web/src/browser/browserEnvironmentHttp.ts | 78 ++++++++++++------- apps/web/src/browser/browserPortForward.ts | 2 +- .../components/preview/PreviewView.test.tsx | 20 +++-- packages/client-runtime/package.json | 4 + .../src/state/environmentHttpAuth.test.ts | 34 +++++++- .../src/state/environmentHttpAuth.ts | 6 +- 6 files changed, 110 insertions(+), 34 deletions(-) diff --git a/apps/web/src/browser/browserEnvironmentHttp.ts b/apps/web/src/browser/browserEnvironmentHttp.ts index ad1446525bc0..cf2067e8cc9a 100644 --- a/apps/web/src/browser/browserEnvironmentHttp.ts +++ b/apps/web/src/browser/browserEnvironmentHttp.ts @@ -1,40 +1,66 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { RemoteEnvironmentAuthorization } from "@t3tools/client-runtime/authorization"; +import { executeAuthenticatedEnvironmentHttpRequest } from "@t3tools/client-runtime/state/environment-http-auth"; +import { + createRuntimeCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import * as Effect from "effect/Effect"; +import { HttpBody, HttpClient } from "effect/unstable/http"; -import { runtime } from "~/lib/runtime"; +import { connectionAtomRuntime } from "~/connection/runtime"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; import { readPreparedConnection } from "~/state/session"; +const post = createRuntimeCommand(connectionAtomRuntime, { + label: "web:preview:post", + execute: (input: { + prepared: NonNullable>; + path: string; + body?: Blob; + }) => + Effect.gen(function* () { + const prepared = input.prepared; + const http = yield* HttpClient.HttpClient; + return yield* executeAuthenticatedEnvironmentHttpRequest({ + prepared, + signer: yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner), + remoteAuthorization: yield* Effect.serviceOption(RemoteEnvironmentAuthorization), + method: "POST", + url: (base) => new URL(input.path, base).toString(), + timeoutMs: input.body ? 120_000 : 15_000, + request: ({ headers, url }) => + Effect.gen(function* () { + const response = yield* http.post(url, { + headers: { ...headers }, + ...(input.body ? { body: HttpBody.raw(input.body) } : {}), + }); + const text = yield* response.text; + return { + url, + status: response.status, + ok: response.status >= 200 && response.status < 300, + json: async (): Promise => JSON.parse(text), + }; + }), + isUnauthorizedResponse: (response) => response.status === 401, + }); + }), +}); + export async function previewEnvironmentPost( environmentId: EnvironmentId, path: string, body?: Blob, ) { - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); - const url = new URL(path, connection.httpBaseUrl).toString(); - const headers: Record = {}; - const authorization = connection.httpAuthorization; - if (authorization?._tag === "Bearer") { - headers.authorization = `Bearer ${authorization.token}`; - } else if (authorization?._tag === "Dpop") { - headers.authorization = `DPoP ${authorization.accessToken}`; - headers.dpop = await runtime.runPromise( - Effect.gen(function* () { - const signer = yield* ManagedRelay.ManagedRelayDpopSigner; - return yield* signer.createProof({ - method: "POST", - url, - accessToken: authorization.accessToken, - }); - }), - ); - } - return fetch(url, { - method: "POST", - headers, + const prepared = readPreparedConnection(environmentId); + if (!prepared) throw new Error(`Environment ${environmentId} is not connected.`); + const result = await post.run(appAtomRegistry, { + prepared, + path, ...(body ? { body } : {}), - credentials: authorization === null ? "include" : "omit", - signal: AbortSignal.timeout(body ? 120_000 : 15_000), }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + return result.value; } diff --git a/apps/web/src/browser/browserPortForward.ts b/apps/web/src/browser/browserPortForward.ts index 77f6d2b7ddd4..25213c38b0ff 100644 --- a/apps/web/src/browser/browserPortForward.ts +++ b/apps/web/src/browser/browserPortForward.ts @@ -48,7 +48,7 @@ export async function resolveForwardedBrowserTarget( typeof ticket.ticket !== "string" ) throw new Error("Invalid preview authorization response."); - const socketUrl = new URL("/api/preview/forward", connection.httpBaseUrl); + const socketUrl = new URL("/api/preview/forward", response.url || connection.httpBaseUrl); socketUrl.protocol = socketUrl.protocol === "https:" ? "wss:" : "ws:"; socketUrl.searchParams.set("port", String(port)); socketUrl.searchParams.set("wsTicket", ticket.ticket); diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 2763b3c2d4a7..7f2338c027fe 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -61,6 +61,10 @@ vi.mock("~/state/session", () => ({ readPreparedConnection: mocks.readPreparedConnection, })); +vi.mock("~/browser/browserEnvironmentHttp", () => ({ + previewEnvironmentPost: () => fetch("https://test.t3coderelay.com/api/auth/websocket-ticket"), +})); + // Stubbed at the direct dependency rather than letting the real module pull in // `useSettings` -> `state/server`, which would drag the whole settings and // connection graph into a test that only cares about the browser chrome. @@ -446,11 +450,17 @@ describe("PreviewView navigation", () => { const document = installTestDom(); const { createRoot } = await import("react-dom/client"); const root = createRoot(document.createElement("div") as unknown as Element); - const first = Promise.withResolvers(); - const second = Promise.withResolvers(); + let resolveFirst!: (port: number) => void; + let resolveSecond!: (port: number) => void; + const first = new Promise((resolve) => { + resolveFirst = resolve; + }); + const second = new Promise((resolve) => { + resolveSecond = resolve; + }); mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://test.t3coderelay.com" }); mocks.ensurePortForward.mockReset(); - mocks.ensurePortForward.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + mocks.ensurePortForward.mockReturnValueOnce(first).mockReturnValueOnce(second); vi.stubGlobal( "fetch", vi.fn(async () => Response.json({ ticket: "test-ticket" })), @@ -465,10 +475,10 @@ describe("PreviewView navigation", () => { }); expect(mocks.ensurePortForward).toHaveBeenCalledTimes(2); await act(async () => { - second.resolve(44000); + resolveSecond(44000); }); await act(async () => { - first.resolve(43000); + resolveFirst(43000); }); expect(mocks.navigate.mock.calls).toEqual([ [TEST_RUNTIME_TAB_ID, "http://localhost:44000/newer"], diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 68f5bad51474..9ab09d69a6c2 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -99,6 +99,10 @@ "types": "./src/state/entities.ts", "default": "./src/state/entities.ts" }, + "./state/environment-http-auth": { + "types": "./src/state/environmentHttpAuth.ts", + "default": "./src/state/environmentHttpAuth.ts" + }, "./state/filesystem": { "types": "./src/state/filesystem.ts", "default": "./src/state/filesystem.ts" diff --git a/packages/client-runtime/src/state/environmentHttpAuth.test.ts b/packages/client-runtime/src/state/environmentHttpAuth.test.ts index 114b2c67e544..e03a1163aea1 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.test.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.test.ts @@ -14,7 +14,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { TestClock } from "effect/testing"; -import type { HttpClient } from "effect/unstable/http"; +import { HttpBody, HttpClient } from "effect/unstable/http"; import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; import { @@ -34,6 +34,7 @@ import { import { fetchEnvironmentSessionState } from "./session.ts"; import { fetchEnvironmentShellSnapshot } from "./shellSnapshotHttp.ts"; import { fetchEnvironmentThreadSnapshot } from "./threadSnapshotHttp.ts"; +import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; const TARGET = new RelayConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -214,6 +215,37 @@ const LOADERS: ReadonlyArray<{ ]; describe("authenticated environment HTTP requests", () => { + it.effect("retries a preview upload with its body and renewed request URL", () => + Effect.gen(function* () { + const body = new Blob(["recording bytes"], { type: "video/webm" }); + const harness = makeHarness( + (attempt) => new Response("{}", { status: attempt === 1 ? 401 : 200 }), + ); + const result = yield* Effect.gen(function* () { + const http = yield* HttpClient.HttpClient; + return yield* executeAuthenticatedEnvironmentHttpRequest({ + ...harness.input, + method: "POST", + url: (base) => new URL("/api/preview/recordings", base).toString(), + timeoutMs: 120_000, + request: ({ url, headers }) => + http.post(url, { headers: { ...headers }, body: HttpBody.raw(body) }), + isUnauthorizedResponse: (response) => response.status === 401, + }); + }).pipe(Effect.provide(harness.httpLayer)); + expect(result.status).toBe(200); + expect(harness.calls.map((call) => call.url)).toEqual([ + `${CURRENT_ORIGIN}/api/preview/recordings`, + `${RENEWED_ORIGIN}/api/preview/recordings`, + ]); + expect(harness.calls.map((call) => call.init.body)).toEqual([body, body]); + expect(harness.proofs.map((proof) => proof.accessToken)).toEqual([ + "current-token", + "renewed-token", + ]); + }), + ); + it.effect.each(LOADERS)("uses current relay authorization and endpoint for $name", (loader) => Effect.gen(function* () { const harness = makeHarness(() => Response.json(loader.response)); diff --git a/packages/client-runtime/src/state/environmentHttpAuth.ts b/packages/client-runtime/src/state/environmentHttpAuth.ts index cfc42f560715..77c9cea91f8b 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.ts @@ -94,6 +94,7 @@ export const executeAuthenticatedEnvironmentHttpRequest = Effect.fn( readonly url: (httpBaseUrl: string) => string; readonly timeoutMs: number; readonly request: (input: { + readonly url: string; readonly client: Effect.Success>; readonly headers: EnvironmentHttpAuthHeaders; }) => Effect.Effect; @@ -142,7 +143,10 @@ export const executeAuthenticatedEnvironmentHttpRequest = Effect.fn( const result = yield* executeEnvironmentHttpRequest( requestUrl, input.timeoutMs, - withEnvironmentCredentials(authorization, input.request({ client, headers })), + withEnvironmentCredentials( + authorization, + input.request({ client, headers, url: requestUrl }), + ), ).pipe(Effect.result); if (Result.isFailure(result)) { From cf6f760243bb2c6412a9985dc50419f5a0c374b8 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 5 Sep 2026 05:28:23 +0000 Subject: [PATCH 11/14] fix(preview): cap slow recovery retries --- apps/server/src/cloud/CliTokenManager.test.ts | 102 ++++++++++++++++++ apps/web/src/browser/HostedBrowserWebview.tsx | 4 +- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index e6eb6b7cd6fd..3aebf76be747 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -6,8 +6,10 @@ import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; +import * as FileSystem from "effect/FileSystem"; 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"; @@ -16,6 +18,9 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import * as CliTokenManager from "./CliTokenManager.ts"; import type { OutOfBandOAuthPromptInput } from "./CliTokenManager.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ExternalLauncher from "../process/externalLauncher.ts"; // pk_test_ const TEST_ENV = { @@ -88,6 +93,103 @@ const provideTestEnv = Effect.provide( const isAuthorizationError = Schema.is(CliTokenManager.CloudCliAuthorizationError); +it.layer(NodeServices.layer)("CliTokenManager.layerWithSharedAuthorization", (it) => { + for (const scenario of [ + { + 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", + }, + ]) { + it.effect(scenario.name, () => + 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), + Effect.provide(ServerSecretStore.layer), + Effect.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), + Effect.provide(ServerConfig.layer(localConfig)), + ); + }).pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-cli-auth-config-" })), + Effect.provide(ExternalLauncher.layer), + Effect.provide(makeTokenEndpointLayer([])), + ), + ); + } +}); + class PromptRejectedError extends Schema.TaggedErrorClass()( "PromptRejectedError", { message: Schema.String }, diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 5140f1947854..d7e29059af2a 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -26,6 +26,7 @@ import { useBrowserViewportResize } from "./useBrowserViewportResize"; import { INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, planWebviewCrashRecovery, + WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS, type WebviewCrashRecoveryState, } from "./webviewCrashRecovery"; @@ -144,6 +145,7 @@ export function HostedBrowserWebview(props: { if (!webview || !config || !bridge) return; let disposed = false; let recoveryTimeout: ReturnType | null = null; + let recoveryFailures = 0; const register = () => { const lease = tabLeaseRef.current; if (!lease) return; @@ -193,7 +195,7 @@ export function HostedBrowserWebview(props: { description: error instanceof Error ? error.message : "Could not recover the preview.", }); - recoverGuest(); + if (++recoveryFailures < WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS) recoverGuest(); }); } }, recovery.delayMs); From 8882519588959a807410a14ce6a6bd42248cef08 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 5 Sep 2026 05:29:31 +0000 Subject: [PATCH 12/14] test(connect): compose credential test dependencies --- apps/server/src/cloud/CliTokenManager.test.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index 3aebf76be747..23d613ad8b2a 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -158,9 +158,12 @@ it.layer(NodeServices.layer)("CliTokenManager.layerWithSharedAuthorization", (it expiresAtEpochMs: Number.MAX_SAFE_INTEGER, }); }).pipe( - Effect.provide(CliTokenManager.layer), - Effect.provide(ServerSecretStore.layer), - Effect.provide(ServerConfig.layer({ ...localConfig, secretsDir })), + Effect.provide( + CliTokenManager.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(ServerConfig.layer({ ...localConfig, secretsDir })), + ), + ), ); } yield* Effect.gen(function* () { @@ -178,13 +181,18 @@ it.layer(NodeServices.layer)("CliTokenManager.layerWithSharedAuthorization", (it assert.isTrue(Option.isSome(token)); assert.equal(Option.getOrThrow(token).accessToken, scenario.expected); }).pipe( - Effect.provide(ServerSecretStore.layer), - Effect.provide(ServerConfig.layer(localConfig)), + Effect.provide( + ServerSecretStore.layer.pipe(Layer.provideMerge(ServerConfig.layer(localConfig))), + ), ); }).pipe( - Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-cli-auth-config-" })), - Effect.provide(ExternalLauncher.layer), - Effect.provide(makeTokenEndpointLayer([])), + Effect.provide( + Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-cli-auth-config-" }), + ExternalLauncher.layer, + makeTokenEndpointLayer([]), + ), + ), ), ); } From 0947ee0e07493f4f5aa9de98d4db8937e35ed4f7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 6 Oct 2026 16:08:23 +0000 Subject: [PATCH 13/14] test(connect): use parameterized credential precedence cases --- apps/server/src/cloud/CliTokenManager.test.ts | 114 +++++++++--------- 1 file changed, 56 insertions(+), 58 deletions(-) diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index 150ce5982570..ae7393b951e3 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -330,7 +330,7 @@ it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => }); it.layer(NodeServices.layer)("CliTokenManager.layerWithSharedAuthorization", (it) => { - for (const scenario of [ + it.effect.each([ { name: "prefers the stored home over the dev-share home", stored: true, @@ -366,70 +366,68 @@ it.layer(NodeServices.layer)("CliTokenManager.layerWithSharedAuthorization", (it configured: false, expected: "local", }, - ]) { - it.effect(scenario.name, () => - 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 })), - ), - ), - ); - } + ])("$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 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); + const manager = yield* CliTokenManager.CloudCliTokenManager; + yield* manager.store({ + accessToken: identity, + refreshToken: identity, + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + }); }).pipe( Effect.provide( - ServerSecretStore.layer.pipe(Layer.provideMerge(ServerConfig.layer(localConfig))), + 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( - Layer.mergeAll( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-cli-auth-config-" }), - ExternalLauncher.layer, - layerDeviceFlow({ requests: [], tokenReplies: [] }), - ), + 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: [] }), ), ), - ); - } + ), + ); }); From 5a898ed84e0bb0f6f86a74af0e779bec0c3f704d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 6 Oct 2026 16:29:41 +0000 Subject: [PATCH 14/14] fix(connect): report startup failures and stop mismatched retries --- .../src/cloud/CloudLink.lifecycle.test.ts | 20 +++++ apps/server/src/server.ts | 75 +++++++++++-------- scripts/dev-runner.test.ts | 12 ++- scripts/dev-runner.ts | 15 +++- 4 files changed, 87 insertions(+), 35 deletions(-) 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/server.ts b/apps/server/src/server.ts index d1cae8f00f11..58f29d5154ed 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -872,18 +872,17 @@ const layerMakeServer = Layer.unwrap( ? config.devUrl.origin : `http://127.0.0.1:${address.port}`; if (config.connectDevShare) { - 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( + 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( @@ -891,27 +890,41 @@ const layerMakeServer = Layer.unwrap( ), }), ); - 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" }), + 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 }, + ), ), - Effect.timeout("5 seconds"), - Effect.retry({ schedule: Schedule.spaced("1 second") }), - Effect.timeout("2 minutes"), ); - const auth = yield* EnvironmentAuth.EnvironmentAuth; - const pairingUrl = yield* auth.issueStartupPairingUrl(publicOrigin); - yield* Effect.logInfo(`[dev-runner] pairingUrl: ${pairingUrl}`); } const endpointRuntime = yield* CloudManagedEndpointRuntime.CloudManagedEndpointRuntime; const recoveryLock = yield* Semaphore.make(1); diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 9c668d2946ae..8f52a9e944a1 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -1237,7 +1237,17 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { Effect.provideService(HostProcessPlatform, "linux"), Effect.flip, ); - assert.equal(error._tag, "DevRunnerConfigurationError"); + 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); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 654336714dff..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", { @@ -705,9 +714,9 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { input.devUrl !== undefined || (host !== undefined && !["localhost", "127.0.0.1"].includes(host))) ) { - return yield* new DevRunnerConfigurationError({ - configKeys: ["--share", "--dev-url"], - cause: "Connect sharing requires the complete dev stack and its local Vite origin.", + return yield* new DevRunnerConnectShareUnsupportedError({ + mode: input.mode, + reason: input.mode !== "dev" ? "mode" : input.devUrl !== undefined ? "dev-url" : "host", }); } if (connectShare) {