diff --git a/apps/web/src/environments/primary/bootstrap.test.ts b/apps/web/src/environments/primary/bootstrap.test.ts index f3a5c2678ac28a5b7050405077dd3e3f1a855a66..e8333c2e078a239c8359f4d860d0252194933add 100644 --- a/apps/web/src/environments/primary/bootstrap.test.ts +++ b/apps/web/src/environments/primary/bootstrap.test.ts @@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { getPrimaryKnownEnvironment, + isDesktopEnvironmentBootstrapIncompleteError, + isPrimaryEnvironmentProtocolUnsupportedError, + isPrimaryEnvironmentUrlInvalidError, + readPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl, resolveInitialPrimaryEnvironmentDescriptor, resetPrimaryEnvironmentDescriptorForTests, @@ -43,6 +47,15 @@ function installTestBrowser(url: string) { }); } +function captureThrown(run: () => unknown): unknown { + try { + run(); + } catch (error) { + return error; + } + throw new Error("Expected the operation to throw."); +} + describe("environmentBootstrap", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -175,4 +188,67 @@ describe("environmentBootstrap", () => { "http://127.0.0.1:5733/.well-known/t3/environment", ); }); + + it("retains the URL parser cause without exposing the configured URL in its message", () => { + vi.stubEnv("VITE_HTTP_URL", "http://["); + + const error = captureThrown(readPrimaryEnvironmentTarget); + + expect(isPrimaryEnvironmentUrlInvalidError(error)).toBe(true); + if (!isPrimaryEnvironmentUrlInvalidError(error)) { + throw new Error("Expected a structured primary environment URL error."); + } + expect(error).toMatchObject({ + source: "configured", + urlKind: "http-base-url", + message: "Could not parse http-base-url for the configured primary environment target.", + }); + expect(error.cause).toBeInstanceOf(TypeError); + expect(error.message).not.toContain("http://["); + }); + + it("describes which desktop bootstrap endpoint is missing", () => { + vi.stubGlobal("window", { + location: new URL("http://127.0.0.1:5733/"), + history: { replaceState: vi.fn() }, + desktopBridge: { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://127.0.0.1:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + }, + }); + + const error = captureThrown(readPrimaryEnvironmentTarget); + + expect(isDesktopEnvironmentBootstrapIncompleteError(error)).toBe(true); + if (!isDesktopEnvironmentBootstrapIncompleteError(error)) { + throw new Error("Expected a structured desktop bootstrap error."); + } + expect(error).toMatchObject({ + hasHttpBaseUrl: true, + hasWsBaseUrl: false, + message: "Desktop bootstrap is missing wsBaseUrl for the local environment.", + }); + }); + + it("preserves an unsupported window-origin protocol", () => { + vi.stubGlobal("window", { + location: { origin: "file:///tmp/t3code/" }, + history: { replaceState: vi.fn() }, + }); + + const error = captureThrown(readPrimaryEnvironmentTarget); + + expect(isPrimaryEnvironmentProtocolUnsupportedError(error)).toBe(true); + if (!isPrimaryEnvironmentProtocolUnsupportedError(error)) { + throw new Error("Expected a structured primary environment protocol error."); + } + expect(error).toMatchObject({ + source: "window-origin", + protocol: "file:", + message: "The window-origin primary environment target uses unsupported protocol file:.", + }); + }); }); diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts index 3cb570d66abbffbb0f7c6ef4f493634cd2503146..e888560539db750f6849c7b96762e20421e649c2 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -38,7 +38,14 @@ export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionSta export { PrimaryEnvironmentHttpClient } from "./httpClient"; export { + DesktopEnvironmentBootstrapIncompleteError, + isDesktopEnvironmentBootstrapIncompleteError, + isPrimaryEnvironmentProtocolUnsupportedError, + isPrimaryEnvironmentUrlInvalidError, + PrimaryEnvironmentProtocolUnsupportedError, + PrimaryEnvironmentUrlInvalidError, readPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname, + type PrimaryEnvironmentTarget, } from "./target"; diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index c62907d3572718491304badfe31ab05244e6d368..a14a99ec4dc2f7f2f037000d9501effed4ce85ab 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -1,7 +1,72 @@ import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const PrimaryEnvironmentTargetSource = Schema.Literals([ + "configured", + "window-origin", + "desktop-managed", +]); +type PrimaryEnvironmentTargetSource = typeof PrimaryEnvironmentTargetSource.Type; + +const PrimaryEnvironmentUrlKind = Schema.Literals([ + "http-base-url", + "websocket-base-url", + "development-server-url", + "window-location-url", +]); +type PrimaryEnvironmentUrlKind = typeof PrimaryEnvironmentUrlKind.Type; + +export class PrimaryEnvironmentUrlInvalidError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentUrlInvalidError", + { + source: PrimaryEnvironmentTargetSource, + urlKind: PrimaryEnvironmentUrlKind, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not parse ${this.urlKind} for the ${this.source} primary environment target.`; + } +} + +export class PrimaryEnvironmentProtocolUnsupportedError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentProtocolUnsupportedError", + { + source: PrimaryEnvironmentTargetSource, + protocol: Schema.String, + }, +) { + override get message(): string { + return `The ${this.source} primary environment target uses unsupported protocol ${this.protocol}.`; + } +} + +export class DesktopEnvironmentBootstrapIncompleteError extends Schema.TaggedErrorClass()( + "DesktopEnvironmentBootstrapIncompleteError", + { + hasHttpBaseUrl: Schema.Boolean, + hasWsBaseUrl: Schema.Boolean, + }, +) { + override get message(): string { + const missing = [ + ...(this.hasHttpBaseUrl ? [] : ["httpBaseUrl"]), + ...(this.hasWsBaseUrl ? [] : ["wsBaseUrl"]), + ]; + return `Desktop bootstrap is missing ${missing.join(" and ")} for the local environment.`; + } +} + +export const isPrimaryEnvironmentUrlInvalidError = Schema.is(PrimaryEnvironmentUrlInvalidError); +export const isPrimaryEnvironmentProtocolUnsupportedError = Schema.is( + PrimaryEnvironmentProtocolUnsupportedError, +); +export const isDesktopEnvironmentBootstrapIncompleteError = Schema.is( + DesktopEnvironmentBootstrapIncompleteError, +); export interface PrimaryEnvironmentTarget { - readonly source: "configured" | "window-origin" | "desktop-managed"; + readonly source: PrimaryEnvironmentTargetSource; readonly target: { readonly httpBaseUrl: string; readonly wsBaseUrl: string; @@ -14,15 +79,49 @@ function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | nu return window.desktopBridge?.getLocalEnvironmentBootstrap() ?? null; } -function normalizeBaseUrl(rawValue: string): string { - return new URL(rawValue, window.location.origin).toString(); +function parseTargetUrl(input: { + readonly rawValue: string; + readonly baseUrl?: string; + readonly source: PrimaryEnvironmentTargetSource; + readonly urlKind: PrimaryEnvironmentUrlKind; +}): URL { + try { + return input.baseUrl === undefined + ? new URL(input.rawValue) + : new URL(input.rawValue, input.baseUrl); + } catch (cause) { + throw new PrimaryEnvironmentUrlInvalidError({ + source: input.source, + urlKind: input.urlKind, + cause, + }); + } +} + +function normalizeBaseUrl( + rawValue: string, + source: PrimaryEnvironmentTargetSource, + urlKind: PrimaryEnvironmentUrlKind, +): string { + return parseTargetUrl({ + rawValue, + baseUrl: window.location.origin, + source, + urlKind, + }).toString(); } function swapBaseUrlProtocol( rawValue: string, nextProtocol: "http:" | "https:" | "ws:" | "wss:", + urlKind: PrimaryEnvironmentUrlKind, ): string { - const url = new URL(normalizeBaseUrl(rawValue)); + const url = parseTargetUrl({ + rawValue, + baseUrl: window.location.origin, + source: "configured", + urlKind, + }); url.protocol = nextProtocol; return url.toString(); } @@ -38,15 +137,29 @@ export function isLoopbackHostname(hostname: string): boolean { return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname)); } -function resolveHttpRequestBaseUrl(httpBaseUrl: string): string { +function resolveHttpRequestBaseUrl(primaryTarget: PrimaryEnvironmentTarget): string { + const httpBaseUrl = primaryTarget.target.httpBaseUrl; const configuredDevServerUrl = import.meta.env.VITE_DEV_SERVER_URL?.trim(); if (!configuredDevServerUrl) { return httpBaseUrl; } - const currentUrl = new URL(window.location.href); - const targetUrl = new URL(httpBaseUrl); - const devServerUrl = new URL(configuredDevServerUrl, currentUrl.origin); + const currentUrl = parseTargetUrl({ + rawValue: window.location.href, + source: "window-origin", + urlKind: "window-location-url", + }); + const targetUrl = parseTargetUrl({ + rawValue: httpBaseUrl, + source: primaryTarget.source, + urlKind: "http-base-url", + }); + const devServerUrl = parseTargetUrl({ + rawValue: configuredDevServerUrl, + baseUrl: currentUrl.origin, + source: "configured", + urlKind: "development-server-url", + }); const isCurrentOriginDevServer = (currentUrl.protocol === "http:" || currentUrl.protocol === "https:") && @@ -75,32 +188,39 @@ function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { const resolvedHttpBaseUrl = configuredHttpBaseUrl ?? (configuredWsBaseUrl?.startsWith("wss:") - ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:") - : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:")); + ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:", "websocket-base-url") + : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:", "websocket-base-url")); const resolvedWsBaseUrl = configuredWsBaseUrl ?? (configuredHttpBaseUrl?.startsWith("https:") - ? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:") - : swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:")); + ? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:", "http-base-url") + : swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:", "http-base-url")); return { source: "configured", target: { - httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl), - wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl), + httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl, "configured", "http-base-url"), + wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl, "configured", "websocket-base-url"), }, }; } function resolveWindowOriginPrimaryTarget(): PrimaryEnvironmentTarget { - const httpBaseUrl = normalizeBaseUrl(window.location.origin); - const url = new URL(httpBaseUrl); + const url = parseTargetUrl({ + rawValue: window.location.origin, + source: "window-origin", + urlKind: "http-base-url", + }); + const httpBaseUrl = url.toString(); if (url.protocol === "http:") { url.protocol = "ws:"; } else if (url.protocol === "https:") { url.protocol = "wss:"; } else { - throw new Error(`Unsupported HTTP base URL protocol: ${url.protocol}`); + throw new PrimaryEnvironmentProtocolUnsupportedError({ + source: "window-origin", + protocol: url.protocol, + }); } return { source: "window-origin", @@ -120,16 +240,25 @@ function resolveDesktopPrimaryTarget(): PrimaryEnvironmentTarget | null { return null; } if (!desktopBootstrap.httpBaseUrl || !desktopBootstrap.wsBaseUrl) { - throw new Error( - "Desktop bootstrap must provide both httpBaseUrl and wsBaseUrl for the local environment.", - ); + throw new DesktopEnvironmentBootstrapIncompleteError({ + hasHttpBaseUrl: Boolean(desktopBootstrap.httpBaseUrl), + hasWsBaseUrl: Boolean(desktopBootstrap.wsBaseUrl), + }); } return { source: "desktop-managed", target: { - httpBaseUrl: normalizeBaseUrl(desktopBootstrap.httpBaseUrl), - wsBaseUrl: normalizeBaseUrl(desktopBootstrap.wsBaseUrl), + httpBaseUrl: normalizeBaseUrl( + desktopBootstrap.httpBaseUrl, + "desktop-managed", + "http-base-url", + ), + wsBaseUrl: normalizeBaseUrl( + desktopBootstrap.wsBaseUrl, + "desktop-managed", + "websocket-base-url", + ), }, }; } @@ -139,11 +268,12 @@ export function resolvePrimaryEnvironmentHttpUrl( searchParams?: Record, ): string { const primaryTarget = readPrimaryEnvironmentTarget(); - if (!primaryTarget) { - throw new Error("Unable to resolve the primary environment HTTP base URL."); - } - const url = new URL(resolveHttpRequestBaseUrl(primaryTarget.target.httpBaseUrl)); + const url = parseTargetUrl({ + rawValue: resolveHttpRequestBaseUrl(primaryTarget), + source: primaryTarget.source, + urlKind: "http-base-url", + }); url.pathname = pathname; if (searchParams) { url.search = new URLSearchParams(searchParams).toString(); @@ -151,7 +281,7 @@ export function resolvePrimaryEnvironmentHttpUrl( return url.toString(); } -export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null { +export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget { return ( resolveDesktopPrimaryTarget() ?? resolveConfiguredPrimaryTarget() ??