[READ-ONLY] Mirror of https://github.com/danielroe/roe.dev. This is the code and content for my personal website, built in Nuxt.
roe.dev
1/** @vitest-environment node */
2
3import { pathToFileURL, fileURLToPath } from 'node:url'
4import fsp from 'node:fs/promises'
5import { execSync } from 'node:child_process'
6
7import { afterAll, beforeAll, describe, expect, it } from 'vitest'
8import { glob as globby } from 'tinyglobby'
9import { join } from 'pathe'
10
11describe('project sizes', () => {
12 const rootDir = fileURLToPath(new URL('../..', import.meta.url))
13 const publicDir = join(rootDir, '.output/public')
14 const serverDir = join(rootDir, '.output/server')
15
16 const stats = {
17 client: { totalBytes: 0, files: [] as string[] },
18 server: { totalBytes: 0, files: [] as string[] },
19 }
20
21 beforeAll(async () => {
22 execSync('pnpm nuxt build', {
23 cwd: rootDir,
24 env: {
25 ...process.env,
26 NODE_ENV: 'production',
27 DISABLE_PRERENDER: 'true',
28 TEST: 'true',
29 VITEST: 'true',
30 },
31 })
32 }, 120 * 1000)
33
34 afterAll(async () => {
35 await fsp.writeFile(
36 join(rootDir, '.output/test-stats.json'),
37 JSON.stringify(stats, null, 2),
38 )
39 })
40
41 it('public (non-admin) client bundle size', async () => {
42 const adminOnly = await loadAdminOnlyChunks(serverDir)
43
44 const allFiles: string[] = await globby(
45 ['**/*.js', '!_scripts/**', '!**/_payload.js', '!_nuxt/builds/**'],
46 { cwd: publicDir },
47 )
48 const publicFiles = allFiles.filter(f => !adminOnly.has(basenameOf(f)))
49 stats.client = await measureFiles(publicFiles, publicDir)
50
51 expect
52 .soft(roundToKilobytes(stats.client.totalBytes))
53 .toMatchInlineSnapshot(`"269k"`)
54 expect.soft(stats.client.files.map(f => f.replace(/\..*\.js/, '.js')).sort())
55 .toMatchInlineSnapshot(`
56 [
57 "_nuxt/BlueskyComments.js",
58 "_nuxt/CalSchedule.js",
59 "_nuxt/ProseA.js",
60 "_nuxt/ProseBlockquote.js",
61 "_nuxt/ProseCode.js",
62 "_nuxt/ProseEm.js",
63 "_nuxt/ProseH1.js",
64 "_nuxt/ProseH2.js",
65 "_nuxt/ProseH3.js",
66 "_nuxt/ProseH4.js",
67 "_nuxt/ProseH5.js",
68 "_nuxt/ProseH6.js",
69 "_nuxt/ProseHr.js",
70 "_nuxt/ProseImg.js",
71 "_nuxt/ProseLi.js",
72 "_nuxt/ProseOl.js",
73 "_nuxt/ProseP.js",
74 "_nuxt/ProsePre.js",
75 "_nuxt/ProseScript.js",
76 "_nuxt/ProseStrong.js",
77 "_nuxt/ProseTable.js",
78 "_nuxt/ProseTbody.js",
79 "_nuxt/ProseTd.js",
80 "_nuxt/ProseTh.js",
81 "_nuxt/ProseThead.js",
82 "_nuxt/ProseTr.js",
83 "_nuxt/ProseUl.js",
84 "_nuxt/SocialPost.js",
85 "_nuxt/entry.js",
86 ]
87 `)
88 })
89
90 it('default server bundle size', async () => {
91 stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
92 expect
93 .soft(roundToKilobytes(stats.server.totalBytes, 10))
94 .toMatchInlineSnapshot(`"2890k"`)
95
96 const modules = await analyzeSizes('node_modules/**/*', serverDir)
97 expect
98 .soft(roundToKilobytes(modules.totalBytes, 10))
99 .toMatchInlineSnapshot(`"39020k"`)
100
101 const packages = modules.files
102 .filter(m => m.endsWith('package.json'))
103 .map(m => m.replace('/package.json', '').replace('node_modules/', ''))
104 .sort()
105 expect.soft(packages).toMatchInlineSnapshot(`
106 [
107 "@atcute/bluesky-richtext-segmenter",
108 "@atproto-labs/did-resolver",
109 "@atproto-labs/fetch",
110 "@atproto-labs/fetch-node",
111 "@atproto-labs/handle-resolver",
112 "@atproto-labs/handle-resolver-node",
113 "@atproto-labs/identity-resolver",
114 "@atproto-labs/pipe",
115 "@atproto-labs/simple-store",
116 "@atproto-labs/simple-store-memory",
117 "@atproto-labs/simple-store-memory/node_modules/lru-cache",
118 "@atproto-labs/simple-store-memory/node_modules/lru-cache/dist/esm",
119 "@atproto/api",
120 "@atproto/common-web",
121 "@atproto/did",
122 "@atproto/jwk",
123 "@atproto/jwk-jose",
124 "@atproto/jwk-webcrypto",
125 "@atproto/jwk/node_modules/multiformats",
126 "@atproto/lex-data",
127 "@atproto/lex-data/node_modules/multiformats",
128 "@atproto/lex-json",
129 "@atproto/lexicon",
130 "@atproto/lexicon/node_modules/multiformats",
131 "@atproto/oauth-client",
132 "@atproto/oauth-client-node",
133 "@atproto/oauth-client/node_modules/multiformats",
134 "@atproto/oauth-types",
135 "@atproto/syntax",
136 "@atproto/xrpc",
137 "@babel/parser",
138 "@fastify/accept-negotiator",
139 "@formkit/drag-and-drop",
140 "@img/colour",
141 "@img/sharp-libvips-linux-x64",
142 "@img/sharp-linux-x64",
143 "@shikijs/core",
144 "@shikijs/engine-javascript",
145 "@shikijs/engine-oniguruma",
146 "@shikijs/langs",
147 "@shikijs/primitive",
148 "@shikijs/themes",
149 "@shikijs/transformers",
150 "@shikijs/types",
151 "@shikijs/vscode-textmate",
152 "@sindresorhus/is",
153 "@takumi-rs/core",
154 "@takumi-rs/core-linux-x64-gnu",
155 "@takumi-rs/helpers",
156 "@ungap/structured-clone",
157 "@vue/compiler-core",
158 "@vue/compiler-core/node_modules/entities",
159 "@vue/compiler-core/node_modules/entities/dist/commonjs",
160 "@vue/compiler-dom",
161 "@vue/compiler-ssr",
162 "@vue/reactivity",
163 "@vue/runtime-core",
164 "@vue/runtime-dom",
165 "@vue/server-renderer",
166 "@vue/shared",
167 "await-lock",
168 "bail",
169 "boolbase",
170 "ccount",
171 "char-regex",
172 "character-entities",
173 "character-entities-html4",
174 "character-entities-legacy",
175 "character-reference-invalid",
176 "comma-separated-tokens",
177 "consola",
178 "cookie-es",
179 "core-js",
180 "css-select",
181 "css-select/lib/esm",
182 "css-tree",
183 "css-tree/node_modules/mdn-data",
184 "css-what",
185 "csso",
186 "csso/node_modules/css-tree",
187 "csso/node_modules/css-tree/node_modules/mdn-data",
188 "decode-named-character-reference",
189 "defu",
190 "destr",
191 "detab",
192 "detect-libc",
193 "devalue",
194 "devlop",
195 "dom-serializer",
196 "dom-serializer/lib/esm",
197 "dom-serializer/node_modules/entities",
198 "dom-serializer/node_modules/entities/lib/esm",
199 "domelementtype",
200 "domelementtype/lib/esm",
201 "domhandler",
202 "domhandler/lib/esm",
203 "domutils",
204 "domutils/lib/esm",
205 "emojilib",
206 "emoticon",
207 "entities",
208 "entities/dist/commonjs",
209 "escape-string-regexp",
210 "estree-walker",
211 "etag",
212 "events-to-async",
213 "events-to-async/module",
214 "extend",
215 "feed",
216 "flat",
217 "github-slugger",
218 "h3",
219 "hast-util-embedded",
220 "hast-util-format",
221 "hast-util-from-parse5",
222 "hast-util-has-property",
223 "hast-util-is-body-ok-link",
224 "hast-util-is-element",
225 "hast-util-minify-whitespace",
226 "hast-util-parse-selector",
227 "hast-util-phrasing",
228 "hast-util-raw",
229 "hast-util-to-html",
230 "hast-util-to-mdast",
231 "hast-util-to-parse5",
232 "hast-util-to-string",
233 "hast-util-to-text",
234 "hast-util-whitespace",
235 "hastscript",
236 "hookable",
237 "html-void-elements",
238 "html-whitespace-sensitive-tag-names",
239 "image-meta",
240 "ipaddr.js",
241 "ipx",
242 "iron-webcrypto",
243 "is-absolute-url",
244 "is-alphabetical",
245 "is-alphanumerical",
246 "is-decimal",
247 "is-hexadecimal",
248 "is-plain-obj",
249 "iso-datestring-validator",
250 "jose",
251 "jose/dist/node/esm",
252 "longest-streak",
253 "lru-cache",
254 "lru-cache/dist/esm",
255 "markdown-table",
256 "mdast-util-find-and-replace",
257 "mdast-util-from-markdown",
258 "mdast-util-gfm",
259 "mdast-util-gfm-autolink-literal",
260 "mdast-util-gfm-footnote",
261 "mdast-util-gfm-strikethrough",
262 "mdast-util-gfm-table",
263 "mdast-util-gfm-task-list-item",
264 "mdast-util-phrasing",
265 "mdast-util-to-hast",
266 "mdast-util-to-markdown",
267 "mdast-util-to-string",
268 "mdn-data",
269 "mediabunny",
270 "micromark",
271 "micromark-core-commonmark",
272 "micromark-extension-gfm",
273 "micromark-extension-gfm-autolink-literal",
274 "micromark-extension-gfm-footnote",
275 "micromark-extension-gfm-strikethrough",
276 "micromark-extension-gfm-table",
277 "micromark-extension-gfm-tagfilter",
278 "micromark-extension-gfm-task-list-item",
279 "micromark-factory-destination",
280 "micromark-factory-label",
281 "micromark-factory-space",
282 "micromark-factory-title",
283 "micromark-factory-whitespace",
284 "micromark-util-character",
285 "micromark-util-chunked",
286 "micromark-util-classify-character",
287 "micromark-util-combine-extensions",
288 "micromark-util-decode-numeric-character-reference",
289 "micromark-util-decode-string",
290 "micromark-util-encode",
291 "micromark-util-html-tag-name",
292 "micromark-util-normalize-identifier",
293 "micromark-util-resolve-all",
294 "micromark-util-sanitize-uri",
295 "micromark-util-subtokenize",
296 "modern-screenshot",
297 "multiformats",
298 "node-emoji",
299 "node-fetch-native",
300 "node-mock-http",
301 "nth-check",
302 "nth-check/lib/esm",
303 "nuxtseo-shared",
304 "ofetch",
305 "oniguruma-parser",
306 "oniguruma-to-es",
307 "oniguruma-to-es/dist/esm",
308 "parse-entities",
309 "parse5",
310 "parse5/node_modules/entities",
311 "parse5/node_modules/entities/dist/esm",
312 "partysocket",
313 "pathe",
314 "perfect-debounce",
315 "property-information",
316 "radix3",
317 "regex",
318 "regex-recursion",
319 "regex-utilities",
320 "rehype-external-links",
321 "rehype-minify-whitespace",
322 "rehype-raw",
323 "rehype-sort-attribute-values",
324 "rehype-sort-attributes",
325 "remark-emoji",
326 "remark-gfm",
327 "remark-mdc",
328 "remark-parse",
329 "remark-rehype",
330 "remark-stringify",
331 "sax",
332 "scule",
333 "semver",
334 "sharp",
335 "shiki",
336 "skin-tone",
337 "source-map-js",
338 "space-separated-tokens",
339 "stringify-entities",
340 "svgo",
341 "svgo/node_modules/css-tree",
342 "svgo/node_modules/css-tree/node_modules/mdn-data",
343 "tlds",
344 "trim-lines",
345 "trim-trailing-lines",
346 "trough",
347 "ts-custom-error",
348 "tslib",
349 "ufo",
350 "uint8arrays",
351 "uint8arrays/node_modules/multiformats",
352 "ultrahtml",
353 "uncrypto",
354 "undici",
355 "unhead",
356 "unicode-emoji-modifier-base",
357 "unicode-segmenter",
358 "unified",
359 "unist-util-find-after",
360 "unist-util-is",
361 "unist-util-position",
362 "unist-util-stringify-position",
363 "unist-util-visit",
364 "unist-util-visit-parents",
365 "vfile",
366 "vfile-location",
367 "vfile-message",
368 "vue",
369 "vue-bundle-renderer",
370 "web-namespaces",
371 "xml-js",
372 "yaml",
373 "zod",
374 "zwitch",
375 ]
376 `)
377 })
378})
379
380async function analyzeSizes (pattern: string | string[], rootDir: string) {
381 const files: string[] = await globby(pattern, { cwd: rootDir })
382 return measureFiles(files, rootDir)
383}
384
385async function measureFiles (files: string[], rootDir: string) {
386 let totalBytes = 0
387 for (const file of files) {
388 const path = join(rootDir, file)
389 const isSymlink = (
390 await fsp.lstat(path).catch(() => null)
391 )?.isSymbolicLink()
392
393 if (!isSymlink) {
394 const bytes = Buffer.byteLength(await fsp.readFile(path))
395 totalBytes += bytes
396 }
397 }
398 return { files, totalBytes }
399}
400
401/**
402 * Reads Nuxt/Nitro's precomputed client manifest (the same graph the SSR
403 * renderer uses to emit `<link rel="modulepreload">` tags) and returns the
404 * set of chunk file names reachable only from `pages/admin/**` source keys.
405 *
406 * "Reachable" here means the per-route `preload` closure that Nitro already
407 * computed for us; we subtract anything reachable from any non-admin source
408 * key so shared chunks (entry, mdc components, etc.) stay in the public set.
409 */
410async function loadAdminOnlyChunks (serverDir: string): Promise<Set<string>> {
411 const manifestPath = join(serverDir, 'chunks/build/client.precomputed.mjs')
412 const mod = await import(pathToFileURL(manifestPath).href) as {
413 default?: { dependencies?: Record<string, ManifestDeps> }
414 client_precomputed?: { dependencies?: Record<string, ManifestDeps> }
415 }
416 const manifest = mod.default ?? mod.client_precomputed
417 const dependencies = manifest?.dependencies ?? {}
418
419 const adminReach = new Set<string>()
420 const publicReach = new Set<string>()
421 for (const [key, val] of Object.entries(dependencies)) {
422 // Skip the synthetic `_<chunk>.js` entries Nitro emits for shared chunks;
423 // they list themselves and would otherwise leak admin-only chunks into the
424 // public set via the chunk's own metadata entry.
425 if (key.startsWith('_') && key.endsWith('.js')) continue
426 const target = key.startsWith('pages/admin/') ? adminReach : publicReach
427 for (const dep of Object.values(val.preload ?? {})) target.add(dep.file)
428 for (const dep of Object.values(val.scripts ?? {})) target.add(dep.file)
429 }
430 return new Set([...adminReach].filter(f => !publicReach.has(f)))
431}
432
433function basenameOf (file: string) {
434 const slash = file.lastIndexOf('/')
435 return slash === -1 ? file : file.slice(slash + 1)
436}
437
438interface ManifestDeps {
439 preload?: Record<string, { file: string }>
440 scripts?: Record<string, { file: string }>
441}
442
443function roundToKilobytes (bytes: number, granularityK = 1) {
444 if (bytes < 100 * 1024) return (bytes / 1024).toFixed(1) + 'k'
445 return Math.round(bytes / 1024 / granularityK) * granularityK + 'k'
446}