@nifrajs/web 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/css-modules.d.ts +34 -0
  4. package/dist/build.d.ts +110 -0
  5. package/dist/build.d.ts.map +1 -0
  6. package/dist/build.js +238 -0
  7. package/dist/build.js.map +1 -0
  8. package/dist/client.d.ts +32 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +204 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/deferred.d.ts +90 -0
  13. package/dist/deferred.d.ts.map +1 -0
  14. package/dist/deferred.js +310 -0
  15. package/dist/deferred.js.map +1 -0
  16. package/dist/dev-error.d.ts +17 -0
  17. package/dist/dev-error.d.ts.map +1 -0
  18. package/dist/dev-error.js +59 -0
  19. package/dist/dev-error.js.map +1 -0
  20. package/dist/dev.d.ts +25 -0
  21. package/dist/dev.d.ts.map +1 -0
  22. package/dist/dev.js +83 -0
  23. package/dist/dev.js.map +1 -0
  24. package/dist/draft.d.ts +58 -0
  25. package/dist/draft.d.ts.map +1 -0
  26. package/dist/draft.js +55 -0
  27. package/dist/draft.js.map +1 -0
  28. package/dist/fonts-google.d.ts +109 -0
  29. package/dist/fonts-google.d.ts.map +1 -0
  30. package/dist/fonts-google.js +291 -0
  31. package/dist/fonts-google.js.map +1 -0
  32. package/dist/fonts.d.ts +67 -0
  33. package/dist/fonts.d.ts.map +1 -0
  34. package/dist/fonts.js +103 -0
  35. package/dist/fonts.js.map +1 -0
  36. package/dist/fs.d.ts +10 -0
  37. package/dist/fs.d.ts.map +1 -0
  38. package/dist/fs.js +24 -0
  39. package/dist/fs.js.map +1 -0
  40. package/dist/index.d.ts +280 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +784 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/islands.d.ts +37 -0
  45. package/dist/islands.d.ts.map +1 -0
  46. package/dist/islands.js +107 -0
  47. package/dist/islands.js.map +1 -0
  48. package/dist/isr.d.ts +154 -0
  49. package/dist/isr.d.ts.map +1 -0
  50. package/dist/isr.js +331 -0
  51. package/dist/isr.js.map +1 -0
  52. package/dist/manifest.d.ts +178 -0
  53. package/dist/manifest.d.ts.map +1 -0
  54. package/dist/manifest.js +213 -0
  55. package/dist/manifest.js.map +1 -0
  56. package/dist/plugins/css-modules.d.ts +40 -0
  57. package/dist/plugins/css-modules.d.ts.map +1 -0
  58. package/dist/plugins/css-modules.js +0 -0
  59. package/dist/plugins/css-modules.js.map +1 -0
  60. package/dist/plugins/kit.d.ts +44 -0
  61. package/dist/plugins/kit.d.ts.map +1 -0
  62. package/dist/plugins/kit.js +105 -0
  63. package/dist/plugins/kit.js.map +1 -0
  64. package/dist/plugins/scss.d.ts +27 -0
  65. package/dist/plugins/scss.d.ts.map +1 -0
  66. package/dist/plugins/scss.js +78 -0
  67. package/dist/plugins/scss.js.map +1 -0
  68. package/dist/prerender.d.ts +77 -0
  69. package/dist/prerender.d.ts.map +1 -0
  70. package/dist/prerender.js +139 -0
  71. package/dist/prerender.js.map +1 -0
  72. package/dist/query.d.ts +62 -0
  73. package/dist/query.d.ts.map +1 -0
  74. package/dist/query.js +184 -0
  75. package/dist/query.js.map +1 -0
  76. package/dist/router.d.ts +169 -0
  77. package/dist/router.d.ts.map +1 -0
  78. package/dist/router.js +531 -0
  79. package/dist/router.js.map +1 -0
  80. package/dist/vite.d.ts +49 -0
  81. package/dist/vite.d.ts.map +1 -0
  82. package/dist/vite.js +144 -0
  83. package/dist/vite.js.map +1 -0
  84. package/package.json +127 -0
  85. package/src/build.ts +375 -0
  86. package/src/client.ts +218 -0
  87. package/src/deferred.ts +339 -0
  88. package/src/dev-error.ts +62 -0
  89. package/src/dev.ts +108 -0
  90. package/src/draft.ts +82 -0
  91. package/src/fonts-google.ts +397 -0
  92. package/src/fonts.ts +147 -0
  93. package/src/fs.ts +26 -0
  94. package/src/index.ts +1202 -0
  95. package/src/islands.ts +121 -0
  96. package/src/isr.ts +461 -0
  97. package/src/manifest.ts +366 -0
  98. package/src/plugins/css-modules.ts +0 -0
  99. package/src/plugins/kit.ts +123 -0
  100. package/src/plugins/scss.ts +108 -0
  101. package/src/prerender.ts +209 -0
  102. package/src/query.ts +250 -0
  103. package/src/router.ts +676 -0
  104. package/src/vite.ts +212 -0
