Skip to content

Commit c559de2

Browse files
committed
fix: production hardening — cache eviction + CSP nonce support
Cache eviction: TS transform cache capped at 500 entries, vendor bundle cache at 100. FIFO eviction prevents unbounded memory growth in long-running servers. CSP nonces: all inline scripts (boot, reload, Suspense resolver) now get nonce= attribute when a Content-Security-Policy header with a nonce directive is present on the request. Enables strict CSP.
1 parent 4b48c10 commit c559de2

3 files changed

Lines changed: 37 additions & 9 deletions

File tree

packages/server/src/dev.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,11 @@ const MIME = {
4949
/**
5050
* Cache of esbuild-transformed `.ts` / `.mts` source.
5151
* Keyed by absolute file path; entries expire when mtime changes.
52+
* Capped at 500 entries to prevent unbounded memory growth in
53+
* long-running production servers.
5254
* @type {Map<string, { mtimeMs: number, code: string, map: string | null }>}
5355
*/
56+
const TS_CACHE_MAX = 500;
5457
const TS_CACHE = new Map();
5558

5659
/**
@@ -794,6 +797,11 @@ async function tsResponse(abs, dev) {
794797
sourcemap: 'inline',
795798
sourcefile: abs,
796799
});
800+
// Evict oldest entry if cache is full (simple FIFO — Map preserves insertion order).
801+
if (TS_CACHE.size >= TS_CACHE_MAX) {
802+
const oldest = TS_CACHE.keys().next().value;
803+
TS_CACHE.delete(oldest);
804+
}
797805
TS_CACHE.set(abs, { mtimeMs: st.mtimeMs, code: result.code, map: null });
798806
return new Response(result.code, {
799807
headers: {

packages/server/src/ssr.js

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export async function ssrPage(route, params, url, opts) {
5959
[route.file, ...route.layouts],
6060
opts.appDir,
6161
);
62+
// Extract CSP nonce from request headers (if present).
63+
const nonce = opts.req ? getNonce(opts.req) : undefined;
6264
return streamingHtmlResponse(
6365
wrapHead({
6466
metadata,
@@ -67,6 +69,7 @@ export async function ssrPage(route, params, url, opts) {
6769
streaming: suspenseCtx.pending.length > 0,
6870
preloads,
6971
lazyComponents,
72+
nonce,
7073
}),
7174
body,
7275
suspenseCtx,
@@ -230,25 +233,25 @@ function wrapInDocument(body, opts) {
230233
* (breaks the ES-module waterfall without a bundler) and any user-declared
231234
* `metadata.preload` entries.
232235
*
233-
* @param {{ metadata: Record<string,any>, moduleUrls: string[], dev: boolean, streaming: boolean, preloads?: string[], lazyComponents?: Record<string, string> }} opts
236+
* @param {{ metadata: Record<string,any>, moduleUrls: string[], dev: boolean, streaming: boolean, preloads?: string[], lazyComponents?: Record<string, string>, nonce?: string }} opts
234237
*/
235238
function wrapHead(opts) {
239+
// CSP nonce: if provided, all inline <script> tags get nonce="…" so they
240+
// pass strict Content-Security-Policy headers. The nonce is extracted from
241+
// the request's CSP header by the caller.
242+
const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
243+
236244
const imports = opts.moduleUrls.map((u) => `import ${JSON.stringify(u)};`).join('\n');
237-
// Lazy-loader boot script: loads the IntersectionObserver-based lazy loader
238-
// and registers tag → URL pairs for below-the-fold components.
239245
const lazyEntries = opts.lazyComponents && Object.keys(opts.lazyComponents).length
240246
? opts.lazyComponents
241247
: null;
242248
const lazyBoot = lazyEntries
243249
? `\nimport { observeLazy } from 'webjs/lazy-loader';\nobserveLazy(${JSON.stringify(lazyEntries)});`
244250
: '';
245-
const boot = (imports || lazyBoot) ? `<script type="module">\n${imports}${lazyBoot}\n</script>` : '';
246-
const reload = opts.dev ? `<script type="module" src="/__webjs/reload.js"></script>` : '';
247-
// Suspense resolver: a single script that auto-resolves streamed-in
248-
// <template data-webjs-resolve> elements. Uses a MutationObserver to
249-
// detect new templates as they stream in — no per-chunk inline scripts.
251+
const boot = (imports || lazyBoot) ? `<script type="module"${n}>\n${imports}${lazyBoot}\n</script>` : '';
252+
const reload = opts.dev ? `<script type="module"${n} src="/__webjs/reload.js"></script>` : '';
250253
const suspenseBoot = opts.streaming
251-
? `<script>(function(){` +
254+
? `<script${n}>(function(){` +
252255
`function r(id){var t=document.querySelector('template[data-webjs-resolve="'+id+'"]');` +
253256
`var b=document.getElementById(id);if(t&&b){b.replaceWith(t.content.cloneNode(true));t.remove();}}` +
254257
`window.__webjsResolve=r;` +
@@ -472,6 +475,18 @@ function toUrlPath(file, appDir) {
472475
return rel;
473476
}
474477

478+
/**
479+
* Extract a CSP nonce from the request's Content-Security-Policy header.
480+
* Matches `'nonce-<base64>'` in the script-src directive.
481+
* @param {Request} req
482+
* @returns {string | undefined}
483+
*/
484+
function getNonce(req) {
485+
const csp = req.headers.get('content-security-policy') || '';
486+
const match = /\bnonce-([A-Za-z0-9+/=]+)/.exec(csp);
487+
return match ? match[1] : undefined;
488+
}
489+
475490
/** @param {string} s */
476491
function escapeHtml(s) {
477492
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;');

packages/server/src/vendor.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { createRequire } from 'node:module';
2828
* @type {Map<string, string>}
2929
*/
3030
const vendorCache = new Map();
31+
const VENDOR_CACHE_MAX = 100;
3132

3233
/**
3334
* Set of package names known to be built-in / already mapped.
@@ -149,6 +150,10 @@ export async function bundlePackage(pkgName, appDir, dev) {
149150
external: [...BUILTIN],
150151
});
151152
const code = result.outputFiles[0].text;
153+
if (vendorCache.size >= VENDOR_CACHE_MAX) {
154+
const oldest = vendorCache.keys().next().value;
155+
vendorCache.delete(oldest);
156+
}
152157
vendorCache.set(pkgName, code);
153158
return code;
154159
} catch (e) {

0 commit comments

Comments
 (0)