1import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts";
2import {
3 deriveWsBaseUrl,
4 environmentEndpointUrl,
5 normalizeHttpBaseUrl,
6} from "@t3tools/shared/advertisedEndpoint";
7import * as Schema from "effect/Schema";
8
9import { isLocalEnvironmentDisabled } from "../../localEnvironment";
10
11const PrimaryEnvironmentTargetSource = Schema.Literals([
12 "configured",
13 "window-origin",
14 "desktop-managed",
15]);
16type PrimaryEnvironmentTargetSource = typeof PrimaryEnvironmentTargetSource.Type;
17
18const PrimaryEnvironmentUrlKind = Schema.Literals([
19 "http-base-url",
20 "websocket-base-url",
21 "development-server-url",
22 "window-location-url",
23]);
24type PrimaryEnvironmentUrlKind = typeof PrimaryEnvironmentUrlKind.Type;
25
26export class PrimaryEnvironmentUrlInvalidError extends Schema.TaggedError<PrimaryEnvironmentUrlInvalidError>()(
27 "PrimaryEnvironmentUrlInvalidError",
28 {
29 source: PrimaryEnvironmentTargetSource,
30 urlKind: PrimaryEnvironmentUrlKind,
31 cause: Schema.Defect(),
32 },
33) {
34 override get message(): string {
35 return `Could not parse ${this.urlKind} for the ${this.source} primary environment target.`;
36 }
37}
38
39export class PrimaryEnvironmentProtocolUnsupportedError extends Schema.TaggedError<PrimaryEnvironmentProtocolUnsupportedError>()(
40 "PrimaryEnvironmentProtocolUnsupportedError",
41 {
42 source: PrimaryEnvironmentTargetSource,
43 protocol: Schema.String,
44 },
45) {
46 override get message(): string {
47 return `The ${this.source} primary environment target uses unsupported protocol ${this.protocol}.`;
48 }
49}
50
51export class DesktopEnvironmentBootstrapIncompleteError extends Schema.TaggedError<DesktopEnvironmentBootstrapIncompleteError>()(
52 "DesktopEnvironmentBootstrapIncompleteError",
53 {
54 hasHttpBaseUrl: Schema.Boolean,
55 hasWsBaseUrl: Schema.Boolean,
56 },
57) {
58 override get message(): string {
59 const missing = [
60 ...(this.hasHttpBaseUrl ? [] : ["httpBaseUrl"]),
61 ...(this.hasWsBaseUrl ? [] : ["wsBaseUrl"]),
62 ];
63 return `Desktop bootstrap is missing ${missing.join(" and ")} for the local environment.`;
64 }
65}
66
67export class PrimaryEnvironmentDisabledError extends Schema.TaggedError<PrimaryEnvironmentDisabledError>()(
68 "PrimaryEnvironmentDisabledError",
69 {},
70) {
71 override get message(): string {
72 return "The local environment is disabled.";
73 }
74}
75
76export const isPrimaryEnvironmentUrlInvalidError = Schema.is(PrimaryEnvironmentUrlInvalidError);
77export const isPrimaryEnvironmentProtocolUnsupportedError = Schema.is(
78 PrimaryEnvironmentProtocolUnsupportedError,
79);
80export const isDesktopEnvironmentBootstrapIncompleteError = Schema.is(
81 DesktopEnvironmentBootstrapIncompleteError,
82);
83
84export interface PrimaryEnvironmentTarget {
85 readonly source: PrimaryEnvironmentTargetSource;
86 readonly target: {
87 readonly httpBaseUrl: string;
88 readonly wsBaseUrl: string;
89 };
90}
91
92const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
93
94function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | null {
95 // The primary (Windows-native) backend keeps the "primary" id. The
96 // plural list may include a second WSL entry; the primary-target
97 // resolver only cares about the primary, so just find it.
98 const bootstraps = window.desktopBridge?.getLocalEnvironmentBootstraps() ?? [];
99 return bootstraps.find((entry) => entry.id === PRIMARY_LOCAL_ENVIRONMENT_ID) ?? null;
100}
101
102function parseTargetUrl(input: {
103 readonly rawValue: string;
104 readonly baseUrl?: string;
105 readonly source: PrimaryEnvironmentTargetSource;
106 readonly urlKind: PrimaryEnvironmentUrlKind;
107}): URL {
108 try {
109 return input.baseUrl === undefined
110 ? new URL(input.rawValue)
111 : new URL(input.rawValue, input.baseUrl);
112 } catch (cause) {
113 throw new PrimaryEnvironmentUrlInvalidError({
114 source: input.source,
115 urlKind: input.urlKind,
116 cause,
117 });
118 }
119}
120
121function normalizeBaseUrl(
122 rawValue: string,
123 source: PrimaryEnvironmentTargetSource,
124 urlKind: PrimaryEnvironmentUrlKind,
125): string {
126 return parseTargetUrl({
127 rawValue,
128 baseUrl: window.location.origin,
129 source,
130 urlKind,
131 }).toString();
132}
133
134function swapBaseUrlProtocol(
135 rawValue: string,
136 nextProtocol: "http:" | "https:" | "ws:" | "wss:",
137 urlKind: PrimaryEnvironmentUrlKind,
138): string {
139 const url = parseTargetUrl({
140 rawValue,
141 baseUrl: window.location.origin,
142 source: "configured",
143 urlKind,
144 });
145 if ((url.protocol === "ws:" || url.protocol === "wss:") && !url.pathname.endsWith("/")) {
146 url.pathname = url.pathname.replace(/[^/]+$/, "");
147 }
148 url.protocol = nextProtocol;
149 return url.toString();
150}
151
152function normalizeHostname(hostname: string): string {
153 return hostname
154 .trim()
155 .toLowerCase()
156 .replace(/^\[(.*)\]$/, "$1");
157}
158
159export function isLoopbackHostname(hostname: string): boolean {
160 return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname));
161}
162
163function resolveHttpRequestBaseUrl(primaryTarget: PrimaryEnvironmentTarget): string {
164 const httpBaseUrl = primaryTarget.target.httpBaseUrl;
165 const configuredDevServerUrl = import.meta.env.VITE_DEV_SERVER_URL?.trim();
166 if (!configuredDevServerUrl) {
167 return httpBaseUrl;
168 }
169
170 const currentUrl = parseTargetUrl({
171 rawValue: window.location.href,
172 source: "window-origin",
173 urlKind: "window-location-url",
174 });
175 const targetUrl = parseTargetUrl({
176 rawValue: httpBaseUrl,
177 source: primaryTarget.source,
178 urlKind: "http-base-url",
179 });
180 const devServerUrl = parseTargetUrl({
181 rawValue: configuredDevServerUrl,
182 baseUrl: currentUrl.origin,
183 source: "configured",
184 urlKind: "development-server-url",
185 });
186
187 const isCurrentOriginDevServer =
188 (currentUrl.protocol === "http:" || currentUrl.protocol === "https:") &&
189 currentUrl.origin === devServerUrl.origin;
190
191 if (
192 !isCurrentOriginDevServer ||
193 currentUrl.origin === targetUrl.origin ||
194 !isLoopbackHostname(currentUrl.hostname) ||
195 !isLoopbackHostname(targetUrl.hostname)
196 ) {
197 return httpBaseUrl;
198 }
199
200 return currentUrl.origin;
201}
202
203function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null {
204 const configuredHttpBaseUrl = import.meta.env.VITE_HTTP_URL?.trim() || undefined;
205 const configuredWsBaseUrl = import.meta.env.VITE_WS_URL?.trim() || undefined;
206
207 if (!configuredHttpBaseUrl && !configuredWsBaseUrl) {
208 return null;
209 }
210
211 // Scheme checks run on the raw configured string, while the URL parser
212 // folds schemes to lowercase ("WSS://host" parses fine). Without the
213 // case folding an uppercase scheme would be classified as plaintext and
214 // swapped to http/ws, silently downgrading TLS.
215 const resolvedHttpBaseUrl =
216 configuredHttpBaseUrl ??
217 (configuredWsBaseUrl?.toLowerCase().startsWith("wss:")
218 ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:", "websocket-base-url")
219 : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:", "websocket-base-url"));
220 const normalizedHttpBaseUrl = normalizeHttpBaseUrl(
221 normalizeBaseUrl(resolvedHttpBaseUrl, "configured", "http-base-url"),
222 );
223 const resolvedWsBaseUrl =
224 configuredWsBaseUrl === undefined
225 ? deriveWsBaseUrl(normalizedHttpBaseUrl)
226 : normalizeBaseUrl(configuredWsBaseUrl, "configured", "websocket-base-url");
227
228 return {
229 source: "configured",
230 target: {
231 httpBaseUrl: normalizedHttpBaseUrl,
232 wsBaseUrl: resolvedWsBaseUrl,
233 },
234 };
235}
236
237function resolveWindowOriginPrimaryTarget(): PrimaryEnvironmentTarget {
238 const url = parseTargetUrl({
239 rawValue: window.location.origin,
240 source: "window-origin",
241 urlKind: "http-base-url",
242 });
243 const httpBaseUrl = url.toString();
244 if (url.protocol === "http:") {
245 url.protocol = "ws:";
246 } else if (url.protocol === "https:") {
247 url.protocol = "wss:";
248 } else {
249 throw new PrimaryEnvironmentProtocolUnsupportedError({
250 source: "window-origin",
251 protocol: url.protocol,
252 });
253 }
254 return {
255 source: "window-origin",
256 target: {
257 httpBaseUrl,
258 wsBaseUrl: url.toString(),
259 },
260 };
261}
262
263function resolveDesktopPrimaryTarget(): PrimaryEnvironmentTarget | null {
264 const desktopBootstrap = getDesktopLocalEnvironmentBootstrap();
265 if (!desktopBootstrap) {
266 return null;
267 }
268 if (!desktopBootstrap.httpBaseUrl && !desktopBootstrap.wsBaseUrl) {
269 return null;
270 }
271 if (!desktopBootstrap.httpBaseUrl || !desktopBootstrap.wsBaseUrl) {
272 throw new DesktopEnvironmentBootstrapIncompleteError({
273 hasHttpBaseUrl: Boolean(desktopBootstrap.httpBaseUrl),
274 hasWsBaseUrl: Boolean(desktopBootstrap.wsBaseUrl),
275 });
276 }
277
278 return {
279 source: "desktop-managed",
280 target: {
281 httpBaseUrl: normalizeBaseUrl(
282 desktopBootstrap.httpBaseUrl,
283 "desktop-managed",
284 "http-base-url",
285 ),
286 wsBaseUrl: normalizeBaseUrl(
287 desktopBootstrap.wsBaseUrl,
288 "desktop-managed",
289 "websocket-base-url",
290 ),
291 },
292 };
293}
294
295export function resolvePrimaryEnvironmentHttpUrl(
296 pathname: string,
297 searchParams?: Record<string, string>,
298): string {
299 const primaryTarget = readPrimaryEnvironmentTarget();
300 if (!primaryTarget) {
301 throw new PrimaryEnvironmentDisabledError();
302 }
303
304 const url = parseTargetUrl({
305 rawValue: resolveHttpRequestBaseUrl(primaryTarget),
306 source: primaryTarget.source,
307 urlKind: "http-base-url",
308 });
309 const endpoint = new URL(environmentEndpointUrl(url.toString(), pathname));
310 if (searchParams) {
311 endpoint.search = new URLSearchParams(searchParams).toString();
312 }
313 return endpoint.toString();
314}
315
316// Null only when the desktop app runs with its local environment disabled;
317// every other host has a primary (falling back to the page origin).
318export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null {
319 if (isLocalEnvironmentDisabled()) {
320 return null;
321 }
322 return (
323 resolveDesktopPrimaryTarget() ??
324 resolveConfiguredPrimaryTarget() ??
325 resolveWindowOriginPrimaryTarget()
326 );
327}