@fin14/core 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pedro Aguiar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,197 @@
1
+ # @fin14/core
2
+
3
+ Measurement engine for the [fin14](https://www.npmjs.com/package/fin14) performance budget CLI. Loads config, measures gzip-compressed page weight against the 14 kB limit, and summarizes results across a static site.
4
+
5
+ Use this package to integrate fin14 measurement into your own build scripts, CI pipelines, or framework tooling. If you just want a CLI, install `fin14` directly.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ npm install @fin14/core
11
+ ```
12
+
13
+ Requires Node.js >= 22. ESM only.
14
+
15
+ ## Usage
16
+
17
+ ### Measure a single page
18
+
19
+ ```js
20
+ import { loadConfig, measurePage } from '@fin14/core';
21
+ import path from 'node:path';
22
+
23
+ const config = await loadConfig({ outputDir: './dist' });
24
+ const result = await measurePage(path.resolve('./dist/index.html'), config);
25
+
26
+ console.log(result.status); // 'pass' | 'warn' | 'fail'
27
+ console.log(result.totalBytes); // compressed bytes
28
+ console.log(result.overBudget); // boolean
29
+ ```
30
+
31
+ ### Measure a whole site with progress callback
32
+
33
+ ```js
34
+ import { loadConfig, measureSite } from '@fin14/core';
35
+
36
+ const config = await loadConfig();
37
+
38
+ const summary = await measureSite('./dist', config, {
39
+ onStart({ total, files }) {
40
+ console.log(`Measuring ${total} pages...`);
41
+ },
42
+ onPageResult(page, { done, total }) {
43
+ console.log(`[${done}/${total}] ${page.file} — ${page.status}`);
44
+ },
45
+ });
46
+
47
+ console.log(`${summary.pass} pass, ${summary.warn} warn, ${summary.fail} fail`);
48
+ ```
49
+
50
+ ## API
51
+
52
+ ### `loadConfig(options?)`
53
+
54
+ ```js
55
+ async function loadConfig(options = {})
56
+ ```
57
+
58
+ Loads and merges configuration from `fin14.toml`, applying all defaults. If no config file is found, defaults are used — `fin14.toml` is optional.
59
+
60
+ | Option | Type | Description |
61
+ |---|---|---|
62
+ | `config` | `string` | Path to `fin14.toml`. Auto-searches current directory if omitted. |
63
+ | `outputDir` | `string` | Overrides `project.output_dir` from the config file. |
64
+
65
+ Returns the full merged config object. Throws on parse errors or unknown fields in `fin14.toml`.
66
+
67
+ ---
68
+
69
+ ### `measurePage(file, config)`
70
+
71
+ ```js
72
+ async function measurePage(file, config)
73
+ ```
74
+
75
+ Measures a single HTML page and all its linked resources.
76
+
77
+ | Param | Type | Description |
78
+ |---|---|---|
79
+ | `file` | `string` | Absolute path to the `.html` file. |
80
+ | `config` | `object` | Result of `loadConfig()`. |
81
+
82
+ Returns a `PageResult`:
83
+
84
+ ```js
85
+ {
86
+ file: string, // relative path from outputDir
87
+ status: 'pass' | 'warn' | 'fail',
88
+ totalBytes: number, // compressed bytes
89
+ limitBytes: number, // budget limit in bytes
90
+ overBudget: boolean,
91
+ unknownBudget: boolean,
92
+ resources: ResourceResult[],
93
+ cutCandidates: ResourceResult[],
94
+ warnings: Warning[],
95
+ skippedResources: SkippedResource[]
96
+ }
97
+ ```
98
+
99
+ ---
100
+
101
+ ### `measureSite(outputDir, config, options?)`
102
+
103
+ ```js
104
+ async function measureSite(outputDir, config, options = {})
105
+ ```
106
+
107
+ Discovers and measures all HTML pages under `outputDir`, after applying
108
+ `project.include` / `project.exclude` glob filters from the config. Each page is
109
+ validated against its resolved budget (`budget.overrides` first-match, else
110
+ `budget.default`).
111
+
112
+ | Param | Type | Description |
113
+ |---|---|---|
114
+ | `outputDir` | `string` | Path to the SSG output folder. |
115
+ | `config` | `object` | Result of `loadConfig()`. |
116
+ | `options.onStart` | `function` | Called once before measurement starts. Receives `{ total: number, files: string[] }`. |
117
+ | `options.onPageResult` | `function` | Called after each page. Receives `(PageResult, { done: number, total: number })`. |
118
+
119
+ Returns a `SiteSummary`.
120
+
121
+ ---
122
+
123
+ ### `summarize(results, outputDir, siteWarnings?)`
124
+
125
+ ```js
126
+ function summarize(results, outputDir, siteWarnings = [])
127
+ ```
128
+
129
+ Aggregates an array of `PageResult` objects into a `SiteSummary`. Useful when you run `measurePage` yourself and want to produce a summary.
130
+
131
+ | Param | Type | Description |
132
+ |---|---|---|
133
+ | `results` | `PageResult[]` | Results from `measurePage`. |
134
+ | `outputDir` | `string` | Path to the output directory. |
135
+ | `siteWarnings` | `Warning[]` | Site-level warnings (e.g. EMFILE retry). |
136
+
137
+ Returns a `SiteSummary`:
138
+
139
+ ```js
140
+ {
141
+ outputDir: string,
142
+ generatedAt: string, // ISO timestamp
143
+ pages: number,
144
+ pass: number,
145
+ warn: number,
146
+ fail: number,
147
+ totalBytes: number,
148
+ topAssets: ResourceResult[],
149
+ siteWarnings: Warning[],
150
+ results: PageResult[]
151
+ }
152
+ ```
153
+
154
+ ### Glob helpers
155
+
156
+ ```js
157
+ import { matchGlob, filterPages, resolveBudget } from '@fin14/core';
158
+ ```
159
+
160
+ | Function | Description |
161
+ |---|---|
162
+ | `matchGlob(path, pattern)` | Anchored glob match (`*`, `**`, `?`) against a posix path. |
163
+ | `filterPages(relPaths, config)` | Applies `project.include` / `project.exclude` to a list of relative page paths. |
164
+ | `resolveBudget(relFile, config)` | Returns the budget in bytes for a page — first matching `budget.overrides[].path`, else `budget.default`. |
165
+
166
+ ## Types
167
+
168
+ ### `ResourceResult`
169
+
170
+ ```js
171
+ {
172
+ key: string, // dedup key (absolute path or URL)
173
+ type: 'html' | 'css' | 'js' | 'image' | 'font',
174
+ group: 'html' | 'css' | 'js' | 'images' | 'external',
175
+ path: string, // absolute path or URL
176
+ displayPath: string, // path shown in output (relative to outputDir)
177
+ external: boolean,
178
+ inline: boolean,
179
+ bytes: number // compressed bytes
180
+ }
181
+ ```
182
+
183
+ ### `Warning`
184
+
185
+ ```js
186
+ {
187
+ code: string, // e.g. 'E14'
188
+ message: string,
189
+ strictFailure: boolean
190
+ }
191
+ ```
192
+
193
+ ---
194
+
195
+ For the CLI, see [fin14](https://www.npmjs.com/package/fin14).
196
+
197
+ MIT
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@fin14/core",
3
+ "version": "0.1.0",
4
+ "description": "fin14 measurement engine",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "duxpe",
8
+ "homepage": "https://github.com/duxpe/fin14#readme",
9
+ "bugs": { "url": "https://github.com/duxpe/fin14/issues" },
10
+ "keywords": ["performance", "budget", "static-site", "14kb", "measurement", "compression", "web-performance", "page-weight", "ssg"],
11
+ "publishConfig": { "access": "public" },
12
+ "repository": { "type": "git", "url": "git+https://github.com/duxpe/fin14.git" },
13
+ "engines": { "node": ">=22.0.0" },
14
+ "exports": { ".": "./src/index.js" },
15
+ "files": ["src", "README.md", "LICENSE"],
16
+ "scripts": { "test": "node --test" },
17
+ "dependencies": {
18
+ "parse5": "^8.0.1",
19
+ "css-tree": "^3.2.1",
20
+ "smol-toml": "^1.6.1"
21
+ }
22
+ }
@@ -0,0 +1,288 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { resolve, relative, dirname } from 'node:path'
3
+ import { parse } from 'parse5'
4
+ import { parse as parseCss, walk as walkCss } from 'css-tree'
5
+
6
+ function walk(node, fn) {
7
+ fn(node)
8
+ if (node.childNodes) node.childNodes.forEach(c => walk(c, fn))
9
+ }
10
+
11
+ function attr(node, name) {
12
+ return node.attrs?.find(a => a.name === name)?.value
13
+ }
14
+
15
+ function textContent(node) {
16
+ if (!node.childNodes) return ''
17
+ return node.childNodes
18
+ .filter(c => c.nodeName === '#text')
19
+ .map(c => c.value)
20
+ .join('')
21
+ }
22
+
23
+ function resolveUrl(href, baseFile, outputDir) {
24
+ if (href.startsWith('http://') || href.startsWith('https://')) {
25
+ const u = new URL(href)
26
+ u.hash = ''
27
+ const key = u.href
28
+ const displayPath = u.hostname + u.pathname
29
+ return { external: true, key, path: href, displayPath }
30
+ }
31
+ let absPath
32
+ if (href.startsWith('/')) {
33
+ absPath = resolve(outputDir, href.slice(1))
34
+ } else {
35
+ absPath = resolve(dirname(baseFile), href)
36
+ }
37
+ const hashIdx = absPath.indexOf('#')
38
+ const queryIdx = absPath.indexOf('?')
39
+ const cutIdx = [hashIdx, queryIdx].filter(i => i !== -1).reduce((a, b) => Math.min(a, b), absPath.length)
40
+ const key = absPath.slice(0, cutIdx)
41
+ return { external: false, key, path: key, displayPath: relative(outputDir, key) }
42
+ }
43
+
44
+ function typeFromAs(asVal) {
45
+ if (asVal === 'script') return 'js'
46
+ if (asVal === 'style') return 'css'
47
+ if (asVal === 'image') return 'image'
48
+ if (asVal === 'font') return 'font'
49
+ return null
50
+ }
51
+
52
+ function groupFromType(type, external) {
53
+ if (external) return 'external'
54
+ if (type === 'html') return 'html'
55
+ if (type === 'css') return 'css'
56
+ if (type === 'js') return 'js'
57
+ if (type === 'image') return 'images'
58
+ if (type === 'font') return 'css'
59
+ return 'external'
60
+ }
61
+
62
+ function extractCssImportUrl(node) {
63
+ const prelude = node.prelude
64
+ if (!prelude) return null
65
+ let url = null
66
+ walkCss(prelude, n => {
67
+ if (url) return
68
+ if (n.type === 'String') {
69
+ url = n.value.replace(/^['"]|['"]$/g, '')
70
+ } else if (n.type === 'Url') {
71
+ const val = n.value
72
+ if (val.type === 'String') {
73
+ url = val.value.replace(/^['"]|['"]$/g, '')
74
+ } else if (val.type === 'Raw') {
75
+ url = val.value
76
+ }
77
+ }
78
+ })
79
+ return url
80
+ }
81
+
82
+ function extractFontFaceSrc(node) {
83
+ const results = []
84
+ if (!node.block) return results
85
+ walkCss(node.block, decl => {
86
+ if (decl.type !== 'Declaration' || decl.property !== 'src') return
87
+ const urls = []
88
+ walkCss(decl.value, n => {
89
+ if (n.type === 'Url') {
90
+ const val = n.value
91
+ let href = ''
92
+ if (typeof val === 'string') {
93
+ href = val.replace(/^['"]|['"]$/g, '')
94
+ } else if (val && val.type === 'String') {
95
+ href = val.value.replace(/^['"]|['"]$/g, '')
96
+ } else if (val && val.type === 'Raw') {
97
+ href = val.value
98
+ }
99
+ if (href) urls.push(href)
100
+ }
101
+ })
102
+ results.push(...urls)
103
+ })
104
+ return results
105
+ }
106
+
107
+ function addRef(resolved, type, group, seenKeys, refs, skipped) {
108
+ if (seenKeys.has(resolved.key)) {
109
+ skipped.push({ key: resolved.key, type, displayPath: resolved.displayPath, reason: 'duplicate', countedAs: seenKeys.get(resolved.key) })
110
+ } else {
111
+ seenKeys.set(resolved.key, group)
112
+ refs.push({ key: resolved.key, type, group, path: resolved.path, displayPath: resolved.displayPath, external: resolved.external, inline: false })
113
+ }
114
+ }
115
+
116
+ // stack: Set of abs paths currently in the import chain — used only for cycle detection.
117
+ // seenKeys handles dedup (already counted → skip as duplicate, not a cycle).
118
+ async function processCss(cssContent, cssFile, outputDir, seenKeys, refs, skipped, warnings, stack, htmlFile) {
119
+ const ast = parseCss(cssContent, { parseValue: true, onParseError: () => {} })
120
+ const baseFile = cssFile || htmlFile
121
+ const imports = []
122
+
123
+ walkCss(ast, {
124
+ visit: 'Atrule',
125
+ enter(node) {
126
+ if (node.name === 'import') {
127
+ const href = extractCssImportUrl(node)
128
+ if (!href || href.startsWith('http://') || href.startsWith('https://')) return
129
+ imports.push(href)
130
+ } else if (node.name === 'font-face') {
131
+ const urls = extractFontFaceSrc(node)
132
+ for (let i = 0; i < urls.length; i++) {
133
+ const href = urls[i]
134
+ const resolved = resolveUrl(href, baseFile, outputDir)
135
+ const type = 'font'
136
+ const group = groupFromType(type, resolved.external)
137
+ if (i === 0) {
138
+ addRef(resolved, type, group, seenKeys, refs, skipped)
139
+ } else {
140
+ skipped.push({ key: resolved.key, type, displayPath: resolved.displayPath, reason: 'alternate_font_src' })
141
+ }
142
+ }
143
+ }
144
+ }
145
+ })
146
+
147
+ for (const href of imports) {
148
+ const absPath = resolve(dirname(baseFile), href)
149
+
150
+ if (stack.has(absPath)) {
151
+ warnings.push({ code: 'E11', message: `CSS @import cycle detected: ${absPath}`, path: absPath, strictFailure: false })
152
+ continue
153
+ }
154
+
155
+ let content
156
+ try {
157
+ content = await readFile(absPath, 'utf8')
158
+ } catch {
159
+ warnings.push({ code: 'E10', message: `CSS @import file not found: ${absPath}`, path: absPath, strictFailure: false })
160
+ continue
161
+ }
162
+
163
+ const displayPath = relative(outputDir, absPath)
164
+ if (!seenKeys.has(absPath)) {
165
+ seenKeys.set(absPath, 'css')
166
+ refs.push({ key: absPath, type: 'css', group: 'css', path: absPath, displayPath, external: false, inline: false })
167
+ await processCss(content, absPath, outputDir, seenKeys, refs, skipped, warnings, new Set([...stack, absPath]), htmlFile)
168
+ } else {
169
+ skipped.push({ key: absPath, type: 'css', displayPath, reason: 'duplicate', countedAs: seenKeys.get(absPath) })
170
+ }
171
+ }
172
+ }
173
+
174
+ export async function collect(htmlFile, outputDir, config) {
175
+ const refs = []
176
+ const skipped = []
177
+ const warnings = []
178
+ const seenKeys = new Map()
179
+
180
+ const htmlBuffer = await readFile(htmlFile)
181
+ seenKeys.set(htmlFile, 'html')
182
+ refs.push({
183
+ key: htmlFile,
184
+ type: 'html',
185
+ group: 'html',
186
+ path: htmlFile,
187
+ displayPath: relative(outputDir, htmlFile),
188
+ external: false,
189
+ inline: true,
190
+ inlineContent: htmlBuffer,
191
+ })
192
+
193
+ const dom = parse(htmlBuffer.toString('utf8'))
194
+ const vp = config.viewport[config.viewport.default]
195
+ const maxLazy = vp.lazy_visible_images
196
+ let lazyCount = 0
197
+ const cssQueue = []
198
+
199
+ walk(dom, node => {
200
+ if (node.nodeName === 'link') {
201
+ const rel = attr(node, 'rel')
202
+ const href = attr(node, 'href')
203
+ if (!href) return
204
+
205
+ if (rel === 'stylesheet') {
206
+ const resolved = resolveUrl(href, htmlFile, outputDir)
207
+ addRef(resolved, 'css', groupFromType('css', resolved.external), seenKeys, refs, skipped)
208
+ if (!resolved.external) cssQueue.push({ absPath: resolved.key, inline: false })
209
+ } else if (rel === 'preload') {
210
+ const type = typeFromAs(attr(node, 'as'))
211
+ if (!type) return
212
+ const resolved = resolveUrl(href, htmlFile, outputDir)
213
+ addRef(resolved, type, groupFromType(type, resolved.external), seenKeys, refs, skipped)
214
+ } else if (rel === 'modulepreload') {
215
+ const resolved = resolveUrl(href, htmlFile, outputDir)
216
+ addRef(resolved, 'js', groupFromType('js', resolved.external), seenKeys, refs, skipped)
217
+ }
218
+ } else if (node.nodeName === 'style') {
219
+ // inline CSS bytes are part of HTML — parse for @import/@font-face discovery only
220
+ cssQueue.push({ inline: true, content: textContent(node) })
221
+ } else if (node.nodeName === 'script') {
222
+ const src = attr(node, 'src')
223
+ const type = attr(node, 'type')
224
+ if (src && (type === undefined || type === null || type === 'module' || type === 'text/javascript')) {
225
+ const resolved = resolveUrl(src, htmlFile, outputDir)
226
+ addRef(resolved, 'js', groupFromType('js', resolved.external), seenKeys, refs, skipped)
227
+ }
228
+ // inline scripts: bytes are part of HTML, not counted separately
229
+ } else if (node.nodeName === 'img') {
230
+ const src = attr(node, 'src')
231
+ const srcset = attr(node, 'srcset')
232
+ const loading = attr(node, 'loading')
233
+
234
+ if (srcset) {
235
+ const resolved = resolveUrl(srcset.split(/[\s,]+/)[0], htmlFile, outputDir)
236
+ skipped.push({ key: resolved.key, type: 'image', displayPath: resolved.displayPath, reason: 'srcset_alternative' })
237
+ }
238
+
239
+ if (src) {
240
+ if (loading === 'lazy') {
241
+ if (lazyCount < maxLazy) {
242
+ lazyCount++
243
+ const resolved = resolveUrl(src, htmlFile, outputDir)
244
+ addRef(resolved, 'image', groupFromType('image', resolved.external), seenKeys, refs, skipped)
245
+ } else {
246
+ const resolved = resolveUrl(src, htmlFile, outputDir)
247
+ skipped.push({ key: resolved.key, type: 'image', displayPath: resolved.displayPath, reason: 'outside_matrix' })
248
+ }
249
+ } else {
250
+ const resolved = resolveUrl(src, htmlFile, outputDir)
251
+ addRef(resolved, 'image', groupFromType('image', resolved.external), seenKeys, refs, skipped)
252
+ }
253
+ }
254
+ } else if (node.nodeName === 'source') {
255
+ const srcset = attr(node, 'srcset')
256
+ if (srcset) {
257
+ const firstSrc = srcset.split(/[\s,]+/).find(s => s && !s.includes('w') && !s.includes('x')) || srcset.split(/[\s,]+/)[0]
258
+ if (firstSrc) {
259
+ const resolved = resolveUrl(firstSrc, htmlFile, outputDir)
260
+ skipped.push({ key: resolved.key, type: 'image', displayPath: resolved.displayPath, reason: 'srcset_alternative' })
261
+ }
262
+ }
263
+ }
264
+ })
265
+
266
+ // processedCss tracks which CSS files' content has been parsed for @imports.
267
+ // Separate from seenKeys (which tracks counted refs) — prevents re-parsing the same
268
+ // file if it appears multiple times in the HTML (e.g., duplicate <link> tags).
269
+ const processedCss = new Set()
270
+
271
+ for (const item of cssQueue) {
272
+ if (item.inline) {
273
+ await processCss(item.content, null, outputDir, seenKeys, refs, skipped, warnings, new Set(), htmlFile)
274
+ } else {
275
+ if (processedCss.has(item.absPath)) continue
276
+ processedCss.add(item.absPath)
277
+ let content
278
+ try {
279
+ content = await readFile(item.absPath, 'utf8')
280
+ } catch {
281
+ continue
282
+ }
283
+ await processCss(content, item.absPath, outputDir, seenKeys, refs, skipped, warnings, new Set([item.absPath]), htmlFile)
284
+ }
285
+ }
286
+
287
+ return { refs, skipped, warnings }
288
+ }
@@ -0,0 +1,96 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import {
5
+ DEFAULT_CONFIG,
6
+ EXTERNAL_CACHE_TTL_SECONDS,
7
+ EXTERNAL_DOMAIN_CONCURRENCY,
8
+ EXTERNAL_DOMAIN_WAIT_MS,
9
+ MILLISECONDS_PER_SECOND,
10
+ } from '../constants.js'
11
+
12
+ function cacheDir() {
13
+ return join(process.cwd(), '.fin14', 'cache')
14
+ }
15
+
16
+ function urlKey(url) {
17
+ return createHash('sha1').update(url).digest('hex')
18
+ }
19
+
20
+ async function readCache(url) {
21
+ const dir = cacheDir()
22
+ const key = urlKey(url)
23
+ try {
24
+ const meta = JSON.parse(await readFile(join(dir, `${key}.json`), 'utf8'))
25
+ if (Math.floor(Date.now() / MILLISECONDS_PER_SECOND) - meta.cachedAt > EXTERNAL_CACHE_TTL_SECONDS) return null
26
+ const body = await readFile(join(dir, `${key}.body`))
27
+ return { body, status: meta.status, contentType: meta.contentType }
28
+ } catch {
29
+ return null
30
+ }
31
+ }
32
+
33
+ async function writeCache(url, body, status, contentType) {
34
+ const dir = cacheDir()
35
+ await mkdir(dir, { recursive: true })
36
+ const key = urlKey(url)
37
+ const meta = { url, cachedAt: Math.floor(Date.now() / MILLISECONDS_PER_SECOND), status, contentType }
38
+ await writeFile(join(dir, `${key}.body`), body)
39
+ await writeFile(join(dir, `${key}.json`), JSON.stringify(meta))
40
+ }
41
+
42
+ const domainCounts = new Map()
43
+
44
+ async function withDomainLimit(hostname, fn) {
45
+ while ((domainCounts.get(hostname) ?? 0) >= EXTERNAL_DOMAIN_CONCURRENCY) {
46
+ await new Promise(r => setTimeout(r, EXTERNAL_DOMAIN_WAIT_MS))
47
+ }
48
+ domainCounts.set(hostname, (domainCounts.get(hostname) ?? 0) + 1)
49
+ try { return await fn() }
50
+ finally { domainCounts.set(hostname, domainCounts.get(hostname) - 1) }
51
+ }
52
+
53
+ export async function fetchExternal(url, config) {
54
+ const cached = await readCache(url)
55
+ if (cached) return cached
56
+
57
+ const hostname = new URL(url).hostname
58
+ return withDomainLimit(hostname, async () => {
59
+ const timeoutMs = config?.network?.timeout_ms ?? DEFAULT_CONFIG.network.timeout_ms
60
+ const maxRedirects = config?.network?.max_redirects ?? DEFAULT_CONFIG.network.max_redirects
61
+ const controller = new AbortController()
62
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
63
+
64
+ try {
65
+ let redirectCount = 0
66
+ let currentUrl = url
67
+ let res
68
+
69
+ while (true) {
70
+ res = await fetch(currentUrl, { redirect: 'manual', signal: controller.signal })
71
+ if (res.status >= 300 && res.status < 400) {
72
+ if (++redirectCount > maxRedirects) throw new Error('too many redirects')
73
+ const location = res.headers.get('location')
74
+ if (!location) throw new Error('redirect missing location header')
75
+ currentUrl = new URL(location, currentUrl).href
76
+ continue
77
+ }
78
+ break
79
+ }
80
+
81
+ if (res.status < 200 || res.status >= 300) {
82
+ throw new Error(`HTTP ${res.status}`)
83
+ }
84
+
85
+ const body = Buffer.from(await res.arrayBuffer())
86
+ const contentType = res.headers.get('content-type') ?? ''
87
+ await writeCache(url, body, res.status, contentType)
88
+ return { body, status: res.status, contentType }
89
+ } catch (err) {
90
+ if (err.name === 'AbortError') throw new Error(`timeout fetching ${url}`)
91
+ throw err
92
+ } finally {
93
+ clearTimeout(timer)
94
+ }
95
+ })
96
+ }
@@ -0,0 +1,68 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { compress } from '../compress.js'
3
+ import { fetchExternal } from './external.js'
4
+
5
+ const MISSING_CODE = {
6
+ css: 'E06',
7
+ js: 'E07',
8
+ image: 'E08',
9
+ font: 'E08',
10
+ }
11
+
12
+ export async function measure(refs, config) {
13
+ const results = []
14
+ const warnings = []
15
+ let unknownBudget = false
16
+
17
+ for (const ref of refs) {
18
+ let bytes
19
+
20
+ if (ref.inline) {
21
+ bytes = compress(ref.inlineContent, config.budget)
22
+ } else if (ref.external) {
23
+ try {
24
+ const { body } = await fetchExternal(ref.path, config)
25
+ bytes = compress(body, config.budget)
26
+ } catch {
27
+ warnings.push({
28
+ code: 'E09',
29
+ message: `${ref.displayPath}: fetch failed — budget unknown`,
30
+ path: ref.path,
31
+ strictFailure: false,
32
+ })
33
+ warnings.push({
34
+ code: 'E19',
35
+ message: `unknown budget: fetch failed for ${ref.displayPath}`,
36
+ path: ref.path,
37
+ strictFailure: true,
38
+ })
39
+ unknownBudget = true
40
+ continue
41
+ }
42
+ } else {
43
+ try {
44
+ const content = await readFile(ref.path)
45
+ bytes = compress(content, config.budget)
46
+ } catch (err) {
47
+ if (err.code === 'ENOENT') {
48
+ const code = MISSING_CODE[ref.type]
49
+ if (code) {
50
+ warnings.push({
51
+ code,
52
+ message: `${ref.displayPath}: file not found`,
53
+ path: ref.path,
54
+ strictFailure: false,
55
+ })
56
+ }
57
+ continue
58
+ }
59
+ throw err
60
+ }
61
+ }
62
+
63
+ const { inlineContent: _, ...rest } = ref
64
+ results.push({ ...rest, bytes })
65
+ }
66
+
67
+ return { results, warnings, unknownBudget }
68
+ }