rosefc 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/deploy/Dockerfile.node +25 -0
- package/deploy/Dockerfile.static +34 -0
- package/deploy/api/rosefd.ts +19 -0
- package/deploy/cloudflare/functions/[[path]].ts +59 -0
- package/deploy/cloudflare/wrangler.toml +14 -0
- package/deploy/docker-compose.yml +45 -0
- package/deploy/fly.toml +28 -0
- package/deploy/k8s/rosefd.yaml +128 -0
- package/deploy/native/RosefdApp.xcscheme +87 -0
- package/deploy/native/android.yml +62 -0
- package/deploy/native/flutter.yml +96 -0
- package/deploy/native/ios.yml +67 -0
- package/deploy/netlify/functions/rosefd.ts +36 -0
- package/deploy/netlify.toml +17 -0
- package/deploy/server.cjs +4 -0
- package/deploy/vercel.json +13 -0
- package/dist-cli/compiler.mjs +1693 -0
- package/dist-cli/edge.mjs +199 -0
- package/dist-cli/export.mjs +12287 -0
- package/dist-cli/rosefd.mjs +12783 -0
- package/dist-cli/runtime.mjs +6 -0
- package/dist-cli/test.mjs +1885 -0
- package/package.json +110 -4
- package/src/FRAMEWORK_VERSION +1 -0
- package/src/cli/content.ts +188 -0
- package/src/cli/edge.ts +298 -0
- package/src/cli/hot-vary.ts +27 -0
- package/src/cli/origin.ts +10 -0
- package/src/cli/prod-server.ts +817 -0
- package/src/cli/shell.ts +610 -0
- package/src/cli/shutdown.ts +56 -0
- package/src/cli/validator.ts +53 -0
- package/src/hash.ts +9 -0
- package/src/runtime/db.ts +166 -0
- package/src/runtime/index.ts +2992 -0
- package/src/runtime/lorawan.ts +149 -0
- package/src/types/rosefd.d.ts +327 -0
- package/README.md +0 -3
package/src/cli/shell.ts
ADDED
|
@@ -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 rosefd.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, { __rosefdScroll: 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.__rosefdScroll === 'number' ? s.__rosefdScroll : 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; Rosefd 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.__rosefd = { 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('__rosefd_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.__rosefd_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.__rosefd_resume ? JSON.parse(window.__rosefd_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.__rosefdLazy && 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).__ROSEFD_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 Rosefd has,
|
|
370
|
+
* and the reason a Rosefd 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-rosefd-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>Rosefd</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-rosefd-head="[^"]*"/, '');
|
|
574
|
+
if (tag === 'html') htmlAttrs = clean;
|
|
575
|
+
else bodyAttrs = clean;
|
|
576
|
+
return '';
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
const titleTag = /<title>/i.test(mergedHead) ? '' : '<title>Rosefd</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="__rosefd_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 { __rosefdOnShutdown?: typeof onShutdown }).__rosefdOnShutdown =
|
|
17
|
+
onShutdown;
|
|
18
|
+
(globalThis as unknown as { __rosefdRunShutdownHooks?: typeof runShutdownHooks }).__rosefdRunShutdownHooks =
|
|
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(`[rosefd] 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
|
+
}
|