rosefc 0.0.0-stage → 0.2.1

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,610 @@
1
+
2
+
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import * as esbuild from 'esbuild';
6
+ import { createHash } from 'crypto';
7
+ import { fnv1a32 } from '../hash.js';
8
+
9
+ const OUT_DIR = path.join(process.cwd(), 'dist');
10
+
11
+ let clientCache: { for: string; mtimeMs: number; source: string } | null = null;
12
+ // Sliced mode caches one entry per slice file (a route's inline bundle), each
13
+ // keyed by its own mtime: a dev rebuild that only touched one route rewrites
14
+ // one slice, and the other routes' documents keep their cached source.
15
+ const sliceCaches = new Map<string, { mtimeMs: number; source: string }>();
16
+
17
+
18
+ let splitMode = false;
19
+ let slicedMode = false;
20
+ export function setBundleMode(mode: string): void {
21
+ splitMode = mode === 'split';
22
+ slicedMode = mode === 'sliced';
23
+ }
24
+
25
+ /**
26
+ * Extra connect-src sources from the app's rosefc.config.js
27
+ * (`export const csp = { connectSrc: 'https:' }`). The framework default is
28
+ * 'self' - a closed network. Apps whose client talks to an API host (a cloud
29
+ * gateway, a user-configured LLM endpoint) widen it here; the value is
30
+ * appended verbatim after 'self'.
31
+ */
32
+ let extraConnectSrc = '';
33
+ let extraImgSrc = '';
34
+ export function setExtraCsp(connectSrc: string, imgSrc: string): void {
35
+ extraConnectSrc = connectSrc.trim();
36
+ extraImgSrc = imgSrc.trim();
37
+ cspCache = null;
38
+ }
39
+
40
+ function cspTail(): string {
41
+ let tail = CSP_TAIL;
42
+ if (extraConnectSrc !== '') tail = tail.replace("connect-src 'self'", `connect-src 'self' ${extraConnectSrc}`);
43
+ if (extraImgSrc !== '') tail = tail.replace("img-src 'self' data: https://fastly.picsum.photos", `img-src 'self' data: https://fastly.picsum.photos ${extraImgSrc}`);
44
+ return tail;
45
+ }
46
+
47
+ /**
48
+ * The inline bundle source for a document.
49
+ *
50
+ * - inline mode: dist/client.js (every route's code, inlined into every
51
+ * interactive document).
52
+ * - split mode: null - the runtime is an external module fetch.
53
+ * - sliced mode: dist/slices/<key>.js when the server named a key (the route's
54
+ * own runtime + render chain), dist/client.js otherwise (the runtime-only
55
+ * core: mount.js embedders and 404/500 documents, which have no route of their
56
+ * own to slice).
57
+ *
58
+ * `dir` defaults to this process's OUT_DIR (the CLI's project dist). The edge
59
+ * adapter serves a dist it was handed at creation, which is NOT the process
60
+ * cwd's dist - a slice resolved against the wrong directory would silently
61
+ * fall back to the core and ship every route's render chain in one document.
62
+ */
63
+ export function getClientSource(routeKey?: string | null, dir: string = OUT_DIR): string | null {
64
+ if (splitMode) return null;
65
+ const file = slicedMode && routeKey
66
+ ? path.join(dir, 'slices', `${routeKey}.js`)
67
+ : path.join(dir, 'client.js');
68
+ try {
69
+ const st = fs.statSync(file);
70
+ if (slicedMode && routeKey) {
71
+ const hit = sliceCaches.get(routeKey + '\0' + dir);
72
+ if (hit && hit.mtimeMs === st.mtimeMs) return hit.source;
73
+ const source = fs.readFileSync(file, 'utf-8');
74
+ sliceCaches.set(routeKey + '\0' + dir, { mtimeMs: st.mtimeMs, source });
75
+ return source;
76
+ }
77
+ if (!clientCache || clientCache.for !== dir || clientCache.mtimeMs !== st.mtimeMs) {
78
+ clientCache = { for: dir, mtimeMs: st.mtimeMs, source: fs.readFileSync(file, 'utf-8') };
79
+ }
80
+ return clientCache.source;
81
+ } catch {
82
+ // A slice file that vanished (a route removed between the build and the
83
+ // serve) falls back to the core: the document still boots, every route
84
+ // riding the nav chunk.
85
+ if (slicedMode && routeKey) return getClientSource(null, dir);
86
+ return null;
87
+ }
88
+ }
89
+
90
+ // Content hash of dist/client.js for cache-busting the split-mode script URL.
91
+ // The hash, not the mtime: deployment platforms normalize file mtimes, so an
92
+ // mtime key would be identical across deploys and returning visitors would
93
+ // keep executing the previous build's runtime.
94
+ let clientVersionCache: { mtimeMs: number; v: string } | null = null;
95
+ export function getClientVersion(): string {
96
+ try {
97
+ const file = path.join(OUT_DIR, 'client.js');
98
+ const st = fs.statSync(file);
99
+ if (!clientVersionCache || clientVersionCache.mtimeMs !== st.mtimeMs) {
100
+ clientVersionCache = { mtimeMs: st.mtimeMs, v: fnv1a32(fs.readFileSync(file)) };
101
+ }
102
+ return clientVersionCache.v;
103
+ } catch {
104
+ return '';
105
+ }
106
+ }
107
+
108
+ // The URL the document loads the split-mode runtime from: versioned when the bundle exists so a deploy is a new URL (fresh fetch, stale cache impossible), unversioned as the fallback.
109
+ function clientSrcUrl(): string {
110
+ const v = getClientVersion();
111
+ return v ? `/client.js?v=${v}` : '/client.js';
112
+ }
113
+
114
+ let styleCache: { mtimeMs: number; source: string } | null = null;
115
+
116
+ // Read dist/styles.css (every component's scoped <style>, merged at build time) with an mtime-based cache.
117
+ export function getStyles(): string {
118
+ const file = path.join(OUT_DIR, 'styles.css');
119
+ try {
120
+ const st = fs.statSync(file);
121
+ if (!styleCache || styleCache.mtimeMs !== st.mtimeMs) {
122
+ styleCache = { mtimeMs: st.mtimeMs, source: fs.readFileSync(file, 'utf-8') };
123
+ }
124
+ return styleCache.source;
125
+ } catch {
126
+ return '';
127
+ }
128
+ }
129
+
130
+ // Bootstrap appended after the inlined bundle: client-side navigation. Distinct names avoid any collision with minified bundle identifiers.
131
+ const BOOTSTRAP_SOURCE = `
132
+ // shell = false: the document's SSR content sits directly in <body> - no
133
+ // #app wrapper, no demo styles, so a real theme's CSS owns the document from
134
+ // the first byte (and a JS-less visitor never sees either). The bundle still
135
+ // runs, and it still needs its container: wrap the content once, here, before
136
+ // any theme script executes (module scripts run before DOMContentLoaded). The
137
+ // wrapper is a plain unstyled div, so the re-parent is visually inert; every
138
+ // later path - adoption, navigation, forms - is then the ordinary one.
139
+ let __app = document.getElementById('app');
140
+ if (!__app) {
141
+ __app = document.createElement('div');
142
+ __app.id = 'app';
143
+ // scripts stay outside the wrapper, exactly where the dressed document
144
+ // keeps them: the state element and the running module must survive a
145
+ // navigation's innerHTML swap (they are the document's, not the route's).
146
+ for (const node of Array.from(document.body.children)) {
147
+ if (node.tagName !== 'SCRIPT') __app.appendChild(node);
148
+ }
149
+ document.body.appendChild(__app);
150
+ }
151
+ // Native View Transitions around client-side navigation (progressive:
152
+ // browsers without the API navigate normally, no polyfill, no CSS required).
153
+ // F35: stash the CURRENT entry's scroll position into its history state
154
+ // before a new push - the browser only restores scroll on full page loads; a
155
+ // pjax swap would land wherever the swap left it.
156
+ function __saveScroll() {
157
+ try {
158
+ const s = history.state || {};
159
+ history.replaceState(Object.assign({}, s, { __rosefcScroll: window.scrollY }), '');
160
+ } catch (e) { /* history state unavailable (file:// edge): best effort */ }
161
+ }
162
+ function __navigate(pathname, restore) {
163
+ const paint = () => {
164
+ const p = start(__app, pathname);
165
+ if (restore) {
166
+ // back/forward: restore the entry's scroll (top when it has none)
167
+ const s = history.state || {};
168
+ const y = typeof s.__rosefcScroll === 'number' ? s.__rosefcScroll : 0;
169
+ return Promise.resolve(p).then(() => { window.scrollTo(0, y); }, () => { window.scrollTo(0, y); });
170
+ }
171
+ // a fresh navigation starts at the top, like a full page load
172
+ window.scrollTo(0, 0);
173
+ return p;
174
+ };
175
+ if (document.startViewTransition) {
176
+ return document.startViewTransition(paint).updateCallbackDone;
177
+ }
178
+ return paint();
179
+ }
180
+ window.addEventListener('popstate', () => __navigate(window.location.pathname, true));
181
+ document.addEventListener('click', (e) => {
182
+ const a = e.target.tagName === 'A' ? e.target : e.target.closest && e.target.closest('a');
183
+ if (a && a.getAttribute('href') && a.getAttribute('href').startsWith('/')) {
184
+ e.preventDefault();
185
+ __saveScroll();
186
+ history.pushState(null, '', a.getAttribute('href'));
187
+ __navigate(window.location.pathname);
188
+ }
189
+ });
190
+ // Prefetch the target route's full render (including $data) on hover, keyboard
191
+ // focus, or touch - the click that follows paints from cache: no await, no
192
+ // request. Other frameworks prefetch data only; Rosefc prefetches the render
193
+ // because the inlined bundle already contains every route.
194
+ function __prefetchHandler(e) {
195
+ const a = e.target.closest && e.target.closest('a[href^="/"]');
196
+ if (a && prefetch) prefetch(a.getAttribute('href'), 'hover');
197
+ }
198
+ document.addEventListener('mouseover', __prefetchHandler);
199
+ document.addEventListener('focusin', __prefetchHandler);
200
+ document.addEventListener('touchstart', __prefetchHandler, { passive: true });
201
+ // Viewport + idle prefetch (viewport-triggered): once a link scrolls into view and the
202
+ // browser is idle, its route renders ahead of any interaction. trade-off: both
203
+ // APIs are feature-detected; without them the hover/focus/touch path above
204
+ // still covers every link.
205
+ if (prefetch && 'IntersectionObserver' in window && 'requestIdleCallback' in window) {
206
+ const io = new IntersectionObserver((entries) => {
207
+ for (const e of entries) {
208
+ if (!e.isIntersecting) continue;
209
+ io.unobserve(e.target); // one-shot per element; fresh links re-observe after nav
210
+ requestIdleCallback(() => prefetch(e.target.getAttribute('href'), 'viewport'));
211
+ }
212
+ });
213
+ // observing an already-observed element is a no-op, so re-sweeping is cheap
214
+ const observeLinks = () => {
215
+ for (const a of document.querySelectorAll('a[href^="/"]')) io.observe(a);
216
+ };
217
+ observeLinks();
218
+ // navigation swaps the container's innerHTML: re-observe the fresh links
219
+ const __rawNav = __navigate;
220
+ __navigate = (p) => __rawNav(p).then(observeLinks, () => {});
221
+ }
222
+ // Progressive-enhancement forms: <form method="POST"> backed by a server
223
+ // action. With JS the submit is intercepted and handed to postForm (one
224
+ // request, in-place adopt). Without JS the browser performs the native POST
225
+ // and the server re-renders the page: the same form works with JavaScript
226
+ // disabled, which Server Actions in other frameworks do not.
227
+ document.addEventListener('submit', (e) => {
228
+ const f = e.target;
229
+ if (!f || f.tagName !== 'FORM' || f.hasAttribute('data-on-submit')) return;
230
+ if ((f.getAttribute('method') || '').toLowerCase() !== 'post') return;
231
+ e.preventDefault();
232
+ postForm(new FormData(f));
233
+ });
234
+ // Dev seam: the live signal graph, readable as JSON. Three callers, one
235
+ // implementation: the dev server's reload bridge snapshots it before a
236
+ // rebuild-triggered reload so the edit lands on the state the developer had,
237
+ // and the devtools extension (devtools/) reads it the same way - the signal
238
+ // graph plus the prefetch counters, which is the whole observable state of a
239
+ // running page. Both are DIRECT references (no wrapper arrow): the names take
240
+ // no arguments, so the call is identical and every document that carries the
241
+ // bundle pays eleven bytes for the pair instead of forty.
242
+ window.__rosefc = { state: serializeState, prefetch: prefetchStats };
243
+ // initial=true: DOM is SSR output, resume state + wire markers (zero hydration).
244
+ // If the document was rendered for a DIFFERENT route (a static-file server's
245
+ // SPA fallback serves index.html for unknown paths), adopt nothing - let the
246
+ // client router render the requested route from scratch.
247
+ const __st = document.getElementById('__rosefc_state');
248
+ let __state = __st ? JSON.parse(__st.textContent || '{}') : {};
249
+ // The dev server's reload bridge hands the pre-reload signal graph back inside
250
+ // the next document (window.__rosefc_resume). The resumed values win over the
251
+ // fresh render's, but the ROUTE stays the one the server just rendered - and
252
+ // the route renders client-side from the bundle rather than adopting the SSR
253
+ // DOM, because a resumed list can be longer than the document's rows and
254
+ // adoption could not add them. Absent in every non-dev document.
255
+ const __resumed = window.__rosefc_resume ? JSON.parse(window.__rosefc_resume) : null;
256
+ if (__resumed) {
257
+ const __route = __state.__route;
258
+ __state = Object.assign(__state, __resumed);
259
+ __state.__route = __route;
260
+ }
261
+ // A NOT-FOUND document (the server stamps __notFound onto its state) boots
262
+ // into the app's own 404 route: the URL matches no route - or a catch-all
263
+ // whose resolver threw notFound(), whose key sits null in the resumed state
264
+ // and whose template would dereference it into a 500. startNotFound paints
265
+ // the not-found route from the resumed state instead (zero requests).
266
+ if (__state.__notFound) {
267
+ startNotFound(__app, window.location.pathname);
268
+ } else if (__resumed) {
269
+ start(__app, window.location.pathname);
270
+ } else if (__state.__route === window.location.pathname) {
271
+ // Lazy hydration (opt-in via export const lazyHydration = true in the root
272
+ // layout): defer the initial wire() until the browser is idle so the SSR
273
+ // paint is interactive-to-paint without blocking on the event wiring. Falls
274
+ // back to eager wiring where requestIdleCallback is unavailable.
275
+ if (window.__rosefcLazy && typeof requestIdleCallback === 'function') {
276
+ requestIdleCallback(() => start(__app, window.location.pathname, true));
277
+ } else {
278
+ start(__app, window.location.pathname, true);
279
+ }
280
+ } else {
281
+ start(__app, window.location.pathname);
282
+ }
283
+ `;
284
+ // Lazy + best-effort: the transform runs on first use, not at module load - the bundled prod server (dist/prod.mjs) boots without esbuild's worker.
285
+ let BOOTSTRAP_CACHE: string | null = null;
286
+ function bootstrap(): string {
287
+ if (BOOTSTRAP_CACHE === null) {
288
+ // The bundled prod server (dist/prod.mjs) sets the skip flag: esbuild's transformSync spawns a worker thread whose entry is the bundle itself.
289
+ if ((globalThis as any).__ROSEFC_SKIP_MINIFY) {
290
+ BOOTSTRAP_CACHE = BOOTSTRAP_SOURCE;
291
+ } else {
292
+ try {
293
+ BOOTSTRAP_CACHE = esbuild.transformSync(BOOTSTRAP_SOURCE, { minify: true }).code;
294
+ } catch {
295
+ BOOTSTRAP_CACHE = BOOTSTRAP_SOURCE;
296
+ }
297
+ }
298
+ }
299
+ return BOOTSTRAP_CACHE;
300
+ }
301
+
302
+
303
+ const CSP_TAIL = `style-src 'unsafe-inline'; img-src 'self' data: https://fastly.picsum.photos; font-src 'self' data:; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'`;
304
+
305
+
306
+ const BASELINE_SECURITY_HEADERS: Record<string, string> = {
307
+ 'x-content-type-options': 'nosniff',
308
+ 'x-frame-options': 'DENY',
309
+ 'referrer-policy': 'strict-origin-when-cross-origin',
310
+ 'permissions-policy': 'accelerometer=(), autoplay=(), camera=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), serial=(), usb=(), xr-spatial-tracking=()',
311
+ };
312
+
313
+ let cspCache: { for: string | null; v: string; value: string } | null = null;
314
+ // The exact bytes between the external bootstrap's script tags - the single source of truth shared by the tag and the CSP hash.
315
+ let EXTERNAL_BOOTSTRAP_CACHE: { v: string; s: string } | null = null;
316
+ function externalBootstrap(): string {
317
+ const v = getClientVersion();
318
+ if (EXTERNAL_BOOTSTRAP_CACHE === null || EXTERNAL_BOOTSTRAP_CACHE.v !== v) {
319
+ EXTERNAL_BOOTSTRAP_CACHE = { v, s: `\nimport { start, startNotFound, prefetch, postForm, prefetchStats, serializeState } from '${clientSrcUrl()}';\n${bootstrap()}` };
320
+ }
321
+ return EXTERNAL_BOOTSTRAP_CACHE.s;
322
+ }
323
+ // The inlined bundle is byte-identical for every document of a build, so the
324
+ // regex scan + slice + concat over the whole client.js is computed once and
325
+ // reused: prerender paid it per route, and a per-request document paid it per
326
+ // request. Keyed by the source string itself (getClientSource() hands back the
327
+ // same instance while dist/client.js is unchanged).
328
+ let inlineScriptCache: { for: string; out: string } | null = null;
329
+ function inlineScript(client: string): string {
330
+ if (inlineScriptCache !== null && inlineScriptCache.for === client) return inlineScriptCache.out;
331
+ const m = /\bexport\s*\{([^}]*)\}\s*;?\s*$/.exec(client);
332
+ let body = client;
333
+ let aliases = "";
334
+ if (m) {
335
+ body = client.slice(0, m.index) + client.slice(m.index + m[0].length);
336
+ aliases = m[1]
337
+ .split(",")
338
+ .map((pair) => {
339
+ const parts = pair.split(" as ").map((x) => x.trim());
340
+ if (parts.length === 1 || parts[0] === parts[1]) return "";
341
+ return parts[0] && parts[1] ? `var ${parts[1]} = ${parts[0]};` : "";
342
+ })
343
+ .join("");
344
+ }
345
+ const out = `\n${body}\n${aliases}\n${bootstrap()}`;
346
+ inlineScriptCache = { for: client, out };
347
+ return out;
348
+ }
349
+ /**
350
+ * A fresh CSP nonce: 128 bits from the platform RNG, base64 - the syntax the
351
+ * header and the tag share . Web Crypto on purpose: the same helper
352
+ * serves the Node server and the edge adapter, and an edge runtime has no
353
+ * node:crypto. Minted per response and never reused: a nonce is only worth
354
+ * anything an attacker cannot predict, and a repeated one would hand a
355
+ * guessed value unlimited script execution.
356
+ */
357
+ export function mintNonce(): string {
358
+ const bytes = new Uint8Array(16);
359
+ crypto.getRandomValues(bytes);
360
+ let raw = '';
361
+ for (const b of bytes) raw += String.fromCharCode(b);
362
+ return btoa(raw);
363
+ }
364
+
365
+ /**
366
+ * The document's CSP. Two shapes, chosen per response:
367
+ *
368
+ * - no nonce (the default): `script-src 'sha256-…'` over the exact bytes the
369
+ * tag will carry. Nothing else may run - the strongest policy Rosefc has,
370
+ * and the reason a Rosefc document needs no 'unsafe-inline'.
371
+ * - a nonce (a route exporting `csp = { nonce: true }`):
372
+ * `script-src 'nonce-…' 'strict-dynamic'`. A hash dies the moment anything
373
+ * between the server and the browser touches the document - a CDN, a WAF,
374
+ * an A/B injector that adds one byte blocks the page's own bundle. A nonce
375
+ * survives that, and 'strict-dynamic' lets the page's own code load more
376
+ * code (a third-party loader, a lazily imported chunk), which is the reason
377
+ * a deployment reaches for a nonce in the first place. It is weaker in one
378
+ * way: whatever the document's script loads is trusted. That is the trade
379
+ * the route opts into, per route, not a framework default.
380
+ */
381
+ export function securityHeaders(client: string | null, nonce = ''): Record<string, string> {
382
+ const headers: Record<string, string> = { ...BASELINE_SECURITY_HEADERS };
383
+ if (nonce) {
384
+ headers['Content-Security-Policy'] = `script-src 'nonce-${nonce}' 'strict-dynamic'; ${cspTail()}`;
385
+ return headers;
386
+ }
387
+ if (!cspCache || cspCache.for !== client || cspCache.v !== (client ? '' : getClientVersion())) {
388
+ // Sliced mode: the document's own bytes are hash-locked as usual, and
389
+ // 'self' is added because the first cross-route navigation dynamically
390
+ // imports /nav.js - a same-origin module fetch the hash alone would block.
391
+ // (Split mode has the same shape: its external runtime needs 'self'.)
392
+ const scriptSrc = client
393
+ ? `'sha256-${createHash('sha256').update(inlineScript(client), 'utf-8').digest('base64')}'${slicedMode ? " 'self'" : ''}`
394
+ : `'self' 'sha256-${createHash('sha256').update(externalBootstrap(), 'utf-8').digest('base64')}'`;
395
+ cspCache = {
396
+ for: client,
397
+ v: client ? '' : getClientVersion(),
398
+ value: `script-src ${scriptSrc}; ${cspTail()}`,
399
+ };
400
+ }
401
+ headers['Content-Security-Policy'] = cspCache.value;
402
+ return headers;
403
+ }
404
+
405
+ const FAVICON = '<link rel="icon" href="data:image/svg+xml,<svg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 100 100\'><text y=\'.9em\' font-size=\'90\'>🌹</text></svg>">';
406
+
407
+ const STYLES = `
408
+ body { font-family: system-ui, sans-serif; margin: 0; padding: 2rem; }
409
+ button { padding: 0.5rem 1rem; font-size: 1rem; margin-right: 0.5rem; }
410
+ a { color: #e91e63; text-decoration: none; font-weight: bold; }
411
+ a:hover { text-decoration: underline; }
412
+ nav a { margin-right: 1rem; }
413
+ .rh { border-bottom: 2px solid #e91e63; margin-bottom: 1.5rem; padding-bottom: 0.75rem; }
414
+ .rb { font-size: 1.25rem; }
415
+ .rn { display: inline-block; margin-left: 1.5rem; }
416
+ .rf { margin-top: 2rem; color: #888; font-size: 0.85rem; }
417
+ ::view-transition-old(root), ::view-transition-new(root) { animation-duration: .18s; }
418
+ `;
419
+
420
+ /** The shell's own global stylesheet, exported for the native exporters: the
421
+ * mini-program emitter filters it (no `<body>` there) instead of re-inventing
422
+ * a base theme, so an exported app keeps the layout it was designed with. */
423
+ export const SHELL_STYLES = STYLES;
424
+
425
+ const STYLES_MIN = STYLES.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^"']+/g, (m) =>
426
+ m[0] === '"' || m[0] === "'" ? m : m.replace(/\s+/g, ' ').replace(/\s*([:;{},])\s*/g, '$1').trim()
427
+ ).trim();
428
+
429
+ /**
430
+ * Merge the route chain's <head> blocks (document order: page first, then its
431
+ * layouts) into one clean <head> fragment. First occurrence wins per key, so a
432
+ * page overrides its layouts - matching the client's reverse-order applyHead.
433
+ *
434
+ * Bug report 2026.9.22 11:46: the keyed whitelist used to be title plus
435
+ * meta/link/base and NOTHING else, so a `<style>` block, a
436
+ * `<script type="application/ld+json">` data block and a `<noscript>`
437
+ * fallback were dropped here in silence - the compiler collected them, the
438
+ * merge threw them away, and a CMS theme system had no way to ship its
439
+ * per-request CSS. Every head element now passes through: the keyed
440
+ * singletons keep their name/property identity, and everything
441
+ * else is keyed by id when it has one and by tag+position when it does not,
442
+ * so two layouts' <style> blocks coexist and a page still overrides its
443
+ * layout's. Passthrough elements carry HEAD_ATTR too (the attribute the
444
+ * runtime manages) so a client-side navigation replaces or drops them like
445
+ * any other managed element instead of leaking the previous route's CSS
446
+ * into every route visited after it.
447
+ *
448
+ * Bug report 2026-10-13 (Oboext): a `<link>` keyed by its bare `rel` meant the
449
+ * SECOND link of any same-rel group was silently dropped - hreflang alternates,
450
+ * multi-size icons, font preloads, several feeds all lost every member but the
451
+ * first. A rel-only key now carries the link's target (`href`, or `type`/`as`
452
+ * when it has no href), so same-rel links coexist. `rel="canonical"` is the
453
+ * one documented singleton and keeps the bare-rel key, so a page's canonical
454
+ * still overrides its layout's. The client's applyHead derives keys the same
455
+ * way, so the two halves keep agreeing without talking to each other.
456
+ */
457
+ function mergeHeadBlocks(blocks: string[]): string {
458
+ const byKey = new Map<string, string>();
459
+ const order: string[] = [];
460
+ const positional = new Map<string, number>();
461
+ const put = (key: string, html: string) => {
462
+ if (!byKey.has(key)) {
463
+ byKey.set(key, html);
464
+ order.push(key);
465
+ }
466
+ };
467
+ for (const block of blocks) {
468
+ for (const m of block.matchAll(/<title[^>]*>[\s\S]*?<\/title>/gi)) put('title', m[0]);
469
+ for (const m of block.matchAll(/<(\w+)((?:\s+[\w:-]+(?:=(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*)\s*(?:\/>|>([\s\S]*?)<\/\1\s*>|>)/gi)) {
470
+ const tag = m[1].toLowerCase();
471
+ if (tag === 'title') continue;
472
+ const attrs = m[2];
473
+ const attr = (name: string): string | null => {
474
+ const mm = attrs.match(new RegExp(`\\s${name}=(?:"([^"]*)"|'([^']*)')`, 'i'));
475
+ return mm ? (mm[1] ?? mm[2]) : null;
476
+ };
477
+ const idMatch = attrs.match(/\sid=(?:"([^"]*)"|'([^']*)')/i);
478
+ const name = attr('name');
479
+ const property = attr('property');
480
+ const rel = attr('rel');
481
+ const charset = attr('charset');
482
+ let key: string;
483
+ if (name !== null) key = `${tag}:${name}`;
484
+ else if (property !== null) key = `${tag}:${property}`;
485
+ else if (rel !== null) key = `${tag}:${rel}${linkIdentitySuffix(rel, attr)}`;
486
+ else if (charset !== null) key = `${tag}:${charset}`;
487
+ else if (idMatch) key = `${tag}:${idMatch[1] ?? idMatch[2]}`;
488
+ else {
489
+ key = `${tag}:#${positional.get(tag) ?? 0}`;
490
+ positional.set(tag, (positional.get(tag) ?? 0) + 1);
491
+ }
492
+ // The key is stamped into a double-quoted attribute value, so a quote
493
+ // inside a (pathological) href must not break the markup.
494
+ key = key.replace(/"/g, '');
495
+ const marked = m[0].replace(/^<\w+/, (t) => `${t} data-rosefc-head="${key}"`);
496
+ put(key, marked);
497
+ }
498
+ }
499
+ return order.map((k) => byKey.get(k)!).join('\n ');
500
+ }
501
+
502
+ /**
503
+ * The suffix that joins a rel-only link's identity key. `canonical` is a
504
+ * spec singleton - the first one wins so a page overrides its layout's. Every
505
+ * other rel may legitimately repeat with a different target, so the target
506
+ * (`href`, falling back to `type`/`as`) becomes part of the key.
507
+ */
508
+ function linkIdentitySuffix(rel: string, attr: (name: string) => string | null): string {
509
+ if (/(^|\s)canonical(\s|$)/i.test(rel)) return '';
510
+ return `@${attr('href') ?? attr('type') ?? attr('as') ?? ''}`;
511
+ }
512
+
513
+ /**
514
+ * The script tag carrying the inlined bundle (+ bootstrap), or the external
515
+ * fallback. `nonce` stamps the same value the CSP header carries, so
516
+ * a route running a nonce policy has its own code allowed to run; without it
517
+ * the tag is byte-identical to what the sha256 in the header was computed
518
+ * over. Both tags in split mode get it: the external module and the inline
519
+ * bootstrap are the same trust root.
520
+ */
521
+ export function clientScriptTag(client: string | null, nonce = ''): string {
522
+ const n = nonce ? ` nonce="${nonce}"` : '';
523
+ if (client) {
524
+ return `<script type="module"${n}>${inlineScript(client)}</script>`;
525
+ }
526
+ // Split / external path: the framework runtime is a separate module fetch.
527
+ // modulepreload (+ crossorigin, matching the module's CORS mode) starts it
528
+ // early, and fetchpriority=high lets it jump the image/style queue so the
529
+ // interactive layer lands before the browser would otherwise schedule it.
530
+ // The URL carries the bundle's content hash: a deploy changes the hash, the
531
+ // browser sees a new URL, and yesterday's cached runtime can never execute
532
+ // against today's documents.
533
+ const src = clientSrcUrl();
534
+ const preload = `<link rel="modulepreload" href="${src}" crossorigin>`;
535
+ return `${preload}\n<script type="module"${n} src="${src}" fetchpriority="high"></script>\n<script type="module"${n}>${externalBootstrap()}</script>`;
536
+ }
537
+
538
+ /**
539
+ * The static half of the document for streaming SSR: everything up to (and
540
+ * including) the open `<div id="app">`. No route tags - the route's <head>
541
+ * content is applied by the client at boot from the body's head markers.
542
+ *
543
+ * `lang`/`dir` are the document's locale and reading direction ( the
544
+ * servers compute them from the [lang] segment with the server bundle's
545
+ * docAttrs). The streaming path flushes this string BEFORE the render, so
546
+ * the browser gets the language metadata in the first byte.
547
+ */
548
+ export function shellOpen(styles: string = getStyles(), lang = 'en', dir = '', shell = true): string {
549
+ const styleTag = styles ? `\n <style>${styles}</style>` : '';
550
+ const demoStyles = shell ? `\n <style>${STYLES_MIN}</style>` : '';
551
+ const appOpen = shell ? `\n <div id="app">` : '';
552
+ return `<!DOCTYPE html>
553
+ <html lang="${lang}"${dir ? ` dir="${dir}"` : ''}>
554
+ <head>
555
+ <meta charset="UTF-8">
556
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
557
+ <title>Rosefc</title>${demoStyles}${styleTag}
558
+ ${FAVICON}
559
+ </head>
560
+ <body>${appOpen}`;
561
+ }
562
+
563
+ /** Build the document from an explicit client source (edge runtimes have no fs). */
564
+ export function buildShell(ssrHtml: string, stateJson: string, client: string | null, head: string[] = [], styles: string = getStyles(), js = true, lang = 'en', dir = '', nonce = '', shell = true): string {
565
+ const mergedHead = mergeHeadBlocks(head);
566
+ const headStamped = nonce
567
+ ? mergedHead.replace(/<script\b([^>]*)>/gi, (m, attrs: string) => (/\bnonce=/.test(attrs) ? m : `<script${attrs} nonce="${nonce}">`))
568
+ : mergedHead;
569
+ let htmlAttrs = ` lang="${lang}"${dir ? ` dir="${dir}"` : ''}`;
570
+ let bodyAttrs = '';
571
+ const headClean = headStamped.replace(/<(html|body)\b([^>]*)>/gi, (_m, tag: string, attrs: string) => {
572
+ // mergeHeadBlocks stamps every passthrough element with its key; the
573
+ const clean = attrs.replace(/\s*data-rosefc-head="[^"]*"/, '');
574
+ if (tag === 'html') htmlAttrs = clean;
575
+ else bodyAttrs = clean;
576
+ return '';
577
+ });
578
+
579
+ const titleTag = /<title>/i.test(mergedHead) ? '' : '<title>Rosefc</title>';
580
+
581
+ const iconTag = /rel=["']?icon/i.test(mergedHead) ? '' : `\n ${FAVICON}`;
582
+ const headTags = headClean ? `\n ${headClean}` : '';
583
+ const styleTag = styles ? `\n <style>${styles}</style>` : '';
584
+ const stateSafe = stateJson.replace(/</g, '\\u003c');
585
+ const jsTail = js
586
+ ? `\n <script type="application/json" id="__rosefc_state">${stateSafe}</script>\n ${clientScriptTag(client, nonce)}`
587
+ : '';
588
+ const open = js && shell ? '<div id="app">' : '';
589
+ const close = js && shell ? '</div>' : '';
590
+ const shellStyles = js && shell ? `\n <style>${STYLES_MIN}</style>` : '';
591
+
592
+ return `<!DOCTYPE html>
593
+ <html${htmlAttrs}>
594
+ <head>
595
+ <meta charset="UTF-8">
596
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">${headTags}${iconTag}
597
+ ${titleTag}${shellStyles}${styleTag}
598
+ </head>
599
+ <body${bodyAttrs}>
600
+ ${open}${ssrHtml}${close}${jsTail}
601
+ </body>
602
+ </html>`;
603
+ }
604
+
605
+ /** Node entry: reads dist/client.js from disk (mtime-cached). */
606
+ export function getHtmlShell(ssrHtml: string, stateJson: string, head: string[] = [], js = true, lang = 'en', dir = '', nonce = '', shell = true, routeKey: string | null = null): string {
607
+ // Sliced mode: routeKey names the route's own inline bundle (the server's
608
+ // sliceKeyFor); null keeps the runtime-only core.
609
+ return buildShell(ssrHtml, stateJson, getClientSource(routeKey), head, getStyles(), js, lang, dir, nonce, shell);
610
+ }
@@ -0,0 +1,56 @@
1
+
2
+
3
+ type ShutdownHook = {
4
+ name: string;
5
+ fn: () => void | Promise<void>;
6
+ };
7
+
8
+ type HookResult = { name: string; ok: boolean; error?: string };
9
+
10
+ const shutdownHooks: ShutdownHook[] = [];
11
+
12
+
13
+ let draining: Promise<HookResult[]> | null = null;
14
+
15
+ // Exposed on globalThis because esbuild bundles app code with no external allowlist: a static import from the app would be inlined as a SECOND copy.
16
+ (globalThis as unknown as { __rosefcOnShutdown?: typeof onShutdown }).__rosefcOnShutdown =
17
+ onShutdown;
18
+ (globalThis as unknown as { __rosefcRunShutdownHooks?: typeof runShutdownHooks }).__rosefcRunShutdownHooks =
19
+ runShutdownHooks;
20
+
21
+ /** Returns an unregister function - a hot-swapped client must drop its old hook. */
22
+ export function onShutdown(name: string, fn: () => void | Promise<void>): () => void {
23
+ const hook: ShutdownHook = { name, fn };
24
+ shutdownHooks.push(hook);
25
+ return () => {
26
+ const at = shutdownHooks.indexOf(hook);
27
+ if (at !== -1) shutdownHooks.splice(at, 1);
28
+ };
29
+ }
30
+
31
+ /** Never rejects; a throwing hook is reported and the rest still run. */
32
+ export function runShutdownHooks(reason: string): Promise<HookResult[]> {
33
+ if (draining) return draining;
34
+ draining = (async () => {
35
+ const results: HookResult[] = [];
36
+
37
+ for (const hook of shutdownHooks.slice()) {
38
+ try {
39
+ await hook.fn();
40
+ results.push({ name: hook.name, ok: true });
41
+ } catch (err) {
42
+ const message = err instanceof Error ? err.message : String(err);
43
+ results.push({ name: hook.name, ok: false, error: message });
44
+ console.error(`[rosefc] shutdown hook ${hook.name} failed (${reason}):`, message);
45
+ }
46
+ }
47
+ return results;
48
+ })().finally(() => {
49
+ draining = null;
50
+ });
51
+ return draining;
52
+ }
53
+
54
+ export function shutdownHookCount(): number {
55
+ return shutdownHooks.length;
56
+ }