Skip to content

Commit 264c402

Browse files
committed
perf: add HTTP Link header (experimental)
1 parent c3090e0 commit 264c402

File tree

3 files changed

+188
-0
lines changed

3 files changed

+188
-0
lines changed

modules/route-rules.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import type { NitroConfig } from 'nitropack'
2+
import { defineNuxtModule } from 'nuxt/kit'
3+
import { resolve } from 'node:path'
4+
import { readFile } from 'node:fs/promises'
5+
import { isPreview, isProduction } from '../config/env'
6+
import { ELEMENT_NODE, parse, walk } from 'ultrahtml'
7+
// import type { Manifest } from "@voidzero-dev/vite-plus-core";
8+
9+
// TODO: use the context and maybe we should move the module to a folder
10+
// interface RouteRulesContext {
11+
// globalFonts: string[]
12+
// manifest: Manifest
13+
// }
14+
15+
// Cache for global fonts extracted from entry CSS
16+
let globalFonts: string[] = []
17+
18+
async function extractFontsFromCss(cssPath: string): Promise<string[]> {
19+
try {
20+
const content = await readFile(cssPath, 'utf8')
21+
const fontUrls = new Set<string>()
22+
// original with backtracking => oxlint complains
23+
// const regex = /url\(\s*(?:["']?)([^"')]+?\.woff2)(?:["']?)\s*\)/g
24+
25+
// Regex to capture woff2 urls: url(../_fonts/...) or url("../_fonts/...")
26+
// Captures the content inside url(...) stripping quotes if present
27+
// Optimized to avoid ReDoS (super-linear backtracking)
28+
const regex = /url\(\s*['"]?([^\s'")\\]+\.woff2)(?:'|")?\s*\)/g
29+
30+
let match
31+
while ((match = regex.exec(content)) !== null) {
32+
const url = match[1]
33+
if (url) {
34+
fontUrls.add(url)
35+
}
36+
}
37+
return Array.from(fontUrls)
38+
} catch (e) {
39+
console.error(`Failed to extract fonts from ${cssPath}:`, e)
40+
return []
41+
}
42+
}
43+
44+
async function collectLinkHeader(path: string): Promise<string | undefined> {
45+
try {
46+
const html = await readFile(path, 'utf8')
47+
const ast = parse(html)
48+
const links: string[] = []
49+
50+
// Add global fonts (preload)
51+
for (const font of globalFonts) {
52+
let fontPath = font
53+
if (font.startsWith('../')) {
54+
fontPath = font.substring(2) // Remove ..
55+
}
56+
if (!fontPath.startsWith('/')) {
57+
fontPath = '/' + fontPath
58+
}
59+
links.push(`<${fontPath}>; rel=preload; as=font; crossorigin`)
60+
}
61+
62+
await walk(ast, node => {
63+
if (node.type === ELEMENT_NODE) {
64+
if (node.name === 'script') {
65+
const type = node.attributes.type
66+
const src = node.attributes.src
67+
if (type === 'module' && src && !src.startsWith('http')) {
68+
const cors =
69+
node.attributes.crossorigin === '' || node.attributes.crossorigin === 'true'
70+
? '; crossorigin'
71+
: ''
72+
links.push(`<${src}>; rel=modulepreload; as=script${cors}`)
73+
}
74+
} else if (node.name === 'link') {
75+
const rel = node.attributes.rel
76+
const href = node.attributes.href
77+
if (rel === 'modulepreload' && href && !href.startsWith('http')) {
78+
const cors =
79+
node.attributes.crossorigin === '' || node.attributes.crossorigin === 'true'
80+
? '; crossorigin'
81+
: ''
82+
links.push(`<${href}>; rel=modulepreload; as=script${cors}`)
83+
} else if (rel === 'stylesheet' && href && !href.startsWith('http')) {
84+
// Preload CSS
85+
const cors =
86+
node.attributes.crossorigin === '' || node.attributes.crossorigin === 'true'
87+
? '; crossorigin'
88+
: ''
89+
links.push(`<${href}>; rel=preload; as=style ${cors}`)
90+
}
91+
}
92+
}
93+
})
94+
95+
// console.log(path, links)
96+
97+
return links.length > 0 ? links.join(', ') : undefined
98+
} catch (e) {
99+
console.error(`error collecting link header for ${path}`, e)
100+
// Ignore file not found (route might not be prerendered yet or is dynamic)
101+
return undefined
102+
}
103+
}
104+
105+
async function collectLinkHeaders(outDir: string, options: NitroConfig): Promise<NitroConfig> {
106+
const routeRules = Object.assign({}, options.routeRules)
107+
108+
for (const route of Object.keys(routeRules)) {
109+
const rule = routeRules[route]
110+
if (
111+
!rule?.prerender ||
112+
route.includes('*') ||
113+
route.endsWith('.html') ||
114+
route.endsWith('.json')
115+
) {
116+
continue
117+
}
118+
119+
let htmlPath = resolve(outDir, route === '/' ? 'index.html' : `${route.slice(1)}/index.html`)
120+
121+
const linkHeader = await collectLinkHeader(htmlPath)
122+
if (linkHeader) {
123+
routeRules[route]!.headers ??= {}
124+
routeRules[route]!.headers.Link = linkHeader
125+
}
126+
}
127+
128+
return { routeRules }
129+
}
130+
131+
export default defineNuxtModule({
132+
meta: {
133+
name: 'npmx:route-rules',
134+
},
135+
setup(_options, nuxt) {
136+
// TODO: use this for local test with http/1.1 and node preset
137+
/*
138+
if (nuxt.options.dev || process.env.TEST /!* || !(isPreview || isProduction)*!/) {
139+
return
140+
}
141+
*/
142+
if (nuxt.options.dev || process.env.TEST || !(isPreview || isProduction)) {
143+
return
144+
}
145+
146+
let outDir = '../.output/public'
147+
148+
nuxt.hook('nitro:init', nitro => {
149+
const nitroConfig = nitro.options
150+
const publicDir = nitroConfig.output?.publicDir ?? nuxt.options.nitro?.output?.publicDir
151+
outDir = publicDir ? resolve(publicDir) : resolve(nuxt.options.buildDir, '../.output/public')
152+
nitro.hooks.hook('prerender:done', async () => {
153+
const updates = await collectLinkHeaders(outDir, nitro.options)
154+
await nitro.updateConfig(updates)
155+
// console.log(updates)
156+
})
157+
})
158+
159+
// Hook into build:manifest to find the entry CSS and extract fonts
160+
nuxt.hook('build:manifest', async manifest => {
161+
// console.log(manifest)
162+
const entry = Object.values(manifest).find(r => r.isEntry)
163+
if (entry && entry.css) {
164+
for (const cssFile of entry.css) {
165+
// Try to resolve CSS path. It might be in _nuxt/ or root of dist/client
166+
// Based on log: node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt/pages...css
167+
// nuxt.options.buildDir points to .nuxt
168+
let cssPath = resolve('node_modules/.cache/nuxt/.nuxt/dist/client/_nuxt', cssFile)
169+
170+
// If cssFile doesn't start with _nuxt but is inside it, adjust
171+
// But usually manifest paths are relative to public path or build output
172+
173+
// Fallback/Check: if file doesn't exist, try adding _nuxt if missing
174+
// But let's trust resolve first.
175+
176+
const fonts = await extractFontsFromCss(cssPath)
177+
globalFonts.push(...fonts)
178+
}
179+
}
180+
181+
// console.log(globalFonts)
182+
})
183+
},
184+
})

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
"std-env": "3.10.0",
101101
"tinyglobby": "0.2.15",
102102
"ufo": "1.6.3",
103+
"ultrahtml": "1.6.0",
103104
"unocss": "66.6.0",
104105
"unplugin-vue-router": "0.19.2",
105106
"valibot": "1.2.0",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)