| 1 | /** |
| 2 | * Pure URL helpers shared between the preview server, desktop main process, |
| 3 | * and web renderer. Centralising these guarantees the four call sites agree |
| 4 | * on what counts as "loopback" and how to normalise a free-form URL string. |
| 5 | */ |
| 6 | |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | |
| 9 | const TAB_ID_PREFIX = "tab_"; |
| 10 | let nextPreviewTabSequence = 0; |
| 11 | |
| 12 | /** |
| 13 | * Generate a fresh preview tab id. Lives in shared (not contracts) because |
| 14 | * the contracts package is schema-only — runtime helpers belong here. |
| 15 | */ |
| 16 | export function newPreviewTabId(): string { |
| 17 | nextPreviewTabSequence += 1; |
| 18 | return `${TAB_ID_PREFIX}${nextPreviewTabSequence.toString(36)}`; |
| 19 | } |
| 20 | |
| 21 | const LOOPBACK_HOSTS: ReadonlySet<string> = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]); |
| 22 | |
| 23 | /** Internal — used by `lsof` parsing where the host string is wire-formatted. */ |
| 24 | export const LSOF_LOCAL_HOST_TOKENS: ReadonlySet<string> = new Set([ |
| 25 | ...LOOPBACK_HOSTS, |
| 26 | "*", |
| 27 | "[::]", |
| 28 | "[::1]", |
| 29 | ]); |
| 30 | |
| 31 | const LOOPBACK_PREFIX_PATTERN = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::|\/|$)/i; |
| 32 | |
| 33 | export function isLoopbackHost(host: string): boolean { |
| 34 | if (LOOPBACK_HOSTS.has(host)) return true; |
| 35 | if (host === "[::1]") return true; |
| 36 | return false; |
| 37 | } |
| 38 | |
| 39 | export class PreviewUrlNormalizationError extends Schema.TaggedError<PreviewUrlNormalizationError>()( |
| 40 | "PreviewUrlNormalizationError", |
| 41 | { |
| 42 | inputLength: Schema.Number, |
| 43 | reason: Schema.Literals(["empty", "parse", "unsupported-protocol"]), |
| 44 | protocol: Schema.optional(Schema.String), |
| 45 | cause: Schema.optional(Schema.Defect()), |
| 46 | }, |
| 47 | ) { |
| 48 | override get message(): string { |
| 49 | const protocol = this.protocol === undefined ? "" : `: ${this.protocol}`; |
| 50 | return `Invalid preview URL (${this.reason}${protocol}; input length ${this.inputLength}).`; |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | export const isPreviewUrlNormalizationError = Schema.is(PreviewUrlNormalizationError); |
| 55 | |
| 56 | function previewUrlProtocol(rawUrl: string): string | undefined { |
| 57 | return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":"); |
| 58 | } |
| 59 | |
| 60 | const SEARCH_URL = "https://duckduckgo.com/?q="; |
| 61 | /** A bare host that is always an address: localhost, an IPv4 literal, or a bracketed IPv6 one. */ |
| 62 | const ADDRESS_HOST_PATTERN = /^(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-f:.]+\])$/i; |
| 63 | const BARE_HOST_PORT_PATTERN = /^[^/?#:@]+:\d+(?:[/?#]|$)/; |
| 64 | /** |
| 65 | * Registered schemes that are never a bare host, so `ftp:21` or `tel:5551234` |
| 66 | * is rejected rather than read as host and port. |
| 67 | */ |
| 68 | const KNOWN_NON_WEB_SCHEMES: ReadonlySet<string> = new Set([ |
| 69 | "about:", |
| 70 | "blob:", |
| 71 | "chrome:", |
| 72 | "data:", |
| 73 | "file:", |
| 74 | "ftp:", |
| 75 | "javascript:", |
| 76 | "mailto:", |
| 77 | "sms:", |
| 78 | "ssh:", |
| 79 | "tel:", |
| 80 | "view-source:", |
| 81 | "ws:", |
| 82 | "wss:", |
| 83 | ]); |
| 84 | |
| 85 | /** |
| 86 | * Turns what a user typed in an address bar into the URL to open: an address |
| 87 | * as `normalizePreviewUrl` reads it, or else a web search for the text. Text |
| 88 | * is an address when it has a scheme, or no spaces and a host that has a dot, |
| 89 | * a port, or is localhost or an IP. Throws only for empty input or an |
| 90 | * explicit unsupported scheme (`ftp://`, `mailto:`, `data:`). |
| 91 | */ |
| 92 | export function resolveAddressBarInput(rawInput: string): string { |
| 93 | const trimmed = rawInput.trim(); |
| 94 | if (/^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) || trimmed.length === 0) |
| 95 | return normalizePreviewUrl(trimmed); |
| 96 | if (!/\s/.test(trimmed)) { |
| 97 | // `mailto:x` or `data:...` is an explicit scheme; `example.com:8080` or |
| 98 | // `devbox:8080` is a bare host and port. `normalizePreviewUrl` would read |
| 99 | // `mailto:alice@example.com` as credentials on a bare host, so reject here. |
| 100 | const protocol = previewUrlProtocol(trimmed); |
| 101 | if ( |
| 102 | protocol !== undefined && |
| 103 | (KNOWN_NON_WEB_SCHEMES.has(protocol) || !BARE_HOST_PORT_PATTERN.test(trimmed)) |
| 104 | ) { |
| 105 | if (protocol !== "http:" && protocol !== "https:") { |
| 106 | throw new PreviewUrlNormalizationError({ |
| 107 | inputLength: rawInput.length, |
| 108 | reason: "unsupported-protocol", |
| 109 | protocol, |
| 110 | }); |
| 111 | } |
| 112 | return normalizePreviewUrl(trimmed); |
| 113 | } |
| 114 | const authority = trimmed.split(/[/?#]/, 1)[0] ?? ""; |
| 115 | const host = authority.replace(/^[^@]*@/, "").replace(/:\d+$/, ""); |
| 116 | const hasPort = host.length < authority.replace(/^[^@]*@/, "").length; |
| 117 | if (hasPort || ADDRESS_HOST_PATTERN.test(host) || /^[^.]+(?:\.[^.]+)+\.?$/.test(host)) { |
| 118 | try { |
| 119 | return normalizePreviewUrl(trimmed); |
| 120 | } catch { |
| 121 | // Not a usable address after all; search for it. |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | return `${SEARCH_URL}${encodeURIComponent(trimmed)}`; |
| 126 | } |
| 127 | |
| 128 | /** |
| 129 | * Normalise a free-form URL string into a fully-qualified `http(s)://` URL. |
| 130 | * |
| 131 | * - Bare loopback hosts (`localhost`, `localhost:5173`) become `http://...`. |
| 132 | * - Bare public hosts (`example.com`) become `https://...`. |
| 133 | * - Already-qualified URLs are validated and returned as `URL.href`. |
| 134 | * |
| 135 | * Throws `PreviewUrlNormalizationError` for empty, unparseable, or |
| 136 | * unsupported-protocol inputs. |
| 137 | */ |
| 138 | export function normalizePreviewUrl(rawUrl: string): string { |
| 139 | const trimmed = rawUrl.trim(); |
| 140 | if (trimmed.length === 0) { |
| 141 | throw new PreviewUrlNormalizationError({ inputLength: rawUrl.length, reason: "empty" }); |
| 142 | } |
| 143 | const useHttp = LOOPBACK_PREFIX_PATTERN.test(trimmed); |
| 144 | const candidate = trimmed.includes("://") |
| 145 | ? trimmed |
| 146 | : `${useHttp ? "http" : "https"}://${trimmed}`; |
| 147 | let parsed: URL; |
| 148 | try { |
| 149 | parsed = new URL(candidate); |
| 150 | } catch (cause) { |
| 151 | throw new PreviewUrlNormalizationError({ |
| 152 | inputLength: rawUrl.length, |
| 153 | reason: "parse", |
| 154 | protocol: previewUrlProtocol(candidate), |
| 155 | cause, |
| 156 | }); |
| 157 | } |
| 158 | if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { |
| 159 | throw new PreviewUrlNormalizationError({ |
| 160 | inputLength: rawUrl.length, |
| 161 | reason: "unsupported-protocol", |
| 162 | protocol: parsed.protocol, |
| 163 | }); |
| 164 | } |
| 165 | return parsed.href; |
| 166 | } |