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
7import * as Schema from "effect/Schema";
8
9const TAB_ID_PREFIX = "tab_";
10let 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 */
16export function newPreviewTabId(): string {
17 nextPreviewTabSequence += 1;
18 return `${TAB_ID_PREFIX}${nextPreviewTabSequence.toString(36)}`;
19}
20
21const 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. */
24export const LSOF_LOCAL_HOST_TOKENS: ReadonlySet<string> = new Set([
25 ...LOOPBACK_HOSTS,
26 "*",
27 "[::]",
28 "[::1]",
29]);
30
31const LOOPBACK_PREFIX_PATTERN = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::|\/|$)/i;
32
33export function isLoopbackHost(host: string): boolean {
34 if (LOOPBACK_HOSTS.has(host)) return true;
35 if (host === "[::1]") return true;
36 return false;
37}
38
39export 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
54export const isPreviewUrlNormalizationError = Schema.is(PreviewUrlNormalizationError);
55
56function previewUrlProtocol(rawUrl: string): string | undefined {
57 return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":");
58}
59
60const SEARCH_URL = "https://duckduckgo.com/?q=";
61/** A bare host that is always an address: localhost, an IPv4 literal, or a bracketed IPv6 one. */
62const ADDRESS_HOST_PATTERN = /^(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-f:.]+\])$/i;
63const 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 */
68const 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 */
92export 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 */
138export 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}