@fiyuu/runtime 0.1.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.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * HTML document rendering, status pages, and response helpers
3
+ * for the Fiyuu runtime server.
4
+ */
5
+ import type { ServerResponse } from "node:http";
6
+ import type { MetaDefinition, RenderMode } from "@fiyuu/core";
7
+ import type { StatusPageInput } from "./server-types.js";
8
+ export declare function renderDocument(input: {
9
+ body: string;
10
+ data: unknown;
11
+ route: string;
12
+ intent: string;
13
+ render: RenderMode;
14
+ clientPath: string | null;
15
+ liveReload: boolean;
16
+ warnings: string[];
17
+ renderTimeMs: number;
18
+ developerTools: boolean;
19
+ requestId: string;
20
+ meta: MetaDefinition;
21
+ websocketPath: string;
22
+ }): string;
23
+ export declare function getStatusTone(statusCode: number): {
24
+ background: string;
25
+ border: string;
26
+ accent: string;
27
+ accentSoft: string;
28
+ };
29
+ export declare function renderStatusPage(input: StatusPageInput): string;
30
+ export declare function sendDocumentStatusPage(response: ServerResponse, input: StatusPageInput): void;
31
+ export declare function renderStartupMessage(mode: "dev" | "start", url: string, actualPort: number, preferredPort: number, websocketUrl?: string): string;
32
+ export declare function serveClientAsset(response: ServerResponse, assetPath: string): Promise<void>;
33
+ export declare function serveClientRuntime(response: ServerResponse, websocketPath: string): void;
34
+ export declare function attachLiveReload(response: ServerResponse, liveClients: Set<ServerResponse>): void;
@@ -0,0 +1,213 @@
1
+ /**
2
+ * HTML document rendering, status pages, and response helpers
3
+ * for the Fiyuu runtime server.
4
+ */
5
+ import { createReadStream, existsSync } from "node:fs";
6
+ import { buildClientRuntime } from "./client-runtime.js";
7
+ import { escapeHtml, sendText, serialize } from "./server-utils.js";
8
+ import { renderUnifiedToolsScript } from "./server-devtools.js";
9
+ // ── Document rendering ────────────────────────────────────────────────────────
10
+ export function renderDocument(input) {
11
+ const liveReloadScript = input.liveReload
12
+ ? `<script type="module">const events=new EventSource('/__fiyuu/live');events.onmessage=(event)=>{if(event.data==='reload'){location.reload();}};</script>`
13
+ : "";
14
+ const liveErrorDebuggerScript = input.liveReload
15
+ ? `<script type="module">(function(){const host=document.createElement('aside');host.style.cssText='position:fixed;left:12px;top:12px;z-index:10000;max-width:min(560px,calc(100vw - 24px));background:#2a1717;color:#ffe9e9;border:1px solid #7f3e3e;border-radius:12px;padding:10px 12px;font:12px/1.45 ui-monospace,monospace;white-space:pre-wrap;display:none';const title=document.createElement('div');title.style.cssText='font-weight:700;margin-bottom:6px';title.textContent='Fiyuu Live Error';const body=document.createElement('div');const close=document.createElement('button');close.textContent='dismiss';close.style.cssText='margin-top:8px;border:1px solid #9f5b5b;background:transparent;color:#ffe9e9;border-radius:999px;padding:2px 8px;cursor:pointer';close.addEventListener('click',()=>{host.style.display='none';});host.append(title,body,close);function show(message){body.textContent=message;host.style.display='block';if(!host.isConnected)document.body.appendChild(host);}window.addEventListener('error',(event)=>{const stack=event.error&&event.error.stack?event.error.stack:'';show(String(event.message||'Unknown runtime error')+(stack?'\n\n'+stack:''));});window.addEventListener('unhandledrejection',(event)=>{const reason=event.reason instanceof Error?(event.reason.stack||event.reason.message):String(event.reason||'Unhandled promise rejection');show(reason);});})();</script>`
16
+ : "";
17
+ const runtimeScript = `<script defer src="/__fiyuu/runtime.js"></script>`;
18
+ const clientScript = input.clientPath ? `<script type="module" src="${input.clientPath}"></script>` : "";
19
+ const unifiedToolsScript = input.liveReload && input.developerTools ? renderUnifiedToolsScript(input) : "";
20
+ return `<!doctype html>
21
+ <html lang="en" data-render-mode="${input.render}">
22
+ <head>
23
+ <meta charset="utf-8" />
24
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
25
+ <title>${escapeHtml(input.meta.seo?.title ?? input.meta.title ?? "Fiyuu")}</title>
26
+ <meta name="description" content="${escapeHtml(input.meta.seo?.description ?? (input.intent || "Fiyuu application"))}" />
27
+ <script>
28
+ // Theme detection runs before anything else to prevent flash of wrong theme.
29
+ (function(){
30
+ try {
31
+ var saved = localStorage.getItem('fiyuu-theme');
32
+ var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
33
+ var isDark = saved === 'dark' || (!saved && prefersDark);
34
+ document.documentElement.classList.toggle('dark', isDark);
35
+ document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
36
+ } catch(e) {}
37
+ })();
38
+ </script>
39
+ <script src="https://cdn.tailwindcss.com"></script>
40
+ <script>
41
+ // Tailwind CDN config must be set after the script loads.
42
+ if (typeof tailwind !== 'undefined') {
43
+ tailwind.config = { darkMode: 'class' };
44
+ }
45
+ </script>
46
+ <style>
47
+ *, *::before, *::after { box-sizing: border-box; }
48
+ :root { color-scheme: light; }
49
+ html.dark { color-scheme: dark; }
50
+ body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: #f7f8f5; color: #172018; }
51
+ html.dark body { background: #111513; color: #f0f5ee; }
52
+ #app { min-height: 100vh; }
53
+ </style>
54
+ </head>
55
+ <body>
56
+ <div id="app">${input.body}</div>
57
+ <script>window.__FIYUU_DATA__=${serialize(input.data)};window.__FIYUU_ROUTE__=${JSON.stringify(input.route)};window.__FIYUU_INTENT__=${JSON.stringify(input.intent)};window.__FIYUU_RENDER__=${JSON.stringify(input.render)};window.__FIYUU_WS_PATH__=${JSON.stringify(input.websocketPath)};</script>
58
+ ${runtimeScript}
59
+ ${clientScript}
60
+ ${unifiedToolsScript}
61
+ ${liveErrorDebuggerScript}
62
+ ${liveReloadScript}
63
+ </body>
64
+ </html>`;
65
+ }
66
+ // ── Status page rendering ─────────────────────────────────────────────────────
67
+ export function getStatusTone(statusCode) {
68
+ if (statusCode >= 500) {
69
+ return {
70
+ background: "#f2dfd5",
71
+ border: "rgba(151, 73, 45, .22)",
72
+ accent: "#97492d",
73
+ accentSoft: "rgba(151, 73, 45, .20)",
74
+ };
75
+ }
76
+ if (statusCode === 404) {
77
+ return {
78
+ background: "#e4ebdf",
79
+ border: "rgba(58, 98, 75, .22)",
80
+ accent: "#3a624b",
81
+ accentSoft: "rgba(58, 98, 75, .18)",
82
+ };
83
+ }
84
+ return {
85
+ background: "#e8e2d4",
86
+ border: "rgba(105, 88, 52, .22)",
87
+ accent: "#695834",
88
+ accentSoft: "rgba(105, 88, 52, .18)",
89
+ };
90
+ }
91
+ export function renderStatusPage(input) {
92
+ const tone = getStatusTone(input.statusCode);
93
+ const badges = [
94
+ `HTTP ${input.statusCode}`,
95
+ input.method ? `Method ${input.method}` : "",
96
+ input.route ? `Route ${input.route}` : "",
97
+ ]
98
+ .filter(Boolean)
99
+ .map((item) => `<span class="badge">${escapeHtml(item)}</span>`)
100
+ .join("");
101
+ const hints = (input.hints ?? []).map((item) => `<li>${escapeHtml(item)}</li>`).join("");
102
+ const diagnostics = (input.diagnostics ?? [])
103
+ .map((item) => `<li><code>${escapeHtml(item)}</code></li>`)
104
+ .join("");
105
+ const requestMeta = input.requestId
106
+ ? `<p class="meta">Request ID: <code>${escapeHtml(input.requestId)}</code></p>`
107
+ : "";
108
+ return `<!doctype html>
109
+ <html lang="en">
110
+ <head>
111
+ <meta charset="utf-8" />
112
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
113
+ <title>${escapeHtml(`${input.statusCode} ${input.title} - Fiyuu`)}</title>
114
+ <style>
115
+ :root {
116
+ color-scheme: light;
117
+ --bg: ${tone.background};
118
+ --panel: rgba(255,255,255,.76);
119
+ --border: ${tone.border};
120
+ --text: #18211d;
121
+ --muted: rgba(24,33,29,.62);
122
+ --accent: ${tone.accent};
123
+ --accent-soft: ${tone.accentSoft};
124
+ --code-bg: rgba(24,33,29,.06);
125
+ }
126
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
127
+ body { background: var(--bg); color: var(--text); font: 14px/1.6 ui-sans-serif, system-ui, sans-serif; min-height: 100dvh; display: flex; align-items: center; justify-content: center; padding: 24px; }
128
+ .card { background: var(--panel); border: 1px solid var(--border); border-radius: 20px; padding: 32px 36px; max-width: 600px; width: 100%; backdrop-filter: blur(12px); box-shadow: 0 8px 32px rgba(0,0,0,.06); }
129
+ .badge { display: inline-flex; padding: 3px 10px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); font: 600 11px/1.5 ui-monospace, monospace; margin-right: 6px; }
130
+ h1 { font-size: 22px; font-weight: 700; margin: 14px 0 8px; }
131
+ .summary { color: var(--muted); margin-bottom: 16px; }
132
+ .detail { background: var(--code-bg); border-radius: 10px; padding: 12px 14px; font: 13px/1.5 ui-monospace, monospace; margin-bottom: 18px; white-space: pre-wrap; word-break: break-word; }
133
+ h2 { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 18px 0 8px; }
134
+ ul { padding-left: 18px; }
135
+ li { margin-top: 6px; }
136
+ code { background: var(--code-bg); border-radius: 4px; padding: 1px 5px; font-family: ui-monospace, monospace; font-size: .9em; }
137
+ .meta { font-size: 12px; color: var(--muted); margin-top: 20px; }
138
+ </style>
139
+ </head>
140
+ <body>
141
+ <div class="card">
142
+ <div>${badges}</div>
143
+ <h1>${escapeHtml(input.title)}</h1>
144
+ <p class="summary">${escapeHtml(input.summary)}</p>
145
+ ${input.detail ? `<pre class="detail">${escapeHtml(input.detail)}</pre>` : ""}
146
+ ${hints ? `<h2>Suggestions</h2><ul>${hints}</ul>` : ""}
147
+ ${diagnostics ? `<h2>Diagnostics</h2><ul>${diagnostics}</ul>` : ""}
148
+ ${requestMeta}
149
+ </div>
150
+ </body>
151
+ </html>`;
152
+ }
153
+ export function sendDocumentStatusPage(response, input) {
154
+ response.statusCode = input.statusCode;
155
+ response.setHeader("content-type", "text/html; charset=utf-8");
156
+ if (input.requestId) {
157
+ response.setHeader("x-fiyuu-request-id", input.requestId);
158
+ }
159
+ response.end(renderStatusPage(input));
160
+ }
161
+ // ── Startup message ───────────────────────────────────────────────────────────
162
+ export function renderStartupMessage(mode, url, actualPort, preferredPort, websocketUrl) {
163
+ const lines = [
164
+ "",
165
+ `Fiyuu ${mode === "dev" ? "Development Server" : "Production Server"}`,
166
+ `- URL: ${url}`,
167
+ `- Mode: ${mode.toUpperCase()}`,
168
+ ];
169
+ if (actualPort !== preferredPort) {
170
+ lines.push(`- Port: ${preferredPort} was busy, using ${actualPort}`);
171
+ }
172
+ else {
173
+ lines.push(`- Port: ${actualPort}`);
174
+ }
175
+ if (mode === "dev") {
176
+ lines.push("- Live Reload: enabled");
177
+ lines.push("- Rendering: per-route SSR/CSR");
178
+ }
179
+ if (websocketUrl) {
180
+ lines.push(`- WebSocket: ${websocketUrl.replace(`:${preferredPort}`, `:${actualPort}`)}`);
181
+ }
182
+ return lines.join("\n");
183
+ }
184
+ // ── Static asset serving ──────────────────────────────────────────────────────
185
+ export async function serveClientAsset(response, assetPath) {
186
+ if (!existsSync(assetPath)) {
187
+ sendText(response, 404, `Missing client asset ${assetPath}`);
188
+ return;
189
+ }
190
+ response.statusCode = 200;
191
+ response.setHeader("content-type", "text/javascript; charset=utf-8");
192
+ response.setHeader("cache-control", "public, max-age=31536000, immutable");
193
+ createReadStream(assetPath).pipe(response);
194
+ }
195
+ export function serveClientRuntime(response, websocketPath) {
196
+ response.statusCode = 200;
197
+ response.setHeader("content-type", "text/javascript; charset=utf-8");
198
+ response.setHeader("cache-control", "public, max-age=300");
199
+ response.end(buildClientRuntime(websocketPath));
200
+ }
201
+ // ── Live reload SSE ───────────────────────────────────────────────────────────
202
+ export function attachLiveReload(response, liveClients) {
203
+ response.writeHead(200, {
204
+ "cache-control": "no-cache",
205
+ connection: "keep-alive",
206
+ "content-type": "text/event-stream",
207
+ });
208
+ response.write(`data: ready\n\n`);
209
+ liveClients.add(response);
210
+ response.on("close", () => {
211
+ liveClients.delete(response);
212
+ });
213
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Dynamic route matching for the Fiyuu runtime server.
3
+ * Builds a RouteIndex from FeatureRecords and matches incoming pathnames.
4
+ */
5
+ import type { FeatureRecord } from "@fiyuu/core";
6
+ import type { RouteIndex, RouteMatch } from "./server-types.js";
7
+ export declare const QUERY_CACHE_SWEEP_INTERVAL_MS = 15000;
8
+ export declare const QUERY_CACHE_MAX_ENTRIES = 2000;
9
+ export declare function buildRouteRegex(route: string): {
10
+ regex: RegExp;
11
+ paramNames: string[];
12
+ };
13
+ export declare function buildRouteIndex(features: FeatureRecord[]): RouteIndex;
14
+ export declare function matchRoute(routeIndex: RouteIndex, pathname: string): RouteMatch | null;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Dynamic route matching for the Fiyuu runtime server.
3
+ * Builds a RouteIndex from FeatureRecords and matches incoming pathnames.
4
+ */
5
+ export const QUERY_CACHE_SWEEP_INTERVAL_MS = 15_000;
6
+ export const QUERY_CACHE_MAX_ENTRIES = 2_000;
7
+ export function buildRouteRegex(route) {
8
+ const paramNames = [];
9
+ const parts = route.split("/").filter(Boolean);
10
+ const regexParts = parts.map((segment) => {
11
+ const optionalCatchAll = segment.match(/^\[\[\.\.\.(\w+)\]\]$/);
12
+ if (optionalCatchAll) {
13
+ paramNames.push(optionalCatchAll[1]);
14
+ return `(?:/(.*))?`;
15
+ }
16
+ const catchAll = segment.match(/^\[\.\.\.(\w+)\]$/);
17
+ if (catchAll) {
18
+ paramNames.push(catchAll[1]);
19
+ return `(.+)`;
20
+ }
21
+ const dynamic = segment.match(/^\[(\w+)\]$/);
22
+ if (dynamic) {
23
+ paramNames.push(dynamic[1]);
24
+ return `([^/]+)`;
25
+ }
26
+ return segment.replace(/[$()*+.[\]?\\^{}|]/g, "\\$&");
27
+ });
28
+ return { regex: new RegExp(`^/${regexParts.join("/")}$`), paramNames };
29
+ }
30
+ export function buildRouteIndex(features) {
31
+ const exact = new Map();
32
+ const dynamic = features
33
+ .filter((feature) => feature.isDynamic)
34
+ .sort((left, right) => {
35
+ if (left.params.length !== right.params.length) {
36
+ return left.params.length - right.params.length;
37
+ }
38
+ return right.route.length - left.route.length;
39
+ })
40
+ .map((feature) => {
41
+ const { regex, paramNames } = buildRouteRegex(feature.route);
42
+ return { feature, regex, paramNames };
43
+ });
44
+ for (const feature of features) {
45
+ if (!feature.isDynamic) {
46
+ exact.set(feature.route, feature);
47
+ }
48
+ }
49
+ return { exact, dynamic };
50
+ }
51
+ export function matchRoute(routeIndex, pathname) {
52
+ const exact = routeIndex.exact.get(pathname);
53
+ if (exact) {
54
+ return { feature: exact, params: {} };
55
+ }
56
+ for (const matcher of routeIndex.dynamic) {
57
+ const match = pathname.match(matcher.regex);
58
+ if (!match)
59
+ continue;
60
+ const params = {};
61
+ for (let i = 0; i < matcher.paramNames.length; i++) {
62
+ params[matcher.paramNames[i]] = match[i + 1] ?? "";
63
+ }
64
+ return { feature: matcher.feature, params };
65
+ }
66
+ return null;
67
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Shared types and interfaces for the Fiyuu runtime server.
3
+ * All other server modules import from here — no implementation code.
4
+ */
5
+ import type { IncomingMessage, ServerResponse } from "node:http";
6
+ import type { WebSocket } from "ws";
7
+ import type { createProjectGraph, FeatureRecord, FiyuuConfig, MetaDefinition, RenderMode } from "@fiyuu/core";
8
+ import type { FiyuuDB } from "@fiyuu/db";
9
+ import type { FiyuuRealtime } from "@fiyuu/realtime";
10
+ import type { ClientAsset } from "./bundler.js";
11
+ import type { InsightsReport } from "./inspector.js";
12
+ export type { IncomingMessage, ServerResponse };
13
+ export type { FeatureRecord, FiyuuConfig, MetaDefinition, RenderMode };
14
+ export interface ModuleShape {
15
+ default?: unknown;
16
+ execute?: (context: any) => Promise<unknown> | unknown;
17
+ page?: {
18
+ intent: string;
19
+ };
20
+ cache?: {
21
+ ttl?: number;
22
+ vary?: string[];
23
+ };
24
+ }
25
+ export interface LayoutModule {
26
+ default?: unknown;
27
+ }
28
+ export interface GeaRenderable {
29
+ props?: Record<string, unknown>;
30
+ template?: (props?: Record<string, unknown>) => string;
31
+ toString: () => string;
32
+ }
33
+ export interface ApiRouteModule {
34
+ GET?: (context: RequestContext) => Promise<unknown> | unknown;
35
+ POST?: (context: RequestContext) => Promise<unknown> | unknown;
36
+ PUT?: (context: RequestContext) => Promise<unknown> | unknown;
37
+ PATCH?: (context: RequestContext) => Promise<unknown> | unknown;
38
+ DELETE?: (context: RequestContext) => Promise<unknown> | unknown;
39
+ }
40
+ export interface SocketModule {
41
+ registerSocketServer?: () => {
42
+ namespace?: string;
43
+ events?: string[];
44
+ onConnect?: (socket: WebSocket) => void;
45
+ onMessage?: (socket: WebSocket, message: string) => void;
46
+ };
47
+ }
48
+ export interface MiddlewareModule {
49
+ middleware?: MiddlewareHandler | MiddlewareHandler[];
50
+ }
51
+ export interface MiddlewareContext {
52
+ request: IncomingMessage;
53
+ url: URL;
54
+ responseHeaders: Record<string, string>;
55
+ requestId: string;
56
+ warnings: string[];
57
+ }
58
+ export interface MiddlewareResult {
59
+ headers?: Record<string, string>;
60
+ response?: {
61
+ status?: number;
62
+ json?: unknown;
63
+ body?: string;
64
+ };
65
+ }
66
+ export type MiddlewareNext = () => Promise<void>;
67
+ export type MiddlewareHandler = (context: MiddlewareContext, next: MiddlewareNext) => Promise<MiddlewareResult | void> | MiddlewareResult | void;
68
+ export interface RequestContext {
69
+ request: IncomingMessage;
70
+ route: string;
71
+ feature: FeatureRecord | null;
72
+ input?: Record<string, unknown>;
73
+ }
74
+ export interface StartServerOptions {
75
+ mode: "dev" | "start";
76
+ rootDirectory: string;
77
+ appDirectory: string;
78
+ config?: FiyuuConfig;
79
+ port?: number;
80
+ maxPort?: number;
81
+ clientOutputDirectory: string;
82
+ staticClientRoot: string;
83
+ }
84
+ export interface StartedServer {
85
+ port: number;
86
+ url: string;
87
+ websocketUrl?: string;
88
+ close: () => Promise<void>;
89
+ }
90
+ export interface QueryCacheEntry {
91
+ data: unknown;
92
+ expiresAt: number;
93
+ }
94
+ export interface SsgCacheEntry {
95
+ html: string;
96
+ etag: string;
97
+ expiresAt: number | null;
98
+ revalidateSeconds: number | null;
99
+ }
100
+ export interface RuntimeState {
101
+ graph: Awaited<ReturnType<typeof createProjectGraph>>;
102
+ features: FeatureRecord[];
103
+ routeIndex: RouteIndex;
104
+ assets: ClientAsset[];
105
+ assetsByRoute: Map<string, ClientAsset>;
106
+ insights: InsightsReport;
107
+ ssgCache: Map<string, SsgCacheEntry>;
108
+ queryCache: Map<string, QueryCacheEntry>;
109
+ queryInflight: Map<string, Promise<unknown>>;
110
+ queryCacheLastPruneAt: number;
111
+ layoutStackCache: Map<string, Array<{
112
+ component: unknown;
113
+ meta: MetaDefinition;
114
+ }>>;
115
+ featureMetaCache: Map<string, MetaDefinition>;
116
+ mergedMetaCache: Map<string, MetaDefinition>;
117
+ serverEvents: Array<{
118
+ at: string;
119
+ level: "info" | "warn" | "error";
120
+ event: string;
121
+ details?: string;
122
+ }>;
123
+ version: number;
124
+ warnings: string[];
125
+ db: FiyuuDB;
126
+ realtime: FiyuuRealtime;
127
+ serviceNames: string[];
128
+ }
129
+ export interface TinyRouteContext {
130
+ request: IncomingMessage;
131
+ response: ServerResponse;
132
+ url: URL;
133
+ state: RuntimeState;
134
+ options: StartServerOptions;
135
+ liveClients: Set<ServerResponse>;
136
+ }
137
+ export interface TinyRoute {
138
+ method: "GET" | "POST";
139
+ path: string;
140
+ type: "exact" | "prefix";
141
+ devOnly?: boolean;
142
+ handler: (context: TinyRouteContext) => Promise<void> | void;
143
+ }
144
+ export interface StatusPageInput {
145
+ statusCode: number;
146
+ title: string;
147
+ summary: string;
148
+ detail?: string;
149
+ route?: string;
150
+ method?: string;
151
+ requestId?: string;
152
+ hints?: string[];
153
+ diagnostics?: string[];
154
+ }
155
+ export interface RouteMatch {
156
+ feature: FeatureRecord;
157
+ params: Record<string, string>;
158
+ }
159
+ export interface DynamicRouteMatcher {
160
+ feature: FeatureRecord;
161
+ regex: RegExp;
162
+ paramNames: string[];
163
+ }
164
+ export interface RouteIndex {
165
+ exact: Map<string, FeatureRecord>;
166
+ dynamic: DynamicRouteMatcher[];
167
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Shared types and interfaces for the Fiyuu runtime server.
3
+ * All other server modules import from here — no implementation code.
4
+ */
5
+ export {};
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Pure utility functions for the Fiyuu runtime server.
3
+ * No dependencies on other server modules — safe to import anywhere.
4
+ */
5
+ import type { IncomingMessage, ServerResponse } from "node:http";
6
+ import type { RuntimeState } from "./server-types.js";
7
+ export declare function escapeHtml(value: unknown): string;
8
+ export declare function serialize(value: unknown): string;
9
+ export declare function createRequestId(): string;
10
+ export declare function prefersHtmlResponse(request: IncomingMessage): boolean;
11
+ export declare function sendJson(response: ServerResponse, statusCode: number, value: unknown): void;
12
+ export declare function sendText(response: ServerResponse, statusCode: number, message: string): void;
13
+ export declare function createWeakEtag(value: string): string;
14
+ export declare function parseRequestBody(request: IncomingMessage): Promise<Record<string, unknown>>;
15
+ export declare function pushServerEvent(state: RuntimeState, level: "info" | "warn" | "error", event: string, details?: string): void;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Pure utility functions for the Fiyuu runtime server.
3
+ * No dependencies on other server modules — safe to import anywhere.
4
+ */
5
+ import { createHash } from "node:crypto";
6
+ // ── HTML escaping ─────────────────────────────────────────────────────────────
7
+ export function escapeHtml(value) {
8
+ const text = value == null ? "" : String(value);
9
+ return text
10
+ .replaceAll("&", "&amp;")
11
+ .replaceAll("<", "&lt;")
12
+ .replaceAll(">", "&gt;")
13
+ .replaceAll('"', "&quot;")
14
+ .replaceAll("'", "&#39;");
15
+ }
16
+ // ── Serialisation ─────────────────────────────────────────────────────────────
17
+ export function serialize(value) {
18
+ return JSON.stringify(value ?? null).replaceAll("<", "\\u003c");
19
+ }
20
+ // ── Request ID ────────────────────────────────────────────────────────────────
21
+ export function createRequestId() {
22
+ return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
23
+ }
24
+ // ── Content negotiation ───────────────────────────────────────────────────────
25
+ export function prefersHtmlResponse(request) {
26
+ if ((request.url ?? "").startsWith("/api")) {
27
+ return false;
28
+ }
29
+ const accept = request.headers.accept ?? "";
30
+ if (accept.includes("application/json") && !accept.includes("text/html")) {
31
+ return false;
32
+ }
33
+ return true;
34
+ }
35
+ // ── Response helpers ──────────────────────────────────────────────────────────
36
+ export function sendJson(response, statusCode, value) {
37
+ response.statusCode = statusCode;
38
+ response.setHeader("content-type", "application/json; charset=utf-8");
39
+ response.end(`${JSON.stringify(value, null, 2)}\n`);
40
+ }
41
+ export function sendText(response, statusCode, message) {
42
+ response.statusCode = statusCode;
43
+ response.setHeader("content-type", "text/plain; charset=utf-8");
44
+ response.end(message);
45
+ }
46
+ export function createWeakEtag(value) {
47
+ const digest = createHash("sha1").update(value).digest("base64url");
48
+ return `W/\"${digest}\"`;
49
+ }
50
+ const MAX_BODY_BYTES = 1_048_576; // 1 MB
51
+ export async function parseRequestBody(request) {
52
+ const chunks = [];
53
+ let totalBytes = 0;
54
+ for await (const chunk of request) {
55
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
56
+ totalBytes += buffer.byteLength;
57
+ if (totalBytes > MAX_BODY_BYTES) {
58
+ throw Object.assign(new Error("Request body too large (limit: 1 MB)."), { statusCode: 413 });
59
+ }
60
+ chunks.push(buffer);
61
+ }
62
+ const rawBody = Buffer.concat(chunks).toString("utf8").trim();
63
+ if (!rawBody) {
64
+ return {};
65
+ }
66
+ const contentType = request.headers["content-type"] ?? "";
67
+ if (contentType.includes("application/json")) {
68
+ const parsed = JSON.parse(rawBody);
69
+ // Reject prototype pollution attempts.
70
+ if (parsed !== null && typeof parsed === "object") {
71
+ const hasOwn = Object.prototype.hasOwnProperty;
72
+ if (hasOwn.call(parsed, "__proto__") ||
73
+ hasOwn.call(parsed, "constructor") ||
74
+ hasOwn.call(parsed, "prototype")) {
75
+ throw Object.assign(new Error("Request body contains forbidden keys."), { statusCode: 400 });
76
+ }
77
+ }
78
+ return parsed;
79
+ }
80
+ if (contentType.includes("application/x-www-form-urlencoded")) {
81
+ const entries = [...new URLSearchParams(rawBody).entries()].filter(([key]) => key !== "__proto__" && key !== "constructor" && key !== "prototype");
82
+ return Object.fromEntries(entries);
83
+ }
84
+ return { raw: rawBody };
85
+ }
86
+ // ── Server event log ──────────────────────────────────────────────────────────
87
+ export function pushServerEvent(state, level, event, details) {
88
+ state.serverEvents.unshift({
89
+ at: new Date().toISOString(),
90
+ level,
91
+ event,
92
+ details,
93
+ });
94
+ if (state.serverEvents.length > 120) {
95
+ state.serverEvents.length = 120;
96
+ }
97
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * WebSocket server attachment for the Fiyuu runtime.
3
+ * Wires up a ws.WebSocketServer on the HTTP server's upgrade event.
4
+ */
5
+ import { createServer } from "node:http";
6
+ import type { StartServerOptions } from "./server-types.js";
7
+ export declare function attachWebsocketServer(server: ReturnType<typeof createServer>, options: StartServerOptions, websocketPath: string): Promise<string | undefined>;