arcway 0.1.19 → 0.1.21

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.
@@ -0,0 +1,48 @@
1
+ import { createElement, lazy, Suspense } from 'react';
2
+
3
+ const isServer = typeof window === 'undefined';
4
+
5
+ // `arcway/dynamic` — component-level lazy loading with an SSR escape hatch.
6
+ //
7
+ // Returns a React component. Default behavior mirrors `React.lazy` wrapped in
8
+ // a local `<Suspense>` so callers don't have to place their own boundary:
9
+ //
10
+ // const Heavy = dynamic(() => import('./heavy.js'), { loading: Skeleton });
11
+ //
12
+ // `ssr: false` disables server rendering of the lazy child: under Node the
13
+ // component short-circuits to the loading fallback (or null), and the loader
14
+ // is never invoked server-side. The chunk is therefore absent from the SSR
15
+ // payload and is fetched on the client on first mount.
16
+ export default function dynamic(loader, opts = {}) {
17
+ const { ssr = true, loading } = opts;
18
+ const Fallback = loading ?? null;
19
+ const fallbackElement = Fallback ? createElement(Fallback) : null;
20
+
21
+ if (!ssr) {
22
+ // On the server we want zero loader activity and zero Suspense churn —
23
+ // just the fallback (or empty). On the client we still lazy-load so the
24
+ // chunk stays out of the initial payload; Suspense wraps it locally.
25
+ if (isServer) {
26
+ const ServerStub = () => fallbackElement;
27
+ ServerStub.displayName = 'DynamicServerStub';
28
+ return ServerStub;
29
+ }
30
+ const Lazy = lazy(loader);
31
+ const ClientOnly = (props) =>
32
+ createElement(Suspense, { fallback: fallbackElement }, createElement(Lazy, props));
33
+ ClientOnly.displayName = 'DynamicClientOnly';
34
+ return ClientOnly;
35
+ }
36
+
37
+ // Default SSR path: React.lazy + local Suspense. Under streaming SSR
38
+ // (renderToPipeableStream) the fallback is emitted first and the resolved
39
+ // chunk streams in when the loader promise settles. Under sync
40
+ // renderToString() the fallback is the final output, which is the expected
41
+ // behavior — pages needing a single-pass SSR render should use `ssr: false`
42
+ // or pre-import the module.
43
+ const Lazy = lazy(loader);
44
+ const Dynamic = (props) =>
45
+ createElement(Suspense, { fallback: fallbackElement }, createElement(Lazy, props));
46
+ Dynamic.displayName = 'Dynamic';
47
+ return Dynamic;
48
+ }
package/client/router.js CHANGED
@@ -103,7 +103,16 @@ function Router({
103
103
  return () => window.removeEventListener('manifest-update', onManifestUpdate);
104
104
  }, []);
105
105
 
