From e7094014b7be4985d0afa037e9b3b86b8fc9f083 Mon Sep 17 00:00:00 2001 From: devsyncwrld Date: Wed, 2 Sep 2026 23:44:35 -0300 Subject: [PATCH] fix(shared): profile.ts must not reference the URL global type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/shared compiles without the DOM or Node type libraries, so `URL` has no type there even though it exists at runtime everywhere this code runs. The web and server builds resolved the same file under their own libs and passed; only the shared package's own `tsc` — which CI runs first — failed, on both runners. Reach the constructor through globalThis with a minimal structural type instead of pulling a lib into a types-only package. Runtime unchanged, so the deployed site (82798d5c) needs no redeploy for this. Lesson recorded for the local checklist: run `pnpm --filter @backspace/shared build` too — the root `typecheck` script already does; the web build alone does not. Co-Authored-By: Claude Fable 5.1 --- packages/shared/src/profile.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/profile.ts b/packages/shared/src/profile.ts index e4c6d94d..99594959 100644 --- a/packages/shared/src/profile.ts +++ b/packages/shared/src/profile.ts @@ -136,6 +136,18 @@ const HEX = /^#[0-9a-fA-F]{6}$/; * proxied — a profile is not a place to load arbitrary third-party pixels. */ const COVER_HOSTS = ['cdn.cloudflare.steamstatic.com', 'cdn.akamai.steamstatic.com', 'shared.cloudflare.steamstatic.com', 'shared.akamai.steamstatic.com', 'steamcdn-a.akamaihd.net']; +/** + * This package compiles without the DOM or Node type libraries, so the `URL` + * global has no type here even though it exists at runtime everywhere this + * code runs (browsers, Node ≥ 10). Reach it through globalThis with a minimal + * structural type rather than pulling a whole lib into a types-only package. + */ +interface UrlLike { protocol: string; hostname: string; toString(): string } +const UrlCtor = (globalThis as unknown as { URL: new (s: string) => UrlLike }).URL; +function parseUrl(value: string): UrlLike | null { + try { return new UrlCtor(value); } catch { return null; } +} + function pick(value: unknown, allowed: readonly T[], fallback: T): T { return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : fallback; } @@ -149,15 +161,14 @@ function httpsUrl(value: unknown, max: number): string | null { if (typeof value !== 'string') return null; const v = value.trim(); if (v.length === 0 || v.length > max) return null; - try { - const u = new URL(v); - return u.protocol === 'https:' ? u.toString() : null; - } catch { return null; } + const u = parseUrl(v); + return u && u.protocol === 'https:' ? u.toString() : null; } function coverUrl(value: unknown): string | null { const u = httpsUrl(value, 300); if (!u) return null; - try { return COVER_HOSTS.includes(new URL(u).hostname) ? u : null; } catch { return null; } + const parsed = parseUrl(u); + return parsed && COVER_HOSTS.includes(parsed.hostname) ? u : null; } /** First grapheme only — one emoji, not a sentence hidden in the tag. */ function oneGrapheme(value: unknown): string { @@ -209,7 +220,7 @@ export function normalizeProfileStyle(input: unknown): ProfileStyle { const o = (l && typeof l === 'object' ? l : {}) as Record; const url = httpsUrl(o.url, PROFILE_LIMITS.linkUrl); if (!url) return null; - const label = str(o.label, PROFILE_LIMITS.linkLabel) || new URL(url).hostname.replace(/^www\./, ''); + const label = str(o.label, PROFILE_LIMITS.linkLabel) || (parseUrl(url)?.hostname ?? url).replace(/^www\./, ''); return { label, url }; }).filter((l): l is ProfileLink => l !== null).slice(0, PROFILE_LIMITS.links) : [];