/** * Pure URL helpers shared between the preview server, desktop main process, * and web renderer. Centralising these guarantees the four call sites agree * on what counts as "loopback" and how to normalise a free-form URL string. */ import * as Schema from "effect/Schema"; const TAB_ID_PREFIX = "tab_"; let nextPreviewTabSequence = 0; /** * Generate a fresh preview tab id. Lives in shared (not contracts) because * the contracts package is schema-only — runtime helpers belong here. */ export function newPreviewTabId(): string { nextPreviewTabSequence += 1; return `${TAB_ID_PREFIX}${nextPreviewTabSequence.toString(36)}`; } const LOOPBACK_HOSTS: ReadonlySet = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]); /** Internal — used by `lsof` parsing where the host string is wire-formatted. */ export const LSOF_LOCAL_HOST_TOKENS: ReadonlySet = new Set([ ...LOOPBACK_HOSTS, "*", "[::]", "[::1]", ]); const LOOPBACK_PREFIX_PATTERN = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::|\/|$)/i; export function isLoopbackHost(host: string): boolean { if (LOOPBACK_HOSTS.has(host)) return true; if (host === "[::1]") return true; return false; } /** True when a raw URL string looks like a loopback dev URL we can preview. */ export function isPreviewableUrl(rawUrl: string): boolean { try { const parsed = new URL(rawUrl); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; return isLoopbackHost(parsed.hostname); } catch { return false; } } export class PreviewUrlNormalizationError extends Schema.TaggedErrorClass()( "PreviewUrlNormalizationError", { inputLength: Schema.Number, reason: Schema.Literals(["empty", "parse", "unsupported-protocol"]), protocol: Schema.optional(Schema.String), cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { const protocol = this.protocol === undefined ? "" : `: ${this.protocol}`; return `Invalid preview URL (${this.reason}${protocol}; input length ${this.inputLength}).`; } } export const isPreviewUrlNormalizationError = Schema.is(PreviewUrlNormalizationError); function previewUrlProtocol(rawUrl: string): string | undefined { return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":"); } /** * Normalise a free-form URL string into a fully-qualified `http(s)://` URL. * * - Bare loopback hosts (`localhost`, `localhost:5173`) become `http://...`. * - Bare public hosts (`example.com`) become `https://...`. * - Already-qualified URLs are validated and returned as `URL.href`. * * Throws `PreviewUrlNormalizationError` for empty, unparseable, or * unsupported-protocol inputs. */ export function normalizePreviewUrl(rawUrl: string): string { const trimmed = rawUrl.trim(); if (trimmed.length === 0) { throw new PreviewUrlNormalizationError({ inputLength: rawUrl.length, reason: "empty" }); } const useHttp = LOOPBACK_PREFIX_PATTERN.test(trimmed); const candidate = trimmed.includes("://") ? trimmed : `${useHttp ? "http" : "https"}://${trimmed}`; let parsed: URL; try { parsed = new URL(candidate); } catch (cause) { throw new PreviewUrlNormalizationError({ inputLength: rawUrl.length, reason: "parse", protocol: previewUrlProtocol(candidate), cause, }); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new PreviewUrlNormalizationError({ inputLength: rawUrl.length, reason: "unsupported-protocol", protocol: parsed.protocol, }); } return parsed.href; }