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(); +} diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index 238475aca37..2117a5862cd 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -28,6 +28,7 @@ import { SessionCredentialService, } from "../Services/SessionCredentialService.ts"; import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts"; +import { ServerConfig } from "../../config.ts"; type BootstrapExchangeResult = { readonly response: AuthBootstrapResult; @@ -67,6 +68,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 => @@ -316,15 +318,14 @@ 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); + url.pathname = `${serverConfig.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/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/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 9e73773d5a5..21ee746853e 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 } from "@t3tools/shared/basePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { deriveServerPaths } from "../config.ts"; import { resolveServerConfig } from "./config.ts"; @@ -46,7 +47,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", } as const; - const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) { const fs = yield* FileSystem.FileSystem; const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); @@ -73,6 +73,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 +117,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 +141,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.some(true), logWebSocketEvents: Option.some(true), + basePath: Option.some("/custom/"), tailscaleServeEnabled: Option.some(true), tailscaleServePort: Option.some(8443), }, @@ -182,6 +185,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, + basePath: "/custom", tailscaleServeEnabled: true, tailscaleServePort: 8443, }); @@ -213,6 +217,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 +256,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 +294,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 +332,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 +359,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 +419,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 +460,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 +497,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 +531,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 +556,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 +596,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..480f2923939 100644 --- a/apps/server/src/cliAuthFormat.ts +++ b/apps/server/src/cliAuthFormat.ts @@ -1,4 +1,5 @@ import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; +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"; @@ -29,12 +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("/pair", options.baseUrl); + const url = new URL(options.baseUrl); + 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/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,14 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { return redirectUrl.toString(); } +function rewriteIndexHtmlAssetUrls(html: string, basePath: NormalizedBasePath): string { + return html.replace( + /\b(src|href)=(["'])(?:\.\/|\/(?!\/))([^"']+)\2/gu, + (_match, attribute: string, quote: string, pathname: string) => + `${attribute}=${quote}${basePath}/${pathname}${quote}`, + ); +} + const requireAuthenticatedRequest = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* ServerAuth; @@ -232,93 +243,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(rewriteIndexHtmlAssetUrls(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..55ab752fe46 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -408,9 +408,19 @@ export const makeServerLayer = Layer.unwrap( : Layer.empty; const serverApplicationLayer = Layer.mergeAll( - HttpRouter.serve(makeRoutesLayer, { - 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 c069623ca8f..46f8bd33c5d 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -250,7 +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; + 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 d3b6898d75b..faf316d70b3 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,6 +1,7 @@ import { networkInterfaces } from "node:os"; import { QrCode } from "@t3tools/shared/qrCode"; +import { ROOT_BASE_PATH, type NormalizedBasePath } from "@t3tools/shared/basePath"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/unstable/http"; @@ -71,10 +72,11 @@ export const resolveHeadlessConnectionHost = ( export const resolveHeadlessConnectionString = ( host: string | undefined, port: number, + basePath: NormalizedBasePath = ROOT_BASE_PATH, interfaces: NetworkInterfacesMap = networkInterfaces(), ): string => { const connectionHost = resolveHeadlessConnectionHost(host, interfaces); - return `http://${formatHostForUrl(connectionHost)}:${port}`; + return `http://${formatHostForUrl(connectionHost)}:${port}${basePath}`; }; export const resolveListeningPort = (address: unknown, fallbackPort: number): number => { @@ -89,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 = "/pair"; + url.pathname = `${basePath}/pair`; url.searchParams.delete("token"); url.hash = new URLSearchParams([["token", token]]).toString(); return url.toString(); @@ -137,12 +143,13 @@ export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessI const connectionString = resolveHeadlessConnectionString( serverConfig.host, resolveListeningPort(httpServer.address, serverConfig.port), + serverConfig.basePath, ); 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 new file mode 100644 index 00000000000..8ba1a8ecadc --- /dev/null +++ b/apps/web/src/basePath.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; + +import { + ROOT_BASE_PATH, + normalizeBasePath, + type NormalizedBasePath, +} from "@t3tools/shared/basePath"; + +function resolveRuntimeBasePath(): NormalizedBasePath { + const moduleUrl = new URL(import.meta.url); + 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)); +} + +export const BASE_PATH = resolveRuntimeBasePath(); 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..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/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx index 65e9c6dd8eb..bf0dbd9388b 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 { BASE_PATH } 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..867b3e84e63 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 { BASE_PATH } 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(`${BASE_PATH}/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..906bb99d476 100644 --- a/apps/web/src/components/settings/pairingUrls.ts +++ b/apps/web/src/components/settings/pairingUrls.ts @@ -1,9 +1,12 @@ +import { 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 = `${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..ad375ba7e25 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 { normalizeBasePath } from "@t3tools/shared/basePath"; + +import { BASE_PATH } 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}${BASE_PATH}/`; 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 = `${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..9243203e0c0 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 { 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 = `${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/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 0ce5f51d93c..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 mockResolveRemoteWebSocketConnectionUrl = vi.fn(async () => "ws://remote.example.test/ws"); +const mockResolveRemoteWebSocketConnectionUrl = vi.fn(async () => "ws://remote.example.test/"); const mockRemoteHttpRunPromise = vi.fn((effect: Promise) => effect); const mockConnectionReconnects: Array> = []; let savedEnvironmentRegistryListener: (() => void) | null = null; diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index fff04830434..c149bc5f20e 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -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,7 +1159,7 @@ function createSavedEnvironmentClient( throw new Error(`Saved environment ${environmentId} not found.`); } return record.desktopSsh - ? await resolveDesktopSshWebSocketConnectionUrl( + ? await resolveDesktopSshWebSocketBaseUrl( record.wsBaseUrl, record.httpBaseUrl, bearerToken, diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 68a7dfaa931..8ff8edf5c6a 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 { 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); +const router = getRouter(history, BASE_PATH); 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..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, @@ -22,6 +23,17 @@ describe("advertised endpoint helpers", () => { 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/client-runtime/src/remote.test.ts b/packages/client-runtime/src/remote.test.ts index c832f4f3858..89528bd7c94 100644 --- a/packages/client-runtime/src/remote.test.ts +++ b/packages/client-runtime/src/remote.test.ts @@ -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( @@ -245,7 +245,7 @@ describe("remote", () => { 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 34dc5aef249..d0a757131d0 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 { normalizeBasePath } from "@t3tools/shared/basePath"; import { FetchHttpClient, HttpClient, @@ -28,7 +29,7 @@ const decodeRemoteAuthErrorBody = decodeJsonResult(RemoteAuthErrorBody); const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { const url = new URL(httpBaseUrl); - url.pathname = pathname; + url.pathname = `${Effect.runSync(normalizeBasePath(url.pathname))}${pathname}`; url.search = ""; url.hash = ""; return url.toString(); @@ -261,9 +262,9 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( }); const url = new URL(input.wsBaseUrl); - if (url.pathname === "" || url.pathname === "/") { - url.pathname = "/ws"; - } + const basePath = yield* normalizeBasePath(new URL(input.httpBaseUrl).pathname); + 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 283512a3ecb..2942e82ea35 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 { 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,8 @@ function resolveWsRpcSocketUrl(rawUrl: string): string { throw new Error(`Unsupported websocket transport URL protocol: ${resolved.protocol}`); } - resolved.pathname = "/ws"; + resolved.pathname = `${Effect.runSync(normalizeBasePath(resolved.pathname))}${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..ef7c842a4d1 100644 --- a/packages/shared/src/advertisedEndpoint.ts +++ b/packages/shared/src/advertisedEndpoint.ts @@ -7,11 +7,14 @@ import type { AdvertisedEndpointStatus, } from "@t3tools/contracts"; +import { ROOT_BASE_PATH, 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"; @@ -21,26 +24,34 @@ 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:"; } 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 = `${options?.basePath ?? ROOT_BASE_PATH}/`; 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(); } @@ -48,8 +59,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"; } @@ -57,17 +69,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 new file mode 100644 index 00000000000..0f6bb9d319f --- /dev/null +++ b/packages/shared/src/basePath.ts @@ -0,0 +1,54 @@ +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 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..8776b3f146b 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,25 +32,20 @@ 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(url.toString()); - if (next.protocol === "http:") { - next.protocol = "ws:"; - } else if (next.protocol === "https:") { - next.protocol = "wss:"; - } - next.pathname = "/"; - next.search = ""; - next.hash = ""; + const next = new URL(toHttpBaseUrl(url)); + next.protocol = next.protocol === "https:" ? "wss:" : "ws:"; return next.toString(); }; +const toHttpBaseUrlFromPairingUrl = (url: URL): string => toHttpBaseUrl(new URL(".", url)); + export interface ResolvedRemotePairingTarget { readonly credential: string; readonly httpBaseUrl: string; @@ -126,10 +124,11 @@ export const resolveRemotePairingTarget = (input: { if (!credential) { throw new Error("Pairing URL is missing its token."); } + const httpBaseUrl = toHttpBaseUrlFromPairingUrl(url); 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..41807ede9d6 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -5,6 +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, type NormalizedBasePath } from "@t3tools/shared/basePath"; export const DEFAULT_TAILSCALE_SERVE_PORT = 443; export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500; @@ -192,13 +193,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(); } @@ -290,12 +292,16 @@ 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("/.well-known/t3/environment", input.baseUrl); + const url = new URL(input.baseUrl); + url.pathname = `${input.basePath ?? ROOT_BASE_PATH}/.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 +315,7 @@ export const probeTailscaleHttpsEndpoint = (input: { export const resolveTailscaleHttpsBaseUrl = ( input: { readonly servePort?: number; + readonly basePath?: NormalizedBasePath; } = {}, ): Effect.Effect< string | null, @@ -321,6 +328,7 @@ export const resolveTailscaleHttpsBaseUrl = ( ? buildTailscaleHttpsBaseUrl({ magicDnsName: status.magicDnsName, ...(input.servePort === undefined ? {} : { servePort: input.servePort }), + ...(input.basePath === undefined ? {} : { basePath: input.basePath }), }) : null, ),