package/src/dev.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `@nifrajs/web/dev` — a zero-config dev server (Bun-only). Builds the client unminified on boot,
3
+ * serves the nifra app + hashed assets, and injects a live-reload client. Watches the routes dir:
4
+ * on change it rebuilds the client and re-creates the app with a fresh `importQuery` (so SSR
5
+ * picks up edited route modules past Bun's import cache), then tells the browser to reload.
6
+ * Bun-only + build-time; never imported by the edge runtime.
7
+ */
8
+ import { readdirSync, unwatchFile, watchFile } from "node:fs"
9
+ import { type BuildClientOptions, buildClient } from "./build.ts"
10
+
11
+ /** Minimal app surface the dev server needs — `createWebApp(...)` satisfies it. */
12
+ interface FetchApp {
13
+ fetch(request: Request): Response | Promise<Response>
14
+ }
15
+
16
+ export interface DevServerOptions extends Omit<BuildClientOptions, "minify"> {
17
+ /**
18
+ * Build the nifra app for the freshly-built client entry. `importQuery` changes on every
19
+ * rebuild — pass it to `discoverRoutes(routesDir, { importQuery })` so SSR re-imports edited
20
+ * route modules instead of Bun's cached copies.
21
+ */
22
+ readonly createApp: (clientEntry: string, importQuery: string) => FetchApp | Promise<FetchApp>
23
+ /** Directories to watch (default: `[routesDir]`). */
24
+ readonly watch?: readonly string[]
25
+ /** Port to listen on (default 3000). */
26
+ readonly port?: number
27
+ }
28
+
29
+ export interface DevServer {
30
+ readonly port: number
31
+ stop(): void
32
+ }
33
+
34
+ const RELOAD_PATH = "/__nifra_dev"
35
+ const RELOAD_TOPIC = "reload"
36
+ // Live-reload client: reloads on a server "reload" push; reconnects if the socket drops.
37
+ const reloadClient = `<script>(()=>{const x=()=>{const w=new WebSocket((location.protocol==="https:"?"wss:":"ws:")+"//"+location.host+"${RELOAD_PATH}");w.onmessage=()=>location.reload();w.onclose=()=>setTimeout(x,500)};x()})()</script>`
38
+
39
+ /** Start the dev server: build → serve → watch → rebuild + reload on change. */
40
+ export async function createDevServer(options: DevServerOptions): Promise<DevServer> {
41
+ const { createApp, port = 3000, outDir, routesDir } = options
42
+ const publicPath = options.publicPath ?? "/assets/"
43
+ let version = 0
44
+ const rebuild = async (): Promise<FetchApp> => {
45
+ const manifest = await buildClient({ ...options, minify: false })
46
+ return createApp(manifest.entry, `v=${version}`)
47
+ }
48
+ let app = await rebuild()
49
+
50
+ const server = Bun.serve({
51
+ port,
52
+ async fetch(req, srv) {
53
+ const url = new URL(req.url)
54
+ if (url.pathname === RELOAD_PATH) {
55
+ return srv.upgrade(req) ? undefined : new Response("expected websocket", { status: 426 })
56
+ }
57
+ if (url.pathname.startsWith(publicPath)) {
58
+ const file = Bun.file(`${outDir}/${url.pathname.slice(publicPath.length)}`)
59
+ return (await file.exists())
60
+ ? new Response(file, { headers: { "content-type": "text/javascript; charset=utf-8" } })
61
+ : new Response("Not Found", { status: 404 })
62
+ }
63
+ const res = await app.fetch(req)
64
+ if (!(res.headers.get("content-type") ?? "").includes("text/html")) return res
65
+ const headers = new Headers(res.headers)
66
+ headers.delete("content-length") // the body grows with the injected reload client
67
+ const html = (await res.text()).replace("</body>", `${reloadClient}</body>`)
68
+ return new Response(html, { status: res.status, headers })
69
+ },
70
+ websocket: {
71
+ open: (ws) => ws.subscribe(RELOAD_TOPIC),
72
+ message: () => {},
73
+ },
74
+ })
75
+
76
+ // On a route change: rebuild the client, re-create the app with a fresh import cache-buster,
77
+ // then push a reload. Debounced so a burst of saves rebuilds once.
78
+ let timer: ReturnType<typeof setTimeout> | undefined
79
+ const onChange = (): void => {
80
+ if (timer) clearTimeout(timer)
81
+ timer = setTimeout(() => {
82
+ version += 1
83
+ rebuild()
84
+ .then((next) => {
85
+ app = next
86
+ server.publish(RELOAD_TOPIC, "reload")
87
+ console.log(`[nifra/web/dev] rebuilt + reloaded (v${version})`)
88
+ })
89
+ .catch((err) => console.error("[nifra/web/dev] rebuild failed:", err))
90
+ }, 60)
91
+ }
92
+ // Poll the source files for changes. Bun's fs.watch is unreliable here, so watchFile (mtime
93
+ // polling) is used. New files need a restart to be tracked — fine for dev.
94
+ const watched = (options.watch ?? [routesDir]).flatMap((dir) =>
95
+ (readdirSync(dir, { recursive: true }) as string[])
96
+ .filter((file) => /\.(tsx|jsx|ts|js|mdx|svelte|vue)$/.test(file))
97
+ .map((file) => `${dir}/${file}`),
98
+ )
99
+ for (const file of watched) watchFile(file, { interval: 150 }, onChange)
100
+
101
+ return {
102
+ port: server.port ?? port,
103
+ stop: () => {
104
+ for (const file of watched) unwatchFile(file)
105
+ server.stop()
106
+ },
107
+ }
108
+ }
package/src/draft.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Draft / preview mode — let an editor see unpublished content by flipping a signed cookie that
3
+ * loaders read (`ctx.draft`) and ISR bypasses (fresh render, never cached). The cookie is **HMAC-signed**
4
+ * (so a visitor can't forge it) and **HttpOnly** (JS can't read it). You gate `enableDraft` yourself —
5
+ * behind an editor login or a `?token=` check — exactly like Next's `draftMode().enable()`.
6
+ *
7
+ * // a route you've protected (e.g. checked ?token= against a secret):
8
+ * app.get("/api/draft", async (c) => {
9
+ * await enableDraft(c, env.DRAFT_SECRET)
10
+ * return redirect("/")
11
+ * })
12
+ *
13
+ * // wire the same secret so the framework can verify the cookie:
14
+ * createWebApp({ ..., draftSecret: env.DRAFT_SECRET }) // → loaders get ctx.draft
15
+ * withISR(app, { ..., draftSecret: env.DRAFT_SECRET }) // → editors bypass the cache
16
+ */
17
+ import { type CookieOptions, parseCookies, signValue, unsignValue } from "@nifrajs/core"
18
+
19
+ /** The cookie name nifra uses for draft/preview mode. */
20
+ export const DRAFT_COOKIE = "__nifra_draft"
21
+
22
+ // The signed cookie's payload is a fixed marker — a *valid signature* (not the value) is the signal
23
+ // that the server issued this via enableDraft(). Nothing secret is stored in it.
24
+ const DRAFT_MARKER = "1"
25
+
26
+ /** The response-cookie surface `enableDraft`/`disableDraft` need — nifra's `c.set`. Structural, so any
27
+ * nifra handler context satisfies it without importing the full `Context`. */
28
+ export interface DraftCookieControls {
29
+ cookie(name: string, value: string, options?: CookieOptions): void
30
+ deleteCookie(name: string, options?: { readonly path?: string; readonly domain?: string }): void
31
+ }
32
+
33
+ export interface EnableDraftOptions {
34
+ /** Cookie lifetime in **seconds** (default `3600` = 1h). Keep it short — draft is an editor session. */
35
+ readonly maxAgeSeconds?: number
36
+ /** Cookie `Path` (default `"/"`). */
37
+ readonly path?: string
38
+ /** Override the `Secure` attribute (defaults to `true` — secure-by-default). Pass `false` only for
39
+ * local `http://` dev, where a `Secure` cookie isn't stored. */
40
+ readonly secure?: boolean
41
+ }
42
+
43
+ /**
44
+ * Turn draft mode **on** for this client by setting a signed, HttpOnly `__nifra_draft` cookie. Call it
45
+ * from a route you've already authorized. `secret` signs the cookie — pass the SAME secret to
46
+ * `createWebApp({ draftSecret })` and `withISR({ draftSecret })` so the framework can verify it.
47
+ */
48
+ export async function enableDraft(
49
+ c: { readonly set: DraftCookieControls },
50
+ secret: string,
51
+ options: EnableDraftOptions = {},
52
+ ): Promise<void> {
53
+ const signed = await signValue(DRAFT_MARKER, secret)
54
+ // Explicit security attributes (don't lean on the context default): signed + HttpOnly + SameSite=Lax.
55
+ const cookieOptions: CookieOptions = {
56
+ httpOnly: true,
57
+ sameSite: "lax",
58
+ path: options.path ?? "/",
59
+ maxAge: options.maxAgeSeconds ?? 3600,
60
+ ...(options.secure === undefined ? {} : { secure: options.secure }),
61
+ }
62
+ c.set.cookie(DRAFT_COOKIE, signed, cookieOptions)
63
+ }
64
+
65
+ /** Turn draft mode **off**: clear the `__nifra_draft` cookie. Match the `path` used in `enableDraft`. */
66
+ export function disableDraft(
67
+ c: { readonly set: DraftCookieControls },
68
+ options: { readonly path?: string } = {},
69
+ ): void {
70
+ c.set.deleteCookie(DRAFT_COOKIE, { path: options.path ?? "/" })
71
+ }
72
+
73
+ /**
74
+ * Whether `request` carries a **valid** signed draft cookie (constant-time verify via `unsignValue`).
75
+ * `createWebApp` uses it to set `ctx.draft`; `withISR` uses it to bypass the cache for editors. A
76
+ * missing, forged, or tampered cookie returns `false`.
77
+ */
78
+ export async function isDraftEnabled(request: Request, secret: string): Promise<boolean> {
79
+ const cookie = parseCookies(request.headers.get("cookie"))[DRAFT_COOKIE]
80
+ if (cookie === undefined) return false
81
+ return (await unsignValue(cookie, secret)) !== null
82
+ }
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Build-time Google Fonts automation — the `next/font/google` equivalent. At **build time** (never on
3
+ * the request path) it: builds the Google Fonts CSS2 URL, downloads the stylesheet, parses the
4
+ * `@font-face` rules, downloads each `.woff2`, content-hashes it, writes it next to your assets, and
5
+ * hands back a **self-hosted** `@font-face` stylesheet + the matching `<link rel="preload">`s. The
6
+ * result is identical to dropping the files in yourself and calling {@link fontFace} — no runtime CDN
7
+ * hotlink, no layout shift, hashed filenames for immutable caching.
8
+ *
9
+ * // fonts.build.ts — run once at build time (e.g. a prebuild step)
10
+ * import { loadGoogleFont } from "@nifrajs/web/fonts"
11
+ * const inter = await loadGoogleFont(
12
+ * { family: "Inter", weights: [400, 700], subsets: ["latin"] },
13
+ * { outDir: "public/fonts" }, // → public/fonts/inter-latin-normal-400-<hash>.woff2
14
+ * )
15
+ * await Bun.write("app/fonts.css", inter.css) // import this stylesheet from your app
16
+ * // inter.preloads → spread into a root layout's `meta.link`
17
+ *
18
+ * Security: this fetches remote content and writes it to disk, so every input is validated and the
19
+ * font-file host is **allowlisted to `fonts.gstatic.com` over https** — a tampered/MITM'd stylesheet
20
+ * cannot make the build fetch an arbitrary URL (SSRF) or write an attacker-chosen blob. Downloads are
21
+ * size-capped. Filenames are derived only from validated tokens + a content hash (no path traversal).
22
+ */
23
+
24
+ import { writeFile as fsWriteFile, mkdir } from "node:fs/promises"
25
+ import { dirname, join } from "node:path"
26
+ import { type FontDisplay, fontFace, fontPreload } from "./fonts.ts"
27
+
28
+ /** Options describing the Google font to fetch + self-host. */
29
+ export interface GoogleFontOptions {
30
+ /** Family name exactly as Google lists it, e.g. `"Inter"`, `"Open Sans"`, `"Roboto Mono"`. */
31
+ readonly family: string
32
+ /** Weights to request — numbers (`400`), numeric strings, a variable range (`"100 900"`), or the
33
+ * keywords `"normal"`/`"bold"`. Defaults to `[400]`. */
34
+ readonly weights?: readonly (number | string)[]
35
+ /** Styles to request. Defaults to `["normal"]`. */
36
+ readonly styles?: readonly ("normal" | "italic")[]
37
+ /** Keep only these named subsets (`"latin"`, `"latin-ext"`, `"cyrillic"`, …). Google returns every
38
+ * subset it has as a separate `@font-face`; this filters to the ones you serve. Defaults to keeping
39
+ * all returned subsets. Ignored when {@link text} is set (glyph subsetting supersedes it). */
40
+ readonly subsets?: readonly string[]
41
+ /** `font-display` strategy for the generated faces. Defaults to `"swap"`. */
42
+ readonly display?: FontDisplay
43
+ /** Glyph subsetting: request only the glyphs needed to render exactly this text (Google's `&text=`).
44
+ * Ideal for a logo/heading font — produces one tiny file. */
45
+ readonly text?: string
46
+ /** CLS metric overrides forwarded to every generated `@font-face` (the layout-shift fix). */
47
+ readonly sizeAdjust?: string
48
+ readonly ascentOverride?: string
49
+ readonly descentOverride?: string
50
+ readonly lineGapOverride?: string
51
+ }
52
+
53
+ /** A single `@font-face` block parsed out of Google's stylesheet. */
54
+ export interface ParsedFontFace {
55
+ readonly family: string
56
+ readonly style: string
57
+ readonly weight: string
58
+ readonly subset: string
59
+ readonly unicodeRange?: string
60
+ readonly src: readonly { readonly url: string; readonly format?: string }[]
61
+ }
62
+
63
+ /** One downloaded + written font file. */
64
+ export interface FontAsset {
65
+ /** The hashed filename written under `outDir` (no directory part). */
66
+ readonly fileName: string
67
+ /** The public URL the generated `@font-face`/preload reference (`${publicPath}/${fileName}`). */
68
+ readonly href: string
69
+ /** The original `fonts.gstatic.com` URL the bytes came from. */
70
+ readonly sourceUrl: string
71
+ readonly bytes: Uint8Array
72
+ readonly subset: string
73
+ readonly weight: string
74
+ readonly style: string
75
+ }
76
+
77
+ export interface LoadGoogleFontResult {
78
+ readonly family: string
79
+ /** A self-hosted `@font-face` stylesheet (one rule per written file). Import it from your app. */
80
+ readonly css: string
81
+ /** Every file written to `outDir`. */
82
+ readonly assets: readonly FontAsset[]
83
+ /** `fontPreload()` link-attribute sets — spread the ones you want into a layout's `meta.link`.
84
+ * Preloading *every* weight/subset is wasteful; usually preload just the primary subset + weight. */
85
+ readonly preloads: readonly Record<string, string>[]
86
+ }
87
+
88
+ export interface LoadGoogleFontIO {
89
+ /** Directory to write the hashed `.woff2` files into (created if missing). */
90
+ readonly outDir: string
91
+ /** URL prefix the files are served under. Defaults to `"/fonts"`. */
92
+ readonly publicPath?: string
93
+ /** Injectable `fetch` (defaults to the global). Tests pass a canned implementation. */
94
+ readonly fetch?: typeof fetch
95
+ /** Injectable writer (defaults to `node:fs`). Tests pass an in-memory sink. */
96
+ readonly writeFile?: (path: string, bytes: Uint8Array) => Promise<void>
97
+ /** Per-file download cap in bytes. Defaults to 5 MB (real woff2 are well under 1 MB). */
98
+ readonly maxBytesPerFile?: number
99
+ }
100
+
101
+ const CSS2_ENDPOINT = "https://fonts.googleapis.com/css2"
102
+
103
+ /** Only this host, only over https, may supply a font file we download + write to disk. The SSRF gate:
104
+ * a tampered stylesheet listing any other `src: url(...)` is rejected, not fetched. */
105
+ const ALLOWED_FONT_HOSTS: ReadonlySet<string> = new Set(["fonts.gstatic.com"])
106
+
107
+ /** A modern desktop-Chrome UA so Google serves `woff2` (it falls back to bulky `ttf` for unknown UAs). */
108
+ const WOFF2_UA =
109
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
110
+
111
+ const DEFAULT_MAX_FONT_BYTES = 5 * 1024 * 1024
112
+ const MAX_CSS_BYTES = 512 * 1024
113
+ const MAX_TEXT_LEN = 2048
114
+
115
+ const VALID_DISPLAY: ReadonlySet<string> = new Set([
116
+ "auto",
117
+ "block",
118
+ "swap",
119
+ "fallback",
120
+ "optional",
121
+ ])
122
+
123
+ /** `true` iff `raw` is an `https://fonts.gstatic.com/…` URL — the only host we'll download from. */
124
+ export function isAllowedFontUrl(raw: string): boolean {
125
+ let url: URL
126
+ try {
127
+ url = new URL(raw)
128
+ } catch {
129
+ return false
130
+ }
131
+ return url.protocol === "https:" && ALLOWED_FONT_HOSTS.has(url.hostname)
132
+ }
133
+
134
+ /** Validate a family name (letters/digits/spaces only). It goes into both a URL and a filename, so a
135
+ * strict allowlist closes injection + path-traversal at the source. */
136
+ function validateFamily(family: string): string {
137
+ if (!/^[A-Za-z0-9][A-Za-z0-9 ]{0,62}$/.test(family)) {
138
+ throw new Error(
139
+ `loadGoogleFont: invalid family ${JSON.stringify(family)} (letters, digits, and spaces only)`,
140
+ )
141
+ }
142
+ return family
143
+ }
144
+
145
+ /** Normalize a weight to the token Google expects: `400`, `"700"`, `normal`→`400`, `bold`→`700`, or a
146
+ * variable range `"100 900"`. Rejects anything else (these land in the request URL). */
147
+ function normalizeWeight(weight: number | string): string {
148
+ if (typeof weight === "number") {
149
+ if (!Number.isInteger(weight) || weight < 1 || weight > 1000) {
150
+ throw new Error(`loadGoogleFont: invalid weight ${weight} (1–1000)`)
151
+ }
152
+ return String(weight)
153
+ }
154
+ const w = weight.trim()
155
+ if (w === "normal") return "400"
156
+ if (w === "bold") return "700"
157
+ // A single numeric weight, or a variable range like "100 900".
158
+ if (/^\d{1,4}$/.test(w) || /^\d{1,4} \d{1,4}$/.test(w)) return w
159
+ throw new Error(`loadGoogleFont: invalid weight ${JSON.stringify(weight)}`)
160
+ }
161
+
162
+ function validateSubset(subset: string): string {
163
+ if (!/^[a-z0-9-]{1,32}$/.test(subset)) {
164
+ throw new Error(`loadGoogleFont: invalid subset ${JSON.stringify(subset)}`)
165
+ }
166
+ return subset
167
+ }
168
+
169
+ /** Build the Google Fonts CSS2 request URL. Pure + fully validated, so it's safe to feed a dynamic
170
+ * family/weights/text. Exported for advanced callers who fetch + parse the stylesheet themselves. */
171
+ export function googleFontsCssUrl(options: GoogleFontOptions): string {
172
+ const family = validateFamily(options.family)
173
+ const styles = options.styles ?? ["normal"]
174
+ for (const s of styles) {
175
+ if (s !== "normal" && s !== "italic") {
176
+ throw new Error(`loadGoogleFont: invalid style ${JSON.stringify(s)}`)
177
+ }
178
+ }
179
+ const display = options.display ?? "swap"
180
+ if (!VALID_DISPLAY.has(display)) {
181
+ throw new Error(`loadGoogleFont: invalid display ${JSON.stringify(display)}`)
182
+ }
183
+
184
+ // De-dupe + sort weights numerically (variable ranges sort by their lower bound).
185
+ const weights = [...new Set((options.weights ?? [400]).map(normalizeWeight))].sort(
186
+ (a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10),
187
+ )
188
+ const hasItalic = styles.includes("italic")
189
+ const hasNormal = styles.includes("normal") || !hasItalic
190
+
191
+ // CSS2 wants spaces in the family as `+`; the `:`, `@`, `,`, `;` axis syntax stays literal.
192
+ const familyName = encodeURIComponent(family).replace(/%20/g, "+")
193
+ let axis: string
194
+ if (hasItalic) {
195
+ // `ital,wght@` with tuples; all normal (ital=0) tuples must precede italic (ital=1).
196
+ const tuples: string[] = []
197
+ if (hasNormal) for (const w of weights) tuples.push(`0,${w}`)
198
+ for (const w of weights) tuples.push(`1,${w}`)
199
+ axis = `ital,wght@${tuples.join(";")}`
200
+ } else {
201
+ axis = `wght@${weights.join(";")}`
202
+ }
203
+
204
+ const params: string[] = [`family=${familyName}:${axis}`, `display=${display}`]
205
+ if (options.text !== undefined) {
206
+ if (options.text.length > MAX_TEXT_LEN) {
207
+ throw new Error(`loadGoogleFont: text exceeds ${MAX_TEXT_LEN} chars`)
208
+ }
209
+ if (options.text.length > 0) params.push(`text=${encodeURIComponent(options.text)}`)
210
+ }
211
+ return `${CSS2_ENDPOINT}?${params.join("&")}`
212
+ }
213
+
214
+ const FACE_RE = /(?:\/\*\s*([^*]+?)\s*\*\/\s*)?@font-face\s*\{([^}]*)\}/g
215
+ const SRC_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)(?:\s*format\(\s*(['"]?)([^'")]+)\3\s*\))?/g
216
+
217
+ function declOf(body: string, prop: string): string | undefined {
218
+ const m = body.match(new RegExp(`${prop}\\s*:\\s*([^;]+);`, "i"))
219
+ return m?.[1]?.trim()
220
+ }
221
+
222
+ /** Parse Google's stylesheet into structured faces, capturing the `/* subset *​/` label that precedes
223
+ * each `@font-face`. Pure — exported so callers can run their own download/write pipeline. */
224
+ export function parseGoogleFontCss(css: string): ParsedFontFace[] {
225
+ const faces: ParsedFontFace[] = []
226
+ for (const match of css.matchAll(FACE_RE)) {
227
+ const subset = (match[1] ?? "").trim() || "default"
228
+ const body = match[2] ?? ""
229
+ const familyRaw = declOf(body, "font-family")
230
+ if (familyRaw === undefined) continue
231
+ const family = familyRaw.replace(/^['"]|['"]$/g, "")
232
+ const src: { url: string; format?: string }[] = []
233
+ for (const s of body.matchAll(SRC_RE)) {
234
+ const url = s[2]
235
+ if (url === undefined) continue
236
+ src.push(s[4] === undefined ? { url } : { url, format: s[4] })
237
+ }
238
+ if (src.length === 0) continue
239
+ const unicodeRange = declOf(body, "unicode-range")
240
+ faces.push({
241
+ family,
242
+ style: declOf(body, "font-style") ?? "normal",
243
+ weight: declOf(body, "font-weight") ?? "400",
244
+ subset,
245
+ ...(unicodeRange === undefined ? {} : { unicodeRange }),
246
+ src,
247
+ })
248
+ }
249
+ return faces
250
+ }
251
+
252
+ /** First 16 hex chars of the SHA-256 of the bytes — a content hash for an immutable, cache-busting
253
+ * filename. Web Crypto, so it runs identically on Bun/Node/edge build hosts. `Uint8Array<ArrayBuffer>`
254
+ * (not the generic `ArrayBufferLike`) to satisfy WebCrypto's `BufferSource` under TS 5.7+ generics. */
255
+ async function contentHash(bytes: Uint8Array<ArrayBuffer>): Promise<string> {
256
+ const digest = await crypto.subtle.digest("SHA-256", bytes)
257
+ let hex = ""
258
+ for (const b of new Uint8Array(digest)) hex += b.toString(16).padStart(2, "0")
259
+ return hex.slice(0, 16)
260
+ }
261
+
262
+ /** Lowercase a validated token into a filename-safe slug. */
263
+ const slug = (s: string): string =>
264
+ s
265
+ .toLowerCase()
266
+ .replace(/[^a-z0-9]+/g, "-")
267
+ .replace(/^-+|-+$/g, "")
268
+
269
+ /** Host of a URL for error messages — we never echo the full untrusted URL+path. */
270
+ const hostOf = (raw: string): string => {
271
+ try {
272
+ return new URL(raw).host
273
+ } catch {
274
+ return "<invalid-url>"
275
+ }
276
+ }
277
+
278
+ /** Fetch with a hard byte ceiling. Checks the advertised `Content-Length` first (cheap reject), then
279
+ * re-checks the materialized body (a lying header can't smuggle a larger payload past us). */
280
+ async function fetchBounded(
281
+ fetchImpl: typeof fetch,
282
+ url: string,
283
+ maxBytes: number,
284
+ headers?: Record<string, string>,
285
+ ): Promise<Uint8Array<ArrayBuffer>> {
286
+ const res = await fetchImpl(url, headers ? { headers } : {})
287
+ if (!res.ok) {
288
+ throw new Error(`loadGoogleFont: fetch failed (${res.status}) from ${hostOf(url)}`)
289
+ }
290
+ const advertised = Number(res.headers.get("content-length"))
291
+ if (Number.isFinite(advertised) && advertised > maxBytes) {
292
+ throw new Error(`loadGoogleFont: response from ${hostOf(url)} exceeds ${maxBytes} bytes`)
293
+ }
294
+ const bytes = new Uint8Array(await res.arrayBuffer())
295
+ if (bytes.byteLength > maxBytes) {
296
+ throw new Error(`loadGoogleFont: response from ${hostOf(url)} exceeds ${maxBytes} bytes`)
297
+ }
298
+ return bytes
299
+ }
300
+
301
+ const defaultWriteFile = async (path: string, bytes: Uint8Array): Promise<void> => {
302
+ await mkdir(dirname(path), { recursive: true })
303
+ await fsWriteFile(path, bytes)
304
+ }
305
+
306
+ /**
307
+ * Download a Google font, self-host it, and return a CLS-safe `@font-face` stylesheet + preloads.
308
+ * See the module header for the full flow and security model. I/O (`fetch`, `writeFile`) is injectable
309
+ * so this is unit-testable without the network.
310
+ */
311
+ export async function loadGoogleFont(
312
+ options: GoogleFontOptions,
313
+ io: LoadGoogleFontIO,
314
+ ): Promise<LoadGoogleFontResult> {
315
+ const family = validateFamily(options.family)
316
+ const fetchImpl = io.fetch ?? fetch
317
+ const writeFile = io.writeFile ?? defaultWriteFile
318
+ const publicPath = (io.publicPath ?? "/fonts").replace(/\/+$/, "")
319
+ const maxBytes = io.maxBytesPerFile ?? DEFAULT_MAX_FONT_BYTES
320
+ const wantedSubsets = options.subsets?.map(validateSubset)
321
+
322
+ const cssUrl = googleFontsCssUrl(options)
323
+ const cssBytes = await fetchBounded(fetchImpl, cssUrl, MAX_CSS_BYTES, { "user-agent": WOFF2_UA })
324
+ const css = new TextDecoder().decode(cssBytes)
325
+
326
+ let faces = parseGoogleFontCss(css)
327
+ if (faces.length === 0) {
328
+ throw new Error(
329
+ `loadGoogleFont: Google returned no @font-face rules for ${JSON.stringify(family)}`,
330
+ )
331
+ }
332
+ // Filter to requested named subsets — but only when not glyph-subsetting (text mode labels faces
333
+ // `[0]`, `[1]`, … which carry no subset name).
334
+ if (wantedSubsets && wantedSubsets.length > 0 && options.text === undefined) {
335
+ faces = faces.filter((f) => wantedSubsets.includes(f.subset))
336
+ if (faces.length === 0) {
337
+ throw new Error(`loadGoogleFont: no faces for subsets [${wantedSubsets.join(", ")}]`)
338
+ }
339
+ }
340
+
341
+ const assets: FontAsset[] = []
342
+ const rules: string[] = []
343
+ const preloads: Record<string, string>[] = []
344
+
345
+ for (const face of faces) {
346
+ // Prefer woff2; the CSS2 endpoint with a Chrome UA only ever returns woff2, but be defensive.
347
+ const source = face.src.find((s) => s.format === "woff2") ?? face.src[0]
348
+ if (!source) continue
349
+ if (!isAllowedFontUrl(source.url)) {
350
+ throw new Error(
351
+ `loadGoogleFont: refusing to download a font from a non-Google host (${hostOf(source.url)}); ` +
352
+ "the stylesheet may be tampered with",
353
+ )
354
+ }
355
+ const bytes = await fetchBounded(fetchImpl, source.url, maxBytes)
356
+ const hash = await contentHash(bytes)
357
+ const style = face.style === "italic" ? "italic" : "normal"
358
+ const fileName = `${slug(family)}-${slug(face.subset)}-${style}-${face.weight.replace(/\s+/g, "_")}-${hash}.woff2`
359
+ const path = join(io.outDir, fileName)
360
+ await writeFile(path, bytes)
361
+
362
+ const href = `${publicPath}/${fileName}`
363
+ assets.push({
364
+ fileName,
365
+ href,
366
+ sourceUrl: source.url,
367
+ bytes,
368
+ subset: face.subset,
369
+ weight: face.weight,
370
+ style,
371
+ })
372
+ rules.push(
373
+ fontFace({
374
+ family,
375
+ src: [{ url: href, format: "woff2" }],
376
+ weight: face.weight,
377
+ style,
378
+ display: options.display ?? "swap",
379
+ ...(face.unicodeRange === undefined ? {} : { unicodeRange: face.unicodeRange }),
380
+ ...(options.sizeAdjust === undefined ? {} : { sizeAdjust: options.sizeAdjust }),
381
+ ...(options.ascentOverride === undefined ? {} : { ascentOverride: options.ascentOverride }),
382
+ ...(options.descentOverride === undefined
383
+ ? {}
384
+ : { descentOverride: options.descentOverride }),
385
+ ...(options.lineGapOverride === undefined
386
+ ? {}
387
+ : { lineGapOverride: options.lineGapOverride }),
388
+ }),
389
+ )
390
+ preloads.push(fontPreload({ href }))
391
+ }
392
+
393
+ if (assets.length === 0) {
394
+ throw new Error(`loadGoogleFont: no downloadable woff2 sources for ${JSON.stringify(family)}`)
395
+ }
396
+ return { family, css: rules.join("\n\n"), assets, preloads }
397
+ }