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.
- package/client/dynamic.js +48 -0
- package/client/router.js +13 -5
- package/package.json +4 -2
- package/server/pages/build-client.js +108 -24
- package/server/pages/build-context.js +1 -1
- package/server/pages/build-manifest.js +5 -4
- package/server/pages/build.js +10 -1
- package/server/pages/chunk-graph.js +59 -0
- package/server/pages/compress.js +103 -0
- package/server/pages/handler.js +4 -0
- package/server/pages/ssr.js +16 -4
- package/server/pages/static.js +51 -4
- package/server/static/index.js +1 -1
|
@@ -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
|
-
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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: []
|
|
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 };
|
package/server/pages/build.js
CHANGED
|
@@ -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: []
|
|
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 };
|
package/server/pages/handler.js
CHANGED
|
@@ -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,
|
package/server/pages/ssr.js
CHANGED
|
@@ -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
|
|
88
|
+
function buildScriptTags(route) {
|
|
84
89
|
const tags = [];
|
|
85
|
-
for (const chunk of
|
|
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
|
|
150
|
-
const
|
|
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,
|
package/server/pages/static.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/server/static/index.js
CHANGED
|
@@ -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)
|