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/package.json
CHANGED
|
@@ -1,6 +1,112 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rosefc",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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, "&")
|
|
58
|
+
.replace(/</g, "<")
|
|
59
|
+
.replace(/>/g, ">")}</code></pre>`,
|
|
60
|
+
);
|
|
61
|
+
return `\u0000CODE${blocks.length - 1}\u0000`;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const inline = (s: string) =>
|
|
65
|
+
s
|
|
66
|
+
.replace(/&/g, "&")
|
|
67
|
+
.replace(/</g, "<")
|
|
68
|
+
.replace(/>/g, ">")
|
|
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, "&")
|
|
175
|
+
.replace(/</g, "<")
|
|
176
|
+
.replace(/>/g, ">")
|
|
177
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|
package/src/cli/edge.ts
ADDED
|
@@ -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
|
+
}
|