@fiyuu/runtime 0.2.0 → 0.4.1
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.ts
DELETED
|
@@ -1,1089 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fiyuu runtime server — main orchestrator.
|
|
3
|
-
*
|
|
4
|
-
* This file wires together the modular pieces:
|
|
5
|
-
* server-types → shared interfaces
|
|
6
|
-
* server-utils → serialise, escapeHtml, sendJson/Text, parseBody, …
|
|
7
|
-
* server-router → buildRouteIndex, matchRoute
|
|
8
|
-
* server-loader → importModule, layout/meta loading, query cache, renderGeaComponent
|
|
9
|
-
* server-renderer → renderDocument, renderStatusPage, serveClientAsset, …
|
|
10
|
-
* server-devtools → renderUnifiedToolsScript (dev only)
|
|
11
|
-
* server-middleware→ runMiddleware
|
|
12
|
-
* server-websocket → attachWebsocketServer
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { existsSync } from "node:fs";
|
|
16
|
-
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
17
|
-
import type { AddressInfo } from "node:net";
|
|
18
|
-
import path from "node:path";
|
|
19
|
-
import { pathToFileURL } from "node:url";
|
|
20
|
-
import chokidar from "chokidar";
|
|
21
|
-
import { createProjectGraph, scanApp, syncProjectArtifacts, type MetaDefinition, type RenderMode } from "@fiyuu/core";
|
|
22
|
-
import { FiyuuDB } from "@fiyuu/db";
|
|
23
|
-
import { FiyuuRealtime } from "@fiyuu/realtime";
|
|
24
|
-
import { bundleClient } from "./bundler.js";
|
|
25
|
-
import { buildInsightsReport } from "./inspector.js";
|
|
26
|
-
import { type FiyuuService, ServiceManager, createServiceManager } from "./service.js";
|
|
27
|
-
|
|
28
|
-
// ── Re-export public types ────────────────────────────────────────────────────
|
|
29
|
-
export type { StartServerOptions, StartedServer } from "./server-types.js";
|
|
30
|
-
import type {
|
|
31
|
-
ApiRouteModule,
|
|
32
|
-
FeatureRecord,
|
|
33
|
-
LayoutModule,
|
|
34
|
-
ModuleShape,
|
|
35
|
-
RouteIndex,
|
|
36
|
-
RuntimeState,
|
|
37
|
-
StartedServer,
|
|
38
|
-
StartServerOptions,
|
|
39
|
-
StatusPageInput,
|
|
40
|
-
TinyRoute,
|
|
41
|
-
TinyRouteContext,
|
|
42
|
-
} from "./server-types.js";
|
|
43
|
-
|
|
44
|
-
// ── Utils ─────────────────────────────────────────────────────────────────────
|
|
45
|
-
import {
|
|
46
|
-
createWeakEtag,
|
|
47
|
-
createRequestId,
|
|
48
|
-
parseRequestBody,
|
|
49
|
-
prefersHtmlResponse,
|
|
50
|
-
pushServerEvent,
|
|
51
|
-
sendJson,
|
|
52
|
-
sendText,
|
|
53
|
-
sendXml,
|
|
54
|
-
} from "./server-utils.js";
|
|
55
|
-
|
|
56
|
-
// ── Router ────────────────────────────────────────────────────────────────────
|
|
57
|
-
import { buildRouteIndex, matchRoute } from "./server-router.js";
|
|
58
|
-
|
|
59
|
-
// ── Loader ────────────────────────────────────────────────────────────────────
|
|
60
|
-
import {
|
|
61
|
-
getCachedLayoutStack,
|
|
62
|
-
getCachedMergedMeta,
|
|
63
|
-
importModule,
|
|
64
|
-
loadFeatureMeta,
|
|
65
|
-
loadLayoutMeta,
|
|
66
|
-
loadLayoutStack,
|
|
67
|
-
mergeMetaDefinitions,
|
|
68
|
-
pruneQueryCache,
|
|
69
|
-
renderGeaComponent,
|
|
70
|
-
resolveApiRouteModule,
|
|
71
|
-
} from "./server-loader.js";
|
|
72
|
-
|
|
73
|
-
// ── Renderer ──────────────────────────────────────────────────────────────────
|
|
74
|
-
import {
|
|
75
|
-
attachLiveReload,
|
|
76
|
-
renderDocument,
|
|
77
|
-
renderStartupMessage,
|
|
78
|
-
renderStatusPage,
|
|
79
|
-
serveClientRuntime,
|
|
80
|
-
sendDocumentStatusPage,
|
|
81
|
-
serveClientAsset,
|
|
82
|
-
} from "./server-renderer.js";
|
|
83
|
-
|
|
84
|
-
// ── Middleware & WebSocket ────────────────────────────────────────────────────
|
|
85
|
-
import { runMiddleware } from "./server-middleware.js";
|
|
86
|
-
import { attachWebsocketServer } from "./server-websocket.js";
|
|
87
|
-
|
|
88
|
-
// ── Window augmentation (client type hints) ───────────────────────────────────
|
|
89
|
-
|
|
90
|
-
declare global {
|
|
91
|
-
interface Window {
|
|
92
|
-
__FIYUU_DATA__?: unknown;
|
|
93
|
-
__FIYUU_ROUTE__?: string;
|
|
94
|
-
__FIYUU_INTENT__?: string;
|
|
95
|
-
__FIYUU_RENDER__?: RenderMode;
|
|
96
|
-
__FIYUU_WS_PATH__?: string;
|
|
97
|
-
__FIYUU_DEVTOOLS__?: unknown;
|
|
98
|
-
fiyuu?: {
|
|
99
|
-
theme: {
|
|
100
|
-
get(): "light" | "dark";
|
|
101
|
-
set(value: "light" | "dark"): void;
|
|
102
|
-
toggle(): void;
|
|
103
|
-
bindToggle(elementId: string): void;
|
|
104
|
-
onChange(fn: (theme: "light" | "dark") => void): void;
|
|
105
|
-
};
|
|
106
|
-
bind(elementId: string, value?: unknown, asHtml?: boolean): void;
|
|
107
|
-
partial(elementId: string, url: string, options?: { loading?: string }): Promise<void>;
|
|
108
|
-
onError(callback: (event: { message: string; error: Error | null; source: string | null; line: number | null }) => void): void;
|
|
109
|
-
state<T>(key: string, initialValue: T): {
|
|
110
|
-
get(): T;
|
|
111
|
-
set(value: T): void;
|
|
112
|
-
bind(elementId: string): object;
|
|
113
|
-
onChange(fn: (value: T) => void): object;
|
|
114
|
-
};
|
|
115
|
-
router: {
|
|
116
|
-
navigate(url: string): Promise<void>;
|
|
117
|
-
on(event: "navigate" | "before", fn: (detail: { route: string; render?: string; title?: string }) => void | false): object;
|
|
118
|
-
};
|
|
119
|
-
ws(overridePath?: string): {
|
|
120
|
-
on(type: string, handler: (data: unknown) => void): object;
|
|
121
|
-
onOpen(handler: () => void): object;
|
|
122
|
-
onClose(handler: () => void): object;
|
|
123
|
-
onError(handler: () => void): object;
|
|
124
|
-
send(data: unknown): object;
|
|
125
|
-
status(): "connecting" | "connected" | "closed" | "unavailable";
|
|
126
|
-
};
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ─── Public entry point ───────────────────────────────────────────────────────
|
|
132
|
-
|
|
133
|
-
export async function startServer(options: StartServerOptions): Promise<StartedServer> {
|
|
134
|
-
const state = await createRuntimeState(options);
|
|
135
|
-
const liveClients = new Set<ServerResponse>();
|
|
136
|
-
const websocketPath = options.config?.websocket?.path ?? "/__fiyuu/ws";
|
|
137
|
-
const handleTinyRoute = createTinyInternalRouter(options, state, liveClients);
|
|
138
|
-
|
|
139
|
-
if (options.mode === "dev") {
|
|
140
|
-
const watchTargets = [options.appDirectory];
|
|
141
|
-
const serverDir = path.join(options.rootDirectory, "server");
|
|
142
|
-
const skillsDir = path.join(options.rootDirectory, "skills");
|
|
143
|
-
if (existsSync(serverDir)) watchTargets.push(serverDir);
|
|
144
|
-
if (existsSync(skillsDir)) watchTargets.push(skillsDir);
|
|
145
|
-
|
|
146
|
-
const watcher = chokidar.watch(watchTargets, {
|
|
147
|
-
ignoreInitial: true,
|
|
148
|
-
ignored: /node_modules|\.fiyuu/,
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
watcher.on("all", async (eventName, filePath) => {
|
|
152
|
-
pushServerEvent(state, "info", "watch.change", `${eventName} ${filePath}`);
|
|
153
|
-
try {
|
|
154
|
-
const nextState = await createRuntimeState(options);
|
|
155
|
-
Object.assign(state, nextState);
|
|
156
|
-
pushServerEvent(state, "info", "watch.rebuild", "runtime state refreshed");
|
|
157
|
-
} catch (error) {
|
|
158
|
-
const message = error instanceof Error ? error.message : "watch rebuild failed";
|
|
159
|
-
pushServerEvent(state, "error", "watch.rebuild.error", message);
|
|
160
|
-
}
|
|
161
|
-
for (const response of liveClients) {
|
|
162
|
-
response.write(`data: reload\n\n`);
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const server = createServer(async (request, response) => {
|
|
168
|
-
// Strip internal x-fiyuu-* headers to prevent spoofing (cf. CVE-2025-29927).
|
|
169
|
-
for (const key of Object.keys(request.headers)) {
|
|
170
|
-
if (key.startsWith("x-fiyuu-") && key !== "x-fiyuu-navigate") {
|
|
171
|
-
delete request.headers[key];
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
try {
|
|
176
|
-
if (!request.url) {
|
|
177
|
-
sendDocumentStatusPage(response, {
|
|
178
|
-
statusCode: 400,
|
|
179
|
-
title: "Malformed request",
|
|
180
|
-
summary: "The incoming request does not include a valid URL.",
|
|
181
|
-
detail: "Fiyuu cannot route a request without a pathname.",
|
|
182
|
-
method: request.method ?? "GET",
|
|
183
|
-
requestId: "",
|
|
184
|
-
hints: [
|
|
185
|
-
"Check the client or proxy that created this request.",
|
|
186
|
-
"Verify the request URL is forwarded correctly.",
|
|
187
|
-
],
|
|
188
|
-
});
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const url = new URL(request.url, `http://localhost:${options.port ?? 4050}`);
|
|
193
|
-
|
|
194
|
-
if (await handleTinyRoute({ request, response, url, state, options, liveClients })) {
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const requestId = options.config?.observability?.requestId === false ? "" : createRequestId();
|
|
199
|
-
pushServerEvent(state, "info", "request.start", `${request.method ?? "GET"} ${url.pathname}`);
|
|
200
|
-
|
|
201
|
-
const middleware = await runMiddleware(options, url, request, options.mode, state.warnings, requestId);
|
|
202
|
-
if (middleware?.response) {
|
|
203
|
-
pushServerEvent(state, "info", "middleware.short-circuit", `${url.pathname} → ${middleware.response.status ?? 200}`);
|
|
204
|
-
if (requestId) response.setHeader("x-fiyuu-request-id", requestId);
|
|
205
|
-
for (const [key, value] of Object.entries(middleware.headers ?? {})) {
|
|
206
|
-
response.setHeader(key, value);
|
|
207
|
-
}
|
|
208
|
-
if (middleware.response.json !== undefined) {
|
|
209
|
-
sendJson(response, middleware.response.status ?? 200, middleware.response.json);
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
sendText(response, middleware.response.status ?? 200, middleware.response.body ?? "");
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (url.pathname === "/sitemap.xml") {
|
|
217
|
-
await handleSitemap(request, response, options, state, url);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
if (url.pathname === "/robots.txt") {
|
|
222
|
-
await handleRobots(request, response, options);
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (url.pathname === "/sitemap.xml") {
|
|
227
|
-
await handleSitemap(request, response, options, state, url);
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
if (url.pathname === "/robots.txt") {
|
|
232
|
-
await handleRobots(request, response, options);
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (url.pathname.startsWith("/api")) {
|
|
237
|
-
await handleApiRoute(request, response, options, url.pathname, middleware?.headers ?? {}, requestId, options.mode);
|
|
238
|
-
pushServerEvent(state, "info", "request.api", `${request.method ?? "GET"} ${url.pathname}`);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
await handleRoute(
|
|
243
|
-
request,
|
|
244
|
-
response,
|
|
245
|
-
url.pathname,
|
|
246
|
-
state,
|
|
247
|
-
options.appDirectory,
|
|
248
|
-
options.mode,
|
|
249
|
-
middleware?.headers ?? {},
|
|
250
|
-
options.config?.developerTools?.enabled !== false,
|
|
251
|
-
requestId,
|
|
252
|
-
websocketPath,
|
|
253
|
-
);
|
|
254
|
-
} catch (error) {
|
|
255
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
256
|
-
pushServerEvent(state, "error", "request.error", err.message);
|
|
257
|
-
if (options.config?.errors?.handler) {
|
|
258
|
-
try {
|
|
259
|
-
await options.config.errors.handler(err, {
|
|
260
|
-
route: request.url ?? "/",
|
|
261
|
-
method: request.method ?? "GET",
|
|
262
|
-
requestId: createRequestId(),
|
|
263
|
-
});
|
|
264
|
-
} catch {
|
|
265
|
-
// custom error handler must not throw
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
await sendRuntimeError(response, error, options, request);
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
const port = await listenWithFallback(server, options.port ?? 4050, options.maxPort ?? (options.port ?? 4050) + 20);
|
|
273
|
-
const url = `http://localhost:${port}`;
|
|
274
|
-
|
|
275
|
-
// ── Initialize DB ──────────────────────────────────────────────────────────
|
|
276
|
-
await state.db.initialize();
|
|
277
|
-
console.log(`[fiyuu] DB initialized — tables: ${state.db.listTables().join(", ") || "none"}`);
|
|
278
|
-
|
|
279
|
-
// ── Initialize Realtime (before standalone WS so it claims connections first) ──
|
|
280
|
-
await state.realtime.initialize(server);
|
|
281
|
-
console.log(`[fiyuu] Realtime initialized — transports: ${state.realtime.stats().transports}`);
|
|
282
|
-
|
|
283
|
-
// ── Standalone WebSocket (fallback for custom socket modules, skips if realtime owns the path) ──
|
|
284
|
-
const websocketUrl = await attachWebsocketServer(server, options, websocketPath);
|
|
285
|
-
|
|
286
|
-
// ── Discover and start services ────────────────────────────────────────────
|
|
287
|
-
const serviceManager = createServiceManager();
|
|
288
|
-
const servicesDir = path.join(options.appDirectory, "services");
|
|
289
|
-
if (existsSync(servicesDir)) {
|
|
290
|
-
const serviceFiles = await discoverServices(servicesDir);
|
|
291
|
-
for (const svcFile of serviceFiles) {
|
|
292
|
-
try {
|
|
293
|
-
const mod = await importService(svcFile);
|
|
294
|
-
const service = (mod.default || mod.service || mod) as Record<string, unknown>;
|
|
295
|
-
if (service && typeof service.start === "function") {
|
|
296
|
-
serviceManager.register(service as unknown as FiyuuService);
|
|
297
|
-
}
|
|
298
|
-
} catch (err) {
|
|
299
|
-
console.warn(`[fiyuu] Failed to load service from ${svcFile}:`, err);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
serviceManager.setContext({
|
|
305
|
-
db: state.db,
|
|
306
|
-
realtime: state.realtime,
|
|
307
|
-
config: options.config || {},
|
|
308
|
-
log: (level, msg, data) => pushServerEvent(state, level, `service.${msg}`, data ? JSON.stringify(data) : undefined),
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
await serviceManager.startAll();
|
|
312
|
-
state.serviceNames = serviceManager.list();
|
|
313
|
-
console.log(`[fiyuu] Services started: ${state.serviceNames.length > 0 ? state.serviceNames.join(", ") : "none"}`);
|
|
314
|
-
|
|
315
|
-
console.log(renderStartupMessage(options.mode, url, port, options.port ?? 4050, websocketUrl));
|
|
316
|
-
|
|
317
|
-
return {
|
|
318
|
-
port,
|
|
319
|
-
url,
|
|
320
|
-
websocketUrl,
|
|
321
|
-
close: async () => {
|
|
322
|
-
// Stop services first
|
|
323
|
-
await serviceManager.stopAll();
|
|
324
|
-
// Shutdown realtime
|
|
325
|
-
await state.realtime.shutdown();
|
|
326
|
-
// Persist DB
|
|
327
|
-
await state.db.shutdown();
|
|
328
|
-
// Close HTTP server
|
|
329
|
-
await new Promise<void>((resolve, reject) => {
|
|
330
|
-
server.close((error) => (error ? reject(error) : resolve()));
|
|
331
|
-
});
|
|
332
|
-
},
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// ─── Runtime state ────────────────────────────────────────────────────────────
|
|
337
|
-
|
|
338
|
-
async function createRuntimeState(options: StartServerOptions): Promise<RuntimeState> {
|
|
339
|
-
const graph = await createProjectGraph(options.appDirectory);
|
|
340
|
-
const features = await scanApp(options.appDirectory);
|
|
341
|
-
await syncProjectArtifacts(options.rootDirectory, options.appDirectory);
|
|
342
|
-
const assets = await bundleClient(features, options.clientOutputDirectory);
|
|
343
|
-
const insights = await buildInsightsReport({
|
|
344
|
-
rootDirectory: options.rootDirectory,
|
|
345
|
-
appDirectory: options.appDirectory,
|
|
346
|
-
features,
|
|
347
|
-
config: options.config,
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
const db = new FiyuuDB({
|
|
351
|
-
path: options.config?.data?.path || path.join(options.rootDirectory, ".fiyuu", "data"),
|
|
352
|
-
autosave: options.config?.data?.autosave !== false,
|
|
353
|
-
autosaveIntervalMs: options.config?.data?.autosaveIntervalMs || 5000,
|
|
354
|
-
tables: options.config?.data?.tables,
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
const realtime = new FiyuuRealtime({
|
|
358
|
-
enabled: options.config?.realtime?.enabled !== false,
|
|
359
|
-
transports: options.config?.realtime?.transports || ["websocket"],
|
|
360
|
-
websocket: {
|
|
361
|
-
path: options.config?.realtime?.websocket?.path || options.config?.websocket?.path || "/__fiyuu/ws",
|
|
362
|
-
heartbeatMs: options.config?.realtime?.websocket?.heartbeatMs || options.config?.websocket?.heartbeatMs || 30000,
|
|
363
|
-
maxPayloadBytes: options.config?.realtime?.websocket?.maxPayloadBytes || options.config?.websocket?.maxPayloadBytes || 65536,
|
|
364
|
-
},
|
|
365
|
-
nats: {
|
|
366
|
-
url: options.config?.realtime?.nats?.url,
|
|
367
|
-
name: options.config?.realtime?.nats?.name,
|
|
368
|
-
},
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
return {
|
|
372
|
-
graph,
|
|
373
|
-
features,
|
|
374
|
-
routeIndex: buildRouteIndex(features),
|
|
375
|
-
assets,
|
|
376
|
-
assetsByRoute: new Map(assets.map((asset) => [asset.route, asset])),
|
|
377
|
-
insights,
|
|
378
|
-
ssgCache: new Map(),
|
|
379
|
-
queryCache: new Map(),
|
|
380
|
-
queryInflight: new Map(),
|
|
381
|
-
queryCacheLastPruneAt: Date.now(),
|
|
382
|
-
layoutStackCache: new Map(),
|
|
383
|
-
featureMetaCache: new Map(),
|
|
384
|
-
mergedMetaCache: new Map(),
|
|
385
|
-
serverEvents: [],
|
|
386
|
-
version: Date.now(),
|
|
387
|
-
warnings: features.flatMap((f) => f.warnings.map((w) => `${f.route}: ${w}`)),
|
|
388
|
-
db,
|
|
389
|
-
realtime,
|
|
390
|
-
serviceNames: [],
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// ─── Tiny internal router (/__fiyuu/* paths) ──────────────────────────────────
|
|
395
|
-
|
|
396
|
-
function createTinyInternalRouter(
|
|
397
|
-
options: StartServerOptions,
|
|
398
|
-
state: RuntimeState,
|
|
399
|
-
liveClients: Set<ServerResponse>,
|
|
400
|
-
): (context: TinyRouteContext) => Promise<boolean> {
|
|
401
|
-
const routes: TinyRoute[] = [
|
|
402
|
-
{
|
|
403
|
-
method: "GET",
|
|
404
|
-
type: "exact",
|
|
405
|
-
path: "/__fiyuu/live",
|
|
406
|
-
devOnly: true,
|
|
407
|
-
handler: ({ response }) => attachLiveReload(response, liveClients),
|
|
408
|
-
},
|
|
409
|
-
{
|
|
410
|
-
method: "GET",
|
|
411
|
-
type: "exact",
|
|
412
|
-
path: "/__fiyuu/devtools",
|
|
413
|
-
devOnly: true,
|
|
414
|
-
handler: ({ response }) =>
|
|
415
|
-
sendJson(response, 200, {
|
|
416
|
-
version: state.version,
|
|
417
|
-
warnings: state.warnings,
|
|
418
|
-
config: {
|
|
419
|
-
websocket: options.config?.websocket?.enabled ?? false,
|
|
420
|
-
middleware: options.config?.middleware?.enabled ?? true,
|
|
421
|
-
analytics: options.config?.analytics?.enabled ?? false,
|
|
422
|
-
featureFlags: options.config?.featureFlags?.defaults ?? {},
|
|
423
|
-
},
|
|
424
|
-
insights: { summary: state.insights.summary, assistant: state.insights.assistant },
|
|
425
|
-
routes: state.features.map((f) => ({ route: f.route, render: f.render })),
|
|
426
|
-
}),
|
|
427
|
-
},
|
|
428
|
-
{
|
|
429
|
-
method: "GET",
|
|
430
|
-
type: "exact",
|
|
431
|
-
path: "/__fiyuu/insights",
|
|
432
|
-
devOnly: true,
|
|
433
|
-
handler: ({ response }) => sendJson(response, 200, state.insights),
|
|
434
|
-
},
|
|
435
|
-
{
|
|
436
|
-
method: "GET",
|
|
437
|
-
type: "exact",
|
|
438
|
-
path: "/__fiyuu/runtime.js",
|
|
439
|
-
handler: ({ response }) => serveClientRuntime(response, options.config?.websocket?.path ?? "/__fiyuu/ws"),
|
|
440
|
-
},
|
|
441
|
-
{
|
|
442
|
-
method: "GET",
|
|
443
|
-
type: "exact",
|
|
444
|
-
path: "/__fiyuu/server-events",
|
|
445
|
-
devOnly: true,
|
|
446
|
-
handler: ({ response }) => sendJson(response, 200, { events: state.serverEvents }),
|
|
447
|
-
},
|
|
448
|
-
{
|
|
449
|
-
method: "GET",
|
|
450
|
-
type: "prefix",
|
|
451
|
-
path: "/__fiyuu/client/",
|
|
452
|
-
handler: async ({ response, url }) =>
|
|
453
|
-
serveClientAsset(response, path.join(options.staticClientRoot, path.basename(url.pathname))),
|
|
454
|
-
},
|
|
455
|
-
];
|
|
456
|
-
|
|
457
|
-
const exactByMethod = new Map<string, Map<string, TinyRoute>>();
|
|
458
|
-
const prefixByMethod = new Map<string, TinyRoute[]>();
|
|
459
|
-
for (const route of routes) {
|
|
460
|
-
if (route.type === "exact") {
|
|
461
|
-
const map = exactByMethod.get(route.method) ?? new Map<string, TinyRoute>();
|
|
462
|
-
map.set(route.path, route);
|
|
463
|
-
exactByMethod.set(route.method, map);
|
|
464
|
-
} else {
|
|
465
|
-
const list = prefixByMethod.get(route.method) ?? [];
|
|
466
|
-
list.push(route);
|
|
467
|
-
prefixByMethod.set(route.method, list);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
return async (context: TinyRouteContext): Promise<boolean> => {
|
|
472
|
-
const method = String(context.request.method ?? "GET").toUpperCase();
|
|
473
|
-
|
|
474
|
-
const exactRoute = exactByMethod.get(method)?.get(context.url.pathname);
|
|
475
|
-
if (exactRoute) {
|
|
476
|
-
if (exactRoute.devOnly && options.mode !== "dev") return false;
|
|
477
|
-
await exactRoute.handler(context);
|
|
478
|
-
return true;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
for (const route of prefixByMethod.get(method) ?? []) {
|
|
482
|
-
if (route.devOnly && options.mode !== "dev") continue;
|
|
483
|
-
if (!context.url.pathname.startsWith(route.path)) continue;
|
|
484
|
-
await route.handler(context);
|
|
485
|
-
return true;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
return false;
|
|
489
|
-
};
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// ─── Route handler ────────────────────────────────────────────────────────────
|
|
493
|
-
|
|
494
|
-
async function handleRoute(
|
|
495
|
-
request: IncomingMessage,
|
|
496
|
-
response: ServerResponse,
|
|
497
|
-
pathname: string,
|
|
498
|
-
state: RuntimeState,
|
|
499
|
-
appDirectory: string,
|
|
500
|
-
mode: "dev" | "start",
|
|
501
|
-
middlewareHeaders: Record<string, string>,
|
|
502
|
-
developerToolsEnabled: boolean,
|
|
503
|
-
requestId: string,
|
|
504
|
-
websocketPath: string,
|
|
505
|
-
): Promise<void> {
|
|
506
|
-
const routeMatch = matchRoute(state.routeIndex, pathname);
|
|
507
|
-
const feature = routeMatch?.feature ?? null;
|
|
508
|
-
const routeParams = routeMatch?.params ?? {};
|
|
509
|
-
|
|
510
|
-
if (!feature) {
|
|
511
|
-
const customNotFound = await tryRenderSystemPage({
|
|
512
|
-
appDirectory, mode, kind: "not-found",
|
|
513
|
-
route: pathname, method: request.method ?? "GET", requestId,
|
|
514
|
-
data: { route: pathname, method: request.method ?? "GET", title: "Page not found" },
|
|
515
|
-
metaFallback: { intent: "Fallback not found page", title: "Page not found" },
|
|
516
|
-
websocketPath,
|
|
517
|
-
});
|
|
518
|
-
if (customNotFound) {
|
|
519
|
-
response.statusCode = 404;
|
|
520
|
-
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
521
|
-
response.end(customNotFound);
|
|
522
|
-
return;
|
|
523
|
-
}
|
|
524
|
-
sendDocumentStatusPage(response, {
|
|
525
|
-
statusCode: 404, title: "Page not found",
|
|
526
|
-
summary: "This route is not defined in the application.",
|
|
527
|
-
detail: `No feature matches "${pathname}".`,
|
|
528
|
-
route: pathname, method: request.method ?? "GET", requestId,
|
|
529
|
-
hints: [
|
|
530
|
-
"Confirm the route folder exists under app/.",
|
|
531
|
-
"Check nested feature names and file-based routing paths.",
|
|
532
|
-
"If this page should exist, run `fiyuu sync` and rebuild.",
|
|
533
|
-
],
|
|
534
|
-
diagnostics: state.features.slice(0, 6).map((item) => `Available route: ${item.route}`),
|
|
535
|
-
});
|
|
536
|
-
return;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
if (request.method === "POST") {
|
|
540
|
-
await handleActionRequest(request, response, feature, mode, middlewareHeaders, requestId);
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
if (!feature.files["page.tsx"]) {
|
|
545
|
-
sendDocumentStatusPage(response, {
|
|
546
|
-
statusCode: 404, title: "Page file missing",
|
|
547
|
-
summary: "The route exists, but it does not provide a `page.tsx` entry.",
|
|
548
|
-
detail: `Feature ${feature.feature || "/"} is missing its page component.`,
|
|
549
|
-
route: pathname, method: request.method ?? "GET", requestId,
|
|
550
|
-
hints: ["Add `page.tsx` to the feature directory."],
|
|
551
|
-
diagnostics: [
|
|
552
|
-
`Feature path: ${feature.feature || "/"}`,
|
|
553
|
-
`Known files: ${Object.keys(feature.files).join(", ") || "none"}`,
|
|
554
|
-
],
|
|
555
|
-
});
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
const startedAt = performance.now();
|
|
560
|
-
|
|
561
|
-
const ifNoneMatch = request.headers["if-none-match"];
|
|
562
|
-
|
|
563
|
-
// SSG cache hit in production
|
|
564
|
-
if (feature.render === "ssg" && mode === "start") {
|
|
565
|
-
const cached = state.ssgCache.get(feature.route);
|
|
566
|
-
if (cached && (cached.expiresAt === null || cached.expiresAt > Date.now())) {
|
|
567
|
-
if (matchesIfNoneMatch(ifNoneMatch, cached.etag)) {
|
|
568
|
-
response.statusCode = 304;
|
|
569
|
-
response.setHeader("etag", cached.etag);
|
|
570
|
-
if (requestId) response.setHeader("x-fiyuu-request-id", requestId);
|
|
571
|
-
for (const [key, value] of Object.entries(middlewareHeaders)) response.setHeader(key, value);
|
|
572
|
-
response.end();
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
response.statusCode = 200;
|
|
576
|
-
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
577
|
-
response.setHeader("etag", cached.etag);
|
|
578
|
-
response.setHeader("cache-control", buildSsgCacheControl(cached.revalidateSeconds));
|
|
579
|
-
response.setHeader("x-fiyuu-cache", "ssg-fresh");
|
|
580
|
-
if (requestId) response.setHeader("x-fiyuu-request-id", requestId);
|
|
581
|
-
for (const [key, value] of Object.entries(middlewareHeaders)) response.setHeader(key, value);
|
|
582
|
-
response.end(cached.html);
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
const pageModule = (await importModule(feature.files["page.tsx"]!, mode)) as ModuleShape;
|
|
588
|
-
const queryModule = feature.files["query.ts"]
|
|
589
|
-
? ((await importModule(feature.files["query.ts"]!, mode)) as ModuleShape)
|
|
590
|
-
: null;
|
|
591
|
-
const asset = state.assetsByRoute.get(feature.route);
|
|
592
|
-
const Page = pageModule.default;
|
|
593
|
-
|
|
594
|
-
if (!Page) {
|
|
595
|
-
sendDocumentStatusPage(response, {
|
|
596
|
-
statusCode: 500, title: "Invalid page module",
|
|
597
|
-
summary: "The route loaded successfully, but its page module has no default export.",
|
|
598
|
-
detail: `Expected a default Gea component in ${feature.files["page.tsx"]}.`,
|
|
599
|
-
route: pathname, method: request.method ?? "GET", requestId,
|
|
600
|
-
hints: ["Export a default Gea component from `page.tsx`."],
|
|
601
|
-
});
|
|
602
|
-
return;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
// Query execution with optional caching
|
|
606
|
-
let data: unknown = null;
|
|
607
|
-
if (queryModule?.execute) {
|
|
608
|
-
const cacheConfig = queryModule.cache;
|
|
609
|
-
if (cacheConfig?.ttl && cacheConfig.ttl > 0) {
|
|
610
|
-
const now = Date.now();
|
|
611
|
-
pruneQueryCache(state, now);
|
|
612
|
-
const requestUrl = new URL(request.url ?? "/", "http://localhost");
|
|
613
|
-
const varyValues = (cacheConfig.vary ?? []).map(
|
|
614
|
-
(key) => requestUrl.searchParams.get(key) ?? "",
|
|
615
|
-
);
|
|
616
|
-
const cacheKey = `${feature.route}:${JSON.stringify(routeParams)}:${varyValues.join(",")}`;
|
|
617
|
-
const cached = state.queryCache.get(cacheKey);
|
|
618
|
-
if (cached && cached.expiresAt > now) {
|
|
619
|
-
data = cached.data;
|
|
620
|
-
pushServerEvent(state, "info", "query.cache-hit", `${pathname} (ttl=${cacheConfig.ttl}s)`);
|
|
621
|
-
} else {
|
|
622
|
-
const pending = state.queryInflight.get(cacheKey);
|
|
623
|
-
if (pending) {
|
|
624
|
-
data = await pending;
|
|
625
|
-
} else {
|
|
626
|
-
const execution = Promise.resolve(
|
|
627
|
-
queryModule.execute({ request, route: pathname, feature, params: routeParams }),
|
|
628
|
-
);
|
|
629
|
-
state.queryInflight.set(cacheKey, execution);
|
|
630
|
-
try {
|
|
631
|
-
data = await execution;
|
|
632
|
-
state.queryCache.set(cacheKey, { data, expiresAt: now + cacheConfig.ttl * 1000 });
|
|
633
|
-
} finally {
|
|
634
|
-
state.queryInflight.delete(cacheKey);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
} else {
|
|
639
|
-
data = await queryModule.execute({ request, route: pathname, feature, params: routeParams });
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
const intent = feature.intent ?? feature.pageIntent ?? pageModule.page?.intent ?? "";
|
|
644
|
-
const render = feature.render;
|
|
645
|
-
|
|
646
|
-
const layoutStack =
|
|
647
|
-
mode === "start"
|
|
648
|
-
? await getCachedLayoutStack(state, appDirectory, feature, mode)
|
|
649
|
-
: await loadLayoutStack(appDirectory, feature, mode);
|
|
650
|
-
|
|
651
|
-
const mergedMeta =
|
|
652
|
-
mode === "start"
|
|
653
|
-
? await getCachedMergedMeta(state, feature, layoutStack, mode)
|
|
654
|
-
: mergeMetaDefinitions(...layoutStack.map((item) => item.meta), await loadFeatureMeta(feature, mode));
|
|
655
|
-
|
|
656
|
-
// SSR body
|
|
657
|
-
let body = "";
|
|
658
|
-
if (render === "ssr") {
|
|
659
|
-
const pageBody = renderGeaComponent(Page, { data, route: pathname, intent, render, params: routeParams });
|
|
660
|
-
body = layoutStack.reduceRight<string>(
|
|
661
|
-
(children, layout) => renderGeaComponent(layout.component, { route: pathname, children }),
|
|
662
|
-
pageBody,
|
|
663
|
-
);
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
const renderTimeMs = Number((performance.now() - startedAt).toFixed(2));
|
|
667
|
-
|
|
668
|
-
// SPA navigation: return JSON instead of full document
|
|
669
|
-
if (request.headers["x-fiyuu-navigate"] === "1") {
|
|
670
|
-
const navPayload = JSON.stringify({
|
|
671
|
-
body, title: mergedMeta.seo?.title ?? mergedMeta.title ?? "Fiyuu",
|
|
672
|
-
description: mergedMeta.seo?.description ?? intent ?? "",
|
|
673
|
-
route: pathname, render, data,
|
|
674
|
-
clientPath: asset?.publicPath ?? null,
|
|
675
|
-
});
|
|
676
|
-
const navEtag = createWeakEtag(navPayload);
|
|
677
|
-
if (matchesIfNoneMatch(ifNoneMatch, navEtag)) {
|
|
678
|
-
response.statusCode = 304;
|
|
679
|
-
response.setHeader("etag", navEtag);
|
|
680
|
-
response.setHeader("x-fiyuu-navigate", "1");
|
|
681
|
-
if (requestId) response.setHeader("x-fiyuu-request-id", requestId);
|
|
682
|
-
for (const [key, value] of Object.entries(middlewareHeaders)) response.setHeader(key, value);
|
|
683
|
-
response.end();
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
response.statusCode = 200;
|
|
687
|
-
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
688
|
-
response.setHeader("x-fiyuu-navigate", "1");
|
|
689
|
-
response.setHeader("etag", navEtag);
|
|
690
|
-
response.setHeader("cache-control", "private, max-age=0, must-revalidate");
|
|
691
|
-
response.setHeader("server-timing", `render;dur=${renderTimeMs}`);
|
|
692
|
-
if (requestId) response.setHeader("x-fiyuu-request-id", requestId);
|
|
693
|
-
for (const [key, value] of Object.entries(middlewareHeaders)) response.setHeader(key, value);
|
|
694
|
-
response.end(`${navPayload}\n`);
|
|
695
|
-
pushServerEvent(state, "info", "request.navigate", `${pathname} (${render.toUpperCase()}) ${renderTimeMs}ms`);
|
|
696
|
-
return;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
const html = renderDocument({
|
|
700
|
-
body, data, route: pathname, intent, render,
|
|
701
|
-
clientPath: asset?.publicPath ?? null,
|
|
702
|
-
liveReload: mode === "dev",
|
|
703
|
-
warnings: feature.warnings,
|
|
704
|
-
renderTimeMs,
|
|
705
|
-
developerTools: developerToolsEnabled,
|
|
706
|
-
requestId, meta: mergedMeta, websocketPath,
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
response.statusCode = 200;
|
|
710
|
-
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
711
|
-
const htmlEtag = createWeakEtag(html);
|
|
712
|
-
if (matchesIfNoneMatch(ifNoneMatch, htmlEtag)) {
|
|
713
|
-
response.statusCode = 304;
|
|
714
|
-
response.setHeader("etag", htmlEtag);
|
|
715
|
-
if (requestId) response.setHeader("x-fiyuu-request-id", requestId);
|
|
716
|
-
for (const [key, value] of Object.entries(middlewareHeaders)) response.setHeader(key, value);
|
|
717
|
-
response.end();
|
|
718
|
-
return;
|
|
719
|
-
}
|
|
720
|
-
response.setHeader("etag", htmlEtag);
|
|
721
|
-
response.setHeader("server-timing", `render;dur=${renderTimeMs}`);
|
|
722
|
-
for (const [key, value] of Object.entries(middlewareHeaders)) response.setHeader(key, value);
|
|
723
|
-
if (requestId) response.setHeader("x-fiyuu-request-id", requestId);
|
|
724
|
-
|
|
725
|
-
if (render === "ssg" && mode === "start") {
|
|
726
|
-
const revalidateSeconds = normalizeRevalidateSeconds(mergedMeta.revalidate);
|
|
727
|
-
response.setHeader("cache-control", buildSsgCacheControl(revalidateSeconds));
|
|
728
|
-
state.ssgCache.set(feature.route, {
|
|
729
|
-
html,
|
|
730
|
-
etag: htmlEtag,
|
|
731
|
-
revalidateSeconds,
|
|
732
|
-
expiresAt: revalidateSeconds === null ? null : Date.now() + revalidateSeconds * 1000,
|
|
733
|
-
});
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
response.end(html);
|
|
737
|
-
pushServerEvent(state, "info", "request.page", `${pathname} (${render.toUpperCase()}) ${renderTimeMs}ms`);
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// ─── System page renderer (not-found / error) ─────────────────────────────────
|
|
741
|
-
|
|
742
|
-
async function tryRenderSystemPage(input: {
|
|
743
|
-
appDirectory: string;
|
|
744
|
-
mode: "dev" | "start";
|
|
745
|
-
kind: "not-found" | "error";
|
|
746
|
-
route: string;
|
|
747
|
-
method: string;
|
|
748
|
-
requestId: string;
|
|
749
|
-
data: unknown;
|
|
750
|
-
metaFallback: MetaDefinition;
|
|
751
|
-
websocketPath: string;
|
|
752
|
-
}): Promise<string | null> {
|
|
753
|
-
const filePath = path.join(
|
|
754
|
-
input.appDirectory,
|
|
755
|
-
input.kind === "not-found" ? "not-found.tsx" : "error.tsx",
|
|
756
|
-
);
|
|
757
|
-
if (!existsSync(filePath)) return null;
|
|
758
|
-
|
|
759
|
-
const module = (await importModule(filePath, input.mode)) as ModuleShape;
|
|
760
|
-
if (!module.default) return null;
|
|
761
|
-
|
|
762
|
-
let body = renderGeaComponent(module.default, {
|
|
763
|
-
data: input.data,
|
|
764
|
-
route: input.route,
|
|
765
|
-
intent: input.metaFallback.intent ?? "",
|
|
766
|
-
render: "ssr",
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
const rootLayoutPath = path.join(input.appDirectory, "layout.tsx");
|
|
770
|
-
if (existsSync(rootLayoutPath)) {
|
|
771
|
-
const rootLayoutModule = (await importModule(rootLayoutPath, input.mode)) as LayoutModule;
|
|
772
|
-
if (rootLayoutModule.default) {
|
|
773
|
-
body = renderGeaComponent(rootLayoutModule.default, { route: input.route, children: body });
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
const rootMeta = await loadLayoutMeta(input.appDirectory, input.mode);
|
|
778
|
-
return renderDocument({
|
|
779
|
-
body, data: input.data,
|
|
780
|
-
route: input.route,
|
|
781
|
-
intent: input.metaFallback.intent ?? "",
|
|
782
|
-
render: "ssr",
|
|
783
|
-
clientPath: null,
|
|
784
|
-
liveReload: input.mode === "dev",
|
|
785
|
-
warnings: [], renderTimeMs: 0,
|
|
786
|
-
developerTools: false,
|
|
787
|
-
requestId: input.requestId,
|
|
788
|
-
meta: mergeMetaDefinitions(rootMeta, input.metaFallback),
|
|
789
|
-
websocketPath: input.websocketPath,
|
|
790
|
-
});
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
// ─── Action request handler ───────────────────────────────────────────────────
|
|
794
|
-
|
|
795
|
-
async function handleActionRequest(
|
|
796
|
-
request: IncomingMessage,
|
|
797
|
-
response: ServerResponse,
|
|
798
|
-
feature: FeatureRecord,
|
|
799
|
-
mode: "dev" | "start",
|
|
800
|
-
middlewareHeaders: Record<string, string>,
|
|
801
|
-
requestId: string,
|
|
802
|
-
): Promise<void> {
|
|
803
|
-
if (!feature.files["action.ts"]) {
|
|
804
|
-
sendText(response, 405, `No action available for ${feature.route}`);
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
const actionModule = (await importModule(feature.files["action.ts"]!, mode)) as ModuleShape;
|
|
809
|
-
if (!actionModule.execute) {
|
|
810
|
-
sendText(response, 500, `Missing execute export in ${feature.files["action.ts"]}`);
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
const input = await parseRequestBody(request);
|
|
815
|
-
const context = { request, route: feature.route, feature, input };
|
|
816
|
-
let result: unknown;
|
|
817
|
-
|
|
818
|
-
try {
|
|
819
|
-
result = await actionModule.execute(context);
|
|
820
|
-
} catch (error) {
|
|
821
|
-
if (error instanceof TypeError) {
|
|
822
|
-
result = await actionModule.execute(input);
|
|
823
|
-
} else {
|
|
824
|
-
throw error;
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
response.statusCode = 200;
|
|
829
|
-
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
830
|
-
if (requestId) response.setHeader("x-fiyuu-request-id", requestId);
|
|
831
|
-
for (const [key, value] of Object.entries(middlewareHeaders)) response.setHeader(key, value);
|
|
832
|
-
response.end(`${JSON.stringify(result ?? null)}\n`);
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
// ─── Sitemap & Robots handlers ────────────────────────────────────────────────
|
|
836
|
-
|
|
837
|
-
async function handleSitemap(
|
|
838
|
-
request: IncomingMessage,
|
|
839
|
-
response: ServerResponse,
|
|
840
|
-
options: StartServerOptions,
|
|
841
|
-
state: RuntimeState,
|
|
842
|
-
url: URL,
|
|
843
|
-
): Promise<void> {
|
|
844
|
-
if (request.method !== "GET") {
|
|
845
|
-
sendText(response, 405, "Method not allowed");
|
|
846
|
-
return;
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
const baseUrl = options.config?.seo?.baseUrl;
|
|
850
|
-
if (!baseUrl) {
|
|
851
|
-
sendText(response, 404, "Sitemap not configured. Set seo.baseUrl in fiyuu.config.ts");
|
|
852
|
-
return;
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
const cleanBaseUrl = baseUrl.replace(/\/$/, "");
|
|
856
|
-
const features = state.features.filter((f) => f.files["page.tsx"] && !f.isDynamic);
|
|
857
|
-
|
|
858
|
-
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n`;
|
|
859
|
-
xml += `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n`;
|
|
860
|
-
|
|
861
|
-
for (const feature of features) {
|
|
862
|
-
const loc = `${cleanBaseUrl}${feature.route}`;
|
|
863
|
-
xml += ` <url>\n`;
|
|
864
|
-
xml += ` <loc>${escapeXml(loc)}</loc>\n`;
|
|
865
|
-
xml += ` <changefreq>weekly</changefreq>\n`;
|
|
866
|
-
xml += ` <priority>${feature.route === "/" ? "1.0" : "0.8"}</priority>\n`;
|
|
867
|
-
xml += ` </url>\n`;
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
xml += `</urlset>\n`;
|
|
871
|
-
|
|
872
|
-
sendXml(response, 200, xml);
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
function escapeXml(value: string): string {
|
|
876
|
-
return value
|
|
877
|
-
.replaceAll("&", "&")
|
|
878
|
-
.replaceAll("<", "<")
|
|
879
|
-
.replaceAll(">", ">")
|
|
880
|
-
.replaceAll('"', """)
|
|
881
|
-
.replaceAll("'", "'");
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
async function handleRobots(
|
|
885
|
-
request: IncomingMessage,
|
|
886
|
-
response: ServerResponse,
|
|
887
|
-
options: StartServerOptions,
|
|
888
|
-
): Promise<void> {
|
|
889
|
-
if (request.method !== "GET") {
|
|
890
|
-
sendText(response, 405, "Method not allowed");
|
|
891
|
-
return;
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
const baseUrl = options.config?.seo?.baseUrl;
|
|
895
|
-
const sitemapEnabled = options.config?.seo?.sitemap === true;
|
|
896
|
-
const sitemapUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/sitemap.xml` : null;
|
|
897
|
-
|
|
898
|
-
let text = `User-agent: *\nAllow: /\n`;
|
|
899
|
-
if (sitemapUrl && sitemapEnabled) {
|
|
900
|
-
text += `Sitemap: ${sitemapUrl}\n`;
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
response.statusCode = 200;
|
|
904
|
-
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
905
|
-
response.end(text);
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
// ─── API route handler ────────────────────────────────────────────────────────
|
|
909
|
-
|
|
910
|
-
async function handleApiRoute(
|
|
911
|
-
request: IncomingMessage,
|
|
912
|
-
response: ServerResponse,
|
|
913
|
-
options: StartServerOptions,
|
|
914
|
-
pathname: string,
|
|
915
|
-
middlewareHeaders: Record<string, string>,
|
|
916
|
-
requestId: string,
|
|
917
|
-
mode: "dev" | "start",
|
|
918
|
-
): Promise<void> {
|
|
919
|
-
const modulePath = resolveApiRouteModule(options.appDirectory, pathname);
|
|
920
|
-
if (!modulePath) {
|
|
921
|
-
sendText(response, 404, `No API route found for ${pathname}`);
|
|
922
|
-
return;
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
const routeModule = (await importModule(modulePath, mode)) as ApiRouteModule;
|
|
926
|
-
const method = (request.method ?? "GET").toUpperCase() as keyof ApiRouteModule;
|
|
927
|
-
const handler = routeModule[method];
|
|
928
|
-
if (!handler) {
|
|
929
|
-
sendText(response, 405, `Method ${method} is not supported for ${pathname}`);
|
|
930
|
-
return;
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
const input = await parseRequestBody(request);
|
|
934
|
-
const result = await handler({ request, route: pathname, feature: null as any, input });
|
|
935
|
-
response.statusCode = 200;
|
|
936
|
-
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
937
|
-
if (requestId) response.setHeader("x-fiyuu-request-id", requestId);
|
|
938
|
-
for (const [key, value] of Object.entries(middlewareHeaders)) response.setHeader(key, value);
|
|
939
|
-
response.end(`${JSON.stringify(result ?? null)}\n`);
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
// ─── Runtime error response ───────────────────────────────────────────────────
|
|
943
|
-
|
|
944
|
-
async function sendRuntimeError(
|
|
945
|
-
response: ServerResponse,
|
|
946
|
-
error: unknown,
|
|
947
|
-
options: StartServerOptions,
|
|
948
|
-
request?: IncomingMessage,
|
|
949
|
-
): Promise<void> {
|
|
950
|
-
const mode = options.mode;
|
|
951
|
-
const message = error instanceof Error ? error.message : "Unknown runtime error";
|
|
952
|
-
const diagnostics: string[] = [];
|
|
953
|
-
|
|
954
|
-
if (request && !prefersHtmlResponse(request)) {
|
|
955
|
-
sendJson(response, 500, {
|
|
956
|
-
error: {
|
|
957
|
-
message,
|
|
958
|
-
requestId: String(response.getHeader("x-fiyuu-request-id") ?? ""),
|
|
959
|
-
...(mode === "dev" && error instanceof Error && error.stack ? { stack: error.stack } : {}),
|
|
960
|
-
},
|
|
961
|
-
});
|
|
962
|
-
return;
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
const customErrorPage = await tryRenderSystemPage({
|
|
966
|
-
appDirectory: options.appDirectory, mode, kind: "error",
|
|
967
|
-
route: request?.url ?? "/", method: request?.method ?? "GET",
|
|
968
|
-
requestId: String(response.getHeader("x-fiyuu-request-id") ?? ""),
|
|
969
|
-
data: {
|
|
970
|
-
message, route: request?.url ?? "/", method: request?.method ?? "GET",
|
|
971
|
-
stack: mode === "dev" && error instanceof Error ? error.stack ?? "" : "",
|
|
972
|
-
},
|
|
973
|
-
metaFallback: {
|
|
974
|
-
intent: "Fallback error page",
|
|
975
|
-
title: mode === "dev" ? "Runtime error" : "Application error",
|
|
976
|
-
},
|
|
977
|
-
websocketPath: options.config?.websocket?.path ?? "/__fiyuu/ws",
|
|
978
|
-
});
|
|
979
|
-
|
|
980
|
-
if (customErrorPage) {
|
|
981
|
-
response.statusCode = 500;
|
|
982
|
-
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
983
|
-
response.end(customErrorPage);
|
|
984
|
-
return;
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
if (error instanceof Error && mode === "dev" && error.stack) {
|
|
988
|
-
diagnostics.push(error.stack);
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
sendDocumentStatusPage(response, {
|
|
992
|
-
statusCode: 500,
|
|
993
|
-
title: mode === "dev" ? "Runtime error" : "Application error",
|
|
994
|
-
summary: mode === "dev"
|
|
995
|
-
? "Fiyuu caught an exception while rendering this request."
|
|
996
|
-
: "Something went wrong while processing this request.",
|
|
997
|
-
detail: message,
|
|
998
|
-
route: request?.url,
|
|
999
|
-
method: request?.method ?? "GET",
|
|
1000
|
-
requestId: String(response.getHeader("x-fiyuu-request-id") ?? ""),
|
|
1001
|
-
hints: mode === "dev"
|
|
1002
|
-
? [
|
|
1003
|
-
"Inspect the stack trace and the route module shown below.",
|
|
1004
|
-
"Check page/query/action exports for missing or invalid values.",
|
|
1005
|
-
]
|
|
1006
|
-
: [
|
|
1007
|
-
"Review server logs for the matching request identifier.",
|
|
1008
|
-
"Retry the request after verifying the latest deployment completed successfully.",
|
|
1009
|
-
],
|
|
1010
|
-
diagnostics,
|
|
1011
|
-
});
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
// ─── TCP listener helpers ─────────────────────────────────────────────────────
|
|
1015
|
-
|
|
1016
|
-
async function listenWithFallback(
|
|
1017
|
-
server: ReturnType<typeof createServer>,
|
|
1018
|
-
preferredPort: number,
|
|
1019
|
-
maxPort: number,
|
|
1020
|
-
): Promise<number> {
|
|
1021
|
-
for (let port = preferredPort; port <= maxPort; port += 1) {
|
|
1022
|
-
try {
|
|
1023
|
-
await listen(server, port);
|
|
1024
|
-
const address = server.address() as AddressInfo | null;
|
|
1025
|
-
return address?.port ?? port;
|
|
1026
|
-
} catch (error) {
|
|
1027
|
-
if (!isAddressInUseError(error) || port === maxPort) throw error;
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
throw new Error(`No available port found between ${preferredPort} and ${maxPort}.`);
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
async function listen(server: ReturnType<typeof createServer>, port: number): Promise<void> {
|
|
1034
|
-
await new Promise<void>((resolve, reject) => {
|
|
1035
|
-
const onError = (error: Error) => { server.off("listening", onListening); reject(error); };
|
|
1036
|
-
const onListening = () => { server.off("error", onError); resolve(); };
|
|
1037
|
-
server.once("error", onError);
|
|
1038
|
-
server.once("listening", onListening);
|
|
1039
|
-
server.listen(port);
|
|
1040
|
-
});
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
function isAddressInUseError(error: unknown): error is NodeJS.ErrnoException {
|
|
1044
|
-
return Boolean(error && typeof error === "object" && "code" in error && error.code === "EADDRINUSE");
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
function normalizeRevalidateSeconds(value: unknown): number | null {
|
|
1048
|
-
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
1049
|
-
if (value <= 0) return null;
|
|
1050
|
-
return Math.floor(value);
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
function buildSsgCacheControl(revalidateSeconds: number | null): string {
|
|
1054
|
-
if (revalidateSeconds === null) {
|
|
1055
|
-
return "public, max-age=300, stale-while-revalidate=300";
|
|
1056
|
-
}
|
|
1057
|
-
return `public, max-age=${revalidateSeconds}, stale-while-revalidate=${revalidateSeconds}`;
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
function matchesIfNoneMatch(header: string | string[] | undefined, etag: string): boolean {
|
|
1061
|
-
if (!header) return false;
|
|
1062
|
-
const raw = Array.isArray(header) ? header.join(",") : header;
|
|
1063
|
-
return raw
|
|
1064
|
-
.split(",")
|
|
1065
|
-
.map((part) => part.trim())
|
|
1066
|
-
.some((candidate) => candidate === "*" || candidate === etag);
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
// ─── Service discovery helpers ──────────────────────────────────────────────────
|
|
1070
|
-
|
|
1071
|
-
async function discoverServices(directory: string): Promise<string[]> {
|
|
1072
|
-
const { promises: fs } = await import("node:fs");
|
|
1073
|
-
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
1074
|
-
const files: string[] = [];
|
|
1075
|
-
|
|
1076
|
-
for (const entry of entries) {
|
|
1077
|
-
if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
|
|
1078
|
-
if (entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
|
|
1079
|
-
files.push(path.join(directory, entry.name));
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
return files;
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
async function importService(filePath: string): Promise<Record<string, unknown>> {
|
|
1087
|
-
const fileUrl = pathToFileURL(filePath).href;
|
|
1088
|
-
return import(`${fileUrl}?t=${Date.now()}`);
|
|
1089
|
-
}
|