106
- const applyLoaded = useCallback((loaded, newPath) => {
106
+ // `bumpQuery` controls whether `queryVersion` is incremented atomically
107
+ // with the page-state updates. It lives inside the same `startTransition`
108
+ // so React commits the new `pathname` and the re-read of
109
+ // `window.location.search` (triggered by `queryVersion`) in one go. Keeping
110
+ // the bump outside the transition raced at default priority and produced
111
+ // an interim render where `pathname` was stale but `useSearchParams` had
112
+ // already moved to the new query string — see the
113
+ // `query-version-outside-navigation-transition` bug report for the
114
+ // downstream data-corruption pattern that unlocked.
115
+ const applyLoaded = useCallback((loaded, newPath, bumpQuery = false) => {
107
116
  startTransition(() => {
108
117
  setPathname(newPath);
109
118
  setPageState({
@@ -113,6 +122,7 @@ function Router({
113
122
  params: loaded.params,
114
123
  });
115
124
  setIsNavigating(false);
125
+ if (bumpQuery) setQueryVersion((v) => v + 1);
116
126
  });
117
127
  }, []);
118
128
 
@@ -179,8 +189,7 @@ function Router({
179
189
  return;
180
190
  }
181
191
 
182
- applyLoaded(loaded, pathOnly);
183
- if (search !== currentSearch) setQueryVersion((v) => v + 1);
192
+ applyLoaded(loaded, pathOnly, search !== currentSearch);
184
193
 
185
194
  if (scroll) {
186
195
  window.scrollTo(0, 0);
@@ -216,8 +225,7 @@ function Router({
216
225
  try {
217
226
  const loaded = await loadPage(manifest, newPath);
218
227
  if (loaded) {
219
- applyLoaded(loaded, newPath);
220
- setQueryVersion((v) => v + 1);
228
+ applyLoaded(loaded, newPath, true);
221
229
  } else {
222
230
  window.location.reload();
223
231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,7 +12,7 @@
12
12
  "README.md"
13
13
  ],
14
14
  "bin": {
15
- "arcway": "./server/bin/solo.js"
15
+ "arcway": "server/bin/solo.js"
16
16
  },
17
17
  "imports": {
18
18
  "#server/*": "./server/*",
@@ -23,6 +23,7 @@
23
23
  "./internals": "./server/internals.js",
24
24
  "./lib/vault": "./server/lib/vault/index.js",
25
25
  "./lib/client": "./client/index.js",
26
+ "./dynamic": "./client/dynamic.js",
26
27
  "./ui": "./client/ui/index.js",
27
28
  "./middlewares": "./server/middlewares/index.js",
28
29
  "./ui/theme.css": "./client/ui/theme.css",
@@ -78,6 +79,7 @@
78
79
  "lucide-react": "^0.564.0",
79
80
  "mailparser": "^3.9.3",
80
81
  "nanoid": "^5.1.6",
82
+ "negotiator": "^1.0.0",
81
83
  "nodemailer": "^8.0.1",
82
84
  "p-limit": "^7.3.0",
83
85
  "pg": "^8.13.0",
@@ -13,6 +13,12 @@ import {
13
13
  restoreMultiFileCache,
14
14
  storeMultiFileCache,
15
15
  } from './build-cache.js';
16
+ import { computeEntryChunks } from './chunk-graph.js';
17
+
18
+ // Bump when the shape produced by `extractMetadata` / the serialized cache
19
+ // payload changes in a way that would make an old cache entry decode into a
20
+ // broken manifest. Prevents a stale v1 cache from ever satisfying a v2 build.
21
+ const METADATA_VERSION = 3;
16
22
 
17
23
  // Build a deterministic plan of the hydration entries that will be written
18
24
  // into the staging dir. Computed up-front so we can derive a cache key
@@ -93,6 +99,7 @@ function computeClientCoarseKey({
93
99
  return sha256(
94
100
  JSON.stringify({
95
101
  esbuildVersion: ESBUILD_VERSION,
102
+ metadataVersion: METADATA_VERSION,
96
103
  target: target ?? '',
97
104
  minify: !!minify,
98
105
  devMode: !!devMode,
@@ -112,7 +119,8 @@ function serializeMetadata(meta) {
112
119
  navBundles: [...meta.navBundles.entries()],
113
120
  layoutNavBundles: [...meta.layoutNavBundles.entries()],
114
121
  loadingNavBundles: [...meta.loadingNavBundles.entries()],
115
- sharedChunks: meta.sharedChunks,
122
+ entryChunks: [...meta.entryChunks.entries()],
123
+ entryCssChunks: [...meta.entryCssChunks.entries()],
116
124
  };
117
125
  }
118
126
 
@@ -122,7 +130,8 @@ function deserializeMetadata(raw) {
122
130
  navBundles: new Map(raw.navBundles),
123
131
  layoutNavBundles: new Map(raw.layoutNavBundles),
124
132
  loadingNavBundles: new Map(raw.loadingNavBundles),
125
- sharedChunks: raw.sharedChunks,
133
+ entryChunks: new Map(raw.entryChunks),
134
+ entryCssChunks: new Map(raw.entryCssChunks),
126
135
  };
127
136
  }
128
137
 
@@ -205,10 +214,18 @@ async function createClientBuildContext(
205
214
  );
206
215
 
207
216
  let disposed = false;
217
+ // Files scheduled for deletion at the end of the NEXT rebuild. The
218
+ // one-generation hold gives in-flight requests from the previous HTML a
219
+ // chance to still complete against the old hashed chunk before we unlink
220
+ // it. See the commentary on `collectStaleOutputs` for why this is
221
+ // necessary in dev but not in prod.
222
+ let pendingStale = new Set();
208
223
 
209
224
  async function rebuild() {
210
225
  const result = await ctx.rebuild();
211
226
  const metadata = extractMetadata(result, plan, outDir);
227
+ await unlinkAll(pendingStale);
228
+ pendingStale = await collectStaleOutputs(result.metafile, clientDir);
212
229
  return { ...metadata, metafile: result.metafile };
213
230
  }
214
231
 
@@ -222,6 +239,62 @@ async function createClientBuildContext(
222
239
  return { rebuild, dispose };
223
240
  }
224
241
 
242
+ async function unlinkAll(absPaths) {
243
+ await Promise.all(
244
+ [...absPaths].map((p) =>
245
+ fs.unlink(p).catch((err) => {
246
+ // Missing files are expected when a subsequent rebuild reshapes the
247
+ // output set further before our GC pass runs; anything else is
248
+ // logged implicitly via the returned promise (non-fatal).
249
+ if (err?.code !== 'ENOENT') throw err;
250
+ }),
251
+ ),
252
+ );
253
+ }
254
+
255
+ // Incremental esbuild builds reuse the same `outdir` across rebuilds (the
256
+ // context requires a stable outdir), so each new hashed chunk lands alongside
257
+ // its predecessor and `chunks/` grows forever. Compute the set of files
258
+ // currently on disk but *not* in the metafile's output set — those are the
259
+ // stale artifacts to GC on the next rebuild.
260
+ //
261
+ // Prod does not need this: `buildPages()` stages into a unique dir and does
262
+ // an atomic rename, so the served clientDir always contains exactly the
263
+ // current build's outputs.
264
+ async function collectStaleOutputs(metafile, clientDir) {
265
+ const currentOutputs = new Set(
266
+ Object.keys(metafile.outputs ?? {}).map((p) => path.resolve(p)),
267
+ );
268
+ // Scope the scan to directories esbuild actually writes to. This keeps
269
+ // sibling artifacts owned by other build steps (e.g. the HMR runtime under
270
+ // `client/hmr/`) out of the GC set, since they will never appear in the
271
+ // client esbuild metafile.
272
+ const dirs = new Set();
273
+ for (const outPath of currentOutputs) dirs.add(path.dirname(outPath));
274
+
275
+ const stale = new Set();
276
+ for (const dir of dirs) {
277
+ let entries;
278
+ try {
279
+ entries = await fs.readdir(dir, { withFileTypes: true });
280
+ } catch (err) {
281
+ if (err?.code === 'ENOENT') continue;
282
+ throw err;
283
+ }
284
+ for (const entry of entries) {
285
+ if (!entry.isFile()) continue;
286
+ const abs = path.join(dir, entry.name);
287
+ if (!currentOutputs.has(abs)) stale.add(abs);
288
+ }
289
+ }
290
+ // `clientDir` is passed for future scoping hooks (e.g. whitelisting) but
291
+ // is intentionally unused today — the dirname set above already bounds the
292
+ // scan tightly and never escapes clientDir because every metafile output
293
+ // sits under it.
294
+ void clientDir;
295
+ return stale;
296
+ }
297
+
225
298
  async function tryRestoreClientFromCache({ rootDir, clientDir, coarseKey }) {
226
299
  const lookup = await lookupMultiFileCache({ rootDir, kind: 'client', coarseKey });
227
300
  if (!lookup.hit) return { hit: false, bucket: lookup.bucket };
@@ -346,32 +419,43 @@ function extractMetadata(result, plan, outDir) {
346
419
  const navBundles = new Map();
347
420
  const layoutNavBundles = new Map();
348
421
  const loadingNavBundles = new Map();
349
- const sharedChunks = [];
422
+ // Map<outputKey, pattern> so we can look up which page pattern owns a given
423
+ // esbuild entry output when we walk the per-entry chunk closure below.
424
+ const entryKeyToPattern = new Map();
350
425
  for (const [outputPath, meta] of Object.entries(result.metafile.outputs)) {
351
- const relPath = path.relative(outDir, outputPath).replace(/\\/g, '/');
352
426
  if (outputPath.endsWith('.map')) continue;
353
- if (relPath.startsWith('client/chunks/')) {
354
- sharedChunks.push(relPath);
355
- continue;
356
- }
357
- if (meta.entryPoint) {
358
- const absEntry = path.resolve(meta.entryPoint);
359
- const pattern =
360
- plan.entryToPattern.get(absEntry) ?? plan.entryToNavPattern.get(absEntry);
361
- if (plan.entryToPattern.has(absEntry)) {
362
- bundles.set(plan.entryToPattern.get(absEntry), relPath);
363
- } else if (plan.entryToNavPattern.has(absEntry)) {
364
- navBundles.set(plan.entryToNavPattern.get(absEntry), relPath);
365
- } else if (plan.entryToLayoutDir.has(absEntry)) {
366
- layoutNavBundles.set(plan.entryToLayoutDir.get(absEntry), relPath);
367
- } else if (plan.entryToLoadingDir.has(absEntry)) {
368
- loadingNavBundles.set(plan.entryToLoadingDir.get(absEntry), relPath);
369
- }
370
- // pattern variable intentionally unused when none of the maps match
371
- void pattern;
427
+ const relPath = path.relative(outDir, outputPath).replace(/\\/g, '/');
428
+ if (relPath.startsWith('client/chunks/')) continue;
429
+ if (!meta.entryPoint) continue;
430
+ const absEntry = path.resolve(meta.entryPoint);
431
+ if (plan.entryToPattern.has(absEntry)) {
432
+ const pattern = plan.entryToPattern.get(absEntry);
433
+ bundles.set(pattern, relPath);
434
+ entryKeyToPattern.set(outputPath, pattern);
435
+ } else if (plan.entryToNavPattern.has(absEntry)) {
436
+ navBundles.set(plan.entryToNavPattern.get(absEntry), relPath);
437
+ } else if (plan.entryToLayoutDir.has(absEntry)) {
438
+ layoutNavBundles.set(plan.entryToLayoutDir.get(absEntry), relPath);
439
+ } else if (plan.entryToLoadingDir.has(absEntry)) {
440
+ loadingNavBundles.set(plan.entryToLoadingDir.get(absEntry), relPath);
372
441
  }
373
442
  }
374
- return { bundles, navBundles, layoutNavBundles, loadingNavBundles, sharedChunks };
443
+ // Per-entry chunk closure keyed by page pattern so the manifest only lists
444
+ // the chunks a given page actually imports, rather than dumping every chunk
445
+ // from every entry on every page. Only hydration entries (mapped to a page
446
+ // pattern) participate — nav/layout/loading bundles are loaded lazily at
447
+ // navigation time and resolve their own chunks then.
448
+ const clientDir = path.join(outDir, 'client');
449
+ const perEntry = computeEntryChunks(result.metafile, clientDir);
450
+ const entryChunks = new Map();
451
+ const entryCssChunks = new Map();
452
+ for (const [entryKey, { js, css }] of perEntry.entries()) {
453
+ const pattern = entryKeyToPattern.get(entryKey);
454
+ if (!pattern) continue;
455
+ entryChunks.set(pattern, js);
456
+ entryCssChunks.set(pattern, css);
457
+ }
458
+ return { bundles, navBundles, layoutNavBundles, loadingNavBundles, entryChunks, entryCssChunks };
375
459
  }
376
460
 
377
461
  function generateHydrationEntry(componentPath, layouts = [], loadings = []) {
@@ -85,7 +85,7 @@ async function createPagesBuildContext(options) {
85
85
  ]);
86
86
 
87
87
  if (pages.length === 0) {
88
- lastResult = { pageCount: 0, outDir, manifest: { entries: [], sharedChunks: [] } };
88
+ lastResult = { pageCount: 0, outDir, manifest: { entries: [] } };
89
89
  return lastResult;
90
90
  }
91
91
 
@@ -34,6 +34,8 @@ function generateManifest(
34
34
  const middlewareServerBundlePaths = middlewareChain
35
35
  .map((m) => middlewareServerBundles.get(m.dirPath))
36
36
  .filter((p) => p !== void 0);
37
+ const sharedChunks = clientResult.entryChunks?.get(page.pattern) ?? [];
38
+ const sharedCssChunks = clientResult.entryCssChunks?.get(page.pattern) ?? [];
37
39
  entries.push({
38
40
  pattern: page.pattern,
39
41
  paramNames: page.paramNames,
@@ -45,11 +47,10 @@ function generateManifest(
45
47
  layoutClientBundles: layoutClientBundlePaths,
46
48
  loadingClientBundles: loadingClientBundlePaths,
47
49
  middlewareServerBundles: middlewareServerBundlePaths,
50
+ sharedChunks,
51
+ sharedCssChunks,
48
52
  });
49
53
  }
50
- return {
51
- entries,
52
- sharedChunks: clientResult.sharedChunks,
53
- };
54
+ return { entries };
54
55
  }
55
56
  export { generateManifest };
@@ -21,6 +21,7 @@ import { generateManifest } from './build-manifest.js';
21
21
  import { resolveFonts } from './fonts.js';
22
22
  import { buildHmrRuntimeBundle } from './hmr.js';
23
23
  import { enforceCacheBudget } from './build-cache.js';
24
+ import { precompressAssets } from './compress.js';
24
25
  async function buildPages(options) {
25
26
  const { rootDir } = options;
26
27
  const pagesDir = options.pagesDir ?? path.join(rootDir, 'pages');
@@ -38,7 +39,7 @@ async function buildPages(options) {
38
39
  discoverStyles(pagesDir),
39
40
  ]);
40
41
  if (pages.length === 0) {
41
- return { pageCount: 0, outDir, manifest: { entries: [], sharedChunks: [] } };
42
+ return { pageCount: 0, outDir, manifest: { entries: [] } };
42
43
  }
43
44
  // Build into a uniquely-suffixed staging directory and atomically swap into place
44
45
  // only after every step succeeds. If the build fails partway through, the previous
@@ -107,6 +108,14 @@ async function buildPages(options) {
107
108
  path.join(buildDir, 'pages-manifest.json'),
108
109
  JSON.stringify(manifest, null, 2),
109
110
  );
111
+ // Pre-compress client assets before the atomic swap so the swapped-in
112
+ // directory already contains .br/.gz siblings and the static handler
113
+ // never sees a window where only the raw file is servable. Dev rebuilds
114
+ // skip this — brotli-11 is expensive and offers no runtime value under
115
+ // `npm run dev`, which serves identity anyway.
116
+ if (!devMode) {
117
+ await precompressAssets(path.join(buildDir, 'client'));
118
+ }
110
119
  await atomicSwap(outDir, buildDir);
111
120
  // Keep the on-disk cache bounded. Fire-and-forget so the build returns
112
121
  // immediately; eviction is idempotent across concurrent builds and any
@@ -0,0 +1,59 @@
1
+ import path from 'node:path';
2
+
3
+ // Given an esbuild metafile and the directory its outputs were emitted into,
4
+ // compute the transitive closure of `chunks/*` outputs reachable from each
5
+ // entry-point output. Returned paths are `client/…` relative to the page
6
+ // build root so they can be dropped straight into the manifest/SSR script
7
+ // tags without additional rewriting.
8
+ //
9
+ // JS and CSS chunks are kept in separate buckets because SSR renders them
10
+ // with different tag types (`<script type="module">` vs `<link rel="stylesheet">`).
11
+ // Mixing them produces a strict-MIME console error on every page load because
12
+ // browsers refuse to execute `text/css` as a module script.
13
+ function computeEntryChunks(metafile, clientDir) {
14
+ const outputs = metafile?.outputs ?? {};
15
+ const resolvedClientDir = path.resolve(clientDir);
16
+
17
+ const chunkKeys = new Set();
18
+ const entryKeys = new Set();
19
+ for (const [key, meta] of Object.entries(outputs)) {
20
+ if (key.endsWith('.map')) continue;
21
+ const abs = path.resolve(key);
22
+ const rel = path.relative(resolvedClientDir, abs).replace(/\\/g, '/');
23
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) continue;
24
+ if (rel.startsWith('chunks/')) {
25
+ chunkKeys.add(key);
26
+ } else if (meta?.entryPoint) {
27
+ entryKeys.add(key);
28
+ }
29
+ }
30
+
31
+ const result = new Map();
32
+ for (const entryKey of entryKeys) {
33
+ const visited = new Set([entryKey]);
34
+ const queue = [entryKey];
35
+ const js = [];
36
+ const css = [];
37
+ while (queue.length > 0) {
38
+ const cur = queue.shift();
39
+ const meta = outputs[cur];
40
+ if (!meta) continue;
41
+ for (const imp of meta.imports ?? []) {
42
+ const target = imp.path;
43
+ if (!target || visited.has(target)) continue;
44
+ visited.add(target);
45
+ queue.push(target);
46
+ if (chunkKeys.has(target)) {
47
+ const rel = path.relative(resolvedClientDir, path.resolve(target)).replace(/\\/g, '/');
48
+ const outRel = `client/${rel}`;
49
+ if (outRel.endsWith('.css')) css.push(outRel);
50
+ else js.push(outRel);
51
+ }
52
+ }
53
+ }
54
+ result.set(entryKey, { js, css });
55
+ }
56
+ return result;
57
+ }
58
+
59
+ export { computeEntryChunks };
@@ -0,0 +1,103 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { brotliCompress, gzip, constants } from 'node:zlib';
4
+ import { promisify } from 'node:util';
5
+
6
+ const brotliCompressAsync = promisify(brotliCompress);
7
+ const gzipAsync = promisify(gzip);
8
+
9
+ const DEFAULT_EXTENSIONS = new Set(['.js', '.css', '.map', '.svg', '.json', '.html']);
10
+ const PRECOMPRESSED_SUFFIXES = ['.br', '.gz'];
11
+ const DEFAULT_THRESHOLD = 1024;
12
+ const DEFAULT_CONCURRENCY = 4;
13
+
14
+ async function precompressAssets(clientDir, opts = {}) {
15
+ const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
16
+ const concurrency = Math.max(1, opts.concurrency ?? DEFAULT_CONCURRENCY);
17
+ const extensions = opts.extensions ? new Set(opts.extensions) : DEFAULT_EXTENSIONS;
18
+
19
+ const targets = [];
20
+ await walk(clientDir, extensions, targets);
21
+
22
+ const jobs = [];
23
+ for (const file of targets) {
24
+ let stat;
25
+ try {
26
+ stat = await fs.stat(file);
27
+ } catch {
28
+ continue;
29
+ }
30
+ if (stat.size < threshold) continue;
31
+
32
+ const mtimeMs = stat.mtimeMs;
33
+ if (!(await isFresh(file + '.br', mtimeMs))) {
34
+ jobs.push({ file, outPath: file + '.br', kind: 'br' });
35
+ }
36
+ if (!(await isFresh(file + '.gz', mtimeMs))) {
37
+ jobs.push({ file, outPath: file + '.gz', kind: 'gz' });
38
+ }
39
+ }
40
+
41
+ let cursor = 0;
42
+ const runOne = async () => {
43
+ while (cursor < jobs.length) {
44
+ const job = jobs[cursor++];
45
+ await compressOne(job);
46
+ }
47
+ };
48
+ const workerCount = Math.min(concurrency, jobs.length);
49
+ await Promise.all(Array.from({ length: workerCount }, runOne));
50
+
51
+ return { compressed: jobs.length };
52
+ }
53
+
54
+ async function compressOne({ file, outPath, kind }) {
55
+ const data = await fs.readFile(file);
56
+ let encoded;
57
+ if (kind === 'br') {
58
+ encoded = await brotliCompressAsync(data, {
59
+ params: {
60
+ [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
61
+ [constants.BROTLI_PARAM_SIZE_HINT]: data.length,
62
+ },
63
+ });
64
+ } else {
65
+ encoded = await gzipAsync(data, { level: constants.Z_BEST_COMPRESSION });
66
+ }
67
+ await fs.writeFile(outPath, encoded);
68
+ // The artifact naturally picks up a write-time mtime strictly later than
69
+ // the source mtime, which is exactly what `isFresh` relies on.
70
+ }
71
+
72
+ async function isFresh(outPath, srcMtimeMs) {
73
+ try {
74
+ const stat = await fs.stat(outPath);
75
+ return stat.mtimeMs >= srcMtimeMs;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ async function walk(dir, extensions, acc) {
82
+ let entries;
83
+ try {
84
+ entries = await fs.readdir(dir, { withFileTypes: true });
85
+ } catch {
86
+ return;
87
+ }
88
+ for (const entry of entries) {
89
+ const full = path.join(dir, entry.name);
90
+ if (entry.isDirectory()) {
91
+ await walk(full, extensions, acc);
92
+ continue;
93
+ }
94
+ if (!entry.isFile()) continue;
95
+ // Skip previously emitted compressed artifacts so we never compress a .br
96
+ // into a .br.br on a subsequent run.
97
+ if (PRECOMPRESSED_SUFFIXES.some((s) => entry.name.endsWith(s))) continue;
98
+ const ext = path.extname(entry.name).toLowerCase();
99
+ if (extensions.has(ext)) acc.push(full);
100
+ }
101
+ }
102
+
103
+ export { precompressAssets };
@@ -6,6 +6,7 @@ import { compilePattern, sortBySpecificity, matchPattern } from '../router/route
6
6
  import { parseCookies, resolveSession, flattenHeaders } from '../session/helpers.js';
7
7
  import {
8
8
  buildCssLinkTag,
9
+ buildRouteCssLinkTags,
9
10
  buildScriptTags,
10
11
  loadComponent,
11
12
  renderErrorPage,
@@ -186,6 +187,8 @@ function compileRoutes(manifest) {
186
187
  clientBundle: entry.clientBundle,
187
188
  layoutServerBundles: entry.layoutServerBundles ?? [],
188
189
  middlewareServerBundles: entry.middlewareServerBundles ?? [],
190
+ sharedChunks: entry.sharedChunks ?? [],
191
+ sharedCssChunks: entry.sharedCssChunks ?? [],
189
192
  };
190
193
  });
191
194
  sortBySpecificity(routes);
@@ -269,6 +272,7 @@ export {
269
272
  MIME_TYPES,
270
273
  buildClientManifestJson,
271
274
  buildCssLinkTag,
275
+ buildRouteCssLinkTags,
272
276
  buildScriptTags,
273
277
  createPagesHandler,
274
278
  matchPageRoute,
@@ -72,6 +72,11 @@ function buildCssLinkTag(manifest) {
72
72
  if (!manifest.cssBundle) return '';
73
73
  return `<link rel="stylesheet" href="/static/${manifest.cssBundle}" />`;
74
74
  }
75
+ function buildRouteCssLinkTags(route) {
76
+ const chunks = route?.sharedCssChunks ?? [];
77
+ if (chunks.length === 0) return '';
78
+ return chunks.map((c) => `<link rel="stylesheet" href="/static/${c}" />`).join('\n');
79
+ }
75
80
  function buildFontPreloadTags(manifest) {
76
81
  return manifest.fontPreloadHtml ?? '';
77
82
  }
@@ -80,9 +85,13 @@ function buildLiveReloadScript() {
80
85
  (function(){var es=new EventSource("/__livereload");es.onmessage=function(e){if(e.data==="reload")window.location.reload()};es.onerror=function(){es.close()}})();
81
86
  </script>`;
82
87
  }
83
- function buildScriptTags(route, manifest) {
88
+ function buildScriptTags(route) {
84
89
  const tags = [];
85
- for (const chunk of manifest.sharedChunks) {
90
+ for (const chunk of route.sharedChunks ?? []) {
91
+ // Defense-in-depth: upstream splits JS/CSS into separate manifest fields
92
+ // so a `.css` should never appear here. Skip any stragglers to guarantee
93
+ // the browser never sees a `<script type="module" src=".css">` tag.
94
+ if (chunk.endsWith('.css')) continue;
86
95
  tags.push(`<script type="module" src="/static/${chunk}"></script>`);
87
96
  }
88
97
  tags.push(`<script type="module" src="/static/${route.clientBundle}"></script>`);
@@ -146,8 +155,10 @@ async function renderPage(
146
155
  layoutComponents.push(Layout);
147
156
  }
148
157
  }
149
- const scriptTags = buildScriptTags(route, manifest);
150
- const cssLinkTag = buildCssLinkTag(manifest);
158
+ const scriptTags = buildScriptTags(route);
159
+ const manifestCssTag = buildCssLinkTag(manifest);
160
+ const routeCssTags = buildRouteCssLinkTags(route);
161
+ const cssLinkTag = [manifestCssTag, routeCssTags].filter(Boolean).join('\n');
151
162
  const fontPreloadTags = buildFontPreloadTags(manifest);
152
163
  const envScriptTag = buildEnvScriptTag(collectPublicEnv());
153
164
  const hmrTag = devMode ? buildHmrScript() : '';
@@ -267,6 +278,7 @@ export {
267
278
  buildCssLinkTag,
268
279
  buildFontPreloadTags,
269
280
  buildHtmlShell,
281
+ buildRouteCssLinkTags,
270
282
  buildScriptTags,
271
283
  loadComponent,
272
284
  renderErrorPage,
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs';
3
+ import Negotiator from 'negotiator';
3
4
  const MIME_TYPES = {
4
5
  '.html': 'text/html',
5
6
  '.css': 'text/css',
@@ -23,7 +24,25 @@ const MIME_TYPES = {
23
24
  '.webm': 'video/webm',
24
25
  '.pdf': 'application/pdf',
25
26
  };
26
- function serveStaticAsset(pathname, res, outDir) {
27
+ // Text-like payloads that benefit from pre-compression. Image/font binaries
28
+ // either have their own compression (woff2, webp) or compress poorly enough
29
+ // that the cost isn't worth the Vary-header cache fanout.
30
+ const COMPRESSIBLE_TYPES = new Set([
31
+ 'text/html',
32
+ 'text/css',
33
+ 'text/plain',
34
+ 'application/javascript',
35
+ 'application/json',
36
+ 'application/xml',
37
+ 'image/svg+xml',
38
+ ]);
39
+ function negotiateEncoding(req) {
40
+ if (!req || !req.headers) return null;
41
+ const best = new Negotiator(req).encodings(['br', 'gzip', 'identity'])[0];
42
+ if (best === 'br' || best === 'gzip') return best;
43
+ return null;
44
+ }
45
+ function serveStaticAsset(pathname, res, outDir, req) {
27
46
  const relativePath = pathname.slice('/static/'.length);
28
47
  const normalized = path.normalize(relativePath);
29
48
  if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
@@ -55,11 +74,39 @@ function serveStaticAsset(pathname, res, outDir) {
55
74
  const cacheControl = hasContentHash
56
75
  ? 'public, max-age=31536000, immutable'
57
76
  : 'public, max-age=3600';
58
- res.writeHead(200, {
77
+ const compressible = COMPRESSIBLE_TYPES.has(contentType);
78
+ let servePath = filePath;
79
+ let contentEncoding = null;
80
+ if (compressible) {
81
+ const preferred = negotiateEncoding(req);
82
+ if (preferred === 'br' && fs.existsSync(filePath + '.br')) {
83
+ servePath = filePath + '.br';
84
+ contentEncoding = 'br';
85
+ } else if (preferred === 'gzip' && fs.existsSync(filePath + '.gz')) {
86
+ servePath = filePath + '.gz';
87
+ contentEncoding = 'gzip';
88
+ } else if (preferred === 'br' && fs.existsSync(filePath + '.gz')) {
89
+ // Client lists br first but we only shipped .gz; gzip is still a win
90
+ // over identity and avoids on-the-fly compression.
91
+ servePath = filePath + '.gz';
92
+ contentEncoding = 'gzip';
93
+ }
94
+ }
95
+ const headers = {
59
96
  'Content-Type': contentType,
60
97
  'Cache-Control': cacheControl,
61
- });
62
- const stream = fs.createReadStream(filePath);
98
+ };
99
+ if (compressible) headers['Vary'] = 'Accept-Encoding';
100
+ if (contentEncoding) headers['Content-Encoding'] = contentEncoding;
101
+ try {
102
+ const stat = fs.statSync(servePath);
103
+ headers['Content-Length'] = String(stat.size);
104
+ } catch {
105
+ // If stat fails here after existsSync passed, fall through without the
106
+ // header; the pipe below will still error cleanly on the caller's side.
107
+ }
108
+ res.writeHead(200, headers);
109
+ const stream = fs.createReadStream(servePath);
63
110
  stream.pipe(res);
64
111
  return true;
65
112
  }
@@ -21,7 +21,7 @@ class StaticRouter {
21
21
 
22
22
  // Built bundles: /static/client/*
23
23
  if (pathname.startsWith('/static/client/')) {
24
- return serveStaticAsset(pathname, res, this.outDir);
24
+ return serveStaticAsset(pathname, res, this.outDir, req);
25
25
  }
26
26
 
27
27
  // Public files: /* (fallback)