@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 +21 -0
- package/README.md +197 -0
- package/package.json +22 -0
- package/src/budget/collector.js +288 -0
- package/src/budget/external.js +96 -0
- package/src/budget/measurer.js +68 -0
- package/src/budget/validator.js +74 -0
- package/src/compress.js +23 -0
- package/src/config.js +313 -0
- package/src/constants.js +42 -0
- package/src/crawler.js +34 -0
- package/src/glob.js +51 -0
- package/src/ignore.js +55 -0
- package/src/index.js +94 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { BYTES_PER_KB, STRICT_IPV6_LIMIT_BYTES } from '../constants.js'
|
|
2
|
+
import { resolveBudget } from '../glob.js'
|
|
3
|
+
|
|
4
|
+
export function deriveStatusWarnings(results, totalBytes, limitBytes, hasOverride, file) {
|
|
5
|
+
const overBudget = totalBytes > limitBytes
|
|
6
|
+
const warnings = []
|
|
7
|
+
|
|
8
|
+
if (totalBytes > STRICT_IPV6_LIMIT_BYTES && !overBudget && !hasOverride) {
|
|
9
|
+
warnings.push({
|
|
10
|
+
code: 'E13',
|
|
11
|
+
message: `${file}: ${(totalBytes / BYTES_PER_KB).toFixed(1)}kB exceeds IPv6 heuristic (12.2kB)`,
|
|
12
|
+
path: file,
|
|
13
|
+
strictFailure: true,
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
for (const result of results) {
|
|
18
|
+
if (result.external === true) {
|
|
19
|
+
warnings.push({
|
|
20
|
+
code: 'E14',
|
|
21
|
+
message: `external resource counted: ${result.displayPath}`,
|
|
22
|
+
path: result.path,
|
|
23
|
+
strictFailure: true,
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return warnings
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function validate(results, skipped, warnings, config, file) {
|
|
32
|
+
const limitBytes = resolveBudget(file, config)
|
|
33
|
+
const hasOverride = limitBytes !== config.budget.default
|
|
34
|
+
const totalBytes = results.reduce((sum, r) => sum + r.bytes, 0)
|
|
35
|
+
const overBudget = totalBytes > limitBytes
|
|
36
|
+
const unknownBudget = warnings.some(w => w.code === 'E19')
|
|
37
|
+
|
|
38
|
+
const newWarnings = [...warnings, ...deriveStatusWarnings(results, totalBytes, limitBytes, hasOverride, file)]
|
|
39
|
+
|
|
40
|
+
const status = overBudget ? 'fail' : newWarnings.length > 0 ? 'warn' : 'pass'
|
|
41
|
+
|
|
42
|
+
const cutCandidates = computeCutCandidates(results, totalBytes, limitBytes, overBudget)
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
file,
|
|
46
|
+
totalBytes,
|
|
47
|
+
limitBytes,
|
|
48
|
+
status,
|
|
49
|
+
overBudget,
|
|
50
|
+
unknownBudget,
|
|
51
|
+
resources: results,
|
|
52
|
+
skippedResources: skipped,
|
|
53
|
+
warnings: newWarnings,
|
|
54
|
+
cutCandidates,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function computeCutCandidates(results, totalBytes, limitBytes, overBudget) {
|
|
59
|
+
const sorted = [...results].sort((a, b) => b.bytes - a.bytes)
|
|
60
|
+
|
|
61
|
+
if (overBudget) {
|
|
62
|
+
const overage = totalBytes - limitBytes
|
|
63
|
+
const candidates = []
|
|
64
|
+
let cumulative = 0
|
|
65
|
+
for (const resource of sorted) {
|
|
66
|
+
candidates.push(resource)
|
|
67
|
+
cumulative += resource.bytes
|
|
68
|
+
if (cumulative >= overage) break
|
|
69
|
+
}
|
|
70
|
+
return candidates.length > 0 ? candidates : [sorted[0]]
|
|
71
|
+
} else {
|
|
72
|
+
return sorted.slice(0, 3)
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/compress.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { gzipSync, brotliCompressSync, constants } from 'node:zlib'
|
|
2
|
+
import { GZIP_MIN_LEVEL, GZIP_MAX_LEVEL } from './constants.js'
|
|
3
|
+
|
|
4
|
+
export function compress(content, config) {
|
|
5
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content)
|
|
6
|
+
if (config.compression === 'brotli') {
|
|
7
|
+
return brotliCompressSync(buf, {
|
|
8
|
+
params: { [constants.BROTLI_PARAM_QUALITY]: config.compression_level },
|
|
9
|
+
}).length
|
|
10
|
+
}
|
|
11
|
+
const level = Math.min(Math.max(config.compression_level, GZIP_MIN_LEVEL), GZIP_MAX_LEVEL)
|
|
12
|
+
return gzipSync(buf, { level }).length
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function gzipLevelClampNotice(budget) {
|
|
16
|
+
if (budget.compression !== 'gzip') return null
|
|
17
|
+
const level = budget.compression_level
|
|
18
|
+
if (level < GZIP_MIN_LEVEL || level > GZIP_MAX_LEVEL) {
|
|
19
|
+
const effective = Math.min(Math.max(level, GZIP_MIN_LEVEL), GZIP_MAX_LEVEL)
|
|
20
|
+
return `compression_level ${level} clamped to ${effective} (gzip range ${GZIP_MIN_LEVEL}–${GZIP_MAX_LEVEL}); use brotli for level 0 or 10–11`
|
|
21
|
+
}
|
|
22
|
+
return null
|
|
23
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { readFile, access } from 'node:fs/promises'
|
|
2
|
+
import { parse } from 'smol-toml'
|
|
3
|
+
import { BYTES_PER_KB, COMPRESSION_LEVEL_MAX, COMPRESSION_LEVEL_MIN, DEFAULT_CONFIG, IGNORE_PRESETS } from './constants.js'
|
|
4
|
+
|
|
5
|
+
const SSG_DETECTORS = [
|
|
6
|
+
{ files: ['hugo.toml', 'hugo.yaml', 'hugo.json'], dir: 'public' },
|
|
7
|
+
{ files: ['.eleventy.js', 'eleventy.config.js', 'eleventy.config.mjs', 'eleventy.config.cjs'], dir: '_site' },
|
|
8
|
+
{ files: ['astro.config.js', 'astro.config.ts', 'astro.config.mjs', 'astro.config.cjs'], dir: 'dist' },
|
|
9
|
+
{ files: ['_config.yml', '_config.toml'], dir: '_site' },
|
|
10
|
+
{ files: ['next.config.js', 'next.config.ts', 'next.config.mjs'], dir: 'out' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
async function detectOutputDir(cwd) {
|
|
14
|
+
for (const { files, dir } of SSG_DETECTORS) {
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
try { await access(`${cwd}/${file}`); return dir } catch {}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const KNOWN_KEYS = {
|
|
23
|
+
project: new Set(['output_dir', 'build_command', 'include', 'exclude']),
|
|
24
|
+
budget: new Set(['default', 'compression', 'compression_level', 'cut_candidate_min_bytes', 'baseline_min_delta_bytes', 'overrides', 'ignore', 'ignore_presets']),
|
|
25
|
+
network: new Set(['timeout_ms', 'max_redirects']),
|
|
26
|
+
viewport: new Set(['default', 'mobile', 'desktop']),
|
|
27
|
+
warnings: new Set(['exclude']),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const OVERRIDE_KEYS = new Set(['path', 'budget'])
|
|
31
|
+
|
|
32
|
+
function validateGlobList(value, fieldPath) {
|
|
33
|
+
if (!Array.isArray(value) || !value.every(v => typeof v === 'string')) {
|
|
34
|
+
throw new Error(`E04: fin14.toml: ${fieldPath} must be an array of strings`)
|
|
35
|
+
}
|
|
36
|
+
return value
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseBudgetOverrides(value) {
|
|
40
|
+
if (!Array.isArray(value)) {
|
|
41
|
+
throw new Error(`E04: fin14.toml: budget.overrides must be an array of { path, budget } tables`)
|
|
42
|
+
}
|
|
43
|
+
return value.map((entry, i) => {
|
|
44
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
45
|
+
throw new Error(`E04: fin14.toml: budget.overrides[${i}] must be a { path, budget } table`)
|
|
46
|
+
}
|
|
47
|
+
for (const key of Object.keys(entry)) {
|
|
48
|
+
if (!OVERRIDE_KEYS.has(key)) {
|
|
49
|
+
throw new Error(`E04: fin14.toml: unknown field 'budget.overrides[${i}].${key}'`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (typeof entry.path !== 'string') {
|
|
53
|
+
throw new Error(`E04: fin14.toml: budget.overrides[${i}].path: expected string`)
|
|
54
|
+
}
|
|
55
|
+
if (entry.budget === undefined) {
|
|
56
|
+
throw new Error(`E04: fin14.toml: budget.overrides[${i}].budget is required`)
|
|
57
|
+
}
|
|
58
|
+
return { path: entry.path, budget: parseBudgetDefault(entry.budget, `budget.overrides[${i}].budget`) }
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const VIEWPORT_PRESET_KEYS = new Set(['width', 'height', 'lazy_visible_images'])
|
|
63
|
+
|
|
64
|
+
function assertType(value, expectedType, fieldPath) {
|
|
65
|
+
if (typeof value !== expectedType) {
|
|
66
|
+
throw new Error(`fin14.toml: ${fieldPath}: expected ${expectedType}, got ${typeof value}`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function assertInteger(value, fieldPath) {
|
|
71
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
72
|
+
throw new Error(`fin14.toml: ${fieldPath}: expected integer, got ${typeof value}`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseBudgetDefault(value, fieldPath = 'budget.default') {
|
|
77
|
+
if (typeof value === 'number') {
|
|
78
|
+
if (!Number.isInteger(value)) {
|
|
79
|
+
throw new Error(`fin14.toml: ${fieldPath}: expected integer or "NNkb" string, got float`)
|
|
80
|
+
}
|
|
81
|
+
return value
|
|
82
|
+
}
|
|
83
|
+
if (typeof value === 'string') {
|
|
84
|
+
const match = value.match(/^(\d+(?:\.\d+)?)kb$/i)
|
|
85
|
+
if (!match) {
|
|
86
|
+
throw new Error(`fin14.toml: ${fieldPath}: invalid value "${value}", expected integer or "NNkb" string`)
|
|
87
|
+
}
|
|
88
|
+
return Math.round(parseFloat(match[1]) * BYTES_PER_KB)
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`fin14.toml: ${fieldPath}: expected integer or string, got ${typeof value}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function validateViewportPreset(preset, name, userPreset) {
|
|
94
|
+
for (const key of Object.keys(userPreset)) {
|
|
95
|
+
if (!VIEWPORT_PRESET_KEYS.has(key)) {
|
|
96
|
+
throw new Error(`E04: fin14.toml: unknown field 'viewport.${name}.${key}'`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const result = { ...preset }
|
|
100
|
+
if (userPreset.width !== undefined) {
|
|
101
|
+
assertInteger(userPreset.width, `viewport.${name}.width`)
|
|
102
|
+
if (userPreset.width <= 0) throw new Error(`fin14.toml: viewport.${name}.width: must be > 0`)
|
|
103
|
+
result.width = userPreset.width
|
|
104
|
+
}
|
|
105
|
+
if (userPreset.height !== undefined) {
|
|
106
|
+
assertInteger(userPreset.height, `viewport.${name}.height`)
|
|
107
|
+
if (userPreset.height <= 0) throw new Error(`fin14.toml: viewport.${name}.height: must be > 0`)
|
|
108
|
+
result.height = userPreset.height
|
|
109
|
+
}
|
|
110
|
+
if (userPreset.lazy_visible_images !== undefined) {
|
|
111
|
+
assertInteger(userPreset.lazy_visible_images, `viewport.${name}.lazy_visible_images`)
|
|
112
|
+
if (userPreset.lazy_visible_images < 0) throw new Error(`fin14.toml: viewport.${name}.lazy_visible_images: must be >= 0`)
|
|
113
|
+
result.lazy_visible_images = userPreset.lazy_visible_images
|
|
114
|
+
}
|
|
115
|
+
return result
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function mergeConfig(user) {
|
|
119
|
+
const result = {
|
|
120
|
+
project: { ...DEFAULT_CONFIG.project, include: [...DEFAULT_CONFIG.project.include], exclude: [...DEFAULT_CONFIG.project.exclude] },
|
|
121
|
+
budget: { ...DEFAULT_CONFIG.budget, overrides: [...DEFAULT_CONFIG.budget.overrides], ignore: [...DEFAULT_CONFIG.budget.ignore], ignore_presets: [...DEFAULT_CONFIG.budget.ignore_presets] },
|
|
122
|
+
network: { ...DEFAULT_CONFIG.network },
|
|
123
|
+
viewport: {
|
|
124
|
+
default: DEFAULT_CONFIG.viewport.default,
|
|
125
|
+
mobile: { ...DEFAULT_CONFIG.viewport.mobile },
|
|
126
|
+
desktop: { ...DEFAULT_CONFIG.viewport.desktop },
|
|
127
|
+
},
|
|
128
|
+
warnings: { exclude: [...DEFAULT_CONFIG.warnings.exclude] },
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const key of Object.keys(user)) {
|
|
132
|
+
if (!KNOWN_KEYS[key]) {
|
|
133
|
+
throw new Error(`E04: fin14.toml: unknown field '${key}'`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (user.project) {
|
|
138
|
+
for (const key of Object.keys(user.project)) {
|
|
139
|
+
if (!KNOWN_KEYS.project.has(key)) {
|
|
140
|
+
throw new Error(`E04: fin14.toml: unknown field 'project.${key}'`)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (user.project.output_dir !== undefined) {
|
|
144
|
+
assertType(user.project.output_dir, 'string', 'project.output_dir')
|
|
145
|
+
result.project.output_dir = user.project.output_dir
|
|
146
|
+
}
|
|
147
|
+
if (user.project.build_command !== undefined) {
|
|
148
|
+
assertType(user.project.build_command, 'string', 'project.build_command')
|
|
149
|
+
result.project.build_command = user.project.build_command
|
|
150
|
+
}
|
|
151
|
+
if (user.project.include !== undefined) {
|
|
152
|
+
result.project.include = validateGlobList(user.project.include, 'project.include')
|
|
153
|
+
}
|
|
154
|
+
if (user.project.exclude !== undefined) {
|
|
155
|
+
result.project.exclude = validateGlobList(user.project.exclude, 'project.exclude')
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (user.budget) {
|
|
160
|
+
for (const key of Object.keys(user.budget)) {
|
|
161
|
+
if (!KNOWN_KEYS.budget.has(key)) {
|
|
162
|
+
throw new Error(`E04: fin14.toml: unknown field 'budget.${key}'`)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (user.budget.default !== undefined) {
|
|
166
|
+
result.budget.default = parseBudgetDefault(user.budget.default)
|
|
167
|
+
}
|
|
168
|
+
if (user.budget.compression !== undefined) {
|
|
169
|
+
assertType(user.budget.compression, 'string', 'budget.compression')
|
|
170
|
+
if (!['gzip', 'brotli'].includes(user.budget.compression)) {
|
|
171
|
+
throw new Error(`fin14.toml: budget.compression: must be "gzip" or "brotli"`)
|
|
172
|
+
}
|
|
173
|
+
result.budget.compression = user.budget.compression
|
|
174
|
+
}
|
|
175
|
+
if (user.budget.compression_level !== undefined) {
|
|
176
|
+
assertInteger(user.budget.compression_level, 'budget.compression_level')
|
|
177
|
+
if (user.budget.compression_level < COMPRESSION_LEVEL_MIN || user.budget.compression_level > COMPRESSION_LEVEL_MAX) {
|
|
178
|
+
throw new Error(`fin14.toml: budget.compression_level: must be ${COMPRESSION_LEVEL_MIN}–${COMPRESSION_LEVEL_MAX}`)
|
|
179
|
+
}
|
|
180
|
+
result.budget.compression_level = user.budget.compression_level
|
|
181
|
+
}
|
|
182
|
+
if (user.budget.cut_candidate_min_bytes !== undefined) {
|
|
183
|
+
if (!Number.isInteger(user.budget.cut_candidate_min_bytes) || user.budget.cut_candidate_min_bytes < 0) {
|
|
184
|
+
throw new Error(`E04: fin14.toml: budget.cut_candidate_min_bytes must be a non-negative integer`)
|
|
185
|
+
}
|
|
186
|
+
result.budget.cut_candidate_min_bytes = user.budget.cut_candidate_min_bytes
|
|
187
|
+
}
|
|
188
|
+
if (user.budget.baseline_min_delta_bytes !== undefined) {
|
|
189
|
+
if (!Number.isInteger(user.budget.baseline_min_delta_bytes) || user.budget.baseline_min_delta_bytes < 0) {
|
|
190
|
+
throw new Error(`E04: fin14.toml: budget.baseline_min_delta_bytes must be a non-negative integer`)
|
|
191
|
+
}
|
|
192
|
+
result.budget.baseline_min_delta_bytes = user.budget.baseline_min_delta_bytes
|
|
193
|
+
}
|
|
194
|
+
if (user.budget.overrides !== undefined) {
|
|
195
|
+
result.budget.overrides = parseBudgetOverrides(user.budget.overrides)
|
|
196
|
+
}
|
|
197
|
+
if (user.budget.ignore !== undefined) {
|
|
198
|
+
result.budget.ignore = validateGlobList(user.budget.ignore, 'budget.ignore')
|
|
199
|
+
}
|
|
200
|
+
if (user.budget.ignore_presets !== undefined) {
|
|
201
|
+
const presets = validateGlobList(user.budget.ignore_presets, 'budget.ignore_presets')
|
|
202
|
+
for (const name of presets) {
|
|
203
|
+
if (!IGNORE_PRESETS[name]) {
|
|
204
|
+
throw new Error(`E04: fin14.toml: unknown budget.ignore_presets value '${name}' (known: ${Object.keys(IGNORE_PRESETS).join(', ')})`)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
result.budget.ignore_presets = presets
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (user.network) {
|
|
212
|
+
for (const key of Object.keys(user.network)) {
|
|
213
|
+
if (!KNOWN_KEYS.network.has(key)) {
|
|
214
|
+
throw new Error(`E04: fin14.toml: unknown field 'network.${key}'`)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (user.network.timeout_ms !== undefined) {
|
|
218
|
+
assertInteger(user.network.timeout_ms, 'network.timeout_ms')
|
|
219
|
+
if (user.network.timeout_ms <= 0) throw new Error(`fin14.toml: network.timeout_ms: must be > 0`)
|
|
220
|
+
result.network.timeout_ms = user.network.timeout_ms
|
|
221
|
+
}
|
|
222
|
+
if (user.network.max_redirects !== undefined) {
|
|
223
|
+
assertInteger(user.network.max_redirects, 'network.max_redirects')
|
|
224
|
+
if (user.network.max_redirects < 0) throw new Error(`fin14.toml: network.max_redirects: must be >= 0`)
|
|
225
|
+
result.network.max_redirects = user.network.max_redirects
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (user.viewport) {
|
|
230
|
+
for (const key of Object.keys(user.viewport)) {
|
|
231
|
+
if (!KNOWN_KEYS.viewport.has(key)) {
|
|
232
|
+
throw new Error(`E04: fin14.toml: unknown field 'viewport.${key}'`)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (user.viewport.default !== undefined) {
|
|
236
|
+
assertType(user.viewport.default, 'string', 'viewport.default')
|
|
237
|
+
if (!['mobile', 'desktop'].includes(user.viewport.default)) {
|
|
238
|
+
throw new Error(`fin14.toml: viewport.default: must be "mobile" or "desktop"`)
|
|
239
|
+
}
|
|
240
|
+
result.viewport.default = user.viewport.default
|
|
241
|
+
}
|
|
242
|
+
if (user.viewport.mobile !== undefined) {
|
|
243
|
+
if (typeof user.viewport.mobile !== 'object' || Array.isArray(user.viewport.mobile)) {
|
|
244
|
+
throw new Error(`fin14.toml: viewport.mobile: expected object, got ${typeof user.viewport.mobile}`)
|
|
245
|
+
}
|
|
246
|
+
result.viewport.mobile = validateViewportPreset(result.viewport.mobile, 'mobile', user.viewport.mobile)
|
|
247
|
+
}
|
|
248
|
+
if (user.viewport.desktop !== undefined) {
|
|
249
|
+
if (typeof user.viewport.desktop !== 'object' || Array.isArray(user.viewport.desktop)) {
|
|
250
|
+
throw new Error(`fin14.toml: viewport.desktop: expected object, got ${typeof user.viewport.desktop}`)
|
|
251
|
+
}
|
|
252
|
+
result.viewport.desktop = validateViewportPreset(result.viewport.desktop, 'desktop', user.viewport.desktop)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (user.warnings) {
|
|
257
|
+
for (const key of Object.keys(user.warnings)) {
|
|
258
|
+
if (!KNOWN_KEYS.warnings.has(key)) {
|
|
259
|
+
throw new Error(`E04: fin14.toml: unknown field 'warnings.${key}'`)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (user.warnings.exclude !== undefined) {
|
|
263
|
+
if (!Array.isArray(user.warnings.exclude) || !user.warnings.exclude.every(v => typeof v === 'string')) {
|
|
264
|
+
throw new Error(`E04: fin14.toml: warnings.exclude must be an array of strings`)
|
|
265
|
+
}
|
|
266
|
+
result.warnings.exclude = user.warnings.exclude
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return result
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function loadConfig(options = {}) {
|
|
274
|
+
let raw = null
|
|
275
|
+
|
|
276
|
+
if (options.config) {
|
|
277
|
+
let content
|
|
278
|
+
try {
|
|
279
|
+
content = await readFile(options.config, 'utf8')
|
|
280
|
+
} catch (err) {
|
|
281
|
+
throw new Error(`E03: fin14.toml: ${err.message}`)
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
raw = parse(content)
|
|
285
|
+
} catch (err) {
|
|
286
|
+
throw new Error(`E03: fin14.toml: ${err.message}`)
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
const defaultPath = `${process.cwd()}/fin14.toml`
|
|
290
|
+
try {
|
|
291
|
+
const content = await readFile(defaultPath, 'utf8')
|
|
292
|
+
try {
|
|
293
|
+
raw = parse(content)
|
|
294
|
+
} catch (err) {
|
|
295
|
+
throw new Error(`E03: fin14.toml: ${err.message}`)
|
|
296
|
+
}
|
|
297
|
+
} catch (err) {
|
|
298
|
+
if (err.message.startsWith('E03:')) throw err
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const config = mergeConfig(raw ?? {})
|
|
303
|
+
|
|
304
|
+
if (options.outputDir !== undefined) {
|
|
305
|
+
config.project.output_dir = options.outputDir
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (config.project.output_dir === null) {
|
|
309
|
+
config.project.output_dir = await detectOutputDir(process.cwd())
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return config
|
|
313
|
+
}
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const BYTES_PER_KB = 1000
|
|
2
|
+
export const MILLISECONDS_PER_SECOND = 1000
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_CONFIG = {
|
|
5
|
+
project: { output_dir: null, build_command: null, include: [], exclude: [] },
|
|
6
|
+
budget: {
|
|
7
|
+
default: 14000,
|
|
8
|
+
compression: 'gzip',
|
|
9
|
+
compression_level: 6,
|
|
10
|
+
cut_candidate_min_bytes: 2800,
|
|
11
|
+
baseline_min_delta_bytes: 100,
|
|
12
|
+
overrides: [],
|
|
13
|
+
ignore: [],
|
|
14
|
+
ignore_presets: [],
|
|
15
|
+
},
|
|
16
|
+
network: { timeout_ms: 5000, max_redirects: 5 },
|
|
17
|
+
viewport: {
|
|
18
|
+
default: 'mobile',
|
|
19
|
+
mobile: { width: 390, height: 844, lazy_visible_images: 1 },
|
|
20
|
+
desktop: { width: 1366, height: 768, lazy_visible_images: 1 },
|
|
21
|
+
},
|
|
22
|
+
warnings: { exclude: [] },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const GZIP_MIN_LEVEL = 1
|
|
26
|
+
export const GZIP_MAX_LEVEL = 9
|
|
27
|
+
export const COMPRESSION_LEVEL_MIN = 0
|
|
28
|
+
export const COMPRESSION_LEVEL_MAX = 11
|
|
29
|
+
|
|
30
|
+
export const STRICT_IPV6_LIMIT_BYTES = 12200
|
|
31
|
+
|
|
32
|
+
export const IGNORE_PRESETS = {
|
|
33
|
+
nextjs: { label: 'Next.js client JS', globs: ['_next/static/chunks/**.js'] },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const SITE_PAGE_BATCH_SIZE = 50
|
|
37
|
+
export const SITE_EMFILE_RETRY_BATCH_SIZE = 10
|
|
38
|
+
export const SUMMARY_TOP_ASSET_LIMIT = 10
|
|
39
|
+
|
|
40
|
+
export const EXTERNAL_CACHE_TTL_SECONDS = 3600
|
|
41
|
+
export const EXTERNAL_DOMAIN_CONCURRENCY = 10
|
|
42
|
+
export const EXTERNAL_DOMAIN_WAIT_MS = 50
|
package/src/crawler.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readdir, stat, lstat } from 'node:fs/promises'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export async function crawl(outputDir) {
|
|
5
|
+
const htmlFiles = []
|
|
6
|
+
|
|
7
|
+
async function walk(dir) {
|
|
8
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
const fullPath = join(dir, entry.name)
|
|
11
|
+
|
|
12
|
+
if (entry.isSymbolicLink()) {
|
|
13
|
+
let linkTarget
|
|
14
|
+
try {
|
|
15
|
+
linkTarget = await stat(fullPath)
|
|
16
|
+
} catch {
|
|
17
|
+
continue
|
|
18
|
+
}
|
|
19
|
+
if (linkTarget.isFile() && entry.name.endsWith('.html')) {
|
|
20
|
+
htmlFiles.push(relative(outputDir, fullPath))
|
|
21
|
+
}
|
|
22
|
+
} else if (entry.isDirectory()) {
|
|
23
|
+
await walk(fullPath)
|
|
24
|
+
} else if (entry.isFile() && entry.name.endsWith('.html')) {
|
|
25
|
+
htmlFiles.push(relative(outputDir, fullPath))
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await walk(outputDir)
|
|
31
|
+
return htmlFiles
|
|
32
|
+
.map(p => p.replace(/\\/g, '/'))
|
|
33
|
+
.sort()
|
|
34
|
+
}
|
package/src/glob.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
function globToRegExp(pattern) {
|
|
2
|
+
const normalized = pattern.replace(/\\/g, '/')
|
|
3
|
+
let re = ''
|
|
4
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
5
|
+
const char = normalized[i]
|
|
6
|
+
if (char === '*') {
|
|
7
|
+
if (normalized[i + 1] === '*') {
|
|
8
|
+
if (normalized[i + 2] === '/') {
|
|
9
|
+
re += '(?:.*/)?'
|
|
10
|
+
i += 2
|
|
11
|
+
} else {
|
|
12
|
+
re += '.*'
|
|
13
|
+
i++
|
|
14
|
+
}
|
|
15
|
+
} else {
|
|
16
|
+
re += '[^/]*'
|
|
17
|
+
}
|
|
18
|
+
} else if (char === '?') {
|
|
19
|
+
re += '[^/]'
|
|
20
|
+
} else if ('.+^${}()|[]\\'.includes(char)) {
|
|
21
|
+
re += '\\' + char
|
|
22
|
+
} else {
|
|
23
|
+
re += char
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return new RegExp('^' + re + '$')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function matchGlob(path, pattern) {
|
|
30
|
+
return globToRegExp(pattern).test(path.replace(/\\/g, '/'))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function matchesAny(path, patterns) {
|
|
34
|
+
return patterns.some(pattern => matchGlob(path, pattern))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function filterPages(relPaths, config) {
|
|
38
|
+
const include = config.project?.include ?? []
|
|
39
|
+
const exclude = config.project?.exclude ?? []
|
|
40
|
+
return relPaths.filter(path => {
|
|
41
|
+
if (include.length > 0 && !matchesAny(path, include)) return false
|
|
42
|
+
if (matchesAny(path, exclude)) return false
|
|
43
|
+
return true
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveBudget(relFile, config) {
|
|
48
|
+
const overrides = config.budget?.overrides ?? []
|
|
49
|
+
const match = overrides.find(override => matchGlob(relFile, override.path))
|
|
50
|
+
return match ? match.budget : config.budget.default
|
|
51
|
+
}
|
package/src/ignore.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { IGNORE_PRESETS } from './constants.js'
|
|
2
|
+
import { matchesAny } from './glob.js'
|
|
3
|
+
import { computeCutCandidates, deriveStatusWarnings } from './budget/validator.js'
|
|
4
|
+
import { summarize } from './index.js'
|
|
5
|
+
|
|
6
|
+
export function resolveIgnoreGlobs(config) {
|
|
7
|
+
const explicit = config.budget?.ignore ?? []
|
|
8
|
+
const presets = config.budget?.ignore_presets ?? []
|
|
9
|
+
const fromPresets = presets.flatMap(name => IGNORE_PRESETS[name]?.globs ?? [])
|
|
10
|
+
return [...explicit, ...fromPresets]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function applyIgnore(resources, ignoreGlobs) {
|
|
14
|
+
const sumBytes = list => list.reduce((s, r) => s + r.bytes, 0)
|
|
15
|
+
if (!ignoreGlobs || ignoreGlobs.length === 0) {
|
|
16
|
+
return { counted: resources, ignored: [], countedBytes: sumBytes(resources), ignoredBytes: 0 }
|
|
17
|
+
}
|
|
18
|
+
const counted = []
|
|
19
|
+
const ignored = []
|
|
20
|
+
for (const r of resources) {
|
|
21
|
+
if (matchesAny(r.displayPath, ignoreGlobs)) ignored.push(r)
|
|
22
|
+
else counted.push(r)
|
|
23
|
+
}
|
|
24
|
+
return { counted, ignored, countedBytes: sumBytes(counted), ignoredBytes: sumBytes(ignored) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function applyIgnoreToPage(page, ignoreGlobs, config) {
|
|
28
|
+
const { counted, ignored, countedBytes, ignoredBytes } = applyIgnore(page.resources, ignoreGlobs)
|
|
29
|
+
const limitBytes = page.limitBytes
|
|
30
|
+
const hasOverride = limitBytes !== config.budget.default
|
|
31
|
+
const overBudget = countedBytes > limitBytes
|
|
32
|
+
|
|
33
|
+
const baseWarnings = page.warnings.filter(w => w.code !== 'E13' && w.code !== 'E14')
|
|
34
|
+
const warnings = [...baseWarnings, ...deriveStatusWarnings(counted, countedBytes, limitBytes, hasOverride, page.file)]
|
|
35
|
+
const status = overBudget ? 'fail' : warnings.length > 0 ? 'warn' : 'pass'
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
...page,
|
|
39
|
+
totalBytes: countedBytes,
|
|
40
|
+
overBudget,
|
|
41
|
+
status,
|
|
42
|
+
resources: counted,
|
|
43
|
+
ignoredResources: ignored,
|
|
44
|
+
ignoredBytes,
|
|
45
|
+
warnings,
|
|
46
|
+
cutCandidates: computeCutCandidates(counted, countedBytes, limitBytes, overBudget),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function applyIgnoreToSummary(summary, ignoreGlobs, config) {
|
|
51
|
+
const results = summary.results.map(page => applyIgnoreToPage(page, ignoreGlobs, config))
|
|
52
|
+
const recomputed = summarize(results, summary.outputDir, summary.siteWarnings)
|
|
53
|
+
recomputed.generatedAt = summary.generatedAt
|
|
54
|
+
return recomputed
|
|
55
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export { loadConfig } from './config.js'
|
|
2
|
+
export { gzipLevelClampNotice } from './compress.js'
|
|
3
|
+
export { matchGlob, filterPages, resolveBudget } from './glob.js'
|
|
4
|
+
export { resolveIgnoreGlobs, applyIgnore, applyIgnoreToPage, applyIgnoreToSummary } from './ignore.js'
|
|
5
|
+
export * from './constants.js'
|
|
6
|
+
import { collect } from './budget/collector.js'
|
|
7
|
+
import { filterPages } from './glob.js'
|
|
8
|
+
import { measure } from './budget/measurer.js'
|
|
9
|
+
import { validate } from './budget/validator.js'
|
|
10
|
+
import { crawl } from './crawler.js'
|
|
11
|
+
import { relative, resolve } from 'node:path'
|
|
12
|
+
import {
|
|
13
|
+
SITE_EMFILE_RETRY_BATCH_SIZE,
|
|
14
|
+
SITE_PAGE_BATCH_SIZE,
|
|
15
|
+
SUMMARY_TOP_ASSET_LIMIT,
|
|
16
|
+
} from './constants.js'
|
|
17
|
+
|
|
18
|
+
export async function measurePage(file, config) {
|
|
19
|
+
const { refs, skipped, warnings: collectWarnings } = await collect(file, config.project.output_dir, config)
|
|
20
|
+
const { results, warnings: measureWarnings, unknownBudget } = await measure(refs, config)
|
|
21
|
+
const allWarnings = [...collectWarnings, ...measureWarnings]
|
|
22
|
+
const relFile = relative(config.project.output_dir, file)
|
|
23
|
+
const pageResult = validate(results, skipped, allWarnings, config, relFile)
|
|
24
|
+
if (unknownBudget) pageResult.unknownBudget = true
|
|
25
|
+
return pageResult
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function measureSite(outputDir, config, options = {}) {
|
|
29
|
+
const configWithDir = { ...config, project: { ...config.project, output_dir: outputDir } }
|
|
30
|
+
const relPaths = filterPages(await crawl(outputDir), configWithDir)
|
|
31
|
+
const absPaths = relPaths.map(p => resolve(outputDir, p))
|
|
32
|
+
const total = absPaths.length
|
|
33
|
+
|
|
34
|
+
options.onStart?.({ total, files: relPaths })
|
|
35
|
+
|
|
36
|
+
let batchSize = SITE_PAGE_BATCH_SIZE
|
|
37
|
+
let i = 0
|
|
38
|
+
let done = 0
|
|
39
|
+
const results = []
|
|
40
|
+
const siteWarnings = []
|
|
41
|
+
|
|
42
|
+
while (i < absPaths.length) {
|
|
43
|
+
const batch = absPaths.slice(i, i + batchSize)
|
|
44
|
+
try {
|
|
45
|
+
const batchResults = await Promise.all(batch.map(async f => {
|
|
46
|
+
const result = await measurePage(f, configWithDir)
|
|
47
|
+
done++
|
|
48
|
+
options.onPageResult?.(result, { done, total })
|
|
49
|
+
return result
|
|
50
|
+
}))
|
|
51
|
+
results.push(...batchResults)
|
|
52
|
+
i += batch.length
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (err.code === 'EMFILE' && batchSize > SITE_EMFILE_RETRY_BATCH_SIZE) {
|
|
55
|
+
batchSize = SITE_EMFILE_RETRY_BATCH_SIZE
|
|
56
|
+
siteWarnings.push({ code: 'E15', message: `too many open files — retrying with batch size ${SITE_EMFILE_RETRY_BATCH_SIZE}`, strictFailure: false })
|
|
57
|
+
} else {
|
|
58
|
+
throw err
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return summarize(results, outputDir, siteWarnings)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function summarize(results, outputDir, siteWarnings = []) {
|
|
67
|
+
const pass = results.filter(r => r.status === 'pass').length
|
|
68
|
+
const warn = results.filter(r => r.status === 'warn').length
|
|
69
|
+
const fail = results.filter(r => r.status === 'fail').length
|
|
70
|
+
const totalBytes = results.reduce((s, r) => s + r.totalBytes, 0)
|
|
71
|
+
|
|
72
|
+
const seen = new Map()
|
|
73
|
+
for (const page of results) {
|
|
74
|
+
for (const res of page.resources) {
|
|
75
|
+
if (!seen.has(res.key) || seen.get(res.key).bytes < res.bytes) {
|
|
76
|
+
seen.set(res.key, res)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const topAssets = [...seen.values()].sort((a, b) => b.bytes - a.bytes).slice(0, SUMMARY_TOP_ASSET_LIMIT)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
outputDir,
|
|
84
|
+
generatedAt: new Date().toISOString(),
|
|
85
|
+
pages: results.length,
|
|
86
|
+
pass,
|
|
87
|
+
warn,
|
|
88
|
+
fail,
|
|
89
|
+
totalBytes,
|
|
90
|
+
topAssets,
|
|
91
|
+
siteWarnings,
|
|
92
|
+
results,
|
|
93
|
+
}
|
|
94
|
+
}
|