[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
0

Configure Feed

Select the types of activity you want to include in your feed.

roe.dev / test / unit / bundle.spec.ts
14 kB 446 lines
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}