| ... | ... | @@ -1,7 +1,72 @@ |
| 1 | 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 | 68 | export interface PrimaryEnvironmentTarget { |
| 4 | | readonly source: "configured" | "window-origin" | "desktop-managed"; |
| 69 | readonly source: PrimaryEnvironmentTargetSource; |
| 5 | 70 | readonly target: { |
| 6 | 71 | readonly httpBaseUrl: string; |
| 7 | 72 | readonly wsBaseUrl: string; |
| ... | ... | @@ -14,15 +79,49 @@ function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | nu |
| 14 | 79 | return window.desktopBridge?.getLocalEnvironmentBootstrap() ?? null; |
| 15 | 80 | } |
| 16 | 81 | |
| 17 | | function normalizeBaseUrl(rawValue: string): string { |
| 18 | | return new URL(rawValue, window.location.origin).toString(); |
| 82 | function parseTargetUrl(input: { |
| 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 | 114 | function swapBaseUrlProtocol( |
| 22 | 115 | rawValue: string, |
| 23 | 116 | nextProtocol: "http:" | "https:" | "ws:" | "wss:", |
| 117 | urlKind: PrimaryEnvironmentUrlKind, |
| 24 | 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 | 125 | url.protocol = nextProtocol; |
| 27 | 126 | return url.toString(); |
| 28 | 127 | } |
| ... | ... | @@ -38,15 +137,29 @@ export function isLoopbackHostname(hostname: string): boolean { |
| 38 | 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 | 142 | const configuredDevServerUrl = import.meta.env.VITE_DEV_SERVER_URL?.trim(); |
| 43 | 143 | if (!configuredDevServerUrl) { |
| 44 | 144 | return httpBaseUrl; |
| 45 | 145 | } |
| 46 | 146 | |
| 47 | | const currentUrl = new URL(window.location.href); |
| 48 | | const targetUrl = new URL(httpBaseUrl); |
| 49 | | const devServerUrl = new URL(configuredDevServerUrl, currentUrl.origin); |
| 147 | const currentUrl = parseTargetUrl({ |
| 148 | rawValue: window.location.href, |
| 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 | 164 | const isCurrentOriginDevServer = |
| 52 | 165 | (currentUrl.protocol === "http:" || currentUrl.protocol === "https:") && |
| ... | ... | @@ -75,32 +188,39 @@ function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { |
| 75 | 188 | const resolvedHttpBaseUrl = |
| 76 | 189 | configuredHttpBaseUrl ?? |
| 77 | 190 | (configuredWsBaseUrl?.startsWith("wss:") |
| 78 | | ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:") |
| 79 | | : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:")); |
| 191 | ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:", "websocket-base-url") |
| 192 | : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:", "websocket-base-url")); |
| 80 | 193 | const resolvedWsBaseUrl = |
| 81 | 194 | configuredWsBaseUrl ?? |
| 82 | 195 | (configuredHttpBaseUrl?.startsWith("https:") |
| 83 | | ? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:") |
| 84 | | : swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:")); |
| 196 | ? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:", "http-base-url") |
| 197 | : swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:", "http-base-url")); |
| 85 | 198 | |
| 86 | 199 | return { |
| 87 | 200 | source: "configured", |
| 88 | 201 | target: { |
| 89 | | httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl), |
| 90 | | wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl), |
| 202 | httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl, "configured", "http-base-url"), |
| 203 | wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl, "configured", "websocket-base-url"), |
| 91 | 204 | }, |
| 92 | 205 | }; |
| 93 | 206 | } |
| 94 | 207 | |
| 95 | 208 | function resolveWindowOriginPrimaryTarget(): PrimaryEnvironmentTarget { |
| 96 | | const httpBaseUrl = normalizeBaseUrl(window.location.origin); |
| 97 | | const url = new URL(httpBaseUrl); |
| 209 | const url = parseTargetUrl({ |
| 210 | rawValue: window.location.origin, |
| 211 | source: "window-origin", |
| 212 | urlKind: "http-base-url", |
| 213 | }); |
| 214 | const httpBaseUrl = url.toString(); |
| 98 | 215 | if (url.protocol === "http:") { |
| 99 | 216 | url.protocol = "ws:"; |
| 100 | 217 | } else if (url.protocol === "https:") { |
| 101 | 218 | url.protocol = "wss:"; |
| 102 | 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 | 225 | return { |
| 106 | 226 | source: "window-origin", |
| ... | ... | @@ -120,16 +240,25 @@ function resolveDesktopPrimaryTarget(): PrimaryEnvironmentTarget | null { |
| 120 | 240 | return null; |
| 121 | 241 | } |
| 122 | 242 | if (!desktopBootstrap.httpBaseUrl || !desktopBootstrap.wsBaseUrl) { |
| 123 | | throw new Error( |
| 124 | | "Desktop bootstrap must provide both httpBaseUrl and wsBaseUrl for the local environment.", |
| 125 | | ); |
| 243 | throw new DesktopEnvironmentBootstrapIncompleteError({ |
| 244 | hasHttpBaseUrl: Boolean(desktopBootstrap.httpBaseUrl), |
| 245 | hasWsBaseUrl: Boolean(desktopBootstrap.wsBaseUrl), |
| 246 | }); |
| 126 | 247 | } |
| 127 | 248 | |
| 128 | 249 | return { |
| 129 | 250 | source: "desktop-managed", |
| 130 | 251 | target: { |
| 131 | | httpBaseUrl: normalizeBaseUrl(desktopBootstrap.httpBaseUrl), |
| 132 | | wsBaseUrl: normalizeBaseUrl(desktopBootstrap.wsBaseUrl), |
| 252 | httpBaseUrl: normalizeBaseUrl( |
| 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 | 268 | searchParams?: Record<string, string>, |
| 140 | 269 | ): string { |
| 141 | 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 | 277 | url.pathname = pathname; |
| 148 | 278 | if (searchParams) { |
| 149 | 279 | url.search = new URLSearchParams(searchParams).toString(); |
| ... | ... | @@ -151,7 +281,7 @@ export function resolvePrimaryEnvironmentHttpUrl( |
| 151 | 281 | return url.toString(); |
| 152 | 282 | } |
| 153 | 283 | |
| 154 | | export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null { |
| 284 | export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget { |
| 155 | 285 | return ( |
| 156 | 286 | resolveDesktopPrimaryTarget() ?? |
| 157 | 287 | resolveConfiguredPrimaryTarget() ?? |