From 8f23e76ed9b40a526b3ba2dd432d057411095d7f Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sun, 31 May 2026 17:37:02 +0300 Subject: [PATCH 1/9] feat: configurable server base path --- apps/server/src/auth/Layers/ServerAuth.ts | 27 ++- apps/server/src/auth/http.ts | 4 +- apps/server/src/bin.test.ts | 2 + apps/server/src/cli/config.test.ts | 17 ++ apps/server/src/cli/config.ts | 19 ++ apps/server/src/cliAuthFormat.ts | 5 +- apps/server/src/config.ts | 4 + .../Layers/ServerEnvironment.test.ts | 2 + apps/server/src/http.ts | 217 +++++++++++------- apps/server/src/server.test.ts | 2 + apps/server/src/server.ts | 16 +- apps/server/src/serverRuntimeStartup.ts | 4 +- apps/server/src/startupAccess.ts | 48 +++- apps/web/index.html | 8 +- apps/web/src/basePath.ts | 15 ++ apps/web/src/components/ChatView.browser.tsx | 2 + .../components/KeybindingsToast.browser.tsx | 2 + apps/web/src/components/SplashScreen.tsx | 2 +- .../components/auth/PairingRouteSurface.tsx | 7 +- .../settings/ConnectionsSettings.tsx | 3 +- .../src/components/settings/pairingUrls.ts | 5 +- apps/web/src/environments/primary/target.ts | 10 +- apps/web/src/environments/runtime/catalog.ts | 6 +- apps/web/src/main.tsx | 3 +- apps/web/src/router.ts | 4 +- apps/web/src/routes/_chat.index.tsx | 4 +- apps/web/vite.config.ts | 1 + bun.lock | 1 + .../src/advertisedEndpoint.test.ts | 8 +- packages/client-runtime/src/remote.ts | 15 +- packages/client-runtime/src/wsRpcProtocol.ts | 11 +- packages/shared/package.json | 4 + packages/shared/src/advertisedEndpoint.ts | 7 +- packages/shared/src/basePath.ts | 59 +++++ packages/shared/src/remote.ts | 33 ++- packages/tailscale/package.json | 1 + packages/tailscale/src/tailscale.ts | 17 +- 37 files changed, 459 insertions(+), 136 deletions(-) create mode 100644 apps/web/src/basePath.ts create mode 100644 packages/shared/src/basePath.ts diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index 238475aca37..7db8a65005c 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -6,6 +6,7 @@ import { type AuthSessionState, type AuthWebSocketTokenResult, } from "@t3tools/contracts"; +import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -316,15 +317,23 @@ export const makeServerAuth = Effect.gen(function* () { ); const issueStartupPairingUrl: ServerAuthShape["issueStartupPairingUrl"] = (baseUrl) => - issuePairingCredential({ role: "owner" }).pipe( - Effect.map((issued) => { - const url = new URL(baseUrl); - url.pathname = "/pair"; - url.searchParams.delete("token"); - url.hash = new URLSearchParams([["token", issued.credential]]).toString(); - return url.toString(); - }), - ); + Effect.gen(function* () { + const issued = yield* issuePairingCredential({ role: "owner" }); + const url = new URL(baseUrl); + const basePath = yield* normalizeBasePath(url.pathname).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid startup pairing URL base path.", + cause, + }), + ), + ); + url.pathname = joinBasePath(basePath, "/pair"); + url.searchParams.delete("token"); + url.hash = new URLSearchParams([["token", issued.credential]]).toString(); + return url.toString(); + }); const issueWebSocketToken: ServerAuthShape["issueWebSocketToken"] = (session) => sessions.issueWebSocketToken(session.sessionId).pipe( diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 670ff5abbff..c037fe70685 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -15,6 +15,7 @@ import { AuthError, ServerAuth } from "./Services/ServerAuth.ts"; import { SessionCredentialService } from "./Services/SessionCredentialService.ts"; import { deriveAuthClientMetadata } from "./utils.ts"; import { browserApiCorsHeaders } from "../httpCors.ts"; +import { ServerConfig } from "../config.ts"; export const respondToAuthError = (error: AuthError) => Effect.gen(function* () { @@ -70,6 +71,7 @@ export const authBootstrapRouteLayer = HttpRouter.add( const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* ServerAuth; const sessions = yield* SessionCredentialService; + const config = yield* ServerConfig; const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe( Effect.mapError( (cause) => @@ -92,7 +94,7 @@ export const authBootstrapRouteLayer = HttpRouter.add( HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, { expires: DateTime.toDate(result.response.expiresAt), httpOnly: true, - path: "/", + path: `${config.basePath}/`, sameSite: "lax", }), ); diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index fbf6d80c560..3bc759dbbe2 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; +import { ROOT_BASE_PATH } from "@t3tools/shared/basePath"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -76,6 +77,7 @@ const makeCliTestServerConfig = (baseDir: string) => desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + basePath: ROOT_BASE_PATH, tailscaleServeEnabled: false, tailscaleServePort: 443, } satisfies ServerConfigShape; diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 9e73773d5a5..f95a70ef232 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -14,6 +14,7 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; +import { ROOT_BASE_PATH, normalizeBasePath } from "@t3tools/shared/basePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { deriveServerPaths } from "../config.ts"; import { resolveServerConfig } from "./config.ts"; @@ -46,6 +47,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", } as const; + const agentBasePath = Effect.runSync(normalizeBasePath("/agent")); const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) { const fs = yield* FileSystem.FileSystem; @@ -73,6 +75,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + basePath: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), }, @@ -116,6 +119,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, + basePath: ROOT_BASE_PATH, tailscaleServeEnabled: false, tailscaleServePort: 443, }); @@ -139,6 +143,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.some(true), logWebSocketEvents: Option.some(true), + basePath: Option.some("/agent"), tailscaleServeEnabled: Option.some(true), tailscaleServePort: Option.some(8443), }, @@ -182,6 +187,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, + basePath: agentBasePath, tailscaleServeEnabled: true, tailscaleServePort: 8443, }); @@ -213,6 +219,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.some(false), logWebSocketEvents: Option.some(false), + basePath: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), }, @@ -251,6 +258,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: "desktop-bootstrap-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + basePath: ROOT_BASE_PATH, tailscaleServeEnabled: false, tailscaleServePort: 443, }); @@ -288,6 +296,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + basePath: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), }, @@ -325,6 +334,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: "desktop-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + basePath: ROOT_BASE_PATH, tailscaleServeEnabled: false, tailscaleServePort: 443, }); @@ -351,6 +361,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + basePath: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), }, @@ -410,6 +421,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + basePath: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), }, @@ -450,6 +462,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: "desktop-token", autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, + basePath: ROOT_BASE_PATH, tailscaleServeEnabled: false, tailscaleServePort: 443, }); @@ -486,6 +499,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + basePath: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), }, @@ -519,6 +533,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + basePath: ROOT_BASE_PATH, tailscaleServeEnabled: false, tailscaleServePort: 443, }); @@ -543,6 +558,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), + basePath: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), }, @@ -582,6 +598,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + basePath: ROOT_BASE_PATH, tailscaleServeEnabled: false, tailscaleServePort: 443, }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 7182854e18c..795094ef97e 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -13,6 +13,7 @@ import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { Argument, Flag } from "effect/unstable/cli"; +import { normalizeBasePath } from "@t3tools/shared/basePath"; import { readBootstrapEnvelope } from "../bootstrap.ts"; import { DEFAULT_PORT, @@ -42,6 +43,10 @@ export const baseDirFlag = Flag.string("base-dir").pipe( Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."), Flag.optional, ); +export const basePathFlag = Flag.string("base-path").pipe( + Flag.withDescription("Path prefix to mount the web app and backend under."), + Flag.optional, +); export const devUrlFlag = Flag.string("dev-url").pipe( Flag.withSchema(Schema.URLFromString), Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), @@ -110,6 +115,10 @@ const EnvServerConfig = Config.all({ ), port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), + basePath: Config.string("T3CODE_BASE_PATH").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)), noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( @@ -142,6 +151,7 @@ export interface CliServerFlags { readonly mode: Option.Option; readonly port: Option.Option; readonly host: Option.Option; + readonly basePath: Option.Option; readonly baseDir: Option.Option; readonly cwd: Option.Option; readonly devUrl: Option.Option; @@ -171,6 +181,7 @@ export const sharedServerCommandFlags = { mode: modeFlag, port: portFlag, host: hostFlag, + basePath: basePathFlag, baseDir: baseDirFlag, cwd: Argument.string("cwd").pipe( Argument.withDescription( @@ -221,6 +232,7 @@ export const resolveServerConfig = ( mode: flags.mode ?? Option.none(), port: flags.port ?? Option.none(), host: flags.host ?? Option.none(), + basePath: flags.basePath ?? Option.none(), baseDir: flags.baseDir ?? Option.none(), cwd: flags.cwd ?? Option.none(), devUrl: flags.devUrl ?? Option.none(), @@ -339,6 +351,11 @@ export const resolveServerConfig = ( ), () => (mode === "desktop" ? "127.0.0.1" : undefined), ); + const basePath = yield* normalizeBasePath( + Option.getOrUndefined( + resolveOptionPrecedence(normalizedFlags.basePath, Option.fromUndefinedOr(env.basePath)), + ), + ); const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); const config: ServerConfigShape = { @@ -361,6 +378,7 @@ export const resolveServerConfig = ( mode, port, cwd, + basePath, baseDir, ...derivedPaths, serverTracePath, @@ -388,6 +406,7 @@ export const resolveCliAuthConfig = ( mode: Option.none(), port: Option.none(), host: Option.none(), + basePath: Option.none(), baseDir: flags.baseDir, cwd: Option.none(), devUrl: flags.devUrl ?? Option.none(), diff --git a/apps/server/src/cliAuthFormat.ts b/apps/server/src/cliAuthFormat.ts index 4078860ff94..8dbd632e4a4 100644 --- a/apps/server/src/cliAuthFormat.ts +++ b/apps/server/src/cliAuthFormat.ts @@ -1,5 +1,7 @@ import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; +import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; import type { IssuedBearerSession, IssuedPairingLink } from "./auth/Services/AuthControlPlane.ts"; @@ -34,7 +36,8 @@ export function formatIssuedPairingCredential( const pairUrl = options?.baseUrl != null && options.baseUrl.length > 0 ? (() => { - const url = new URL("/pair", options.baseUrl); + const url = new URL(options.baseUrl); + url.pathname = joinBasePath(Effect.runSync(normalizeBasePath(url.pathname)), "/pair"); url.searchParams.delete("token"); url.hash = new URLSearchParams([["token", credential.credential]]).toString(); return url.toString(); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index b0a23cb273c..a035af665f6 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -14,6 +14,8 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; +import { ROOT_BASE_PATH, type NormalizedBasePath } from "@t3tools/shared/basePath"; + export const DEFAULT_PORT = 3773; export const RuntimeMode = Schema.Literals(["web", "desktop"]); @@ -62,6 +64,7 @@ export interface ServerConfigShape extends ServerDerivedPaths { readonly mode: RuntimeMode; readonly port: number; readonly host: string | undefined; + readonly basePath: NormalizedBasePath; readonly cwd: string; readonly baseDir: string; readonly staticDir: string | undefined; @@ -170,6 +173,7 @@ export class ServerConfig extends Context.Service`; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); +const INDEX_HTML_FILE_NAME = "index.html"; export const browserApiCorsLayer = HttpRouter.cors({ allowedMethods: [...browserApiCorsAllowedMethods], @@ -61,6 +64,19 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { return redirectUrl.toString(); } +function rewriteIndexHtmlAssetUrls(html: string, basePath: NormalizedBasePath): string { + const assetBase = basePath === "" ? "" : basePath; + return html.replace( + /\b(src|href)=(["'])\.\/([^"']+)\2/gu, + (_match, attribute: string, quote: string, pathname: string) => + `${attribute}=${quote}${assetBase}/${pathname}${quote}`, + ); +} + +function prepareIndexHtml(html: string, basePath: NormalizedBasePath): string { + return rewriteIndexHtmlAssetUrls(html, basePath); +} + const requireAuthenticatedRequest = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* ServerAuth; @@ -150,7 +166,9 @@ export const attachmentsRouteLayer = HttpRouter.add( const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); if (!normalizedRelativePath) { - return HttpServerResponse.text("Invalid attachment path", { status: 400 }); + return HttpServerResponse.text("Invalid attachment path", { + status: 400, + }); } const isIdLookup = @@ -232,93 +250,134 @@ export const projectFaviconRouteLayer = HttpRouter.add( }).pipe(Effect.catchTag("AuthError", respondToAuthError)), ); -export const staticAndDevRouteLayer = HttpRouter.add( - "GET", - "*", +export const staticAndDevRouteLayer = Layer.unwrap( Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const url = HttpServerRequest.toURL(request); - - if (Option.isNone(url)) { - return HttpServerResponse.text("Bad Request", { status: 400 }); - } - const config = yield* ServerConfig; - if (config.devUrl && isLoopbackHostname(url.value.hostname)) { - return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { - status: 302, - }); - } - - const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : undefined); - if (!staticDir) { - return HttpServerResponse.text("No static directory configured and no dev URL set.", { - status: 503, - }); - } - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const staticRoot = path.resolve(staticDir); - const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname; - const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); - const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); - const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, ""); - const hasPathTraversalSegment = staticRelativePath.startsWith(".."); - if ( - staticRelativePath.length === 0 || - hasRawLeadingParentSegment || - hasPathTraversalSegment || - staticRelativePath.includes("\0") - ) { - return HttpServerResponse.text("Invalid static file path", { status: 400 }); + const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : undefined); + const staticRoot = staticDir ? path.resolve(staticDir) : null; + const indexHtml = + staticRoot === null + ? null + : yield* fileSystem + .readFileString(path.resolve(staticRoot, INDEX_HTML_FILE_NAME)) + .pipe(Effect.catch(() => Effect.succeed(null))); + + const indexHtmlResponse = + indexHtml === null + ? HttpServerResponse.text("Not Found", { status: 404 }) + : HttpServerResponse.text(prepareIndexHtml(indexHtml, config.basePath), { + status: 200, + contentType: "text/html; charset=utf-8", + }); + + if (staticRoot === null) { + return HttpRouter.add( + "GET", + "*", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + + if (Option.isNone(url)) { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } + + if (config.devUrl && isLoopbackHostname(url.value.hostname)) { + return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { + status: 302, + }); + } + + return HttpServerResponse.text("No static directory configured and no dev URL set.", { + status: 503, + }); + }), + ); } const isWithinStaticRoot = (candidate: string) => candidate === staticRoot || candidate.startsWith(staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`); - let filePath = path.resolve(staticRoot, staticRelativePath); - if (!isWithinStaticRoot(filePath)) { - return HttpServerResponse.text("Invalid static file path", { status: 400 }); - } - - const ext = path.extname(filePath); - if (!ext) { - filePath = path.resolve(filePath, "index.html"); - if (!isWithinStaticRoot(filePath)) { - return HttpServerResponse.text("Invalid static file path", { status: 400 }); - } - } - - const fileInfo = yield* fileSystem - .stat(filePath) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (!fileInfo || fileInfo.type !== "File") { - const indexPath = path.resolve(staticRoot, "index.html"); - const indexData = yield* fileSystem - .readFile(indexPath) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (!indexData) { - return HttpServerResponse.text("Not Found", { status: 404 }); - } - return HttpServerResponse.uint8Array(indexData, { - status: 200, - contentType: "text/html; charset=utf-8", - }); - } - - const contentType = Mime.getType(filePath) ?? "application/octet-stream"; - const data = yield* fileSystem - .readFile(filePath) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (!data) { - return HttpServerResponse.text("Internal Server Error", { status: 500 }); - } - - return HttpServerResponse.uint8Array(data, { - status: 200, - contentType, - }); + return HttpRouter.add( + "GET", + "*", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + + if (Option.isNone(url)) { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } + + if (config.devUrl && isLoopbackHostname(url.value.hostname)) { + return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { + status: 302, + }); + } + + const staticRequestPath = + url.value.pathname === "/" ? `/${INDEX_HTML_FILE_NAME}` : url.value.pathname; + const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); + const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); + const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, ""); + const hasPathTraversalSegment = staticRelativePath.startsWith(".."); + if ( + staticRelativePath.length === 0 || + hasRawLeadingParentSegment || + hasPathTraversalSegment || + staticRelativePath.includes("\0") + ) { + return HttpServerResponse.text("Invalid static file path", { + status: 400, + }); + } + + let filePath = path.resolve(staticRoot, staticRelativePath); + if (!isWithinStaticRoot(filePath)) { + return HttpServerResponse.text("Invalid static file path", { + status: 400, + }); + } + + const ext = path.extname(filePath); + if (!ext) { + filePath = path.resolve(filePath, INDEX_HTML_FILE_NAME); + if (!isWithinStaticRoot(filePath)) { + return HttpServerResponse.text("Invalid static file path", { + status: 400, + }); + } + } + + const fileInfo = yield* fileSystem + .stat(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!fileInfo || fileInfo.type !== "File") { + return indexHtmlResponse; + } + + const contentType = Mime.getType(filePath) ?? "application/octet-stream"; + if (path.basename(filePath) === INDEX_HTML_FILE_NAME) { + return indexHtmlResponse; + } + + const data = yield* fileSystem + .readFile(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!data) { + return HttpServerResponse.text("Internal Server Error", { + status: 500, + }); + } + + return HttpServerResponse.uint8Array(data, { + status: 200, + contentType, + }); + }), + ); }), ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index ae920aeb88e..41b31528e5a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -25,6 +25,7 @@ import { WsRpcGroup, EditorId, } from "@t3tools/contracts"; +import { ROOT_BASE_PATH } from "@t3tools/shared/basePath"; import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; import * as Clock from "effect/Clock"; @@ -372,6 +373,7 @@ const buildAppUnderTest = (options?: { desktopBootstrapToken: defaultDesktopBootstrapToken, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, + basePath: ROOT_BASE_PATH, tailscaleServeEnabled: false, tailscaleServePort: 443, ...options?.config, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 13516c77259..2953a782ee3 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -321,6 +321,20 @@ export const makeRoutesLayer = Layer.mergeAll( websocketRpcRouteLayer, ).pipe(Layer.provide(browserApiCorsLayer)); +const makeMountedRoutesLayer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig; + if (config.basePath === "") { + return makeRoutesLayer; + } + + const router = yield* HttpRouter.HttpRouter; + return makeRoutesLayer.pipe( + Layer.provide(Layer.succeed(HttpRouter.HttpRouter)(router.prefixed(config.basePath))), + ); + }), +); + export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig; @@ -408,7 +422,7 @@ export const makeServerLayer = Layer.unwrap( : Layer.empty; const serverApplicationLayer = Layer.mergeAll( - HttpRouter.serve(makeRoutesLayer, { + HttpRouter.serve(makeMountedRoutesLayer, { disableLogger: !config.logWebSocketEvents, }), httpListeningLayer, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index c069623ca8f..543d524101c 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -250,7 +250,9 @@ const resolveStartupBrowserTarget = Effect.gen(function* () { serverConfig.host && !isWildcardHost(serverConfig.host) ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}` : localUrl; - const baseTarget = serverConfig.devUrl?.toString() ?? bindUrl; + const baseTarget = + serverConfig.devUrl?.toString() ?? + `${bindUrl}${serverConfig.basePath === "" ? "" : `${serverConfig.basePath}/`}`; return yield* Effect.succeed(serverConfig.mode === "desktop" ? baseTarget : undefined).pipe( Effect.flatMap((target) => target ? Effect.succeed(target) : serverAuth.issueStartupPairingUrl(baseTarget), diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index d3b6898d75b..4a5c09a7ebe 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,6 +1,13 @@ import { networkInterfaces } from "node:os"; import { QrCode } from "@t3tools/shared/qrCode"; +import { + ROOT_BASE_PATH, + joinBasePath, + normalizeBasePath, + type NormalizedBasePath, +} from "@t3tools/shared/basePath"; +import { probeTailscaleHttpsEndpoint, resolveTailscaleHttpsBaseUrl } from "@t3tools/tailscale"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/unstable/http"; @@ -71,10 +78,15 @@ export const resolveHeadlessConnectionHost = ( export const resolveHeadlessConnectionString = ( host: string | undefined, port: number, + basePathOrInterfaces: NormalizedBasePath | NetworkInterfacesMap = ROOT_BASE_PATH, interfaces: NetworkInterfacesMap = networkInterfaces(), ): string => { - const connectionHost = resolveHeadlessConnectionHost(host, interfaces); - return `http://${formatHostForUrl(connectionHost)}:${port}`; + const basePath = typeof basePathOrInterfaces === "string" ? basePathOrInterfaces : ROOT_BASE_PATH; + const networkInterfacesMap = + typeof basePathOrInterfaces === "string" ? interfaces : basePathOrInterfaces; + const connectionHost = resolveHeadlessConnectionHost(host, networkInterfacesMap); + const connectionPath = basePath === "" ? "" : `${basePath}/`; + return `http://${formatHostForUrl(connectionHost)}:${port}${connectionPath}`; }; export const resolveListeningPort = (address: unknown, fallbackPort: number): number => { @@ -91,7 +103,7 @@ export const resolveListeningPort = (address: unknown, fallbackPort: number): nu export const buildPairingUrl = (connectionString: string, token: string): string => { const url = new URL(connectionString); - url.pathname = "/pair"; + url.pathname = joinBasePath(Effect.runSync(normalizeBasePath(url.pathname)), "/pair"); url.searchParams.delete("token"); url.hash = new URLSearchParams([["token", token]]).toString(); return url.toString(); @@ -134,10 +146,38 @@ export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessI const serverConfig = yield* ServerConfig; const httpServer = yield* HttpServer.HttpServer; const serverAuth = yield* ServerAuth; - const connectionString = resolveHeadlessConnectionString( + const localConnectionString = resolveHeadlessConnectionString( serverConfig.host, resolveListeningPort(httpServer.address, serverConfig.port), + serverConfig.basePath, ); + const tailscaleConnectionString = serverConfig.tailscaleServeEnabled + ? yield* resolveTailscaleHttpsBaseUrl({ + servePort: serverConfig.tailscaleServePort, + basePath: serverConfig.basePath, + }).pipe( + Effect.flatMap((baseUrl) => + baseUrl + ? probeTailscaleHttpsEndpoint({ baseUrl }).pipe( + Effect.flatMap((isReachable) => + isReachable + ? Effect.succeed(baseUrl) + : Effect.logWarning( + "Tailscale HTTPS endpoint did not pass readiness probe; using local connection string.", + { baseUrl }, + ).pipe(Effect.as(null)), + ), + ) + : Effect.succeed(null), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to resolve Tailscale HTTPS base URL", { cause }).pipe( + Effect.as(null), + ), + ), + ) + : null; + const connectionString = tailscaleConnectionString ?? localConnectionString; const issued = yield* serverAuth.issuePairingCredential({ role: "owner" }); return { diff --git a/apps/web/index.html b/apps/web/index.html index 88e1c8b4f23..7d7b4deff74 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -9,8 +9,8 @@ - - + + + diff --git a/apps/web/src/basePath.ts b/apps/web/src/basePath.ts new file mode 100644 index 00000000000..ce831bc88ce --- /dev/null +++ b/apps/web/src/basePath.ts @@ -0,0 +1,15 @@ +import * as Effect from "effect/Effect"; + +import { + ROOT_BASE_PATH, + normalizeBasePath, + type NormalizedBasePath, +} from "@t3tools/shared/basePath"; + +export function readRuntimeBasePath(): NormalizedBasePath { + const moduleUrl = new URL(import.meta.url); + if (moduleUrl.protocol !== "http:" && moduleUrl.protocol !== "https:") { + return ROOT_BASE_PATH; + } + return Effect.runSync(normalizeBasePath(new URL("..", moduleUrl).pathname)); +} diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d4eb3da3263..c28f84ff94d 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -23,6 +23,7 @@ import { ServerConfig as ServerConfigSchema, } from "@t3tools/contracts"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import { ROOT_BASE_PATH } from "@t3tools/shared/basePath"; import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import * as Option from "effect/Option"; @@ -1619,6 +1620,7 @@ async function mountChatView(options: { createMemoryHistory({ initialEntries: [options.initialPath ?? `/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`], }), + ROOT_BASE_PATH, ); const screen = await render( diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 4320f6ecf4a..69966d88b7a 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -16,6 +16,7 @@ import { type ThreadId, WS_METHODS, } from "@t3tools/contracts"; +import { ROOT_BASE_PATH } from "@t3tools/shared/basePath"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { ws, http, HttpResponse } from "msw"; import { setupWorker } from "msw/browser"; @@ -458,6 +459,7 @@ async function mountApp(): Promise<{ cleanup: () => Promise }> { const router = getRouter( createMemoryHistory({ initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`] }), + ROOT_BASE_PATH, ); const screen = await render( diff --git a/apps/web/src/components/SplashScreen.tsx b/apps/web/src/components/SplashScreen.tsx index a0b593a9507..ed35097bdb8 100644 --- a/apps/web/src/components/SplashScreen.tsx +++ b/apps/web/src/components/SplashScreen.tsx @@ -2,7 +2,7 @@ export function SplashScreen() { return (
- T3 Code + T3 Code
); diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx index 65e9c6dd8eb..236bffc60b9 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -9,6 +9,7 @@ import { submitServerAuthCredential, } from "../../environments/primary"; import { readHostedPairingRequest } from "../../hostedPairing"; +import { readRuntimeBasePath } from "../../basePath"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -272,7 +273,11 @@ export function HostedPairingRouteSurface() { ) : null} {status === "paired" ? ( - ) : null} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 8549b42b526..815b809d5d1 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -72,6 +72,7 @@ import { } from "../ui/menu"; import { Textarea } from "../ui/textarea"; import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; +import { readRuntimeBasePath } from "../../basePath"; import { readHostedPairingRequest } from "../../hostedPairing"; import { createServerPairingCredential, @@ -480,7 +481,7 @@ function resolveAdvertisedEndpointPairingUrl( } function resolveCurrentOriginPairingUrl(credential: string): string { - const url = new URL("/pair", window.location.href); + const url = new URL(`${readRuntimeBasePath()}/pair`, window.location.href); return setPairingTokenOnUrl(url, credential).toString(); } diff --git a/apps/web/src/components/settings/pairingUrls.ts b/apps/web/src/components/settings/pairingUrls.ts index 891fe04ad6b..d31340878d8 100644 --- a/apps/web/src/components/settings/pairingUrls.ts +++ b/apps/web/src/components/settings/pairingUrls.ts @@ -1,9 +1,12 @@ +import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; +import * as Effect from "effect/Effect"; + import { buildHostedPairingUrl } from "../../hostedPairing"; import { setPairingTokenOnUrl } from "../../pairingUrl"; export function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { const url = new URL(endpointUrl); - url.pathname = "/pair"; + url.pathname = joinBasePath(Effect.runSync(normalizeBasePath(url.pathname)), "/pair"); return setPairingTokenOnUrl(url, credential).toString(); } diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index 04b7d903d4b..9dddd98995c 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -1,5 +1,9 @@ import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; import type { KnownEnvironment } from "@t3tools/client-runtime"; +import * as Effect from "effect/Effect"; +import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; + +import { readRuntimeBasePath } from "../../basePath"; export interface PrimaryEnvironmentTarget { readonly source: KnownEnvironment["source"]; @@ -91,7 +95,7 @@ function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { } function resolveWindowOriginPrimaryTarget(): PrimaryEnvironmentTarget { - const httpBaseUrl = normalizeBaseUrl(window.location.origin); + const httpBaseUrl = `${window.location.origin}${readRuntimeBasePath()}/`; const url = new URL(httpBaseUrl); if (url.protocol === "http:") { url.protocol = "ws:"; @@ -142,7 +146,9 @@ export function resolvePrimaryEnvironmentHttpUrl( } const url = new URL(resolveHttpRequestBaseUrl(primaryTarget.target.httpBaseUrl)); - url.pathname = pathname; + url.pathname = joinBasePath(Effect.runSync(normalizeBasePath(url.pathname)), pathname); + url.search = ""; + url.hash = ""; if (searchParams) { url.search = new URLSearchParams(searchParams).toString(); } diff --git a/apps/web/src/environments/runtime/catalog.ts b/apps/web/src/environments/runtime/catalog.ts index 7ece1ccb0ca..403740daf3b 100644 --- a/apps/web/src/environments/runtime/catalog.ts +++ b/apps/web/src/environments/runtime/catalog.ts @@ -1,4 +1,6 @@ import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime"; +import * as Effect from "effect/Effect"; +import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; import type { AuthSessionRole, EnvironmentId, @@ -220,7 +222,9 @@ export function resolveEnvironmentHttpUrl(input: { } const url = new URL(httpBaseUrl); - url.pathname = input.pathname; + url.pathname = joinBasePath(Effect.runSync(normalizeBasePath(url.pathname)), input.pathname); + url.search = ""; + url.hash = ""; if (input.searchParams) { url.search = new URLSearchParams(input.searchParams).toString(); } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 68a7dfaa931..c566c64331a 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -10,11 +10,12 @@ import { isElectron } from "./env"; import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; +import { readRuntimeBasePath } from "./basePath"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); -const router = getRouter(history); +const router = getRouter(history, readRuntimeBasePath()); if (isElectron) { syncDocumentWindowControlsOverlayClass(); diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 84beaf9fc4e..801a1433b4f 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -1,16 +1,18 @@ import { createElement } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRouter, RouterHistory } from "@tanstack/react-router"; +import type { NormalizedBasePath } from "@t3tools/shared/basePath"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import { routeTree } from "./routeTree.gen"; -export function getRouter(history: RouterHistory) { +export function getRouter(history: RouterHistory, basepath: NormalizedBasePath) { const queryClient = new QueryClient(); return createRouter({ routeTree, history, + basepath, context: { queryClient, }, diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 98a125bdfe4..14779fb22a0 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, Link } from "@tanstack/react-router"; import { LinkIcon, PlusIcon } from "lucide-react"; import { NoActiveThreadState } from "../components/NoActiveThreadState"; @@ -52,7 +52,7 @@ function HostedStaticOnboardingState() { manually. Your saved environments stay in this browser.
- diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 38819e28d73..73fcae040d1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -56,6 +56,7 @@ function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { const devProxyTarget = resolveDevProxyTarget(configuredWsUrl); export default defineConfig({ + base: "", plugins: [ tanstackRouter(), react(), diff --git a/bun.lock b/bun.lock index 29c54444357..79fd9e79919 100644 --- a/bun.lock +++ b/bun.lock @@ -290,6 +290,7 @@ "name": "@t3tools/tailscale", "dependencies": { "@effect/platform-node": "catalog:", + "@t3tools/shared": "workspace:*", "effect": "catalog:", }, "devDependencies": { diff --git a/packages/client-runtime/src/advertisedEndpoint.test.ts b/packages/client-runtime/src/advertisedEndpoint.test.ts index 1cbfde87bd3..75d041b5356 100644 --- a/packages/client-runtime/src/advertisedEndpoint.test.ts +++ b/packages/client-runtime/src/advertisedEndpoint.test.ts @@ -16,9 +16,11 @@ const coreProvider = { describe("advertised endpoint helpers", () => { it("normalizes HTTP and WebSocket base URLs", () => { - expect(normalizeHttpBaseUrl("https://example.com/path?x=1#hash")).toBe("https://example.com/"); - expect(normalizeHttpBaseUrl("wss://example.com/socket")).toBe("https://example.com/"); - expect(deriveWsBaseUrl("https://example.com/api")).toBe("wss://example.com/"); + expect(normalizeHttpBaseUrl("https://example.com/path?x=1#hash")).toBe( + "https://example.com/path/", + ); + expect(normalizeHttpBaseUrl("wss://example.com/socket")).toBe("https://example.com/socket/"); + expect(deriveWsBaseUrl("https://example.com/api")).toBe("wss://example.com/api/"); expect(deriveWsBaseUrl("http://127.0.0.1:3773")).toBe("ws://127.0.0.1:3773/"); }); diff --git a/packages/client-runtime/src/remote.ts b/packages/client-runtime/src/remote.ts index 34dc5aef249..af423f3e25d 100644 --- a/packages/client-runtime/src/remote.ts +++ b/packages/client-runtime/src/remote.ts @@ -13,6 +13,7 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { identity } from "effect/Function"; +import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; import { FetchHttpClient, HttpClient, @@ -25,10 +26,11 @@ const RemoteAuthErrorBody = Schema.Struct({ error: Schema.optional(Schema.String), }); const decodeRemoteAuthErrorBody = decodeJsonResult(RemoteAuthErrorBody); +const WS_RPC_PATH = "/ws"; const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { const url = new URL(httpBaseUrl); - url.pathname = pathname; + url.pathname = joinBasePath(Effect.runSync(normalizeBasePath(url.pathname)), pathname); url.search = ""; url.hash = ""; return url.toString(); @@ -261,9 +263,14 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( }); const url = new URL(input.wsBaseUrl); - if (url.pathname === "" || url.pathname === "/") { - url.pathname = "/ws"; - } + const pathname = url.pathname.replace(/\/+$/u, ""); + const basePathname = + pathname === WS_RPC_PATH || pathname.endsWith(`${WS_RPC_PATH}`) + ? pathname.slice(0, -WS_RPC_PATH.length) || "/" + : pathname; + const basePath = yield* normalizeBasePath(basePathname); + url.pathname = joinBasePath(basePath, "/ws"); + url.hash = ""; url.searchParams.set("wsToken", issued.token); return url.toString(); }); diff --git a/packages/client-runtime/src/wsRpcProtocol.ts b/packages/client-runtime/src/wsRpcProtocol.ts index 283512a3ecb..c5389d367a8 100644 --- a/packages/client-runtime/src/wsRpcProtocol.ts +++ b/packages/client-runtime/src/wsRpcProtocol.ts @@ -1,4 +1,5 @@ import { WsRpcGroup } from "@t3tools/contracts"; +import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -12,6 +13,8 @@ import { type ReconnectBackoffConfig, } from "./reconnectBackoff.ts"; +const WS_RPC_PATH = "/ws"; + export interface WsProtocolLifecycleHandlers { readonly getConnectionLabel?: () => string | null; readonly getVersionMismatchHint?: () => string | null; @@ -83,7 +86,13 @@ function resolveWsRpcSocketUrl(rawUrl: string): string { throw new Error(`Unsupported websocket transport URL protocol: ${resolved.protocol}`); } - resolved.pathname = "/ws"; + const pathname = resolved.pathname.replace(/\/+$/u, ""); + const basePathname = + pathname === WS_RPC_PATH || pathname.endsWith(`${WS_RPC_PATH}`) + ? pathname.slice(0, -WS_RPC_PATH.length) || "/" + : pathname; + resolved.pathname = joinBasePath(Effect.runSync(normalizeBasePath(basePathname)), WS_RPC_PATH); + resolved.hash = ""; return resolved.toString(); } diff --git a/packages/shared/package.json b/packages/shared/package.json index 9a38b19bbe0..a37d9ea465a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -11,6 +11,10 @@ "types": "./src/advertisedEndpoint.ts", "import": "./src/advertisedEndpoint.ts" }, + "./basePath": { + "types": "./src/basePath.ts", + "import": "./src/basePath.ts" + }, "./git": { "types": "./src/git.ts", "import": "./src/git.ts" diff --git a/packages/shared/src/advertisedEndpoint.ts b/packages/shared/src/advertisedEndpoint.ts index 314d8272c81..67c9fc4b65a 100644 --- a/packages/shared/src/advertisedEndpoint.ts +++ b/packages/shared/src/advertisedEndpoint.ts @@ -6,6 +6,9 @@ import type { AdvertisedEndpointSource, AdvertisedEndpointStatus, } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { normalizeBasePath } from "./basePath.ts"; export interface CreateAdvertisedEndpointInput { readonly id: string; @@ -28,12 +31,10 @@ export function normalizeHttpBaseUrl(rawValue: string): string { } else if (url.protocol === "wss:") { url.protocol = "https:"; } - if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error(`Endpoint must use HTTP or HTTPS. Received ${url.protocol}`); } - - url.pathname = "/"; + url.pathname = `${Effect.runSync(normalizeBasePath(url.pathname))}/`; url.search = ""; url.hash = ""; return url.toString(); diff --git a/packages/shared/src/basePath.ts b/packages/shared/src/basePath.ts new file mode 100644 index 00000000000..cb0798fa05c --- /dev/null +++ b/packages/shared/src/basePath.ts @@ -0,0 +1,59 @@ +import * as Brand from "effect/Brand"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; + +export class BasePathParseError extends Data.TaggedError("BasePathParseError")<{ + readonly value: string; +}> { + override get message(): string { + return `Invalid base path: ${this.value || ""}`; + } +} + +export type NormalizedBasePath = Brand.Branded; +export const NormalizedBasePath = Brand.nominal(); +export const ROOT_BASE_PATH: NormalizedBasePath = NormalizedBasePath(""); + +export const normalizeBasePath = ( + rawValue: string | null | undefined, +): Effect.Effect => + Effect.suspend(() => { + const value = rawValue?.trim() ?? ""; + if (value.length === 0 || value === "/") { + return Effect.succeed(ROOT_BASE_PATH); + } + + if (!value.startsWith("/") || value.includes("?") || value.includes("#")) { + return Effect.fail(new BasePathParseError({ value })); + } + + const normalized = value.replace(/\/+$/u, ""); + const segments = normalized.slice(1).split("/"); + if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) { + return Effect.fail(new BasePathParseError({ value })); + } + + return Effect.succeed(NormalizedBasePath(normalized)); + }); + +export function joinBasePath(basePath: NormalizedBasePath, pathname: string): string { + const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`; + return `${basePath}${normalizedPathname}`; +} + +export function stripBasePathFromPathname( + basePath: NormalizedBasePath, + pathname: string, +): string | null { + if (basePath === "") { + return pathname.startsWith("/") ? pathname : `/${pathname}`; + } + if (pathname === basePath) { + return "/"; + } + if (pathname.startsWith(`${basePath}/`)) { + const stripped = pathname.slice(basePath.length); + return stripped.length === 0 ? "/" : stripped; + } + return null; +} diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index c2d6079680d..8b334d8d0c6 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -1,3 +1,7 @@ +import * as Effect from "effect/Effect"; + +import { normalizeBasePath } from "./basePath.ts"; + const PAIRING_TOKEN_PARAM = "token"; const HOSTED_PAIRING_HOST_PARAM = "host"; const HOSTED_PAIRING_LABEL_PARAM = "label"; @@ -16,7 +20,6 @@ const normalizeRemoteBaseUrl = (rawValue: string): URL => { ? trimmed : `https://${trimmed}`; const url = new URL(normalizedInput); - url.pathname = "/"; url.search = ""; url.hash = ""; return url; @@ -29,23 +32,28 @@ const toHttpBaseUrl = (url: URL): string => { } else if (next.protocol === "wss:") { next.protocol = "https:"; } - next.pathname = "/"; + next.pathname = `${Effect.runSync(normalizeBasePath(next.pathname))}/`; next.search = ""; next.hash = ""; return next.toString(); }; const toWsBaseUrl = (url: URL): string => { + const next = new URL(toHttpBaseUrl(url)); + next.protocol = next.protocol === "https:" ? "wss:" : "ws:"; + return next.toString(); +}; + +const toHttpBaseUrlFromPathUrl = (url: URL, pathSuffix: string): string => { const next = new URL(url.toString()); - if (next.protocol === "http:") { - next.protocol = "ws:"; - } else if (next.protocol === "https:") { - next.protocol = "wss:"; + const normalizedSuffix = pathSuffix.startsWith("/") ? pathSuffix : `/${pathSuffix}`; + const pathname = next.pathname.replace(/\/+$/u, ""); + if (pathname === normalizedSuffix) { + next.pathname = "/"; + } else if (pathname.endsWith(normalizedSuffix)) { + next.pathname = pathname.slice(0, -normalizedSuffix.length) || "/"; } - next.pathname = "/"; - next.search = ""; - next.hash = ""; - return next.toString(); + return toHttpBaseUrl(next); }; export interface ResolvedRemotePairingTarget { @@ -126,10 +134,11 @@ export const resolveRemotePairingTarget = (input: { if (!credential) { throw new Error("Pairing URL is missing its token."); } + const httpBaseUrl = toHttpBaseUrlFromPathUrl(url, "/pair"); return { credential, - httpBaseUrl: toHttpBaseUrl(url), - wsBaseUrl: toWsBaseUrl(url), + httpBaseUrl, + wsBaseUrl: toWsBaseUrl(new URL(httpBaseUrl)), }; } diff --git a/packages/tailscale/package.json b/packages/tailscale/package.json index 4109505a4e7..fa048d07a02 100644 --- a/packages/tailscale/package.json +++ b/packages/tailscale/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@t3tools/shared": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index c8d9cab462d..7e1c7d4af62 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -5,6 +5,12 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { + ROOT_BASE_PATH, + joinBasePath, + normalizeBasePath, + type NormalizedBasePath, +} from "@t3tools/shared/basePath"; export const DEFAULT_TAILSCALE_SERVE_PORT = 443; export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500; @@ -192,13 +198,14 @@ export const readTailscaleStatus: Effect.Effect< export function buildTailscaleHttpsBaseUrl(input: { readonly magicDnsName: string; readonly servePort?: number; + readonly basePath?: NormalizedBasePath; }): string { const url = new URL(`https://${input.magicDnsName}`); const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; if (servePort !== DEFAULT_TAILSCALE_SERVE_PORT) { url.port = String(servePort); } - url.pathname = "/"; + url.pathname = `${input.basePath ?? ROOT_BASE_PATH}/`; return url.toString(); } @@ -295,7 +302,11 @@ export const probeTailscaleHttpsEndpoint = (input: { Effect.gen(function* () { const client = yield* HttpClient.HttpClient; const response = yield* Effect.gen(function* () { - const url = new URL("/.well-known/t3/environment", input.baseUrl); + const url = new URL(input.baseUrl); + const basePath = yield* normalizeBasePath(url.pathname); + url.pathname = joinBasePath(basePath, "/.well-known/t3/environment"); + url.search = ""; + url.hash = ""; const request = HttpClientRequest.get(url.toString()); return yield* client.execute(request); }).pipe(Effect.timeoutOption(input.timeoutMs ?? TAILSCALE_PROBE_TIMEOUT_MS)); @@ -309,6 +320,7 @@ export const probeTailscaleHttpsEndpoint = (input: { export const resolveTailscaleHttpsBaseUrl = ( input: { readonly servePort?: number; + readonly basePath?: NormalizedBasePath; } = {}, ): Effect.Effect< string | null, @@ -321,6 +333,7 @@ export const resolveTailscaleHttpsBaseUrl = ( ? buildTailscaleHttpsBaseUrl({ magicDnsName: status.magicDnsName, ...(input.servePort === undefined ? {} : { servePort: input.servePort }), + ...(input.basePath === undefined ? {} : { basePath: input.basePath }), }) : null, ), From cb5ccb85268e32dfbc494e6424408b1eb51cb6e8 Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sun, 31 May 2026 18:09:05 +0300 Subject: [PATCH 2/9] address base path review comments --- apps/server/src/auth/Layers/ServerAuth.ts | 15 +++------ apps/server/src/cli/auth.ts | 9 ++--- apps/server/src/cliAuthFormat.ts | 6 ++-- apps/server/src/http.ts | 23 +++++++------ apps/server/src/server.ts | 30 ++++++++--------- apps/server/src/serverRuntimeStartup.ts | 4 +-- apps/server/src/startupAccess.test.ts | 3 +- apps/server/src/startupAccess.ts | 10 ++---- apps/web/index.html | 8 ++--- apps/web/src/basePath.ts | 4 ++- .../components/auth/PairingRouteSurface.tsx | 4 +-- .../settings/ConnectionsSettings.tsx | 4 +-- apps/web/src/environments/primary/target.ts | 4 +-- apps/web/src/main.tsx | 4 +-- packages/client-runtime/src/remote.ts | 15 ++++----- packages/client-runtime/src/wsRpcProtocol.ts | 12 +++---- packages/shared/src/advertisedEndpoint.ts | 33 ++++++++++++++----- packages/shared/src/basePath.ts | 33 +++++++++++++++++++ packages/shared/src/remote.ts | 10 ++---- 19 files changed, 128 insertions(+), 103 deletions(-) diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index 7db8a65005c..43d872dff49 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -6,7 +6,7 @@ import { type AuthSessionState, type AuthWebSocketTokenResult, } from "@t3tools/contracts"; -import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; +import { joinBasePath } from "@t3tools/shared/basePath"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -29,6 +29,7 @@ import { SessionCredentialService, } from "../Services/SessionCredentialService.ts"; import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts"; +import { ServerConfig } from "../../config.ts"; type BootstrapExchangeResult = { readonly response: AuthBootstrapResult; @@ -68,6 +69,7 @@ export const makeServerAuth = Effect.gen(function* () { const bootstrapCredentials = yield* BootstrapCredentialService; const authControlPlane = yield* AuthControlPlane; const sessions = yield* SessionCredentialService; + const serverConfig = yield* ServerConfig; const descriptor = yield* policy.getDescriptor(); const authenticateToken = (token: string): Effect.Effect => @@ -320,16 +322,7 @@ export const makeServerAuth = Effect.gen(function* () { Effect.gen(function* () { const issued = yield* issuePairingCredential({ role: "owner" }); const url = new URL(baseUrl); - const basePath = yield* normalizeBasePath(url.pathname).pipe( - Effect.mapError( - (cause) => - new AuthError({ - message: "Invalid startup pairing URL base path.", - cause, - }), - ), - ); - url.pathname = joinBasePath(basePath, "/pair"); + url.pathname = joinBasePath(serverConfig.basePath, "/pair"); url.searchParams.delete("token"); url.hash = new URLSearchParams([["token", issued.credential]]).toString(); return url.toString(); diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index d54731b4a24..a9663602854 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -15,7 +15,7 @@ import { formatPairingCredentialList, formatSessionList, } from "../cliAuthFormat.ts"; -import { ServerConfig } from "../config.ts"; +import { ServerConfig, type ServerConfigShape } from "../config.ts"; import { authLocationFlags, type CliAuthLocationFlags, @@ -25,7 +25,7 @@ import { const runWithAuthControlPlane = ( flags: CliAuthLocationFlags, - run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect, + run: (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) => Effect.Effect, options?: { readonly quietLogs?: boolean; }, @@ -36,7 +36,7 @@ const runWithAuthControlPlane = ( const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; return yield* Effect.gen(function* () { const authControlPlane = yield* AuthControlPlane; - return yield* run(authControlPlane); + return yield* run(authControlPlane, config); }).pipe( Effect.provide( Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe( @@ -94,7 +94,7 @@ const pairingCreateCommand = Command.make("create", { Command.withHandler((flags) => runWithAuthControlPlane( flags, - (authControlPlane) => + (authControlPlane, config) => Effect.gen(function* () { const issued = yield* authControlPlane.createPairingLink({ role: "client", @@ -105,6 +105,7 @@ const pairingCreateCommand = Command.make("create", { const output = formatIssuedPairingCredential(issued, { json: flags.json, ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}), + basePath: config.basePath, }); yield* Console.log(output); }), diff --git a/apps/server/src/cliAuthFormat.ts b/apps/server/src/cliAuthFormat.ts index 8dbd632e4a4..8827a2cce0a 100644 --- a/apps/server/src/cliAuthFormat.ts +++ b/apps/server/src/cliAuthFormat.ts @@ -1,7 +1,6 @@ import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; -import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; +import { ROOT_BASE_PATH, joinBasePath, type NormalizedBasePath } from "@t3tools/shared/basePath"; import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; import type { IssuedBearerSession, IssuedPairingLink } from "./auth/Services/AuthControlPlane.ts"; @@ -31,13 +30,14 @@ export function formatIssuedPairingCredential( options?: { readonly json?: boolean; readonly baseUrl?: string; + readonly basePath?: NormalizedBasePath; }, ): string { const pairUrl = options?.baseUrl != null && options.baseUrl.length > 0 ? (() => { const url = new URL(options.baseUrl); - url.pathname = joinBasePath(Effect.runSync(normalizeBasePath(url.pathname)), "/pair"); + url.pathname = joinBasePath(options.basePath ?? ROOT_BASE_PATH, "/pair"); url.searchParams.delete("token"); url.hash = new URLSearchParams([["token", credential.credential]]).toString(); return url.toString(); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 78400a35bea..2d1aaf19151 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -65,18 +65,19 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { } function rewriteIndexHtmlAssetUrls(html: string, basePath: NormalizedBasePath): string { - const assetBase = basePath === "" ? "" : basePath; return html.replace( - /\b(src|href)=(["'])\.\/([^"']+)\2/gu, - (_match, attribute: string, quote: string, pathname: string) => - `${attribute}=${quote}${assetBase}/${pathname}${quote}`, + /\b(src|href)=(["'])(\.\/[^"']+|\/(?!\/)[^"']*)\2/gu, + (_match, attribute: string, quote: string, rawPathname: string) => { + const pathname = rawPathname.startsWith("./") ? `/${rawPathname.slice(2)}` : rawPathname; + const prefixedPathname = + basePath !== "" && pathname !== basePath && !pathname.startsWith(`${basePath}/`) + ? `${basePath}${pathname}` + : pathname; + return `${attribute}=${quote}${prefixedPathname}${quote}`; + }, ); } -function prepareIndexHtml(html: string, basePath: NormalizedBasePath): string { - return rewriteIndexHtmlAssetUrls(html, basePath); -} - const requireAuthenticatedRequest = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* ServerAuth; @@ -166,9 +167,7 @@ export const attachmentsRouteLayer = HttpRouter.add( const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); if (!normalizedRelativePath) { - return HttpServerResponse.text("Invalid attachment path", { - status: 400, - }); + return HttpServerResponse.text("Invalid attachment path", { status: 400 }); } const isIdLookup = @@ -267,7 +266,7 @@ export const staticAndDevRouteLayer = Layer.unwrap( const indexHtmlResponse = indexHtml === null ? HttpServerResponse.text("Not Found", { status: 404 }) - : HttpServerResponse.text(prepareIndexHtml(indexHtml, config.basePath), { + : HttpServerResponse.text(rewriteIndexHtmlAssetUrls(indexHtml, config.basePath), { status: 200, contentType: "text/html; charset=utf-8", }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2953a782ee3..55ab752fe46 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -321,20 +321,6 @@ export const makeRoutesLayer = Layer.mergeAll( websocketRpcRouteLayer, ).pipe(Layer.provide(browserApiCorsLayer)); -const makeMountedRoutesLayer = Layer.unwrap( - Effect.gen(function* () { - const config = yield* ServerConfig; - if (config.basePath === "") { - return makeRoutesLayer; - } - - const router = yield* HttpRouter.HttpRouter; - return makeRoutesLayer.pipe( - Layer.provide(Layer.succeed(HttpRouter.HttpRouter)(router.prefixed(config.basePath))), - ); - }), -); - export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig; @@ -422,9 +408,19 @@ export const makeServerLayer = Layer.unwrap( : Layer.empty; const serverApplicationLayer = Layer.mergeAll( - HttpRouter.serve(makeMountedRoutesLayer, { - disableLogger: !config.logWebSocketEvents, - }), + HttpRouter.serve( + Layer.unwrap( + Effect.gen(function* () { + const router = yield* HttpRouter.HttpRouter; + return makeRoutesLayer.pipe( + Layer.provide(Layer.succeed(HttpRouter.HttpRouter)(router.prefixed(config.basePath))), + ); + }), + ), + { + disableLogger: !config.logWebSocketEvents, + }, + ), httpListeningLayer, runtimeStateLayer, tailscaleServeLayer, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 543d524101c..46f8bd33c5d 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -250,9 +250,7 @@ const resolveStartupBrowserTarget = Effect.gen(function* () { serverConfig.host && !isWildcardHost(serverConfig.host) ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}` : localUrl; - const baseTarget = - serverConfig.devUrl?.toString() ?? - `${bindUrl}${serverConfig.basePath === "" ? "" : `${serverConfig.basePath}/`}`; + const baseTarget = serverConfig.devUrl?.toString() ?? `${bindUrl}${serverConfig.basePath}`; return yield* Effect.succeed(serverConfig.mode === "desktop" ? baseTarget : undefined).pipe( Effect.flatMap((target) => target ? Effect.succeed(target) : serverAuth.issueStartupPairingUrl(baseTarget), diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 03c01170f15..1c2d5e21bd1 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -1,5 +1,6 @@ import { assert, expect, it } from "@effect/vitest"; +import { ROOT_BASE_PATH } from "@t3tools/shared/basePath"; import { buildPairingUrl, formatHeadlessServeOutput, @@ -20,7 +21,7 @@ it("keeps explicit bind hosts in the connection string", () => { }); it("resolves wildcard hosts to a concrete external interface when one is available", () => { - const connectionString = resolveHeadlessConnectionString("0.0.0.0", 3773, { + const connectionString = resolveHeadlessConnectionString("0.0.0.0", 3773, ROOT_BASE_PATH, { en0: [ { address: "192.168.1.42", diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 4a5c09a7ebe..22a9309b188 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -78,15 +78,11 @@ export const resolveHeadlessConnectionHost = ( export const resolveHeadlessConnectionString = ( host: string | undefined, port: number, - basePathOrInterfaces: NormalizedBasePath | NetworkInterfacesMap = ROOT_BASE_PATH, + basePath: NormalizedBasePath = ROOT_BASE_PATH, interfaces: NetworkInterfacesMap = networkInterfaces(), ): string => { - const basePath = typeof basePathOrInterfaces === "string" ? basePathOrInterfaces : ROOT_BASE_PATH; - const networkInterfacesMap = - typeof basePathOrInterfaces === "string" ? interfaces : basePathOrInterfaces; - const connectionHost = resolveHeadlessConnectionHost(host, networkInterfacesMap); - const connectionPath = basePath === "" ? "" : `${basePath}/`; - return `http://${formatHostForUrl(connectionHost)}:${port}${connectionPath}`; + const connectionHost = resolveHeadlessConnectionHost(host, interfaces); + return `http://${formatHostForUrl(connectionHost)}:${port}${basePath}`; }; export const resolveListeningPort = (address: unknown, fallbackPort: number): number => { diff --git a/apps/web/index.html b/apps/web/index.html index 7d7b4deff74..88e1c8b4f23 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -9,8 +9,8 @@ - - + + + diff --git a/apps/web/src/basePath.ts b/apps/web/src/basePath.ts index ce831bc88ce..7f1aa1039fc 100644 --- a/apps/web/src/basePath.ts +++ b/apps/web/src/basePath.ts @@ -6,10 +6,12 @@ import { type NormalizedBasePath, } from "@t3tools/shared/basePath"; -export function readRuntimeBasePath(): NormalizedBasePath { +function resolveRuntimeBasePath(): NormalizedBasePath { const moduleUrl = new URL(import.meta.url); if (moduleUrl.protocol !== "http:" && moduleUrl.protocol !== "https:") { return ROOT_BASE_PATH; } return Effect.runSync(normalizeBasePath(new URL("..", moduleUrl).pathname)); } + +export const runtimeBasePath = resolveRuntimeBasePath(); diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx index 236bffc60b9..2b5f4efb216 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -9,7 +9,7 @@ import { submitServerAuthCredential, } from "../../environments/primary"; import { readHostedPairingRequest } from "../../hostedPairing"; -import { readRuntimeBasePath } from "../../basePath"; +import { runtimeBasePath } from "../../basePath"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -276,7 +276,7 @@ export function HostedPairingRouteSurface() { diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 815b809d5d1..306700565c8 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -72,7 +72,7 @@ import { } from "../ui/menu"; import { Textarea } from "../ui/textarea"; import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; -import { readRuntimeBasePath } from "../../basePath"; +import { runtimeBasePath } from "../../basePath"; import { readHostedPairingRequest } from "../../hostedPairing"; import { createServerPairingCredential, @@ -481,7 +481,7 @@ function resolveAdvertisedEndpointPairingUrl( } function resolveCurrentOriginPairingUrl(credential: string): string { - const url = new URL(`${readRuntimeBasePath()}/pair`, window.location.href); + const url = new URL(`${runtimeBasePath}/pair`, window.location.href); return setPairingTokenOnUrl(url, credential).toString(); } diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index 9dddd98995c..8ebc7c0b1a7 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -3,7 +3,7 @@ import type { KnownEnvironment } from "@t3tools/client-runtime"; import * as Effect from "effect/Effect"; import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; -import { readRuntimeBasePath } from "../../basePath"; +import { runtimeBasePath } from "../../basePath"; export interface PrimaryEnvironmentTarget { readonly source: KnownEnvironment["source"]; @@ -95,7 +95,7 @@ function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { } function resolveWindowOriginPrimaryTarget(): PrimaryEnvironmentTarget { - const httpBaseUrl = `${window.location.origin}${readRuntimeBasePath()}/`; + const httpBaseUrl = `${window.location.origin}${runtimeBasePath}/`; const url = new URL(httpBaseUrl); if (url.protocol === "http:") { url.protocol = "ws:"; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index c566c64331a..beb548e5fb4 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -10,12 +10,12 @@ import { isElectron } from "./env"; import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; -import { readRuntimeBasePath } from "./basePath"; +import { runtimeBasePath } from "./basePath"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); -const router = getRouter(history, readRuntimeBasePath()); +const router = getRouter(history, runtimeBasePath); if (isElectron) { syncDocumentWindowControlsOverlayClass(); diff --git a/packages/client-runtime/src/remote.ts b/packages/client-runtime/src/remote.ts index af423f3e25d..2bcbfd15d63 100644 --- a/packages/client-runtime/src/remote.ts +++ b/packages/client-runtime/src/remote.ts @@ -13,7 +13,11 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { identity } from "effect/Function"; -import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; +import { + joinBasePath, + normalizeBasePath, + resolveBasePathFromMountedPathname, +} from "@t3tools/shared/basePath"; import { FetchHttpClient, HttpClient, @@ -263,13 +267,8 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( }); const url = new URL(input.wsBaseUrl); - const pathname = url.pathname.replace(/\/+$/u, ""); - const basePathname = - pathname === WS_RPC_PATH || pathname.endsWith(`${WS_RPC_PATH}`) - ? pathname.slice(0, -WS_RPC_PATH.length) || "/" - : pathname; - const basePath = yield* normalizeBasePath(basePathname); - url.pathname = joinBasePath(basePath, "/ws"); + const basePath = yield* resolveBasePathFromMountedPathname(url.pathname, WS_RPC_PATH); + url.pathname = joinBasePath(basePath, WS_RPC_PATH); url.hash = ""; url.searchParams.set("wsToken", issued.token); return url.toString(); diff --git a/packages/client-runtime/src/wsRpcProtocol.ts b/packages/client-runtime/src/wsRpcProtocol.ts index c5389d367a8..65b168e1d28 100644 --- a/packages/client-runtime/src/wsRpcProtocol.ts +++ b/packages/client-runtime/src/wsRpcProtocol.ts @@ -1,5 +1,5 @@ import { WsRpcGroup } from "@t3tools/contracts"; -import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; +import { joinBasePath, resolveBasePathFromMountedPathname } from "@t3tools/shared/basePath"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -86,12 +86,10 @@ function resolveWsRpcSocketUrl(rawUrl: string): string { throw new Error(`Unsupported websocket transport URL protocol: ${resolved.protocol}`); } - const pathname = resolved.pathname.replace(/\/+$/u, ""); - const basePathname = - pathname === WS_RPC_PATH || pathname.endsWith(`${WS_RPC_PATH}`) - ? pathname.slice(0, -WS_RPC_PATH.length) || "/" - : pathname; - resolved.pathname = joinBasePath(Effect.runSync(normalizeBasePath(basePathname)), WS_RPC_PATH); + resolved.pathname = joinBasePath( + Effect.runSync(resolveBasePathFromMountedPathname(resolved.pathname, WS_RPC_PATH)), + WS_RPC_PATH, + ); resolved.hash = ""; return resolved.toString(); } diff --git a/packages/shared/src/advertisedEndpoint.ts b/packages/shared/src/advertisedEndpoint.ts index 67c9fc4b65a..2d6d298f746 100644 --- a/packages/shared/src/advertisedEndpoint.ts +++ b/packages/shared/src/advertisedEndpoint.ts @@ -8,13 +8,14 @@ import type { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { normalizeBasePath } from "./basePath.ts"; +import { normalizeBasePath, type NormalizedBasePath } from "./basePath.ts"; export interface CreateAdvertisedEndpointInput { readonly id: string; readonly label: string; readonly provider: AdvertisedEndpointProvider; readonly httpBaseUrl: string; + readonly basePath?: NormalizedBasePath; readonly reachability: AdvertisedEndpointReachability; readonly hostedHttpsCompatibility?: AdvertisedEndpointHostedHttpsCompatibility; readonly desktopCompatibility?: "compatible" | "unknown"; @@ -24,7 +25,14 @@ export interface CreateAdvertisedEndpointInput { readonly description?: string; } -export function normalizeHttpBaseUrl(rawValue: string): string { +export interface AdvertisedEndpointBaseUrlOptions { + readonly basePath?: NormalizedBasePath; +} + +export function normalizeHttpBaseUrl( + rawValue: string, + options?: AdvertisedEndpointBaseUrlOptions, +): string { const url = new URL(rawValue); if (url.protocol === "ws:") { url.protocol = "http:"; @@ -34,14 +42,17 @@ export function normalizeHttpBaseUrl(rawValue: string): string { if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error(`Endpoint must use HTTP or HTTPS. Received ${url.protocol}`); } - url.pathname = `${Effect.runSync(normalizeBasePath(url.pathname))}/`; + url.pathname = `${options?.basePath ?? Effect.runSync(normalizeBasePath(url.pathname))}/`; url.search = ""; url.hash = ""; return url.toString(); } -export function deriveWsBaseUrl(httpBaseUrl: string): string { - const url = new URL(normalizeHttpBaseUrl(httpBaseUrl)); +export function deriveWsBaseUrl( + httpBaseUrl: string, + options?: AdvertisedEndpointBaseUrlOptions, +): string { + const url = new URL(normalizeHttpBaseUrl(httpBaseUrl, options)); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; return url.toString(); } @@ -49,8 +60,9 @@ export function deriveWsBaseUrl(httpBaseUrl: string): string { export function classifyHostedHttpsCompatibility( httpBaseUrl: string, fallback: AdvertisedEndpointHostedHttpsCompatibility = "unknown", + options?: AdvertisedEndpointBaseUrlOptions, ): AdvertisedEndpointHostedHttpsCompatibility { - const url = new URL(normalizeHttpBaseUrl(httpBaseUrl)); + const url = new URL(normalizeHttpBaseUrl(httpBaseUrl, options)); if (url.protocol === "http:") { return "mixed-content-blocked"; } @@ -58,17 +70,20 @@ export function classifyHostedHttpsCompatibility( } export function createAdvertisedEndpoint(input: CreateAdvertisedEndpointInput): AdvertisedEndpoint { - const httpBaseUrl = normalizeHttpBaseUrl(input.httpBaseUrl); + const baseUrlOptions = + input.basePath === undefined ? undefined : ({ basePath: input.basePath } as const); + const httpBaseUrl = normalizeHttpBaseUrl(input.httpBaseUrl, baseUrlOptions); return { id: input.id, label: input.label, provider: input.provider, httpBaseUrl, - wsBaseUrl: deriveWsBaseUrl(httpBaseUrl), + wsBaseUrl: deriveWsBaseUrl(httpBaseUrl, baseUrlOptions), reachability: input.reachability, compatibility: { hostedHttpsApp: - input.hostedHttpsCompatibility ?? classifyHostedHttpsCompatibility(httpBaseUrl), + input.hostedHttpsCompatibility ?? + classifyHostedHttpsCompatibility(httpBaseUrl, "unknown", baseUrlOptions), desktopApp: input.desktopCompatibility ?? "compatible", }, source: input.source, diff --git a/packages/shared/src/basePath.ts b/packages/shared/src/basePath.ts index cb0798fa05c..47c24f2542c 100644 --- a/packages/shared/src/basePath.ts +++ b/packages/shared/src/basePath.ts @@ -57,3 +57,36 @@ export function stripBasePathFromPathname( } return null; } + +export const resolveBasePathFromMountedPathname = ( + pathname: string, + mountedPathname: string, +): Effect.Effect => + Effect.gen(function* () { + const normalizedPathname = (pathname.startsWith("/") ? pathname : `/${pathname}`).replace( + /\/+$/u, + "", + ); + const normalizedMountedPathname = ( + mountedPathname.startsWith("/") ? mountedPathname : `/${mountedPathname}` + ).replace(/\/+$/u, ""); + const candidatePathname = normalizedPathname || "/"; + const candidateMountedPathname = normalizedMountedPathname || "/"; + + if ( + candidateMountedPathname !== "/" && + (candidatePathname === candidateMountedPathname || + candidatePathname.endsWith(candidateMountedPathname)) + ) { + const candidateBasePath = yield* normalizeBasePath( + candidatePathname.slice(0, -candidateMountedPathname.length) || "/", + ); + if ( + stripBasePathFromPathname(candidateBasePath, candidatePathname) === candidateMountedPathname + ) { + return candidateBasePath; + } + } + + return yield* normalizeBasePath(candidatePathname); + }); diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index 8b334d8d0c6..5665eeb8dbb 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -1,6 +1,6 @@ import * as Effect from "effect/Effect"; -import { normalizeBasePath } from "./basePath.ts"; +import { normalizeBasePath, resolveBasePathFromMountedPathname } from "./basePath.ts"; const PAIRING_TOKEN_PARAM = "token"; const HOSTED_PAIRING_HOST_PARAM = "host"; @@ -46,13 +46,7 @@ const toWsBaseUrl = (url: URL): string => { const toHttpBaseUrlFromPathUrl = (url: URL, pathSuffix: string): string => { const next = new URL(url.toString()); - const normalizedSuffix = pathSuffix.startsWith("/") ? pathSuffix : `/${pathSuffix}`; - const pathname = next.pathname.replace(/\/+$/u, ""); - if (pathname === normalizedSuffix) { - next.pathname = "/"; - } else if (pathname.endsWith(normalizedSuffix)) { - next.pathname = pathname.slice(0, -normalizedSuffix.length) || "/"; - } + next.pathname = Effect.runSync(resolveBasePathFromMountedPathname(next.pathname, pathSuffix)); return toHttpBaseUrl(next); }; From a78ad9b8b9d9d42ea3b8df787459de81d1871395 Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sun, 31 May 2026 18:27:36 +0300 Subject: [PATCH 3/9] simplify base path followups --- apps/server/src/http.ts | 12 ++--- apps/server/src/startupAccess.ts | 47 ++++--------------- apps/web/src/basePath.ts | 2 +- .../components/auth/PairingRouteSurface.tsx | 4 +- .../settings/ConnectionsSettings.tsx | 4 +- apps/web/src/environments/primary/target.ts | 4 +- apps/web/src/main.tsx | 4 +- packages/client-runtime/src/remote.ts | 8 +--- packages/client-runtime/src/wsRpcProtocol.ts | 4 +- packages/shared/src/basePath.ts | 33 ------------- packages/shared/src/remote.ts | 10 ++-- packages/tailscale/src/tailscale.ts | 11 ++--- 12 files changed, 31 insertions(+), 112 deletions(-) diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 2d1aaf19151..ecf1252a3c5 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -66,15 +66,9 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { function rewriteIndexHtmlAssetUrls(html: string, basePath: NormalizedBasePath): string { return html.replace( - /\b(src|href)=(["'])(\.\/[^"']+|\/(?!\/)[^"']*)\2/gu, - (_match, attribute: string, quote: string, rawPathname: string) => { - const pathname = rawPathname.startsWith("./") ? `/${rawPathname.slice(2)}` : rawPathname; - const prefixedPathname = - basePath !== "" && pathname !== basePath && !pathname.startsWith(`${basePath}/`) - ? `${basePath}${pathname}` - : pathname; - return `${attribute}=${quote}${prefixedPathname}${quote}`; - }, + /\b(src|href)=(["'])(?:\.\/|\/(?!\/))([^"']+)\2/gu, + (_match, attribute: string, quote: string, pathname: string) => + `${attribute}=${quote}${basePath}/${pathname}${quote}`, ); } diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 22a9309b188..c8246ad4b45 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,13 +1,7 @@ import { networkInterfaces } from "node:os"; import { QrCode } from "@t3tools/shared/qrCode"; -import { - ROOT_BASE_PATH, - joinBasePath, - normalizeBasePath, - type NormalizedBasePath, -} from "@t3tools/shared/basePath"; -import { probeTailscaleHttpsEndpoint, resolveTailscaleHttpsBaseUrl } from "@t3tools/tailscale"; +import { ROOT_BASE_PATH, joinBasePath, type NormalizedBasePath } from "@t3tools/shared/basePath"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/unstable/http"; @@ -97,9 +91,13 @@ export const resolveListeningPort = (address: unknown, fallbackPort: number): nu return fallbackPort; }; -export const buildPairingUrl = (connectionString: string, token: string): string => { +export const buildPairingUrl = ( + connectionString: string, + token: string, + basePath: NormalizedBasePath = ROOT_BASE_PATH, +): string => { const url = new URL(connectionString); - url.pathname = joinBasePath(Effect.runSync(normalizeBasePath(url.pathname)), "/pair"); + url.pathname = joinBasePath(basePath, "/pair"); url.searchParams.delete("token"); url.hash = new URLSearchParams([["token", token]]).toString(); return url.toString(); @@ -142,43 +140,16 @@ export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessI const serverConfig = yield* ServerConfig; const httpServer = yield* HttpServer.HttpServer; const serverAuth = yield* ServerAuth; - const localConnectionString = resolveHeadlessConnectionString( + const connectionString = resolveHeadlessConnectionString( serverConfig.host, resolveListeningPort(httpServer.address, serverConfig.port), serverConfig.basePath, ); - const tailscaleConnectionString = serverConfig.tailscaleServeEnabled - ? yield* resolveTailscaleHttpsBaseUrl({ - servePort: serverConfig.tailscaleServePort, - basePath: serverConfig.basePath, - }).pipe( - Effect.flatMap((baseUrl) => - baseUrl - ? probeTailscaleHttpsEndpoint({ baseUrl }).pipe( - Effect.flatMap((isReachable) => - isReachable - ? Effect.succeed(baseUrl) - : Effect.logWarning( - "Tailscale HTTPS endpoint did not pass readiness probe; using local connection string.", - { baseUrl }, - ).pipe(Effect.as(null)), - ), - ) - : Effect.succeed(null), - ), - Effect.catch((cause) => - Effect.logWarning("Failed to resolve Tailscale HTTPS base URL", { cause }).pipe( - Effect.as(null), - ), - ), - ) - : null; - const connectionString = tailscaleConnectionString ?? localConnectionString; const issued = yield* serverAuth.issuePairingCredential({ role: "owner" }); return { connectionString, token: issued.credential, - pairingUrl: buildPairingUrl(connectionString, issued.credential), + pairingUrl: buildPairingUrl(connectionString, issued.credential, serverConfig.basePath), } satisfies HeadlessServeAccessInfo; }); diff --git a/apps/web/src/basePath.ts b/apps/web/src/basePath.ts index 7f1aa1039fc..4dfc2dbaa5e 100644 --- a/apps/web/src/basePath.ts +++ b/apps/web/src/basePath.ts @@ -14,4 +14,4 @@ function resolveRuntimeBasePath(): NormalizedBasePath { return Effect.runSync(normalizeBasePath(new URL("..", moduleUrl).pathname)); } -export const runtimeBasePath = resolveRuntimeBasePath(); +export const BASE_PATH = resolveRuntimeBasePath(); diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx index 2b5f4efb216..bf0dbd9388b 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -9,7 +9,7 @@ import { submitServerAuthCredential, } from "../../environments/primary"; import { readHostedPairingRequest } from "../../hostedPairing"; -import { runtimeBasePath } from "../../basePath"; +import { BASE_PATH } from "../../basePath"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -276,7 +276,7 @@ export function HostedPairingRouteSurface() { diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 306700565c8..867b3e84e63 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -72,7 +72,7 @@ import { } from "../ui/menu"; import { Textarea } from "../ui/textarea"; import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; -import { runtimeBasePath } from "../../basePath"; +import { BASE_PATH } from "../../basePath"; import { readHostedPairingRequest } from "../../hostedPairing"; import { createServerPairingCredential, @@ -481,7 +481,7 @@ function resolveAdvertisedEndpointPairingUrl( } function resolveCurrentOriginPairingUrl(credential: string): string { - const url = new URL(`${runtimeBasePath}/pair`, window.location.href); + const url = new URL(`${BASE_PATH}/pair`, window.location.href); return setPairingTokenOnUrl(url, credential).toString(); } diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index 8ebc7c0b1a7..b47516dc34e 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -3,7 +3,7 @@ import type { KnownEnvironment } from "@t3tools/client-runtime"; import * as Effect from "effect/Effect"; import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; -import { runtimeBasePath } from "../../basePath"; +import { BASE_PATH } from "../../basePath"; export interface PrimaryEnvironmentTarget { readonly source: KnownEnvironment["source"]; @@ -95,7 +95,7 @@ function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { } function resolveWindowOriginPrimaryTarget(): PrimaryEnvironmentTarget { - const httpBaseUrl = `${window.location.origin}${runtimeBasePath}/`; + const httpBaseUrl = `${window.location.origin}${BASE_PATH}/`; const url = new URL(httpBaseUrl); if (url.protocol === "http:") { url.protocol = "ws:"; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index beb548e5fb4..8ff8edf5c6a 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -10,12 +10,12 @@ import { isElectron } from "./env"; import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; -import { runtimeBasePath } from "./basePath"; +import { BASE_PATH } from "./basePath"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); -const router = getRouter(history, runtimeBasePath); +const router = getRouter(history, BASE_PATH); if (isElectron) { syncDocumentWindowControlsOverlayClass(); diff --git a/packages/client-runtime/src/remote.ts b/packages/client-runtime/src/remote.ts index 2bcbfd15d63..d01aff9d58f 100644 --- a/packages/client-runtime/src/remote.ts +++ b/packages/client-runtime/src/remote.ts @@ -13,11 +13,7 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { identity } from "effect/Function"; -import { - joinBasePath, - normalizeBasePath, - resolveBasePathFromMountedPathname, -} from "@t3tools/shared/basePath"; +import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; import { FetchHttpClient, HttpClient, @@ -267,7 +263,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( }); const url = new URL(input.wsBaseUrl); - const basePath = yield* resolveBasePathFromMountedPathname(url.pathname, WS_RPC_PATH); + const basePath = yield* normalizeBasePath(new URL(input.httpBaseUrl).pathname); url.pathname = joinBasePath(basePath, WS_RPC_PATH); url.hash = ""; url.searchParams.set("wsToken", issued.token); diff --git a/packages/client-runtime/src/wsRpcProtocol.ts b/packages/client-runtime/src/wsRpcProtocol.ts index 65b168e1d28..e5ce53a8c35 100644 --- a/packages/client-runtime/src/wsRpcProtocol.ts +++ b/packages/client-runtime/src/wsRpcProtocol.ts @@ -1,5 +1,5 @@ import { WsRpcGroup } from "@t3tools/contracts"; -import { joinBasePath, resolveBasePathFromMountedPathname } from "@t3tools/shared/basePath"; +import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -87,7 +87,7 @@ function resolveWsRpcSocketUrl(rawUrl: string): string { } resolved.pathname = joinBasePath( - Effect.runSync(resolveBasePathFromMountedPathname(resolved.pathname, WS_RPC_PATH)), + Effect.runSync(normalizeBasePath(resolved.pathname)), WS_RPC_PATH, ); resolved.hash = ""; diff --git a/packages/shared/src/basePath.ts b/packages/shared/src/basePath.ts index 47c24f2542c..cb0798fa05c 100644 --- a/packages/shared/src/basePath.ts +++ b/packages/shared/src/basePath.ts @@ -57,36 +57,3 @@ export function stripBasePathFromPathname( } return null; } - -export const resolveBasePathFromMountedPathname = ( - pathname: string, - mountedPathname: string, -): Effect.Effect => - Effect.gen(function* () { - const normalizedPathname = (pathname.startsWith("/") ? pathname : `/${pathname}`).replace( - /\/+$/u, - "", - ); - const normalizedMountedPathname = ( - mountedPathname.startsWith("/") ? mountedPathname : `/${mountedPathname}` - ).replace(/\/+$/u, ""); - const candidatePathname = normalizedPathname || "/"; - const candidateMountedPathname = normalizedMountedPathname || "/"; - - if ( - candidateMountedPathname !== "/" && - (candidatePathname === candidateMountedPathname || - candidatePathname.endsWith(candidateMountedPathname)) - ) { - const candidateBasePath = yield* normalizeBasePath( - candidatePathname.slice(0, -candidateMountedPathname.length) || "/", - ); - if ( - stripBasePathFromPathname(candidateBasePath, candidatePathname) === candidateMountedPathname - ) { - return candidateBasePath; - } - } - - return yield* normalizeBasePath(candidatePathname); - }); diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index 5665eeb8dbb..8776b3f146b 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -1,6 +1,6 @@ import * as Effect from "effect/Effect"; -import { normalizeBasePath, resolveBasePathFromMountedPathname } from "./basePath.ts"; +import { normalizeBasePath } from "./basePath.ts"; const PAIRING_TOKEN_PARAM = "token"; const HOSTED_PAIRING_HOST_PARAM = "host"; @@ -44,11 +44,7 @@ const toWsBaseUrl = (url: URL): string => { return next.toString(); }; -const toHttpBaseUrlFromPathUrl = (url: URL, pathSuffix: string): string => { - const next = new URL(url.toString()); - next.pathname = Effect.runSync(resolveBasePathFromMountedPathname(next.pathname, pathSuffix)); - return toHttpBaseUrl(next); -}; +const toHttpBaseUrlFromPairingUrl = (url: URL): string => toHttpBaseUrl(new URL(".", url)); export interface ResolvedRemotePairingTarget { readonly credential: string; @@ -128,7 +124,7 @@ export const resolveRemotePairingTarget = (input: { if (!credential) { throw new Error("Pairing URL is missing its token."); } - const httpBaseUrl = toHttpBaseUrlFromPathUrl(url, "/pair"); + const httpBaseUrl = toHttpBaseUrlFromPairingUrl(url); return { credential, httpBaseUrl, diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 7e1c7d4af62..f093c288cdf 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -5,12 +5,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { - ROOT_BASE_PATH, - joinBasePath, - normalizeBasePath, - type NormalizedBasePath, -} from "@t3tools/shared/basePath"; +import { ROOT_BASE_PATH, joinBasePath, type NormalizedBasePath } from "@t3tools/shared/basePath"; export const DEFAULT_TAILSCALE_SERVE_PORT = 443; export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500; @@ -297,14 +292,14 @@ export const disableTailscaleServe = ( export const probeTailscaleHttpsEndpoint = (input: { readonly baseUrl: string; + readonly basePath?: NormalizedBasePath; readonly timeoutMs?: number; }): Effect.Effect => Effect.gen(function* () { const client = yield* HttpClient.HttpClient; const response = yield* Effect.gen(function* () { const url = new URL(input.baseUrl); - const basePath = yield* normalizeBasePath(url.pathname); - url.pathname = joinBasePath(basePath, "/.well-known/t3/environment"); + url.pathname = joinBasePath(input.basePath ?? ROOT_BASE_PATH, "/.well-known/t3/environment"); url.search = ""; url.hash = ""; const request = HttpClientRequest.get(url.toString()); From c8b7dc8cf55a03f70352ced48ac7af54d6fb0ca6 Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sun, 31 May 2026 18:38:21 +0300 Subject: [PATCH 4/9] remove join base path helper --- apps/server/src/auth/Layers/ServerAuth.ts | 3 +-- apps/server/src/cliAuthFormat.ts | 4 ++-- apps/server/src/startupAccess.ts | 4 ++-- apps/web/src/components/settings/pairingUrls.ts | 4 ++-- apps/web/src/environments/primary/target.ts | 4 ++-- apps/web/src/environments/runtime/catalog.ts | 4 ++-- packages/client-runtime/src/remote.ts | 6 +++--- packages/client-runtime/src/wsRpcProtocol.ts | 7 ++----- packages/shared/src/basePath.ts | 5 ----- packages/tailscale/src/tailscale.ts | 4 ++-- 10 files changed, 18 insertions(+), 27 deletions(-) diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index 43d872dff49..2117a5862cd 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -6,7 +6,6 @@ import { type AuthSessionState, type AuthWebSocketTokenResult, } from "@t3tools/contracts"; -import { joinBasePath } from "@t3tools/shared/basePath"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -322,7 +321,7 @@ export const makeServerAuth = Effect.gen(function* () { Effect.gen(function* () { const issued = yield* issuePairingCredential({ role: "owner" }); const url = new URL(baseUrl); - url.pathname = joinBasePath(serverConfig.basePath, "/pair"); + url.pathname = `${serverConfig.basePath}/pair`; url.searchParams.delete("token"); url.hash = new URLSearchParams([["token", issued.credential]]).toString(); return url.toString(); diff --git a/apps/server/src/cliAuthFormat.ts b/apps/server/src/cliAuthFormat.ts index 8827a2cce0a..480f2923939 100644 --- a/apps/server/src/cliAuthFormat.ts +++ b/apps/server/src/cliAuthFormat.ts @@ -1,5 +1,5 @@ import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; -import { ROOT_BASE_PATH, joinBasePath, type NormalizedBasePath } from "@t3tools/shared/basePath"; +import { ROOT_BASE_PATH, type NormalizedBasePath } from "@t3tools/shared/basePath"; import * as DateTime from "effect/DateTime"; import type { IssuedBearerSession, IssuedPairingLink } from "./auth/Services/AuthControlPlane.ts"; @@ -37,7 +37,7 @@ export function formatIssuedPairingCredential( options?.baseUrl != null && options.baseUrl.length > 0 ? (() => { const url = new URL(options.baseUrl); - url.pathname = joinBasePath(options.basePath ?? ROOT_BASE_PATH, "/pair"); + url.pathname = `${options.basePath ?? ROOT_BASE_PATH}/pair`; url.searchParams.delete("token"); url.hash = new URLSearchParams([["token", credential.credential]]).toString(); return url.toString(); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index c8246ad4b45..faf316d70b3 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,7 +1,7 @@ import { networkInterfaces } from "node:os"; import { QrCode } from "@t3tools/shared/qrCode"; -import { ROOT_BASE_PATH, joinBasePath, type NormalizedBasePath } from "@t3tools/shared/basePath"; +import { ROOT_BASE_PATH, type NormalizedBasePath } from "@t3tools/shared/basePath"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/unstable/http"; @@ -97,7 +97,7 @@ export const buildPairingUrl = ( basePath: NormalizedBasePath = ROOT_BASE_PATH, ): string => { const url = new URL(connectionString); - url.pathname = joinBasePath(basePath, "/pair"); + url.pathname = `${basePath}/pair`; url.searchParams.delete("token"); url.hash = new URLSearchParams([["token", token]]).toString(); return url.toString(); diff --git a/apps/web/src/components/settings/pairingUrls.ts b/apps/web/src/components/settings/pairingUrls.ts index d31340878d8..906bb99d476 100644 --- a/apps/web/src/components/settings/pairingUrls.ts +++ b/apps/web/src/components/settings/pairingUrls.ts @@ -1,4 +1,4 @@ -import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; +import { normalizeBasePath } from "@t3tools/shared/basePath"; import * as Effect from "effect/Effect"; import { buildHostedPairingUrl } from "../../hostedPairing"; @@ -6,7 +6,7 @@ import { setPairingTokenOnUrl } from "../../pairingUrl"; export function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { const url = new URL(endpointUrl); - url.pathname = joinBasePath(Effect.runSync(normalizeBasePath(url.pathname)), "/pair"); + url.pathname = `${Effect.runSync(normalizeBasePath(url.pathname))}/pair`; return setPairingTokenOnUrl(url, credential).toString(); } diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index b47516dc34e..ad375ba7e25 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -1,7 +1,7 @@ import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; import type { KnownEnvironment } from "@t3tools/client-runtime"; import * as Effect from "effect/Effect"; -import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; +import { normalizeBasePath } from "@t3tools/shared/basePath"; import { BASE_PATH } from "../../basePath"; @@ -146,7 +146,7 @@ export function resolvePrimaryEnvironmentHttpUrl( } const url = new URL(resolveHttpRequestBaseUrl(primaryTarget.target.httpBaseUrl)); - url.pathname = joinBasePath(Effect.runSync(normalizeBasePath(url.pathname)), pathname); + url.pathname = `${Effect.runSync(normalizeBasePath(url.pathname))}${pathname}`; url.search = ""; url.hash = ""; if (searchParams) { diff --git a/apps/web/src/environments/runtime/catalog.ts b/apps/web/src/environments/runtime/catalog.ts index 403740daf3b..9243203e0c0 100644 --- a/apps/web/src/environments/runtime/catalog.ts +++ b/apps/web/src/environments/runtime/catalog.ts @@ -1,6 +1,6 @@ import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime"; import * as Effect from "effect/Effect"; -import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; +import { normalizeBasePath } from "@t3tools/shared/basePath"; import type { AuthSessionRole, EnvironmentId, @@ -222,7 +222,7 @@ export function resolveEnvironmentHttpUrl(input: { } const url = new URL(httpBaseUrl); - url.pathname = joinBasePath(Effect.runSync(normalizeBasePath(url.pathname)), input.pathname); + url.pathname = `${Effect.runSync(normalizeBasePath(url.pathname))}${input.pathname}`; url.search = ""; url.hash = ""; if (input.searchParams) { diff --git a/packages/client-runtime/src/remote.ts b/packages/client-runtime/src/remote.ts index d01aff9d58f..3996bf32c47 100644 --- a/packages/client-runtime/src/remote.ts +++ b/packages/client-runtime/src/remote.ts @@ -13,7 +13,7 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { identity } from "effect/Function"; -import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; +import { normalizeBasePath } from "@t3tools/shared/basePath"; import { FetchHttpClient, HttpClient, @@ -30,7 +30,7 @@ const WS_RPC_PATH = "/ws"; const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { const url = new URL(httpBaseUrl); - url.pathname = joinBasePath(Effect.runSync(normalizeBasePath(url.pathname)), pathname); + url.pathname = `${Effect.runSync(normalizeBasePath(url.pathname))}${pathname}`; url.search = ""; url.hash = ""; return url.toString(); @@ -264,7 +264,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( const url = new URL(input.wsBaseUrl); const basePath = yield* normalizeBasePath(new URL(input.httpBaseUrl).pathname); - url.pathname = joinBasePath(basePath, WS_RPC_PATH); + url.pathname = `${basePath}${WS_RPC_PATH}`; url.hash = ""; url.searchParams.set("wsToken", issued.token); return url.toString(); diff --git a/packages/client-runtime/src/wsRpcProtocol.ts b/packages/client-runtime/src/wsRpcProtocol.ts index e5ce53a8c35..2942e82ea35 100644 --- a/packages/client-runtime/src/wsRpcProtocol.ts +++ b/packages/client-runtime/src/wsRpcProtocol.ts @@ -1,5 +1,5 @@ import { WsRpcGroup } from "@t3tools/contracts"; -import { joinBasePath, normalizeBasePath } from "@t3tools/shared/basePath"; +import { normalizeBasePath } from "@t3tools/shared/basePath"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -86,10 +86,7 @@ function resolveWsRpcSocketUrl(rawUrl: string): string { throw new Error(`Unsupported websocket transport URL protocol: ${resolved.protocol}`); } - resolved.pathname = joinBasePath( - Effect.runSync(normalizeBasePath(resolved.pathname)), - WS_RPC_PATH, - ); + resolved.pathname = `${Effect.runSync(normalizeBasePath(resolved.pathname))}${WS_RPC_PATH}`; resolved.hash = ""; return resolved.toString(); } diff --git a/packages/shared/src/basePath.ts b/packages/shared/src/basePath.ts index cb0798fa05c..0f6bb9d319f 100644 --- a/packages/shared/src/basePath.ts +++ b/packages/shared/src/basePath.ts @@ -36,11 +36,6 @@ export const normalizeBasePath = ( return Effect.succeed(NormalizedBasePath(normalized)); }); -export function joinBasePath(basePath: NormalizedBasePath, pathname: string): string { - const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`; - return `${basePath}${normalizedPathname}`; -} - export function stripBasePathFromPathname( basePath: NormalizedBasePath, pathname: string, diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index f093c288cdf..41807ede9d6 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -5,7 +5,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { ROOT_BASE_PATH, joinBasePath, type NormalizedBasePath } from "@t3tools/shared/basePath"; +import { ROOT_BASE_PATH, type NormalizedBasePath } from "@t3tools/shared/basePath"; export const DEFAULT_TAILSCALE_SERVE_PORT = 443; export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500; @@ -299,7 +299,7 @@ export const probeTailscaleHttpsEndpoint = (input: { const client = yield* HttpClient.HttpClient; const response = yield* Effect.gen(function* () { const url = new URL(input.baseUrl); - url.pathname = joinBasePath(input.basePath ?? ROOT_BASE_PATH, "/.well-known/t3/environment"); + url.pathname = `${input.basePath ?? ROOT_BASE_PATH}/.well-known/t3/environment`; url.search = ""; url.hash = ""; const request = HttpClientRequest.get(url.toString()); From 6696704a9b86042b3f211df07e0b3659a8ec7152 Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sun, 31 May 2026 19:08:03 +0300 Subject: [PATCH 5/9] fix base path websocket ownership --- .../src/state/use-remote-environment-registry.ts | 4 ++-- apps/server/src/cli/config.test.ts | 8 +++----- apps/server/src/cli/config.ts | 14 +++++++++++++- apps/web/src/basePath.ts | 4 ++++ .../runtime/service.addSavedEnvironment.test.ts | 6 +++--- .../runtime/service.savedEnvironments.test.ts | 4 ++-- .../runtime/service.threadSubscriptions.test.ts | 4 ++-- apps/web/src/environments/runtime/service.ts | 8 ++++---- packages/client-runtime/src/remote.test.ts | 8 ++++---- packages/client-runtime/src/remote.ts | 8 ++++---- packages/client-runtime/src/wsRpcProtocol.ts | 1 + 11 files changed, 42 insertions(+), 27 deletions(-) diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 4e27cb27f8b..a9d97df8ea3 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -10,7 +10,7 @@ import { createWsRpcClient, EnvironmentConnectionState, WsTransport, - resolveRemoteWebSocketConnectionUrl, + resolveRemoteWebSocketBaseUrl, } from "@t3tools/client-runtime"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Arr from "effect/Array"; @@ -237,7 +237,7 @@ export async function connectSavedEnvironment( const transport = new WsTransport( () => mobileRemoteHttpRuntime.runPromise( - resolveRemoteWebSocketConnectionUrl({ + resolveRemoteWebSocketBaseUrl({ wsBaseUrl: connection.wsBaseUrl, httpBaseUrl: connection.httpBaseUrl, bearerToken: connection.bearerToken, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index f95a70ef232..e2dabdcdf41 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -14,7 +14,7 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; -import { ROOT_BASE_PATH, normalizeBasePath } from "@t3tools/shared/basePath"; +import { ROOT_BASE_PATH } from "@t3tools/shared/basePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { deriveServerPaths } from "../config.ts"; import { resolveServerConfig } from "./config.ts"; @@ -47,8 +47,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", } as const; - const agentBasePath = Effect.runSync(normalizeBasePath("/agent")); - const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) { const fs = yield* FileSystem.FileSystem; const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); @@ -143,7 +141,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.some(true), logWebSocketEvents: Option.some(true), - basePath: Option.some("/agent"), + basePath: Option.none(), tailscaleServeEnabled: Option.some(true), tailscaleServePort: Option.some(8443), }, @@ -187,7 +185,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, - basePath: agentBasePath, + basePath: ROOT_BASE_PATH, tailscaleServeEnabled: true, tailscaleServePort: 8443, }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 795094ef97e..9bd62bb3203 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -2,6 +2,7 @@ import * as NetService from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import * as Config from "effect/Config"; +import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -13,7 +14,7 @@ import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { Argument, Flag } from "effect/unstable/cli"; -import { normalizeBasePath } from "@t3tools/shared/basePath"; +import { ROOT_BASE_PATH, normalizeBasePath } from "@t3tools/shared/basePath"; import { readBootstrapEnvelope } from "../bootstrap.ts"; import { DEFAULT_PORT, @@ -163,6 +164,14 @@ export interface CliServerFlags { readonly tailscaleServePort: Option.Option; } +export class DevBasePathUnsupportedError extends Data.TaggedError("DevBasePathUnsupportedError")<{ + readonly basePath: string; +}> { + override get message(): string { + return `Custom base paths are not supported with dev URL mode. Received ${this.basePath}.`; + } +} + export interface CliAuthLocationFlags { readonly baseDir: Option.Option; readonly devUrl?: Option.Option; @@ -356,6 +365,9 @@ export const resolveServerConfig = ( resolveOptionPrecedence(normalizedFlags.basePath, Option.fromUndefinedOr(env.basePath)), ), ); + if (devUrl !== undefined && basePath !== ROOT_BASE_PATH) { + return yield* new DevBasePathUnsupportedError({ basePath }); + } const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); const config: ServerConfigShape = { diff --git a/apps/web/src/basePath.ts b/apps/web/src/basePath.ts index 4dfc2dbaa5e..039ec851514 100644 --- a/apps/web/src/basePath.ts +++ b/apps/web/src/basePath.ts @@ -7,6 +7,10 @@ import { } from "@t3tools/shared/basePath"; function resolveRuntimeBasePath(): NormalizedBasePath { + if (import.meta.env.DEV) { + return ROOT_BASE_PATH; + } + const moduleUrl = new URL(import.meta.url); if (moduleUrl.protocol !== "http:" && moduleUrl.protocol !== "https:") { return ROOT_BASE_PATH; diff --git a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts index 7e847780e04..873cd4968ca 100644 --- a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +++ b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts @@ -8,7 +8,7 @@ const mockFetchRemoteEnvironmentDescriptor = vi.fn(); const mockBootstrapRemoteBearerSession = vi.fn(); const mockFetchRemoteSessionState = vi.fn(); const mockIsRemoteEnvironmentAuthHttpError = vi.fn((_: unknown) => false); -const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); +const mockResolveRemoteWebSocketBaseUrl = vi.fn(); const mockRemoteHttpRunPromise = vi.fn((effect: Promise) => effect); const mockBootstrapSshBearerSession = vi.fn(); const mockFetchSshSessionState = vi.fn(); @@ -129,7 +129,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, fetchRemoteSessionState: mockFetchRemoteSessionState, isRemoteEnvironmentAuthHttpError: mockIsRemoteEnvironmentAuthHttpError, - resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, + resolveRemoteWebSocketBaseUrl: mockResolveRemoteWebSocketBaseUrl, }; }); @@ -184,7 +184,7 @@ describe("addSavedEnvironment", () => { role: "owner", }); mockIsRemoteEnvironmentAuthHttpError.mockReturnValue(false); - mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue( + mockResolveRemoteWebSocketBaseUrl.mockResolvedValue( "wss://remote.example.com/?wsToken=remote-token", ); mockFetchSshEnvironmentDescriptor.mockResolvedValue({ diff --git a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts index d47a6109af0..7b76fe8f0d8 100644 --- a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts +++ b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockCreateEnvironmentConnection = vi.fn(); const mockCreateWsRpcClient = vi.fn(); const mockFetchRemoteSessionState = vi.fn(); -const mockResolveRemoteWebSocketConnectionUrl = vi.fn(() => "ws://remote.example.test"); +const mockResolveRemoteWebSocketBaseUrl = vi.fn(() => "ws://remote.example.test"); const mockRemoteHttpRunPromise = vi.fn((effect: Promise) => effect); const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); const mockListSavedEnvironmentRecords = vi.fn(); @@ -74,7 +74,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { ...actual, createWsRpcClient: mockCreateWsRpcClient, fetchRemoteSessionState: mockFetchRemoteSessionState, - resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, + resolveRemoteWebSocketBaseUrl: mockResolveRemoteWebSocketBaseUrl, }; }); diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 0ce5f51d93c..3b5a7dfb490 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -21,7 +21,7 @@ const mockReadSavedEnvironmentBearerToken = vi.fn(); const mockSavedEnvironmentRegistrySubscribe = vi.fn(); const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn()); const mockFetchRemoteSessionState = vi.fn(); -const mockResolveRemoteWebSocketConnectionUrl = vi.fn(async () => "ws://remote.example.test/ws"); +const mockResolveRemoteWebSocketBaseUrl = vi.fn(async () => "ws://remote.example.test/"); const mockRemoteHttpRunPromise = vi.fn((effect: Promise) => effect); const mockConnectionReconnects: Array> = []; let savedEnvironmentRegistryListener: (() => void) | null = null; @@ -154,7 +154,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { createWsRpcClient: vi.fn(() => stubWsClient), fetchRemoteSessionState: mockFetchRemoteSessionState, isRemoteEnvironmentAuthHttpError: vi.fn(() => false), - resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, + resolveRemoteWebSocketBaseUrl: mockResolveRemoteWebSocketBaseUrl, }; }); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index fff04830434..a0ac446127d 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -16,7 +16,7 @@ import { fetchRemoteEnvironmentDescriptor, fetchRemoteSessionState, isRemoteEnvironmentAuthHttpError, - resolveRemoteWebSocketConnectionUrl, + resolveRemoteWebSocketBaseUrl, } from "@t3tools/client-runtime"; import { type QueryClient } from "@tanstack/react-query"; @@ -742,7 +742,7 @@ async function fetchDesktopSshSessionState(httpBaseUrl: string, bearerToken: str return await getDesktopSshBridge().fetchSshSessionState(httpBaseUrl, bearerToken); } -async function resolveDesktopSshWebSocketConnectionUrl( +async function resolveDesktopSshWebSocketBaseUrl( wsBaseUrl: string, httpBaseUrl: string, bearerToken: string, @@ -1159,13 +1159,13 @@ function createSavedEnvironmentClient( throw new Error(`Saved environment ${environmentId} not found.`); } return record.desktopSsh - ? await resolveDesktopSshWebSocketConnectionUrl( + ? await resolveDesktopSshWebSocketBaseUrl( record.wsBaseUrl, record.httpBaseUrl, bearerToken, ) : await remoteHttpRuntime.runPromise( - resolveRemoteWebSocketConnectionUrl({ + resolveRemoteWebSocketBaseUrl({ wsBaseUrl: record.wsBaseUrl, httpBaseUrl: record.httpBaseUrl, bearerToken, diff --git a/packages/client-runtime/src/remote.test.ts b/packages/client-runtime/src/remote.test.ts index c832f4f3858..158ceaf1d5d 100644 --- a/packages/client-runtime/src/remote.test.ts +++ b/packages/client-runtime/src/remote.test.ts @@ -12,7 +12,7 @@ import { issueRemoteWebSocketToken, remoteHttpClientLayer, RemoteEnvironmentAuthTimeoutError, - resolveRemoteWebSocketConnectionUrl, + resolveRemoteWebSocketBaseUrl, } from "./remote.ts"; type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit]; @@ -227,7 +227,7 @@ describe("remote", () => { }).pipe(Effect.provide(TestClock.layer())), ); - it.effect("mints a websocket url that targets the rpc route with a short-lived ws token", () => + it.effect("mints an authenticated websocket base url with a short-lived ws token", () => Effect.gen(function* () { const fetch = recordedFetch( Response.json( @@ -239,13 +239,13 @@ describe("remote", () => { ), ); - const url = yield* resolveRemoteWebSocketConnectionUrl({ + const url = yield* resolveRemoteWebSocketBaseUrl({ wsBaseUrl: "wss://remote.example.com/", httpBaseUrl: "https://remote.example.com/", bearerToken: "bearer-token", }).pipe(provideRemoteHttp(fetch.fetchFn)); - expect(url).toBe("wss://remote.example.com/ws?wsToken=ws-token"); + expect(url).toBe("wss://remote.example.com/?wsToken=ws-token"); }), ); }); diff --git a/packages/client-runtime/src/remote.ts b/packages/client-runtime/src/remote.ts index 3996bf32c47..6ec589c8946 100644 --- a/packages/client-runtime/src/remote.ts +++ b/packages/client-runtime/src/remote.ts @@ -26,7 +26,6 @@ const RemoteAuthErrorBody = Schema.Struct({ error: Schema.optional(Schema.String), }); const decodeRemoteAuthErrorBody = decodeJsonResult(RemoteAuthErrorBody); -const WS_RPC_PATH = "/ws"; const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { const url = new URL(httpBaseUrl); @@ -248,8 +247,8 @@ export const issueRemoteWebSocketToken = Effect.fn( }); }); -export const resolveRemoteWebSocketConnectionUrl = Effect.fn( - "clientRuntime.remote.resolveRemoteWebSocketConnectionUrl", +export const resolveRemoteWebSocketBaseUrl = Effect.fn( + "clientRuntime.remote.resolveRemoteWebSocketBaseUrl", )(function* (input: { readonly wsBaseUrl: string; readonly httpBaseUrl: string; @@ -264,7 +263,8 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( const url = new URL(input.wsBaseUrl); const basePath = yield* normalizeBasePath(new URL(input.httpBaseUrl).pathname); - url.pathname = `${basePath}${WS_RPC_PATH}`; + // WsRpcProtocol owns the RPC endpoint path; this helper only returns the authenticated base URL. + url.pathname = `${basePath}/`; url.hash = ""; url.searchParams.set("wsToken", issued.token); return url.toString(); diff --git a/packages/client-runtime/src/wsRpcProtocol.ts b/packages/client-runtime/src/wsRpcProtocol.ts index 2942e82ea35..27e49a118e9 100644 --- a/packages/client-runtime/src/wsRpcProtocol.ts +++ b/packages/client-runtime/src/wsRpcProtocol.ts @@ -86,6 +86,7 @@ function resolveWsRpcSocketUrl(rawUrl: string): string { throw new Error(`Unsupported websocket transport URL protocol: ${resolved.protocol}`); } + // WsRpcProtocol is the single owner of the concrete RPC route. resolved.pathname = `${Effect.runSync(normalizeBasePath(resolved.pathname))}${WS_RPC_PATH}`; resolved.hash = ""; return resolved.toString(); From 77ab0291995eb068c219bb6ca8fd32e26677d181 Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sun, 31 May 2026 19:20:25 +0300 Subject: [PATCH 6/9] simplify base path handling --- .../src/state/use-remote-environment-registry.ts | 4 ++-- apps/server/src/cli/config.test.ts | 4 ++-- apps/server/src/cli/config.ts | 14 +------------- apps/web/src/basePath.ts | 4 ---- .../runtime/service.addSavedEnvironment.test.ts | 6 +++--- .../runtime/service.savedEnvironments.test.ts | 4 ++-- .../runtime/service.threadSubscriptions.test.ts | 4 ++-- apps/web/src/environments/runtime/service.ts | 4 ++-- packages/client-runtime/src/remote.test.ts | 4 ++-- packages/client-runtime/src/remote.ts | 5 ++--- packages/client-runtime/src/wsRpcProtocol.ts | 1 - 11 files changed, 18 insertions(+), 36 deletions(-) diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index a9d97df8ea3..4e27cb27f8b 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -10,7 +10,7 @@ import { createWsRpcClient, EnvironmentConnectionState, WsTransport, - resolveRemoteWebSocketBaseUrl, + resolveRemoteWebSocketConnectionUrl, } from "@t3tools/client-runtime"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Arr from "effect/Array"; @@ -237,7 +237,7 @@ export async function connectSavedEnvironment( const transport = new WsTransport( () => mobileRemoteHttpRuntime.runPromise( - resolveRemoteWebSocketBaseUrl({ + resolveRemoteWebSocketConnectionUrl({ wsBaseUrl: connection.wsBaseUrl, httpBaseUrl: connection.httpBaseUrl, bearerToken: connection.bearerToken, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index e2dabdcdf41..21ee746853e 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -141,7 +141,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.some(true), logWebSocketEvents: Option.some(true), - basePath: Option.none(), + basePath: Option.some("/custom/"), tailscaleServeEnabled: Option.some(true), tailscaleServePort: Option.some(8443), }, @@ -185,7 +185,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, - basePath: ROOT_BASE_PATH, + basePath: "/custom", tailscaleServeEnabled: true, tailscaleServePort: 8443, }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 9bd62bb3203..795094ef97e 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -2,7 +2,6 @@ import * as NetService from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import * as Config from "effect/Config"; -import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -14,7 +13,7 @@ import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { Argument, Flag } from "effect/unstable/cli"; -import { ROOT_BASE_PATH, normalizeBasePath } from "@t3tools/shared/basePath"; +import { normalizeBasePath } from "@t3tools/shared/basePath"; import { readBootstrapEnvelope } from "../bootstrap.ts"; import { DEFAULT_PORT, @@ -164,14 +163,6 @@ export interface CliServerFlags { readonly tailscaleServePort: Option.Option; } -export class DevBasePathUnsupportedError extends Data.TaggedError("DevBasePathUnsupportedError")<{ - readonly basePath: string; -}> { - override get message(): string { - return `Custom base paths are not supported with dev URL mode. Received ${this.basePath}.`; - } -} - export interface CliAuthLocationFlags { readonly baseDir: Option.Option; readonly devUrl?: Option.Option; @@ -365,9 +356,6 @@ export const resolveServerConfig = ( resolveOptionPrecedence(normalizedFlags.basePath, Option.fromUndefinedOr(env.basePath)), ), ); - if (devUrl !== undefined && basePath !== ROOT_BASE_PATH) { - return yield* new DevBasePathUnsupportedError({ basePath }); - } const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); const config: ServerConfigShape = { diff --git a/apps/web/src/basePath.ts b/apps/web/src/basePath.ts index 039ec851514..4dfc2dbaa5e 100644 --- a/apps/web/src/basePath.ts +++ b/apps/web/src/basePath.ts @@ -7,10 +7,6 @@ import { } from "@t3tools/shared/basePath"; function resolveRuntimeBasePath(): NormalizedBasePath { - if (import.meta.env.DEV) { - return ROOT_BASE_PATH; - } - const moduleUrl = new URL(import.meta.url); if (moduleUrl.protocol !== "http:" && moduleUrl.protocol !== "https:") { return ROOT_BASE_PATH; diff --git a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts index 873cd4968ca..7e847780e04 100644 --- a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +++ b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts @@ -8,7 +8,7 @@ const mockFetchRemoteEnvironmentDescriptor = vi.fn(); const mockBootstrapRemoteBearerSession = vi.fn(); const mockFetchRemoteSessionState = vi.fn(); const mockIsRemoteEnvironmentAuthHttpError = vi.fn((_: unknown) => false); -const mockResolveRemoteWebSocketBaseUrl = vi.fn(); +const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); const mockRemoteHttpRunPromise = vi.fn((effect: Promise) => effect); const mockBootstrapSshBearerSession = vi.fn(); const mockFetchSshSessionState = vi.fn(); @@ -129,7 +129,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, fetchRemoteSessionState: mockFetchRemoteSessionState, isRemoteEnvironmentAuthHttpError: mockIsRemoteEnvironmentAuthHttpError, - resolveRemoteWebSocketBaseUrl: mockResolveRemoteWebSocketBaseUrl, + resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, }; }); @@ -184,7 +184,7 @@ describe("addSavedEnvironment", () => { role: "owner", }); mockIsRemoteEnvironmentAuthHttpError.mockReturnValue(false); - mockResolveRemoteWebSocketBaseUrl.mockResolvedValue( + mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue( "wss://remote.example.com/?wsToken=remote-token", ); mockFetchSshEnvironmentDescriptor.mockResolvedValue({ diff --git a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts index 7b76fe8f0d8..d47a6109af0 100644 --- a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts +++ b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockCreateEnvironmentConnection = vi.fn(); const mockCreateWsRpcClient = vi.fn(); const mockFetchRemoteSessionState = vi.fn(); -const mockResolveRemoteWebSocketBaseUrl = vi.fn(() => "ws://remote.example.test"); +const mockResolveRemoteWebSocketConnectionUrl = vi.fn(() => "ws://remote.example.test"); const mockRemoteHttpRunPromise = vi.fn((effect: Promise) => effect); const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); const mockListSavedEnvironmentRecords = vi.fn(); @@ -74,7 +74,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { ...actual, createWsRpcClient: mockCreateWsRpcClient, fetchRemoteSessionState: mockFetchRemoteSessionState, - resolveRemoteWebSocketBaseUrl: mockResolveRemoteWebSocketBaseUrl, + resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, }; }); diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 3b5a7dfb490..6bb4a7c1f33 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -21,7 +21,7 @@ const mockReadSavedEnvironmentBearerToken = vi.fn(); const mockSavedEnvironmentRegistrySubscribe = vi.fn(); const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn()); const mockFetchRemoteSessionState = vi.fn(); -const mockResolveRemoteWebSocketBaseUrl = vi.fn(async () => "ws://remote.example.test/"); +const mockResolveRemoteWebSocketConnectionUrl = vi.fn(async () => "ws://remote.example.test/"); const mockRemoteHttpRunPromise = vi.fn((effect: Promise) => effect); const mockConnectionReconnects: Array> = []; let savedEnvironmentRegistryListener: (() => void) | null = null; @@ -154,7 +154,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { createWsRpcClient: vi.fn(() => stubWsClient), fetchRemoteSessionState: mockFetchRemoteSessionState, isRemoteEnvironmentAuthHttpError: vi.fn(() => false), - resolveRemoteWebSocketBaseUrl: mockResolveRemoteWebSocketBaseUrl, + resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, }; }); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index a0ac446127d..c149bc5f20e 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -16,7 +16,7 @@ import { fetchRemoteEnvironmentDescriptor, fetchRemoteSessionState, isRemoteEnvironmentAuthHttpError, - resolveRemoteWebSocketBaseUrl, + resolveRemoteWebSocketConnectionUrl, } from "@t3tools/client-runtime"; import { type QueryClient } from "@tanstack/react-query"; @@ -1165,7 +1165,7 @@ function createSavedEnvironmentClient( bearerToken, ) : await remoteHttpRuntime.runPromise( - resolveRemoteWebSocketBaseUrl({ + resolveRemoteWebSocketConnectionUrl({ wsBaseUrl: record.wsBaseUrl, httpBaseUrl: record.httpBaseUrl, bearerToken, diff --git a/packages/client-runtime/src/remote.test.ts b/packages/client-runtime/src/remote.test.ts index 158ceaf1d5d..89528bd7c94 100644 --- a/packages/client-runtime/src/remote.test.ts +++ b/packages/client-runtime/src/remote.test.ts @@ -12,7 +12,7 @@ import { issueRemoteWebSocketToken, remoteHttpClientLayer, RemoteEnvironmentAuthTimeoutError, - resolveRemoteWebSocketBaseUrl, + resolveRemoteWebSocketConnectionUrl, } from "./remote.ts"; type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit]; @@ -239,7 +239,7 @@ describe("remote", () => { ), ); - const url = yield* resolveRemoteWebSocketBaseUrl({ + const url = yield* resolveRemoteWebSocketConnectionUrl({ wsBaseUrl: "wss://remote.example.com/", httpBaseUrl: "https://remote.example.com/", bearerToken: "bearer-token", diff --git a/packages/client-runtime/src/remote.ts b/packages/client-runtime/src/remote.ts index 6ec589c8946..d0a757131d0 100644 --- a/packages/client-runtime/src/remote.ts +++ b/packages/client-runtime/src/remote.ts @@ -247,8 +247,8 @@ export const issueRemoteWebSocketToken = Effect.fn( }); }); -export const resolveRemoteWebSocketBaseUrl = Effect.fn( - "clientRuntime.remote.resolveRemoteWebSocketBaseUrl", +export const resolveRemoteWebSocketConnectionUrl = Effect.fn( + "clientRuntime.remote.resolveRemoteWebSocketConnectionUrl", )(function* (input: { readonly wsBaseUrl: string; readonly httpBaseUrl: string; @@ -263,7 +263,6 @@ export const resolveRemoteWebSocketBaseUrl = Effect.fn( const url = new URL(input.wsBaseUrl); const basePath = yield* normalizeBasePath(new URL(input.httpBaseUrl).pathname); - // WsRpcProtocol owns the RPC endpoint path; this helper only returns the authenticated base URL. url.pathname = `${basePath}/`; url.hash = ""; url.searchParams.set("wsToken", issued.token); diff --git a/packages/client-runtime/src/wsRpcProtocol.ts b/packages/client-runtime/src/wsRpcProtocol.ts index 27e49a118e9..2942e82ea35 100644 --- a/packages/client-runtime/src/wsRpcProtocol.ts +++ b/packages/client-runtime/src/wsRpcProtocol.ts @@ -86,7 +86,6 @@ function resolveWsRpcSocketUrl(rawUrl: string): string { throw new Error(`Unsupported websocket transport URL protocol: ${resolved.protocol}`); } - // WsRpcProtocol is the single owner of the concrete RPC route. resolved.pathname = `${Effect.runSync(normalizeBasePath(resolved.pathname))}${WS_RPC_PATH}`; resolved.hash = ""; return resolved.toString(); From a29e812e8168b77ad0febe63f6b1635ab7ff8818 Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sun, 31 May 2026 19:30:44 +0300 Subject: [PATCH 7/9] fix mobile remote asset urls --- apps/mobile/src/components/ProjectFavicon.tsx | 7 +++++- .../features/threads/threadPresentation.ts | 7 ++++-- apps/mobile/src/lib/remoteUrl.ts | 25 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/lib/remoteUrl.ts diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 32297d8d9d2..a3f377ce0ab 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,6 +1,7 @@ import { SymbolView } from "expo-symbols"; import { useState } from "react"; import { Image, View } from "react-native"; +import { resolveRemoteHttpUrl } from "../lib/remoteUrl"; import { useThemeColor } from "../lib/useThemeColor"; /* ─── Favicon cache (matches web pattern) ────────────────────────────── */ @@ -19,7 +20,11 @@ export function ProjectFavicon(props: { const faviconUrl = props.httpBaseUrl && props.workspaceRoot - ? `${props.httpBaseUrl}/api/project-favicon?cwd=${encodeURIComponent(props.workspaceRoot)}` + ? resolveRemoteHttpUrl({ + httpBaseUrl: props.httpBaseUrl, + pathname: "/api/project-favicon", + searchParams: { cwd: props.workspaceRoot }, + }) : null; const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index 4253cedbc7e..ea934327ace 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -1,5 +1,6 @@ import type { StatusTone } from "../../components/StatusPill"; import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; +import { resolveRemoteHttpUrl } from "../../lib/remoteUrl"; export function threadSortValue(thread: EnvironmentScopedThreadShell): number { const candidate = Date.parse(thread.updatedAt ?? thread.createdAt); @@ -48,6 +49,8 @@ export function messageImageUrl(httpBaseUrl: string | null, attachmentId: string return null; } - const url = new URL(`/attachments/${encodeURIComponent(attachmentId)}`, httpBaseUrl); - return url.toString(); + return resolveRemoteHttpUrl({ + httpBaseUrl, + pathname: `/attachments/${encodeURIComponent(attachmentId)}`, + }); } diff --git a/apps/mobile/src/lib/remoteUrl.ts b/apps/mobile/src/lib/remoteUrl.ts new file mode 100644 index 00000000000..d2d9886a86b --- /dev/null +++ b/apps/mobile/src/lib/remoteUrl.ts @@ -0,0 +1,25 @@ +import * as Effect from "effect/Effect"; + +import { normalizeBasePath } from "@t3tools/shared/basePath"; + +export function resolveRemoteHttpUrl(input: { + readonly httpBaseUrl: string; + readonly pathname: string; + readonly searchParams?: Readonly>; +}): string { + const url = new URL(input.httpBaseUrl); + const basePath = Effect.runSync(normalizeBasePath(url.pathname)); + const pathname = input.pathname.startsWith("/") ? input.pathname : `/${input.pathname}`; + + url.pathname = `${basePath}${pathname}`; + url.search = ""; + url.hash = ""; + + for (const [key, value] of Object.entries(input.searchParams ?? {})) { + if (value !== null && value !== undefined) { + url.searchParams.set(key, value); + } + } + + return url.toString(); +} From 73c02149dceb95ab2fca7bc5efe957c83339a5a1 Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sun, 31 May 2026 20:11:26 +0300 Subject: [PATCH 8/9] address base path review comments --- apps/web/src/components/SplashScreen.tsx | 8 +++++++- .../src/advertisedEndpoint.test.ts | 20 ++++++++++++++----- packages/shared/src/advertisedEndpoint.ts | 5 ++--- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/SplashScreen.tsx b/apps/web/src/components/SplashScreen.tsx index ed35097bdb8..06412549e5a 100644 --- a/apps/web/src/components/SplashScreen.tsx +++ b/apps/web/src/components/SplashScreen.tsx @@ -1,8 +1,14 @@ +import { BASE_PATH } from "../basePath"; + export function SplashScreen() { return (
- T3 Code + T3 Code
); diff --git a/packages/client-runtime/src/advertisedEndpoint.test.ts b/packages/client-runtime/src/advertisedEndpoint.test.ts index 75d041b5356..ba18ddcde02 100644 --- a/packages/client-runtime/src/advertisedEndpoint.test.ts +++ b/packages/client-runtime/src/advertisedEndpoint.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { NormalizedBasePath } from "@t3tools/shared/basePath"; import { classifyHostedHttpsCompatibility, createAdvertisedEndpoint, @@ -16,14 +17,23 @@ const coreProvider = { describe("advertised endpoint helpers", () => { it("normalizes HTTP and WebSocket base URLs", () => { - expect(normalizeHttpBaseUrl("https://example.com/path?x=1#hash")).toBe( - "https://example.com/path/", - ); - expect(normalizeHttpBaseUrl("wss://example.com/socket")).toBe("https://example.com/socket/"); - expect(deriveWsBaseUrl("https://example.com/api")).toBe("wss://example.com/api/"); + expect(normalizeHttpBaseUrl("https://example.com/path?x=1#hash")).toBe("https://example.com/"); + expect(normalizeHttpBaseUrl("wss://example.com/socket")).toBe("https://example.com/"); + expect(deriveWsBaseUrl("https://example.com/api")).toBe("wss://example.com/"); expect(deriveWsBaseUrl("http://127.0.0.1:3773")).toBe("ws://127.0.0.1:3773/"); }); + it("uses explicit base path when provided", () => { + const basePath = NormalizedBasePath("/custom"); + + expect(normalizeHttpBaseUrl("https://example.com/path?x=1#hash", { basePath })).toBe( + "https://example.com/custom/", + ); + expect(deriveWsBaseUrl("https://example.com/api", { basePath })).toBe( + "wss://example.com/custom/", + ); + }); + it("marks HTTP endpoints as blocked from hosted HTTPS apps", () => { expect(classifyHostedHttpsCompatibility("http://192.168.1.44:3773")).toBe( "mixed-content-blocked", diff --git a/packages/shared/src/advertisedEndpoint.ts b/packages/shared/src/advertisedEndpoint.ts index 2d6d298f746..ef7c842a4d1 100644 --- a/packages/shared/src/advertisedEndpoint.ts +++ b/packages/shared/src/advertisedEndpoint.ts @@ -6,9 +6,8 @@ import type { AdvertisedEndpointSource, AdvertisedEndpointStatus, } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import { normalizeBasePath, type NormalizedBasePath } from "./basePath.ts"; +import { ROOT_BASE_PATH, type NormalizedBasePath } from "./basePath.ts"; export interface CreateAdvertisedEndpointInput { readonly id: string; @@ -42,7 +41,7 @@ export function normalizeHttpBaseUrl( if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error(`Endpoint must use HTTP or HTTPS. Received ${url.protocol}`); } - url.pathname = `${options?.basePath ?? Effect.runSync(normalizeBasePath(url.pathname))}/`; + url.pathname = `${options?.basePath ?? ROOT_BASE_PATH}/`; url.search = ""; url.hash = ""; return url.toString(); From 054881699a1e7aee780a14518bbe539a8852a565 Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sun, 31 May 2026 20:26:23 +0300 Subject: [PATCH 9/9] fix runtime base path from assets --- apps/web/src/basePath.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/src/basePath.ts b/apps/web/src/basePath.ts index 4dfc2dbaa5e..8ba1a8ecadc 100644 --- a/apps/web/src/basePath.ts +++ b/apps/web/src/basePath.ts @@ -11,6 +11,12 @@ function resolveRuntimeBasePath(): NormalizedBasePath { if (moduleUrl.protocol !== "http:" && moduleUrl.protocol !== "https:") { return ROOT_BASE_PATH; } + + const assetsPathIndex = moduleUrl.pathname.lastIndexOf("/assets/"); + if (assetsPathIndex !== -1) { + return Effect.runSync(normalizeBasePath(moduleUrl.pathname.slice(0, assetsPathIndex))); + } + return Effect.runSync(normalizeBasePath(new URL("..", moduleUrl).pathname)); }