| ... | @@ -1,7 +1,72 @@ | ... | @@ -1,7 +1,72 @@ |
| 1 | import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; | 1 | import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; |
| | 2 | import * as Schema from "effect/Schema"; |
| | 3 | |
| | 4 | const PrimaryEnvironmentTargetSource = Schema.Literals([ |
| | 5 | "configured", |
| | 6 | "window-origin", |
| | 7 | "desktop-managed", |
| | 8 | ]); |
| | 9 | type PrimaryEnvironmentTargetSource = typeof PrimaryEnvironmentTargetSource.Type; |
| | 10 | |
| | 11 | const PrimaryEnvironmentUrlKind = Schema.Literals([ |
| | 12 | "http-base-url", |
| | 13 | "websocket-base-url", |
| | 14 | "development-server-url", |
| | 15 | "window-location-url", |
| | 16 | ]); |
| | 17 | type PrimaryEnvironmentUrlKind = typeof PrimaryEnvironmentUrlKind.Type; |
| | 18 | |
| | 19 | export class PrimaryEnvironmentUrlInvalidError extends Schema.TaggedErrorClass<PrimaryEnvironmentUrlInvalidError>()( |
| | 20 | "PrimaryEnvironmentUrlInvalidError", |
| | 21 | { |
| | 22 | source: PrimaryEnvironmentTargetSource, |
| | 23 | urlKind: PrimaryEnvironmentUrlKind, |
| | 24 | cause: Schema.Defect(), |
| | 25 | }, |
| | 26 | ) { |
| | 27 | override get message(): string { |
| | 28 | return `Could not parse ${this.urlKind} for the ${this.source} primary environment target.`; |
| | 29 | } |
| | 30 | } |
| | 31 | |
| | 32 | export class PrimaryEnvironmentProtocolUnsupportedError extends Schema.TaggedErrorClass<PrimaryEnvironmentProtocolUnsupportedError>()( |
| | 33 | "PrimaryEnvironmentProtocolUnsupportedError", |
| | 34 | { |
| | 35 | source: PrimaryEnvironmentTargetSource, |
| | 36 | protocol: Schema.String, |
| | 37 | }, |
| | 38 | ) { |
| | 39 | override get message(): string { |
| | 40 | return `The ${this.source} primary environment target uses unsupported protocol ${this.protocol}.`; |
| | 41 | } |
| | 42 | } |
| | 43 | |
| | 44 | export class DesktopEnvironmentBootstrapIncompleteError extends Schema.TaggedErrorClass<DesktopEnvironmentBootstrapIncompleteError>()( |
| | 45 | "DesktopEnvironmentBootstrapIncompleteError", |
| | 46 | { |
| | 47 | hasHttpBaseUrl: Schema.Boolean, |
| | 48 | hasWsBaseUrl: Schema.Boolean, |
| | 49 | }, |
| | 50 | ) { |
| | 51 | override get message(): string { |
| | 52 | const missing = [ |
| | 53 | ...(this.hasHttpBaseUrl ? [] : ["httpBaseUrl"]), |
| | 54 | ...(this.hasWsBaseUrl ? [] : ["wsBaseUrl"]), |
| | 55 | ]; |
| | 56 | return `Desktop bootstrap is missing ${missing.join(" and ")} for the local environment.`; |
| | 57 | } |
| | 58 | } |
| | 59 | |
| | 60 | export const isPrimaryEnvironmentUrlInvalidError = Schema.is(PrimaryEnvironmentUrlInvalidError); |
| | 61 | export const isPrimaryEnvironmentProtocolUnsupportedError = Schema.is( |
| | 62 | PrimaryEnvironmentProtocolUnsupportedError, |
| | 63 | ); |
| | 64 | export const isDesktopEnvironmentBootstrapIncompleteError = Schema.is( |
| | 65 | DesktopEnvironmentBootstrapIncompleteError, |
| | 66 | ); |
| 2 | | 67 | |
| 3 | export interface PrimaryEnvironmentTarget { | 68 | export interface PrimaryEnvironmentTarget { |
| 4 | readonly source: "configured" | "window-origin" | "desktop-managed"; | 69 | readonly source: PrimaryEnvironmentTargetSource; |
| 5 | readonly target: { | 70 | readonly target: { |
| 6 | readonly httpBaseUrl: string; | 71 | readonly httpBaseUrl: string; |
| 7 | readonly wsBaseUrl: string; | 72 | readonly wsBaseUrl: string; |
| ... | @@ -14,15 +79,49 @@ function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | nu | ... | @@ -14,15 +79,49 @@ function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | nu |
| 14 | return window.desktopBridge?.getLocalEnvironmentBootstrap() ?? null; | 79 | return window.desktopBridge?.getLocalEnvironmentBootstrap() ?? null; |
| 15 | } | 80 | } |
| 16 | | 81 | |
| 17 | function normalizeBaseUrl(rawValue: string): string { | 82 | function parseTargetUrl(input: { |
| 18 | return new URL(rawValue, window.location.origin).toString(); | 83 | readonly rawValue: string; |
| | 84 | readonly baseUrl?: string; |
| | 85 | readonly source: PrimaryEnvironmentTargetSource; |
| | 86 | readonly urlKind: PrimaryEnvironmentUrlKind; |
| | 87 | }): URL { |
| | 88 | try { |
| | 89 | return input.baseUrl === undefined |
| | 90 | ? new URL(input.rawValue) |
| | 91 | : new URL(input.rawValue, input.baseUrl); |
| | 92 | } catch (cause) { |
| | 93 | throw new PrimaryEnvironmentUrlInvalidError({ |
| | 94 | source: input.source, |
| | 95 | urlKind: input.urlKind, |
| | 96 | cause, |
| | 97 | }); |
| | 98 | } |
| | 99 | } |
| | 100 | |
| | 101 | function normalizeBaseUrl( |
| | 102 | rawValue: string, |
| | 103 | source: PrimaryEnvironmentTargetSource, |
| | 104 | urlKind: PrimaryEnvironmentUrlKind, |
| | 105 | ): string { |
| | 106 | return parseTargetUrl({ |
| | 107 | rawValue, |
| | 108 | baseUrl: window.location.origin, |
| | 109 | source, |
| | 110 | urlKind, |
| | 111 | }).toString(); |
| 19 | } | 112 | } |
| 20 | | 113 | |
| 21 | function swapBaseUrlProtocol( | 114 | function swapBaseUrlProtocol( |
| 22 | rawValue: string, | 115 | rawValue: string, |
| 23 | nextProtocol: "http:" | "https:" | "ws:" | "wss:", | 116 | nextProtocol: "http:" | "https:" | "ws:" | "wss:", |
| | 117 | urlKind: PrimaryEnvironmentUrlKind, |
| 24 | ): string { | 118 | ): string { |
| 25 | const url = new URL(normalizeBaseUrl(rawValue)); | 119 | const url = parseTargetUrl({ |
| | 120 | rawValue, |
| | 121 | baseUrl: window.location.origin, |
| | 122 | source: "configured", |
| | 123 | urlKind, |
| | 124 | }); |
| 26 | url.protocol = nextProtocol; | 125 | url.protocol = nextProtocol; |
| 27 | return url.toString(); | 126 | return url.toString(); |
| 28 | } | 127 | } |
| ... | @@ -38,15 +137,29 @@ export function isLoopbackHostname(hostname: string): boolean { | ... | @@ -38,15 +137,29 @@ export function isLoopbackHostname(hostname: string): boolean { |
| 38 | return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname)); | 137 | return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname)); |
| 39 | } | 138 | } |
| 40 | | 139 | |
| 41 | function resolveHttpRequestBaseUrl(httpBaseUrl: string): string { | 140 | function resolveHttpRequestBaseUrl(primaryTarget: PrimaryEnvironmentTarget): string { |
| | 141 | const httpBaseUrl = primaryTarget.target.httpBaseUrl; |
| 42 | const configuredDevServerUrl = import.meta.env.VITE_DEV_SERVER_URL?.trim(); | 142 | const configuredDevServerUrl = import.meta.env.VITE_DEV_SERVER_URL?.trim(); |
| 43 | if (!configuredDevServerUrl) { | 143 | if (!configuredDevServerUrl) { |
| 44 | return httpBaseUrl; | 144 | return httpBaseUrl; |
| 45 | } | 145 | } |
| 46 | | 146 | |
| 47 | const currentUrl = new URL(window.location.href); | 147 | const currentUrl = parseTargetUrl({ |
| 48 | const targetUrl = new URL(httpBaseUrl); | 148 | rawValue: window.location.href, |
| 49 | const devServerUrl = new URL(configuredDevServerUrl, currentUrl.origin); | 149 | source: "window-origin", |
| | 150 | urlKind: "window-location-url", |
| | 151 | }); |
| | 152 | const targetUrl = parseTargetUrl({ |
| | 153 | rawValue: httpBaseUrl, |
| | 154 | source: primaryTarget.source, |
| | 155 | urlKind: "http-base-url", |
| | 156 | }); |
| | 157 | const devServerUrl = parseTargetUrl({ |
| | 158 | rawValue: configuredDevServerUrl, |
| | 159 | baseUrl: currentUrl.origin, |
| | 160 | source: "configured", |
| | 161 | urlKind: "development-server-url", |
| | 162 | }); |
| 50 | | 163 | |
| 51 | const isCurrentOriginDevServer = | 164 | const isCurrentOriginDevServer = |
| 52 | (currentUrl.protocol === "http:" || currentUrl.protocol === "https:") && | 165 | (currentUrl.protocol === "http:" || currentUrl.protocol === "https:") && |
| ... | @@ -75,32 +188,39 @@ function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { | ... | @@ -75,32 +188,39 @@ function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { |
| 75 | const resolvedHttpBaseUrl = | 188 | const resolvedHttpBaseUrl = |
| 76 | configuredHttpBaseUrl ?? | 189 | configuredHttpBaseUrl ?? |
| 77 | (configuredWsBaseUrl?.startsWith("wss:") | 190 | (configuredWsBaseUrl?.startsWith("wss:") |
| 78 | ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:") | 191 | ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:", "websocket-base-url") |
| 79 | : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:")); | 192 | : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:", "websocket-base-url")); |
| 80 | const resolvedWsBaseUrl = | 193 | const resolvedWsBaseUrl = |
| 81 | configuredWsBaseUrl ?? | 194 | configuredWsBaseUrl ?? |
| 82 | (configuredHttpBaseUrl?.startsWith("https:") | 195 | (configuredHttpBaseUrl?.startsWith("https:") |
| 83 | ? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:") | 196 | ? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:", "http-base-url") |
| 84 | : swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:")); | 197 | : swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:", "http-base-url")); |
| 85 | | 198 | |
| 86 | return { | 199 | return { |
| 87 | source: "configured", | 200 | source: "configured", |
| 88 | target: { | 201 | target: { |
| 89 | httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl), | 202 | httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl, "configured", "http-base-url"), |
| 90 | wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl), | 203 | wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl, "configured", "websocket-base-url"), |
| 91 | }, | 204 | }, |
| 92 | }; | 205 | }; |
| 93 | } | 206 | } |
| 94 | | 207 | |
| 95 | function resolveWindowOriginPrimaryTarget(): PrimaryEnvironmentTarget { | 208 | function resolveWindowOriginPrimaryTarget(): PrimaryEnvironmentTarget { |
| 96 | const httpBaseUrl = normalizeBaseUrl(window.location.origin); | 209 | const url = parseTargetUrl({ |
| 97 | const url = new URL(httpBaseUrl); | 210 | rawValue: window.location.origin, |
| | 211 | source: "window-origin", |
| | 212 | urlKind: "http-base-url", |
| | 213 | }); |
| | 214 | const httpBaseUrl = url.toString(); |
| 98 | if (url.protocol === "http:") { | 215 | if (url.protocol === "http:") { |
| 99 | url.protocol = "ws:"; | 216 | url.protocol = "ws:"; |
| 100 | } else if (url.protocol === "https:") { | 217 | } else if (url.protocol === "https:") { |
| 101 | url.protocol = "wss:"; | 218 | url.protocol = "wss:"; |
| 102 | } else { | 219 | } else { |
| 103 | throw new Error(`Unsupported HTTP base URL protocol: ${url.protocol}`); | 220 | throw new PrimaryEnvironmentProtocolUnsupportedError({ |
| | 221 | source: "window-origin", |
| | 222 | protocol: url.protocol, |
| | 223 | }); |
| 104 | } | 224 | } |
| 105 | return { | 225 | return { |
| 106 | source: "window-origin", | 226 | source: "window-origin", |
| ... | @@ -120,16 +240,25 @@ function resolveDesktopPrimaryTarget(): PrimaryEnvironmentTarget | null { | ... | @@ -120,16 +240,25 @@ function resolveDesktopPrimaryTarget(): PrimaryEnvironmentTarget | null { |
| 120 | return null; | 240 | return null; |
| 121 | } | 241 | } |
| 122 | if (!desktopBootstrap.httpBaseUrl || !desktopBootstrap.wsBaseUrl) { | 242 | if (!desktopBootstrap.httpBaseUrl || !desktopBootstrap.wsBaseUrl) { |
| 123 | throw new Error( | 243 | throw new DesktopEnvironmentBootstrapIncompleteError({ |
| 124 | "Desktop bootstrap must provide both httpBaseUrl and wsBaseUrl for the local environment.", | 244 | hasHttpBaseUrl: Boolean(desktopBootstrap.httpBaseUrl), |
| 125 | ); | 245 | hasWsBaseUrl: Boolean(desktopBootstrap.wsBaseUrl), |
| | 246 | }); |
| 126 | } | 247 | } |
| 127 | | 248 | |
| 128 | return { | 249 | return { |
| 129 | source: "desktop-managed", | 250 | source: "desktop-managed", |
| 130 | target: { | 251 | target: { |
| 131 | httpBaseUrl: normalizeBaseUrl(desktopBootstrap.httpBaseUrl), | 252 | httpBaseUrl: normalizeBaseUrl( |
| 132 | wsBaseUrl: normalizeBaseUrl(desktopBootstrap.wsBaseUrl), | 253 | desktopBootstrap.httpBaseUrl, |
| | 254 | "desktop-managed", |
| | 255 | "http-base-url", |
| | 256 | ), |
| | 257 | wsBaseUrl: normalizeBaseUrl( |
| | 258 | desktopBootstrap.wsBaseUrl, |
| | 259 | "desktop-managed", |
| | 260 | "websocket-base-url", |
| | 261 | ), |
| 133 | }, | 262 | }, |
| 134 | }; | 263 | }; |
| 135 | } | 264 | } |
| ... | @@ -139,11 +268,12 @@ export function resolvePrimaryEnvironmentHttpUrl( | ... | @@ -139,11 +268,12 @@ export function resolvePrimaryEnvironmentHttpUrl( |
| 139 | searchParams?: Record<string, string>, | 268 | searchParams?: Record<string, string>, |
| 140 | ): string { | 269 | ): string { |
| 141 | const primaryTarget = readPrimaryEnvironmentTarget(); | 270 | const primaryTarget = readPrimaryEnvironmentTarget(); |
| 142 | if (!primaryTarget) { | | |
| 143 | throw new Error("Unable to resolve the primary environment HTTP base URL."); | | |
| 144 | } | | |
| 145 | | 271 | |
| 146 | const url = new URL(resolveHttpRequestBaseUrl(primaryTarget.target.httpBaseUrl)); | 272 | const url = parseTargetUrl({ |
| | 273 | rawValue: resolveHttpRequestBaseUrl(primaryTarget), |
| | 274 | source: primaryTarget.source, |
| | 275 | urlKind: "http-base-url", |
| | 276 | }); |
| 147 | url.pathname = pathname; | 277 | url.pathname = pathname; |
| 148 | if (searchParams) { | 278 | if (searchParams) { |
| 149 | url.search = new URLSearchParams(searchParams).toString(); | 279 | url.search = new URLSearchParams(searchParams).toString(); |
| ... | @@ -151,7 +281,7 @@ export function resolvePrimaryEnvironmentHttpUrl( | ... | @@ -151,7 +281,7 @@ export function resolvePrimaryEnvironmentHttpUrl( |
| 151 | return url.toString(); | 281 | return url.toString(); |
| 152 | } | 282 | } |
| 153 | | 283 | |
| 154 | export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null { | 284 | export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget { |
| 155 | return ( | 285 | return ( |
| 156 | resolveDesktopPrimaryTarget() ?? | 286 | resolveDesktopPrimaryTarget() ?? |
| 157 | resolveConfiguredPrimaryTarget() ?? | 287 | resolveConfiguredPrimaryTarget() ?? |