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/package.json CHANGED
@@ -1,6 +1,112 @@
1
1
  {
2
2
  "name": "rosefc",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "The next-century web framework: zero-hydration, compile-time reactivity, single binary deploy",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "tsx src/cli/index.ts dev example",
8
+ "build": "tsx src/cli/index.ts build example",
9
+ "preview": "tsx src/cli/index.ts preview example",
10
+ "serve": "tsx src/cli/index.ts serve example",
11
+ "pack": "node pack.mjs",
12
+ "prepublishOnly": "npm run pack",
13
+ "benchmark": "tsx tests/benchmark.ts",
14
+ "test": "tsx src/cli/index.ts build example && tsx tests/test-client.ts",
15
+ "test:cli": "node tests/test-cli.mjs",
16
+ "test:e2e": "tsx tests/test-e2e.ts",
17
+ "test:als": "node tests/test-dev-als.mjs",
18
+ "test:edge": "tsx tests/test-edge.ts",
19
+ "test:harness": "tsx tests/test-harness.ts",
20
+ "test:sliced": "tsx tests/test-sliced.ts",
21
+ "test:export": "tsx tests/test-export.ts",
22
+ "test:go": "tsx src/cli/index.ts build example && node tests/test-go.mjs",
23
+ "test:pack": "node tests/test-pack.mjs",
24
+ "test:resilience": "tsx tests/test-resilience.ts",
25
+ "build:go": "node -e \"const fs=require('fs');if(!fs.existsSync('dist')){console.error('build:go: dist/ missing - run npm run build first');process.exit(1)}\" && go build -o rosefd-server ."
26
+ },
27
+ "bin": {
28
+ "rosefd": "dist-cli/rosefd.mjs"
29
+ },
30
+ "exports": {
31
+ "./edge": "./dist-cli/edge.mjs",
32
+ "./runtime": "./dist-cli/runtime.mjs",
33
+ "./compiler": "./dist-cli/compiler.mjs",
34
+ "./export": "./dist-cli/export.mjs",
35
+ "./test": "./dist-cli/test.mjs"
36
+ },
37
+ "files": [
38
+ "dist-cli",
39
+ "src/FRAMEWORK_VERSION",
40
+ "src/types/rosefd.d.ts",
41
+ "src/cli/content.ts",
42
+ "src/cli/edge.ts",
43
+ "src/cli/hot-vary.ts",
44
+ "src/cli/origin.ts",
45
+ "src/cli/prod-server.ts",
46
+ "src/cli/shell.ts",
47
+ "src/cli/shutdown.ts",
48
+ "src/cli/validator.ts",
49
+ "src/hash.ts",
50
+ "src/runtime/db.ts",
51
+ "src/runtime/index.ts",
52
+ "src/runtime/lorawan.ts",
53
+ "deploy/api",
54
+ "deploy/cloudflare",
55
+ "deploy/k8s",
56
+ "deploy/netlify",
57
+ "deploy/Dockerfile.node",
58
+ "deploy/Dockerfile.static",
59
+ "deploy/docker-compose.yml",
60
+ "deploy/fly.toml",
61
+ "deploy/netlify.toml",
62
+ "deploy/native",
63
+ "deploy/server.cjs",
64
+ "deploy/vercel.json",
65
+ "LICENSE"
66
+ ],
67
+ "license": "MIT",
68
+ "author": {
69
+ "name": "okdunli",
70
+ "url": "https://github.com/okdunli"
71
+ },
72
+ "repository": {
73
+ "type": "git",
74
+ "url": "git+https://github.com/okdunli/rosefd.git"
75
+ },
76
+ "homepage": "https://github.com/okdunli/rosefd#readme",
77
+ "bugs": {
78
+ "url": "https://github.com/okdunli/rosefd/issues"
79
+ },
80
+ "keywords": [
81
+ "framework",
82
+ "ssr",
83
+ "zero-hydration",
84
+ "compile-time-reactivity",
85
+ "fullstack",
86
+ "miniprogram",
87
+ "flutter",
88
+ "android",
89
+ "arkts",
90
+ "swiftui",
91
+ "electron",
92
+ "edge",
93
+ "esm"
94
+ ],
95
+ "engines": {
96
+ "node": ">=22"
97
+ },
98
+ "dependencies": {
99
+ "esbuild": "^0.24.0"
100
+ },
101
+ "devDependencies": {
102
+ "@types/node": "^22.0.0",
103
+ "jsdom": "^29.1.1",
104
+ "pg": "^8.23.0",
105
+ "tsx": "^4.7.0",
106
+ "typescript": "^5.6.0"
107
+ },
108
+ "publishConfig": {
109
+ "access": "public",
110
+ "registry": "https://registry.npmjs.org/"
111
+ }
112
+ }
@@ -0,0 +1 @@
1
+ 0.2.0
@@ -0,0 +1,188 @@
1
+
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export interface ContentEntry {
6
+ slug: string;
7
+ frontmatter: Record<string, string | string[]>;
8
+ /** markdown source, unrendered (the page renders it, highlight applies) */
9
+ body: string;
10
+ /** rendered html with heading anchors (T20-free: ids for TOC deep links) */
11
+ html: string;
12
+ readingTime: number;
13
+ wordCount: number;
14
+ toc: { level: number; text: string; id: string }[];
15
+ excerpt: string;
16
+ }
17
+
18
+ /** Parse `---`-delimited frontmatter: `key: value` lines, `[a, b]` arrays. */
19
+ export function parseFrontmatter(raw: string): {
20
+ frontmatter: Record<string, string | string[]>;
21
+ body: string;
22
+ } {
23
+ if (!raw.startsWith("---")) return { frontmatter: {}, body: raw };
24
+ const end = raw.indexOf("\n---", 3);
25
+ if (end < 0) return { frontmatter: {}, body: raw };
26
+ const head = raw.slice(4, end);
27
+ const body = raw.slice(raw.indexOf("\n", end + 1) + 1).replace(/^\s*\n/, "");
28
+ const frontmatter: Record<string, string | string[]> = {};
29
+ for (const line of head.split("\n")) {
30
+ const m = /^(\w[\w-]*):\s*(.*)$/.exec(line.trim());
31
+ if (!m) continue;
32
+ const [, k, v] = m;
33
+ const arr = /^\[(.+)\]$/.exec(v.trim());
34
+ frontmatter[k] = arr
35
+ ? arr[1].split(",").map((x) => x.trim().replace(/^["']|["']$/g, ""))
36
+ : v.trim().replace(/^["']|["']$/g, "");
37
+ }
38
+ return { frontmatter, body };
39
+ }
40
+
41
+ /** Mini-markdown → HTML: headings, fenced code, lists, bold/italic/code,
42
+ * links, paragraphs. Zero dependencies; ~100 lines cover the blog 90%. */
43
+ function interpolate(md: string, vars: Record<string, unknown>): string {
44
+ return md.replace(/{{(\w[\w.]*)}}/g, (_m, k) => {
45
+ const v = k.split(".").reduce((o: any, p) => (o == null ? o : o[p]), vars as any);
46
+ return v === undefined || v === null ? "" : String(v);
47
+ });
48
+ }
49
+
50
+ export function renderMarkdown(md: string, vars?: Record<string, unknown>): string {
51
+ if (vars) md = interpolate(md, vars);
52
+ const blocks: string[] = [];
53
+ // fenced code blocks first: stash them, placeholder back in later
54
+ let src = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, lang, code) => {
55
+ blocks.push(
56
+ `<pre data-lang="${lang}"><code>${code
57
+ .replace(/&/g, "&amp;")
58
+ .replace(/</g, "&lt;")
59
+ .replace(/>/g, "&gt;")}</code></pre>`,
60
+ );
61
+ return `\u0000CODE${blocks.length - 1}\u0000`;
62
+ });
63
+
64
+ const inline = (s: string) =>
65
+ s
66
+ .replace(/&/g, "&amp;")
67
+ .replace(/</g, "&lt;")
68
+ .replace(/>/g, "&gt;")
69
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
70
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
71
+ .replace(/\*([^*]+)\*/g, "<em>$1</em>")
72
+ .replace(
73
+ /\[([^\]]+)\]\(([^)]+)\)/g,
74
+ '<a href="$2" rel="noopener">$1</a>',
75
+ );
76
+
77
+ const out: string[] = [];
78
+ let listOpen: "ul" | "ol" | null = null;
79
+ const closeList = () => {
80
+ if (listOpen) { out.push(`</${listOpen}>`); listOpen = null; }
81
+ };
82
+ for (const line of src.split("\n")) {
83
+ const h = /^(#{1,4})\s+(.*)$/.exec(line);
84
+ const li = /^\s*[-*]\s+(.*)$/.exec(line);
85
+ const oli = /^\s*\d+\.\s+(.*)$/.exec(line);
86
+ if (h) { closeList(); out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`); }
87
+ else if (li) {
88
+ if (listOpen !== "ul") { closeList(); out.push("<ul>"); listOpen = "ul"; }
89
+ out.push(`<li>${inline(li[1])}</li>`);
90
+ } else if (oli) {
91
+ if (listOpen !== "ol") { closeList(); out.push("<ol>"); listOpen = "ol"; }
92
+ out.push(`<li>${inline(oli[1])}</li>`);
93
+ } else if (line.trim() === "") { closeList(); }
94
+ else if (line.startsWith("\u0000CODE")) { closeList(); out.push(line); }
95
+ else { closeList(); out.push(`<p>${inline(line)}</p>`); }
96
+ }
97
+ closeList();
98
+ return out
99
+ .join("\n")
100
+ .replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => blocks[Number(i)]);
101
+ }
102
+
103
+ /** Scan src/content/<collection>/*.md into entries (slug = filename). */
104
+ export function scanContentDir(contentDir: string): Record<string, ContentEntry[]> {
105
+ const collections: Record<string, ContentEntry[]> = {};
106
+ if (!fs.existsSync(contentDir)) return collections;
107
+ for (const coll of fs.readdirSync(contentDir, { withFileTypes: true })) {
108
+ if (!coll.isDirectory()) continue;
109
+ const entries: ContentEntry[] = [];
110
+ const dir = path.join(contentDir, coll.name);
111
+ for (const f of fs.readdirSync(dir)) {
112
+ if (!f.endsWith(".md")) continue;
113
+ const raw = fs.readFileSync(path.join(dir, f), "utf-8");
114
+ const { frontmatter, body } = parseFrontmatter(raw);
115
+ if (String(frontmatter.draft ?? "").toLowerCase() === "true") continue;
116
+ const slug = f.replace(/\.md$/, "");
117
+ const html = renderMarkdown(body);
118
+ const words = body.replace(/[#*`\[\]()>-]/g, " ").split(/\s+/).filter(Boolean).length;
119
+ const headings: { level: number; text: string; id: string }[] = [];
120
+ const tocHtml = html.replace(/<h([1-4])>([^<]+)<\/h\1>/g, (_m, lvl: string, txt: string) => {
121
+ const id = txt.toLowerCase().replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-");
122
+ headings.push({ level: Number(lvl), text: txt, id });
123
+ return `<h${lvl} id="${id}">${txt}</h${lvl}>`;
124
+ });
125
+ entries.push({
126
+ slug, frontmatter,
127
+ body, html: tocHtml,
128
+ readingTime: Math.max(1, Math.round(words / 200)),
129
+ wordCount: words,
130
+ toc: headings,
131
+ excerpt: String(frontmatter.summary ?? "") || (html.match(/<p>([\s\S]*?)<\/p>/)?.[1] ?? "").replace(/<[^>]+>/g, "").slice(0, 160),
132
+ });
133
+ }
134
+ entries.sort((a, b) =>
135
+ String(b.frontmatter.date ?? "").localeCompare(String(a.frontmatter.date ?? "")),
136
+ );
137
+ collections[coll.name] = entries;
138
+ }
139
+ return collections;
140
+ }
141
+
142
+ /** Prev/next navigation within a collection (date-descending order). */
143
+ export function prevNext(
144
+ entries: ContentEntry[],
145
+ slug: string,
146
+ ): { prev: ContentEntry | null; next: ContentEntry | null } {
147
+ const i = entries.findIndex((e) => e.slug === slug);
148
+ return { prev: i > 0 ? entries[i - 1] : null, next: i >= 0 && i < entries.length - 1 ? entries[i + 1] : null };
149
+ }
150
+
151
+ /** Group a collection by a frontmatter array field (tags/categories). */
152
+ export function byTag(
153
+ entries: ContentEntry[],
154
+ field: string,
155
+ ): Record<string, ContentEntry[]> {
156
+ const out: Record<string, ContentEntry[]> = {};
157
+ for (const e of entries) {
158
+ for (const t of (e.frontmatter[field] as string[] | undefined) ?? []) {
159
+ (out[t] ??= []).push(e);
160
+ }
161
+ }
162
+ return out;
163
+ }
164
+
165
+ globalThis.__rosefdContentMk = renderMarkdown;
166
+
167
+ /** RSS 2.0 feed from a collection - wire to /feed.xml or /api/feed. */
168
+ export function buildRss(
169
+ entries: ContentEntry[],
170
+ opts: { title: string; origin: string; description?: string; pathPrefix?: string },
171
+ ): string {
172
+ const esc = (v: unknown) =>
173
+ String(v ?? "")
174
+ .replace(/&/g, "&amp;")
175
+ .replace(/</g, "&lt;")
176
+ .replace(/>/g, "&gt;")
177
+ .replace(/"/g, "&quot;");
178
+ const prefix = opts.pathPrefix ?? "/";
179
+ const items = entries
180
+ .map((e) => {
181
+ const url = `${opts.origin}${prefix}${e.slug}`;
182
+ const pub = e.frontmatter.date ? new Date(String(e.frontmatter.date)).toUTCString() : "";
183
+ const excerpt = e.excerpt ? `<description>${esc(e.excerpt)}</description>` : "";
184
+ return ` <item>\n <title>${esc(e.frontmatter.title)}</title>\n <link>${esc(url)}</link>\n <guid isPermaLink="true">${esc(url)}</guid>\n ${pub ? `<pubDate>${pub}</pubDate>\n ` : ""}${excerpt}\n </item>`;
185
+ })
186
+ .join("\n");
187
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0"><channel>\n<title>${esc(opts.title)}</title>\n<link>${esc(opts.origin)}${prefix}</link>\n<description>${esc(opts.description ?? "")}</description>\n${items}\n</channel></rss>`;
188
+ }
@@ -0,0 +1,298 @@
1
+
2
+ // Rosefd Edge adapter - Web-standard fetch handler.
3
+ import { pathToFileURL } from 'url';
4
+ import { join } from 'path';
5
+ import { buildShell, shellOpen, clientScriptTag, securityHeaders, mintNonce, getClientSource, setBundleMode } from './shell.js';
6
+ import { hotVaryValues } from './hot-vary.js';
7
+
8
+ type ServerModule = {
9
+ renderPage(pathname: string, form?: FormData): Promise<{ html: string; state: string; head: string[]; status?: number; csr?: boolean; shell?: boolean; lang?: string; dir?: string }>;
10
+ renderPageStream(pathname: string, write: (chunk: string) => void, shellOpen: string, clientTag: string): Promise<number>;
11
+ canStream(pathname: string): boolean;
12
+ isNoJs(pathname: string): boolean;
13
+ /** the matched route exported `shell = false` - the streaming shell must ask before it flushes */
14
+ shellOff?(pathname: string): boolean;
15
+ /** P0-2: routes that exported `buffer = true` - buffered, never streamed */
16
+ isBuffered(pathname: string): boolean;
17
+ isApi(pathname: string): boolean;
18
+ handleApi(method: string, pathname: string, request: Request): Promise<Response>;
19
+ /** The document's <html lang>/<dir> for a pathname (streaming needs them before the render) */
20
+ docAttrs(pathname: string): { lang: string; dir: string };
21
+ /** The headers the matched route declared with `export const hotVary` - extra cache-key dimensions, or null when it declared none */
22
+ hotVaryFor?(pathname: string): string[] | null;
23
+ /** pages/_middleware.rose's handler, or null when the project has none */
24
+ middleware: ((request: Request) => Promise<Response | void | null>) | null;
25
+ /** run the middleware once per request; returns its short-circuit Response or null */
26
+ runMiddleware(rawReq: any, form?: FormData): Promise<Response | null>;
27
+ /** The runtime plugin hooks, and whether the project declares any */
28
+ runRequestHooks(ctx: {
29
+ request: Request; url: URL; method: string; pathname: string; state: Record<string, unknown>;
30
+ }): Promise<Response | null>;
31
+ runResponseHooks(ctx: {
32
+ request: Request; url: URL; method: string; pathname: string; state: Record<string, unknown>;
33
+ }, res: Response): Promise<Response>;
34
+ hasRequestHooks: boolean;
35
+ hasResponseHooks: boolean;
36
+ /** routes whose component reads getContext(): never prerendered, always live */
37
+ dynamicRoutes: string[];
38
+ /** per-route response headers [] when no route exports any */
39
+ routeHeaders: Array<{ pattern: string; headers: Record<string, string> }>;
40
+ /** the route's own exported headers for a pathname, or null */
41
+ headersFor(pathname: string): Record<string, string> | null;
42
+ /** Routes that exported `csp = { nonce: true }` - nonce policy, never baked */
43
+ cspNonce(pathname: string): boolean;
44
+ /** P0-1: the query of the request being rendered, for $query() */
45
+ setQuery(query: Record<string, string>): void;
46
+ };
47
+
48
+
49
+ export { onShutdown, runShutdownHooks, shutdownHookCount } from './shutdown.js';
50
+
51
+ // CSRF guard: a state-changing request that did not come from this site is refused before middleware, hooks and routing see it.
52
+ function crossSiteWrite(request: Request): Response | null {
53
+ const m = request.method;
54
+ if (m !== 'POST' && m !== 'PUT' && m !== 'PATCH' && m !== 'DELETE') return null;
55
+ const origin = request.headers.get('origin');
56
+ if (origin) {
57
+ let host: string;
58
+ try {
59
+ host = new URL(origin).host;
60
+ } catch {
61
+ return new Response('Cross-site request blocked (unparsable Origin header)', { status: 403 });
62
+ }
63
+ return host === new URL(request.url).host
64
+ ? null
65
+ : new Response('Cross-site request blocked (Origin does not match Host)', { status: 403 });
66
+ }
67
+ const site = request.headers.get('sec-fetch-site');
68
+ if (site && site !== 'same-origin' && site !== 'same-site' && site !== 'none') {
69
+ return new Response(`Cross-site request blocked (sec-fetch-site: ${site})`, { status: 403 });
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * The whole request unit - setQuery through the rendered response - runs one
76
+ * at a time, on the same promise-chain shape as the runtime's fallback render
77
+ * mutex. The runtime lock protects the render MAPS (isolateRender swaps them
78
+ * once it is inside the render), but setQuery writes a module-level variable
79
+ * OUTSIDE that lock, so without this queue two concurrent requests could
80
+ * still overwrite each other's query before either render began - a render
81
+ * then read the other request's $query(). Queueing the unit also means the
82
+ * render lock inside is always free when a unit is admitted, so the two locks
83
+ * can never deadlock against each other. Under a context store (Node) none of
84
+ * this applies: each chain carries its own maps and query bag.
85
+ */
86
+ let edgeQueue: Promise<void> = Promise.resolve();
87
+ const withEdgeQueue = <T>(fn: () => Promise<T>): Promise<T> => {
88
+ const prev = edgeQueue;
89
+ let release!: () => void;
90
+ edgeQueue = new Promise<void>((r) => { release = r; });
91
+ return prev.then(fn).finally(release);
92
+ };
93
+
94
+ /**
95
+ * Create a fetch handler from a built Rosefd project.
96
+ * @param outDir directory containing server.js and client.js
97
+ * @param preloaded for runtimes with NO filesystem (Cloudflare Workers /
98
+ * Pages): the caller statically imports the server bundle (so the deploy
99
+ * compiler bundles it in) and fetches the site's own static assets once
100
+ * per isolate. Anything provided here is used verbatim; anything absent
101
+ * falls back to reading it from outDir, exactly as before.
102
+ */
103
+ export async function createEdgeHandler(
104
+ outDir: string,
105
+ preloaded?: { server?: ServerModule; client?: string; styles?: string; broken?: string[] },
106
+ ): Promise<(request: Request) => Promise<Response>> {
107
+ const mod: ServerModule = preloaded?.server ?? await import(pathToFileURL(join(outDir, 'server.js')).href);
108
+ let client: string | null = null;
109
+ let styles = '';
110
+ if (preloaded && 'client' in preloaded) {
111
+ client = preloaded.client ?? null;
112
+ } else {
113
+ try {
114
+ client = await (await import('fs/promises')).readFile(join(outDir, 'client.js'), 'utf-8');
115
+ } catch {
116
+ client = null;
117
+ }
118
+ }
119
+ if (client && client.includes('chunks/')) client = null;
120
+ // Sliced mode: every route's document inlines its OWN bundle, so the source
121
+ // is resolved per request (mtime-cached inside shell.ts). The server bundle
122
+ // exports sliceKeyFor only in sliced builds - its presence is the mode test,
123
+ // and it also flips shell.ts's resolver into per-route mode (this process
124
+ // never ran the CLI's build(), which is what sets the flag there).
125
+ const sliced = typeof (mod as { sliceKeyFor?: unknown }).sliceKeyFor === 'function';
126
+ if (sliced) setBundleMode('sliced');
127
+ // The dist this handler serves, not the process cwd's: an edge runtime is
128
+ // handed a directory, and a slice resolved against the wrong one would
129
+ // silently fall back to the core (every route's chain in one document).
130
+ const clientFor = (pathname: string): string | null =>
131
+ sliced ? getClientSource((mod as { sliceKeyFor: (p: string) => string | null }).sliceKeyFor(pathname), outDir) : client;
132
+ if (preloaded && 'styles' in preloaded) {
133
+ styles = preloaded.styles ?? '';
134
+ } else {
135
+ try {
136
+ styles = await (await import('fs/promises')).readFile(join(outDir, 'styles.css'), 'utf-8');
137
+ } catch {
138
+ styles = '';
139
+ }
140
+ }
141
+ let broken = new Set<string>();
142
+ if (preloaded && 'broken' in preloaded) {
143
+ broken = new Set(preloaded.broken);
144
+ } else {
145
+ try {
146
+ broken = new Set(JSON.parse(await (await import('fs/promises')).readFile(join(outDir, 'broken.json'), 'utf-8')));
147
+ } catch {
148
+ broken = new Set();
149
+ }
150
+ }
151
+
152
+ const pageHeaders = (pathname: string, nonce = ''): Record<string, string> => {
153
+ const headers: Record<string, string> = {};
154
+ for (const [k, v] of Object.entries(securityHeaders(clientFor(pathname), nonce))) headers[k.toLowerCase()] = v;
155
+ if (!headers['cache-control']) headers['cache-control'] = 'no-cache';
156
+ const own = mod.headersFor?.(pathname);
157
+ if (own) for (const [k, v] of Object.entries(own)) headers[k.toLowerCase()] = v;
158
+ return headers;
159
+ };
160
+
161
+ return async function handle(request: Request): Promise<Response> {
162
+ const refused = crossSiteWrite(request);
163
+ if (refused) return refused;
164
+ const { pathname, searchParams } = new URL(request.url);
165
+ const canonical = pathname.replace(/\/+$/, '') || '/';
166
+ if (canonical !== pathname) {
167
+ return new Response(null, { status: 308, headers: { location: canonical + new URL(request.url).search } });
168
+ }
169
+ // setQuery onward: the serialized unit. The streaming branch returns as
170
+ // soon as the Response exists (its start() is lazy and runs outside the
171
+ // queue), so a slow reader never holds the queue - the render lock inside
172
+ // still serializes the render itself.
173
+ return withEdgeQueue(async () => {
174
+ mod.setQuery?.(Object.fromEntries(searchParams));
175
+ const hookCtx = mod.hasRequestHooks || mod.hasResponseHooks
176
+ ? { request, url: new URL(request.url), method: request.method, pathname, state: {} as Record<string, unknown> }
177
+ : null;
178
+ if (hookCtx) {
179
+ const short = await mod.runRequestHooks(hookCtx);
180
+ if (short) return mod.runResponseHooks(hookCtx, short);
181
+ }
182
+ if (mod.middleware) {
183
+ const mw = await mod.runMiddleware(request);
184
+ if (mw) return hookCtx ? mod.runResponseHooks(hookCtx, mw) : mw;
185
+ }
186
+ // 0.2.0: the native data channel. A mini program / mobile client cannot
187
+ // execute the render, so it asks for the JSON half of one - the state the
188
+ // page's $data produced. Middleware already ran above (a wall short-
189
+ // circuited before this point), so a gated route never leaks through it.
190
+ if (pathname === '/__rosefd/data' && request.method === 'GET') {
191
+ const target = searchParams.get('path') || '/';
192
+ try {
193
+ // The gate is per-page: judge the target path, not the channel URL.
194
+ if (mod.middleware) {
195
+ const mw = await mod.runMiddleware(new Request(new URL(target, request.url), { method: 'GET', headers: request.headers }));
196
+ if (mw) {
197
+ const headers = new Headers(mw.headers);
198
+ headers.set('access-control-allow-origin', '*');
199
+ return new Response(await mw.arrayBuffer(), { status: mw.status, headers });
200
+ }
201
+ }
202
+ mod.setQuery?.(Object.fromEntries(new URL('http://x' + target).searchParams));
203
+ const page = await (mod as any).renderPage(target, undefined, { withState: true });
204
+ let state: unknown = {};
205
+ try { state = JSON.parse(page?.state || '{}'); } catch { state = {}; }
206
+ return new Response(JSON.stringify({ path: target, status: page?.status ?? 200, state }), {
207
+ status: 200,
208
+ headers: {
209
+ 'content-type': 'application/json; charset=utf-8',
210
+ 'access-control-allow-origin': '*',
211
+ 'cache-control': 'no-store',
212
+ },
213
+ });
214
+ } catch (err) {
215
+ return new Response(JSON.stringify({ error: String((err as Error)?.message ?? err).slice(0, 200) }), {
216
+ status: 500,
217
+ headers: { 'content-type': 'application/json; charset=utf-8', 'access-control-allow-origin': '*' },
218
+ });
219
+ }
220
+ }
221
+ if (mod.isApi(pathname)) {
222
+ if (request.method !== 'GET') {
223
+ const fn = (mod as any).invalidateRenderCache;
224
+ if (typeof fn === 'function') fn();
225
+ }
226
+ const apiRes = await mod.handleApi(request.method, pathname, request);
227
+ return hookCtx ? mod.runResponseHooks(hookCtx, apiRes) : apiRes;
228
+ }
229
+ const form = request.method === 'POST' ? await request.formData() : undefined;
230
+ if (form) {
231
+ const fn = (mod as any).invalidateRenderCache;
232
+ if (typeof fn === 'function') fn();
233
+ }
234
+ if (!form && !broken.has(pathname) && !mod.isBuffered?.(pathname) && mod.canStream(pathname) && !mod.isNoJs(pathname) && !request.headers.get('x-pjax')) {
235
+ const nonce = mod.cspNonce?.(pathname) ? mintNonce() : '';
236
+ const streamed = new Response(null, {
237
+ status: 200,
238
+ headers: { 'content-type': 'text/html; charset=utf-8', ...pageHeaders(pathname, nonce) }
239
+ });
240
+ const head = hookCtx ? await mod.runResponseHooks(hookCtx, streamed) : streamed;
241
+ const encoder = new TextEncoder();
242
+ const stream = new ReadableStream<Uint8Array>({
243
+ async start(controller) {
244
+ const doc = mod.docAttrs(pathname);
245
+ await mod.renderPageStream(
246
+ pathname,
247
+ (chunk) => controller.enqueue(encoder.encode(chunk)),
248
+ shellOpen(styles, doc.lang, doc.dir, !mod.shellOff?.(pathname)),
249
+ clientScriptTag(clientFor(pathname), nonce)
250
+ );
251
+ controller.close();
252
+ }
253
+ });
254
+ return new Response(stream, { status: head.status, headers: head.headers });
255
+ }
256
+ const cookieHeader = request.headers.get('cookie') || '';
257
+ const qs = searchParams.toString();
258
+ // The render memo is shared across requests, so its key carries the
259
+ // negotiation fingerprint too - accept-language plus whatever the matched
260
+ // route declared with `export const hotVary`.
261
+ const dims = hotVaryValues(pathname, (mod as any).hotVaryFor, (n) => request.headers.get(n)).join('\u0000');
262
+ const memoHit = !form && (mod as any).isCachedRoute?.(pathname)
263
+ ? (mod as any).peekRenderCache?.(pathname, cookieHeader, qs, dims)
264
+ : null;
265
+ const page = memoHit ?? ((mod as any).renderPageCached && !form
266
+ ? await (mod as any).renderPageCached(pathname, cookieHeader, qs, dims)
267
+ : await mod.renderPage(pathname, form));
268
+ const nonce = mod.cspNonce?.(pathname) ? mintNonce() : '';
269
+ // A page that called redirect() returns { redirect, status } with no html;
270
+ // the prod edge entry must honor it or the document 500s (dev/preview route
271
+ // through a different handler that already does).
272
+ if ((page as { redirect?: string }).redirect) {
273
+ const target = (page as { redirect: string }).redirect;
274
+ const loc = new URL(target, request.url);
275
+ const body = `<!DOCTYPE html><title>Redirecting</title><p>Redirecting to <a href="${target}">${target}</a>.</p>`;
276
+ return new Response(body, { status: (page as { status?: number }).status ?? 302, headers: { 'content-type': 'text/html; charset=utf-8', location: loc.pathname + loc.search } });
277
+ }
278
+ let html = buildShell(page.html, page.state, clientFor(pathname), page.head, styles, page.csr !== false, page.lang, page.dir, nonce, page.shell !== false);
279
+ if (request.headers.get('x-pjax')) html = pjaxFragment(html);
280
+ const buffered = new Response(html, {
281
+ status: page.status ?? 200,
282
+ headers: { 'content-type': 'text/html; charset=utf-8', ...pageHeaders(pathname, nonce) }
283
+ });
284
+ return hookCtx ? mod.runResponseHooks(hookCtx, buffered) : buffered;
285
+ });
286
+ };
287
+ }
288
+
289
+ function pjaxFragment(html: string): string {
290
+ const mi = html.indexOf('<main id="sk-main"');
291
+ if (mi < 0) return html;
292
+ const me = html.indexOf('</main>', mi);
293
+ if (me < 0) return html;
294
+ const main = html.slice(mi, me + '</main>'.length);
295
+ const tm = html.match(/<title>([\s\S]*?)<\/title>/i);
296
+ const title = tm ? tm[0] : '';
297
+ return title + main;
298
+ }
@@ -0,0 +1,27 @@
1
+ // The dimensions a SHARED cache entry is negotiated on.
2
+ //
3
+ // Every server-side cache in this framework - the prod server's hot cache and
4
+ // the route render memo (`export const cache = N`) - answers bytes that were
5
+ // rendered for a different request, so each one's key has to name the whole
6
+ // representation. URL + cookie is not the whole representation: a render that
7
+ // localizes on Accept-Language, or that reads a private header of its own
8
+ // (x-locale, x-rv-url), produces different bytes per request under one URL
9
+ // (BUG.md AB). The framework carries accept-language unconditionally; anything
10
+ // beyond it has to be declared by the route - `export const hotVary =
11
+ // ['x-rv-url']` - which the build turns into a pattern-matched lookup
12
+ // (hotVaryFor) in the server bundle.
13
+ //
14
+ // Index 0 is therefore always accept-language. The rest are the declared
15
+ // headers, in declaration order, so a caller that replays them onto a
16
+ // synthesized request (the hot cache's warm render) can pair names with values
17
+ // by position.
18
+ export function hotVaryValues(
19
+ pathname: string,
20
+ hotVaryFor: ((p: string) => string[] | null) | undefined,
21
+ get: (name: string) => string | null | undefined,
22
+ ): string[] {
23
+ const al = String(get('accept-language') ?? '').trim().toLowerCase();
24
+ const dims = hotVaryFor?.(pathname) ?? [];
25
+ if (!dims.length) return [al];
26
+ return [al, ...dims.map((d) => String(get(d) ?? '').trim())];
27
+ }
@@ -0,0 +1,10 @@
1
+
2
+ export function requestOrigin(req: any, fallbackPort: number | string): string {
3
+ const rawProto = String(req.headers?.['x-forwarded-proto'] || '');
4
+ // A chain of proxies appends: "https, http". The client-facing hop is first.
5
+ const proto = rawProto.split(',')[0]!.trim();
6
+ const scheme = proto === 'https' || proto === 'http' ? proto : 'http';
7
+ const rawHost = String(req.headers?.['x-forwarded-host'] || '');
8
+ const host = rawHost.split(',')[0]!.trim() || req.headers?.host || `localhost:${fallbackPort}`;
9
+ return `${scheme}://${host}`;
10
+ }