@fiyuu/runtime 0.2.0 → 0.4.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/README.md +62 -0
- package/package.json +23 -4
- package/src/bundler.ts +0 -151
- package/src/cli.ts +0 -32
- package/src/client-runtime.ts +0 -528
- package/src/index.ts +0 -4
- package/src/inspector.ts +0 -329
- package/src/server-devtools.ts +0 -133
- package/src/server-loader.ts +0 -213
- package/src/server-middleware.ts +0 -71
- package/src/server-renderer.ts +0 -260
- package/src/server-router.ts +0 -77
- package/src/server-types.ts +0 -198
- package/src/server-utils.ts +0 -137
- package/src/server-websocket.ts +0 -71
- package/src/server.ts +0 -1089
- package/src/service.ts +0 -97
package/src/server-loader.ts
DELETED
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Module loading, layout stacking, meta merging, query caching,
|
|
3
|
-
* and GEA component rendering for the Fiyuu runtime server.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { existsSync } from "node:fs";
|
|
7
|
-
import path from "node:path";
|
|
8
|
-
import { pathToFileURL } from "node:url";
|
|
9
|
-
import type { FeatureRecord, MetaDefinition } from "@fiyuu/core";
|
|
10
|
-
import type { GeaRenderable, LayoutModule, ModuleShape, RuntimeState } from "./server-types.js";
|
|
11
|
-
import { QUERY_CACHE_MAX_ENTRIES, QUERY_CACHE_SWEEP_INTERVAL_MS } from "./server-router.js";
|
|
12
|
-
|
|
13
|
-
// ── Dynamic module import ─────────────────────────────────────────────────────
|
|
14
|
-
|
|
15
|
-
export async function importModule(modulePath: string, mode: "dev" | "start"): Promise<unknown> {
|
|
16
|
-
const fileUrl = pathToFileURL(modulePath).href;
|
|
17
|
-
try {
|
|
18
|
-
return await import(mode === "dev" ? `${fileUrl}?t=${Date.now()}` : fileUrl);
|
|
19
|
-
} catch (err: unknown) {
|
|
20
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
21
|
-
const shortPath = modulePath.replace(process.cwd(), ".");
|
|
22
|
-
|
|
23
|
-
let hint = "";
|
|
24
|
-
if (message.includes("Cannot find package") || message.includes("ERR_MODULE_NOT_FOUND")) {
|
|
25
|
-
const match = message.match(/Cannot find (?:package|module) '([^']+)'/);
|
|
26
|
-
const missing = match ? match[1] : "a dependency";
|
|
27
|
-
hint = `\n → Missing package: "${missing}". Run \`npm install\` in the project root.`;
|
|
28
|
-
} else if (message.includes("SyntaxError") || message.includes("Unexpected token")) {
|
|
29
|
-
hint = `\n → Syntax error in ${shortPath}. Check for typos or invalid TypeScript.`;
|
|
30
|
-
} else if (message.includes("ERR_INVALID_URL")) {
|
|
31
|
-
hint = `\n → Invalid file path: ${shortPath}`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const enhanced = new Error(
|
|
35
|
-
`Failed to load module: ${shortPath}\n ${message}${hint}`,
|
|
36
|
-
);
|
|
37
|
-
enhanced.stack = err instanceof Error ? err.stack : undefined;
|
|
38
|
-
throw enhanced;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// ── API route resolution ──────────────────────────────────────────────────────
|
|
43
|
-
|
|
44
|
-
export function resolveApiRouteModule(appDirectory: string, pathname: string): string | null {
|
|
45
|
-
const relativePath = pathname.replace(/^\//, "");
|
|
46
|
-
const normalizedRoot = path.resolve(appDirectory) + path.sep;
|
|
47
|
-
|
|
48
|
-
const directModule = path.resolve(appDirectory, relativePath, "route.ts");
|
|
49
|
-
if (!directModule.startsWith(normalizedRoot)) return null; // path traversal guard
|
|
50
|
-
if (existsSync(directModule)) return directModule;
|
|
51
|
-
|
|
52
|
-
const rootModule = path.resolve(appDirectory, relativePath + ".ts");
|
|
53
|
-
if (!rootModule.startsWith(normalizedRoot)) return null; // path traversal guard
|
|
54
|
-
return existsSync(rootModule) ? rootModule : null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// ── Meta loading & merging ────────────────────────────────────────────────────
|
|
58
|
-
|
|
59
|
-
export async function loadMetaFile(filePath: string, mode: "dev" | "start"): Promise<MetaDefinition> {
|
|
60
|
-
if (!existsSync(filePath)) {
|
|
61
|
-
return { intent: "" };
|
|
62
|
-
}
|
|
63
|
-
const module = (await importModule(filePath, mode)) as { default?: MetaDefinition };
|
|
64
|
-
return module.default ?? { intent: "" };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export async function loadLayoutMeta(directory: string, mode: "dev" | "start"): Promise<MetaDefinition> {
|
|
68
|
-
return loadMetaFile(path.join(directory, "layout.meta.ts"), mode);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function mergeMetaDefinitions(...definitions: MetaDefinition[]): MetaDefinition {
|
|
72
|
-
return definitions.reduce<MetaDefinition>(
|
|
73
|
-
(current, item) => ({
|
|
74
|
-
...current,
|
|
75
|
-
...item,
|
|
76
|
-
seo: {
|
|
77
|
-
...current.seo,
|
|
78
|
-
...item.seo,
|
|
79
|
-
},
|
|
80
|
-
}),
|
|
81
|
-
{ intent: "" },
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// ── Layout stack loading ──────────────────────────────────────────────────────
|
|
86
|
-
|
|
87
|
-
export async function loadLayoutStack(
|
|
88
|
-
appDirectory: string,
|
|
89
|
-
feature: FeatureRecord,
|
|
90
|
-
mode: "dev" | "start",
|
|
91
|
-
): Promise<Array<{ component: unknown; meta: MetaDefinition }>> {
|
|
92
|
-
const parts = feature.feature ? feature.feature.split("/") : [];
|
|
93
|
-
const directories = [appDirectory];
|
|
94
|
-
for (let index = 0; index < parts.length; index += 1) {
|
|
95
|
-
directories.push(path.join(appDirectory, ...parts.slice(0, index + 1)));
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const stack: Array<{ component: unknown; meta: MetaDefinition }> = [];
|
|
99
|
-
for (const directory of directories) {
|
|
100
|
-
const layoutFile = path.join(directory, "layout.tsx");
|
|
101
|
-
const metaFile = path.join(directory, "layout.meta.ts");
|
|
102
|
-
if (existsSync(layoutFile)) {
|
|
103
|
-
const module = (await importModule(layoutFile, mode)) as LayoutModule;
|
|
104
|
-
if (module.default) {
|
|
105
|
-
stack.push({ component: module.default, meta: await loadMetaFile(metaFile, mode) });
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
return stack;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export async function loadFeatureMeta(feature: FeatureRecord, mode: "dev" | "start"): Promise<MetaDefinition> {
|
|
113
|
-
return feature.files["meta.ts"]
|
|
114
|
-
? loadMetaFile(feature.files["meta.ts"], mode)
|
|
115
|
-
: { intent: feature.intent ?? "" };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ── Cached layout stack (production only) ────────────────────────────────────
|
|
119
|
-
|
|
120
|
-
export async function getCachedLayoutStack(
|
|
121
|
-
state: RuntimeState,
|
|
122
|
-
appDirectory: string,
|
|
123
|
-
feature: FeatureRecord,
|
|
124
|
-
mode: "dev" | "start",
|
|
125
|
-
): Promise<Array<{ component: unknown; meta: MetaDefinition }>> {
|
|
126
|
-
const cached = state.layoutStackCache.get(feature.route);
|
|
127
|
-
if (cached) return cached;
|
|
128
|
-
|
|
129
|
-
const layoutStack = await loadLayoutStack(appDirectory, feature, mode);
|
|
130
|
-
state.layoutStackCache.set(feature.route, layoutStack);
|
|
131
|
-
return layoutStack;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export async function getCachedMergedMeta(
|
|
135
|
-
state: RuntimeState,
|
|
136
|
-
feature: FeatureRecord,
|
|
137
|
-
layoutStack: Array<{ component: unknown; meta: MetaDefinition }>,
|
|
138
|
-
mode: "dev" | "start",
|
|
139
|
-
): Promise<MetaDefinition> {
|
|
140
|
-
const cached = state.mergedMetaCache.get(feature.route);
|
|
141
|
-
if (cached) return cached;
|
|
142
|
-
|
|
143
|
-
let featureMeta = state.featureMetaCache.get(feature.route);
|
|
144
|
-
if (!featureMeta) {
|
|
145
|
-
featureMeta = await loadFeatureMeta(feature, mode);
|
|
146
|
-
state.featureMetaCache.set(feature.route, featureMeta);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const merged = mergeMetaDefinitions(...layoutStack.map((item) => item.meta), featureMeta);
|
|
150
|
-
state.mergedMetaCache.set(feature.route, merged);
|
|
151
|
-
return merged;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// ── Query cache ───────────────────────────────────────────────────────────────
|
|
155
|
-
|
|
156
|
-
export function pruneQueryCache(state: RuntimeState, now: number): void {
|
|
157
|
-
if (
|
|
158
|
-
now - state.queryCacheLastPruneAt < QUERY_CACHE_SWEEP_INTERVAL_MS &&
|
|
159
|
-
state.queryCache.size < QUERY_CACHE_MAX_ENTRIES
|
|
160
|
-
) {
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
state.queryCacheLastPruneAt = now;
|
|
165
|
-
for (const [key, entry] of state.queryCache) {
|
|
166
|
-
if (entry.expiresAt <= now) {
|
|
167
|
-
state.queryCache.delete(key);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
if (state.queryCache.size <= QUERY_CACHE_MAX_ENTRIES) return;
|
|
172
|
-
|
|
173
|
-
const survivors = [...state.queryCache.entries()].sort(
|
|
174
|
-
(left, right) => left[1].expiresAt - right[1].expiresAt,
|
|
175
|
-
);
|
|
176
|
-
const overflowCount = survivors.length - QUERY_CACHE_MAX_ENTRIES;
|
|
177
|
-
for (let index = 0; index < overflowCount; index += 1) {
|
|
178
|
-
state.queryCache.delete(survivors[index][0]);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ── GEA component rendering ───────────────────────────────────────────────────
|
|
183
|
-
|
|
184
|
-
const geaComponentModeCache = new WeakMap<Function, "class" | "function">();
|
|
185
|
-
|
|
186
|
-
export function renderGeaComponent(component: unknown, props: Record<string, unknown>): string {
|
|
187
|
-
if (typeof component !== "function") {
|
|
188
|
-
throw new Error("Route module default export must be a Gea component class or function.");
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const candidate = component as {
|
|
192
|
-
prototype?: { template?: (props?: Record<string, unknown>) => string };
|
|
193
|
-
new (props?: Record<string, unknown>): GeaRenderable;
|
|
194
|
-
(props?: Record<string, unknown>): unknown;
|
|
195
|
-
};
|
|
196
|
-
const componentKey = candidate as unknown as Function;
|
|
197
|
-
|
|
198
|
-
const cachedMode = geaComponentModeCache.get(componentKey);
|
|
199
|
-
const mode = cachedMode ?? (typeof candidate.prototype?.template === "function" ? "class" : "function");
|
|
200
|
-
if (!cachedMode) {
|
|
201
|
-
geaComponentModeCache.set(componentKey, mode);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (mode === "class") {
|
|
205
|
-
const instance = new candidate(props);
|
|
206
|
-
if (typeof instance.template === "function") {
|
|
207
|
-
return String(instance.template(instance.props ?? props));
|
|
208
|
-
}
|
|
209
|
-
return String(instance.toString());
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return String(candidate(props));
|
|
213
|
-
}
|
package/src/server-middleware.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Middleware runner for the Fiyuu runtime server.
|
|
3
|
-
* Loads the app-level middleware.ts module and chains handlers.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { existsSync } from "node:fs";
|
|
7
|
-
import path from "node:path";
|
|
8
|
-
import type { IncomingMessage } from "node:http";
|
|
9
|
-
import type {
|
|
10
|
-
MiddlewareContext,
|
|
11
|
-
MiddlewareHandler,
|
|
12
|
-
MiddlewareModule,
|
|
13
|
-
MiddlewareResult,
|
|
14
|
-
StartServerOptions,
|
|
15
|
-
} from "./server-types.js";
|
|
16
|
-
import { importModule } from "./server-loader.js";
|
|
17
|
-
|
|
18
|
-
export async function runMiddleware(
|
|
19
|
-
options: StartServerOptions,
|
|
20
|
-
url: URL,
|
|
21
|
-
request: IncomingMessage,
|
|
22
|
-
mode: "dev" | "start",
|
|
23
|
-
stateWarnings: string[] = [],
|
|
24
|
-
requestId = "",
|
|
25
|
-
): Promise<MiddlewareResult | undefined> {
|
|
26
|
-
if (options.config?.middleware?.enabled === false) {
|
|
27
|
-
return undefined;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const middlewarePath = path.join(options.appDirectory, "middleware.ts");
|
|
31
|
-
if (!existsSync(middlewarePath)) {
|
|
32
|
-
return undefined;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const module = (await importModule(middlewarePath, mode)) as MiddlewareModule;
|
|
36
|
-
const handlers: MiddlewareHandler[] = Array.isArray(module.middleware)
|
|
37
|
-
? module.middleware
|
|
38
|
-
: module.middleware
|
|
39
|
-
? [module.middleware]
|
|
40
|
-
: [];
|
|
41
|
-
|
|
42
|
-
const context: MiddlewareContext = {
|
|
43
|
-
request,
|
|
44
|
-
url,
|
|
45
|
-
responseHeaders: {},
|
|
46
|
-
requestId,
|
|
47
|
-
warnings: stateWarnings,
|
|
48
|
-
};
|
|
49
|
-
let shortCircuit: MiddlewareResult["response"];
|
|
50
|
-
let index = -1;
|
|
51
|
-
|
|
52
|
-
async function dispatch(position: number): Promise<void> {
|
|
53
|
-
if (position <= index) {
|
|
54
|
-
throw new Error("Middleware next() called multiple times.");
|
|
55
|
-
}
|
|
56
|
-
index = position;
|
|
57
|
-
const handler = handlers[position];
|
|
58
|
-
if (!handler) return;
|
|
59
|
-
|
|
60
|
-
const result = await handler(context, async () => dispatch(position + 1));
|
|
61
|
-
if (result?.headers) {
|
|
62
|
-
Object.assign(context.responseHeaders, result.headers);
|
|
63
|
-
}
|
|
64
|
-
if (result?.response) {
|
|
65
|
-
shortCircuit = result.response;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
await dispatch(0);
|
|
70
|
-
return { headers: context.responseHeaders, response: shortCircuit };
|
|
71
|
-
}
|
package/src/server-renderer.ts
DELETED
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTML document rendering, status pages, and response helpers
|
|
3
|
-
* for the Fiyuu runtime server.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { createReadStream, existsSync } from "node:fs";
|
|
7
|
-
import type { ServerResponse } from "node:http";
|
|
8
|
-
import type { MetaDefinition, RenderMode } from "@fiyuu/core";
|
|
9
|
-
import { buildClientRuntime } from "./client-runtime.js";
|
|
10
|
-
import { escapeHtml, sendText, serialize } from "./server-utils.js";
|
|
11
|
-
import { renderUnifiedToolsScript } from "./server-devtools.js";
|
|
12
|
-
import type { StatusPageInput } from "./server-types.js";
|
|
13
|
-
|
|
14
|
-
// ── Document rendering ────────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
export function renderDocument(input: {
|
|
17
|
-
body: string;
|
|
18
|
-
data: unknown;
|
|
19
|
-
route: string;
|
|
20
|
-
intent: string;
|
|
21
|
-
render: RenderMode;
|
|
22
|
-
clientPath: string | null;
|
|
23
|
-
liveReload: boolean;
|
|
24
|
-
warnings: string[];
|
|
25
|
-
renderTimeMs: number;
|
|
26
|
-
developerTools: boolean;
|
|
27
|
-
requestId: string;
|
|
28
|
-
meta: MetaDefinition;
|
|
29
|
-
websocketPath: string;
|
|
30
|
-
}): string {
|
|
31
|
-
const liveReloadScript = input.liveReload
|
|
32
|
-
? `<script type="module">const events=new EventSource('/__fiyuu/live');events.onmessage=(event)=>{if(event.data==='reload'){location.reload();}};</script>`
|
|
33
|
-
: "";
|
|
34
|
-
const liveErrorDebuggerScript = input.liveReload
|
|
35
|
-
? `<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>`
|
|
36
|
-
: "";
|
|
37
|
-
const runtimeScript = `<script defer src="/__fiyuu/runtime.js"></script>`;
|
|
38
|
-
const clientScript = input.clientPath ? `<script type="module" src="${input.clientPath}"></script>` : "";
|
|
39
|
-
const unifiedToolsScript = input.liveReload && input.developerTools ? renderUnifiedToolsScript(input) : "";
|
|
40
|
-
|
|
41
|
-
return `<!doctype html>
|
|
42
|
-
<html lang="en" data-render-mode="${input.render}">
|
|
43
|
-
<head>
|
|
44
|
-
<meta charset="utf-8" />
|
|
45
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
46
|
-
<title>${escapeHtml(input.meta.seo?.title ?? input.meta.title ?? "Fiyuu")}</title>
|
|
47
|
-
<meta name="description" content="${escapeHtml(input.meta.seo?.description ?? (input.intent || "Fiyuu application"))}" />
|
|
48
|
-
<script>
|
|
49
|
-
// Theme detection runs before anything else to prevent flash of wrong theme.
|
|
50
|
-
(function(){
|
|
51
|
-
try {
|
|
52
|
-
var saved = localStorage.getItem('fiyuu-theme');
|
|
53
|
-
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
54
|
-
var isDark = saved === 'dark' || (!saved && prefersDark);
|
|
55
|
-
document.documentElement.classList.toggle('dark', isDark);
|
|
56
|
-
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
|
57
|
-
} catch(e) {}
|
|
58
|
-
})();
|
|
59
|
-
</script>
|
|
60
|
-
<script src="https://cdn.tailwindcss.com"></script>
|
|
61
|
-
<script>
|
|
62
|
-
// Tailwind CDN config must be set after the script loads.
|
|
63
|
-
if (typeof tailwind !== 'undefined') {
|
|
64
|
-
tailwind.config = { darkMode: 'class' };
|
|
65
|
-
}
|
|
66
|
-
</script>
|
|
67
|
-
<style>
|
|
68
|
-
*, *::before, *::after { box-sizing: border-box; }
|
|
69
|
-
:root { color-scheme: light; }
|
|
70
|
-
html.dark { color-scheme: dark; }
|
|
71
|
-
body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: #f7f8f5; color: #172018; }
|
|
72
|
-
html.dark body { background: #111513; color: #f0f5ee; }
|
|
73
|
-
#app { min-height: 100vh; }
|
|
74
|
-
</style>
|
|
75
|
-
</head>
|
|
76
|
-
<body>
|
|
77
|
-
<div id="app">${input.body}</div>
|
|
78
|
-
<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>
|
|
79
|
-
${runtimeScript}
|
|
80
|
-
${clientScript}
|
|
81
|
-
${unifiedToolsScript}
|
|
82
|
-
${liveErrorDebuggerScript}
|
|
83
|
-
${liveReloadScript}
|
|
84
|
-
</body>
|
|
85
|
-
</html>`;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// ── Status page rendering ─────────────────────────────────────────────────────
|
|
89
|
-
|
|
90
|
-
export function getStatusTone(statusCode: number): {
|
|
91
|
-
background: string;
|
|
92
|
-
border: string;
|
|
93
|
-
accent: string;
|
|
94
|
-
accentSoft: string;
|
|
95
|
-
} {
|
|
96
|
-
if (statusCode >= 500) {
|
|
97
|
-
return {
|
|
98
|
-
background: "#f2dfd5",
|
|
99
|
-
border: "rgba(151, 73, 45, .22)",
|
|
100
|
-
accent: "#97492d",
|
|
101
|
-
accentSoft: "rgba(151, 73, 45, .20)",
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
if (statusCode === 404) {
|
|
105
|
-
return {
|
|
106
|
-
background: "#e4ebdf",
|
|
107
|
-
border: "rgba(58, 98, 75, .22)",
|
|
108
|
-
accent: "#3a624b",
|
|
109
|
-
accentSoft: "rgba(58, 98, 75, .18)",
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
return {
|
|
113
|
-
background: "#e8e2d4",
|
|
114
|
-
border: "rgba(105, 88, 52, .22)",
|
|
115
|
-
accent: "#695834",
|
|
116
|
-
accentSoft: "rgba(105, 88, 52, .18)",
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function renderStatusPage(input: StatusPageInput): string {
|
|
121
|
-
const tone = getStatusTone(input.statusCode);
|
|
122
|
-
const badges = [
|
|
123
|
-
`HTTP ${input.statusCode}`,
|
|
124
|
-
input.method ? `Method ${input.method}` : "",
|
|
125
|
-
input.route ? `Route ${input.route}` : "",
|
|
126
|
-
]
|
|
127
|
-
.filter(Boolean)
|
|
128
|
-
.map((item) => `<span class="badge">${escapeHtml(item)}</span>`)
|
|
129
|
-
.join("");
|
|
130
|
-
const hints = (input.hints ?? []).map((item) => `<li>${escapeHtml(item)}</li>`).join("");
|
|
131
|
-
const diagnostics = (input.diagnostics ?? [])
|
|
132
|
-
.map((item) => `<li><code>${escapeHtml(item)}</code></li>`)
|
|
133
|
-
.join("");
|
|
134
|
-
const requestMeta = input.requestId
|
|
135
|
-
? `<p class="meta">Request ID: <code>${escapeHtml(input.requestId)}</code></p>`
|
|
136
|
-
: "";
|
|
137
|
-
|
|
138
|
-
return `<!doctype html>
|
|
139
|
-
<html lang="en">
|
|
140
|
-
<head>
|
|
141
|
-
<meta charset="utf-8" />
|
|
142
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
143
|
-
<title>${escapeHtml(`${input.statusCode} ${input.title} - Fiyuu`)}</title>
|
|
144
|
-
<style>
|
|
145
|
-
:root {
|
|
146
|
-
color-scheme: light;
|
|
147
|
-
--bg: ${tone.background};
|
|
148
|
-
--panel: rgba(255,255,255,.76);
|
|
149
|
-
--border: ${tone.border};
|
|
150
|
-
--text: #18211d;
|
|
151
|
-
--muted: rgba(24,33,29,.62);
|
|
152
|
-
--accent: ${tone.accent};
|
|
153
|
-
--accent-soft: ${tone.accentSoft};
|
|
154
|
-
--code-bg: rgba(24,33,29,.06);
|
|
155
|
-
}
|
|
156
|
-
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
157
|
-
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; }
|
|
158
|
-
.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); }
|
|
159
|
-
.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; }
|
|
160
|
-
h1 { font-size: 22px; font-weight: 700; margin: 14px 0 8px; }
|
|
161
|
-
.summary { color: var(--muted); margin-bottom: 16px; }
|
|
162
|
-
.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; }
|
|
163
|
-
h2 { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 18px 0 8px; }
|
|
164
|
-
ul { padding-left: 18px; }
|
|
165
|
-
li { margin-top: 6px; }
|
|
166
|
-
code { background: var(--code-bg); border-radius: 4px; padding: 1px 5px; font-family: ui-monospace, monospace; font-size: .9em; }
|
|
167
|
-
.meta { font-size: 12px; color: var(--muted); margin-top: 20px; }
|
|
168
|
-
</style>
|
|
169
|
-
</head>
|
|
170
|
-
<body>
|
|
171
|
-
<div class="card">
|
|
172
|
-
<div>${badges}</div>
|
|
173
|
-
<h1>${escapeHtml(input.title)}</h1>
|
|
174
|
-
<p class="summary">${escapeHtml(input.summary)}</p>
|
|
175
|
-
${input.detail ? `<pre class="detail">${escapeHtml(input.detail)}</pre>` : ""}
|
|
176
|
-
${hints ? `<h2>Suggestions</h2><ul>${hints}</ul>` : ""}
|
|
177
|
-
${diagnostics ? `<h2>Diagnostics</h2><ul>${diagnostics}</ul>` : ""}
|
|
178
|
-
${requestMeta}
|
|
179
|
-
</div>
|
|
180
|
-
</body>
|
|
181
|
-
</html>`;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export function sendDocumentStatusPage(response: ServerResponse, input: StatusPageInput): void {
|
|
185
|
-
response.statusCode = input.statusCode;
|
|
186
|
-
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
187
|
-
if (input.requestId) {
|
|
188
|
-
response.setHeader("x-fiyuu-request-id", input.requestId);
|
|
189
|
-
}
|
|
190
|
-
response.end(renderStatusPage(input));
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// ── Startup message ───────────────────────────────────────────────────────────
|
|
194
|
-
|
|
195
|
-
export function renderStartupMessage(
|
|
196
|
-
mode: "dev" | "start",
|
|
197
|
-
url: string,
|
|
198
|
-
actualPort: number,
|
|
199
|
-
preferredPort: number,
|
|
200
|
-
websocketUrl?: string,
|
|
201
|
-
): string {
|
|
202
|
-
const lines = [
|
|
203
|
-
"",
|
|
204
|
-
`Fiyuu ${mode === "dev" ? "Development Server" : "Production Server"}`,
|
|
205
|
-
`- URL: ${url}`,
|
|
206
|
-
`- Mode: ${mode.toUpperCase()}`,
|
|
207
|
-
];
|
|
208
|
-
|
|
209
|
-
if (actualPort !== preferredPort) {
|
|
210
|
-
lines.push(`- Port: ${preferredPort} was busy, using ${actualPort}`);
|
|
211
|
-
} else {
|
|
212
|
-
lines.push(`- Port: ${actualPort}`);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (mode === "dev") {
|
|
216
|
-
lines.push("- Live Reload: enabled");
|
|
217
|
-
lines.push("- Rendering: per-route SSR/CSR");
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (websocketUrl) {
|
|
221
|
-
lines.push(`- WebSocket: ${websocketUrl.replace(`:${preferredPort}`, `:${actualPort}`)}`);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return lines.join("\n");
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// ── Static asset serving ──────────────────────────────────────────────────────
|
|
228
|
-
|
|
229
|
-
export async function serveClientAsset(response: ServerResponse, assetPath: string): Promise<void> {
|
|
230
|
-
if (!existsSync(assetPath)) {
|
|
231
|
-
sendText(response, 404, `Missing client asset ${assetPath}`);
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
response.statusCode = 200;
|
|
235
|
-
response.setHeader("content-type", "text/javascript; charset=utf-8");
|
|
236
|
-
response.setHeader("cache-control", "public, max-age=31536000, immutable");
|
|
237
|
-
createReadStream(assetPath).pipe(response);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export function serveClientRuntime(response: ServerResponse, websocketPath: string): void {
|
|
241
|
-
response.statusCode = 200;
|
|
242
|
-
response.setHeader("content-type", "text/javascript; charset=utf-8");
|
|
243
|
-
response.setHeader("cache-control", "public, max-age=300");
|
|
244
|
-
response.end(buildClientRuntime(websocketPath));
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// ── Live reload SSE ───────────────────────────────────────────────────────────
|
|
248
|
-
|
|
249
|
-
export function attachLiveReload(response: ServerResponse, liveClients: Set<ServerResponse>): void {
|
|
250
|
-
response.writeHead(200, {
|
|
251
|
-
"cache-control": "no-cache",
|
|
252
|
-
connection: "keep-alive",
|
|
253
|
-
"content-type": "text/event-stream",
|
|
254
|
-
});
|
|
255
|
-
response.write(`data: ready\n\n`);
|
|
256
|
-
liveClients.add(response);
|
|
257
|
-
response.on("close", () => {
|
|
258
|
-
liveClients.delete(response);
|
|
259
|
-
});
|
|
260
|
-
}
|
package/src/server-router.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dynamic route matching for the Fiyuu runtime server.
|
|
3
|
-
* Builds a RouteIndex from FeatureRecords and matches incoming pathnames.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { FeatureRecord } from "@fiyuu/core";
|
|
7
|
-
import type { DynamicRouteMatcher, RouteIndex, RouteMatch } from "./server-types.js";
|
|
8
|
-
|
|
9
|
-
export const QUERY_CACHE_SWEEP_INTERVAL_MS = 15_000;
|
|
10
|
-
export const QUERY_CACHE_MAX_ENTRIES = 2_000;
|
|
11
|
-
|
|
12
|
-
export function buildRouteRegex(route: string): { regex: RegExp; paramNames: string[] } {
|
|
13
|
-
const paramNames: string[] = [];
|
|
14
|
-
const parts = route.split("/").filter(Boolean);
|
|
15
|
-
const regexParts = parts.map((segment) => {
|
|
16
|
-
const optionalCatchAll = segment.match(/^\[\[\.\.\.(\w+)\]\]$/);
|
|
17
|
-
if (optionalCatchAll) {
|
|
18
|
-
paramNames.push(optionalCatchAll[1]);
|
|
19
|
-
return `(?:/(.*))?`;
|
|
20
|
-
}
|
|
21
|
-
const catchAll = segment.match(/^\[\.\.\.(\w+)\]$/);
|
|
22
|
-
if (catchAll) {
|
|
23
|
-
paramNames.push(catchAll[1]);
|
|
24
|
-
return `(.+)`;
|
|
25
|
-
}
|
|
26
|
-
const dynamic = segment.match(/^\[(\w+)\]$/);
|
|
27
|
-
if (dynamic) {
|
|
28
|
-
paramNames.push(dynamic[1]);
|
|
29
|
-
return `([^/]+)`;
|
|
30
|
-
}
|
|
31
|
-
return segment.replace(/[$()*+.[\]?\\^{}|]/g, "\\$&");
|
|
32
|
-
});
|
|
33
|
-
return { regex: new RegExp(`^/${regexParts.join("/")}$`), paramNames };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function buildRouteIndex(features: FeatureRecord[]): RouteIndex {
|
|
37
|
-
const exact = new Map<string, FeatureRecord>();
|
|
38
|
-
const dynamic = features
|
|
39
|
-
.filter((feature) => feature.isDynamic)
|
|
40
|
-
.sort((left, right) => {
|
|
41
|
-
if (left.params.length !== right.params.length) {
|
|
42
|
-
return left.params.length - right.params.length;
|
|
43
|
-
}
|
|
44
|
-
return right.route.length - left.route.length;
|
|
45
|
-
})
|
|
46
|
-
.map((feature) => {
|
|
47
|
-
const { regex, paramNames } = buildRouteRegex(feature.route);
|
|
48
|
-
return { feature, regex, paramNames } satisfies DynamicRouteMatcher;
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
for (const feature of features) {
|
|
52
|
-
if (!feature.isDynamic) {
|
|
53
|
-
exact.set(feature.route, feature);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return { exact, dynamic };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function matchRoute(routeIndex: RouteIndex, pathname: string): RouteMatch | null {
|
|
61
|
-
const exact = routeIndex.exact.get(pathname);
|
|
62
|
-
if (exact) {
|
|
63
|
-
return { feature: exact, params: {} };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
for (const matcher of routeIndex.dynamic) {
|
|
67
|
-
const match = pathname.match(matcher.regex);
|
|
68
|
-
if (!match) continue;
|
|
69
|
-
const params: Record<string, string> = {};
|
|
70
|
-
for (let i = 0; i < matcher.paramNames.length; i++) {
|
|
71
|
-
params[matcher.paramNames[i]] = match[i + 1] ?? "";
|
|
72
|
-
}
|
|
73
|
-
return { feature: matcher.feature, params };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return null;
|
|
77
|
-
}
|