@fiyuu/runtime 0.1.0 → 0.1.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/package.json +5 -5
- package/src/bundler.ts +151 -0
- package/src/cli.ts +32 -0
- package/src/{client-runtime.js → client-runtime.ts} +3 -2
- package/src/inspector.ts +329 -0
- package/src/{server-devtools.js → server-devtools.ts} +22 -11
- package/src/server-loader.ts +213 -0
- package/src/server-middleware.ts +71 -0
- package/src/server-renderer.ts +260 -0
- package/src/server-router.ts +77 -0
- package/src/server-types.ts +198 -0
- package/src/server-utils.ts +137 -0
- package/src/server-websocket.ts +71 -0
- package/src/server.ts +1089 -0
- package/src/service.ts +97 -0
- package/LICENSE +0 -674
- package/README.md +0 -194
- package/src/bundler.d.ts +0 -9
- package/src/bundler.js +0 -124
- package/src/cli.d.ts +0 -2
- package/src/cli.js +0 -25
- package/src/client-runtime.d.ts +0 -15
- package/src/index.js +0 -4
- package/src/inspector.d.ts +0 -38
- package/src/inspector.js +0 -261
- package/src/server-devtools.d.ts +0 -13
- package/src/server-loader.d.ts +0 -26
- package/src/server-loader.js +0 -158
- package/src/server-middleware.d.ts +0 -7
- package/src/server-middleware.js +0 -49
- package/src/server-renderer.d.ts +0 -34
- package/src/server-renderer.js +0 -213
- package/src/server-router.d.ts +0 -14
- package/src/server-router.js +0 -67
- package/src/server-types.d.ts +0 -167
- package/src/server-types.js +0 -5
- package/src/server-utils.d.ts +0 -15
- package/src/server-utils.js +0 -97
- package/src/server-websocket.d.ts +0 -7
- package/src/server-websocket.js +0 -55
- package/src/server.d.ts +0 -68
- package/src/server.js +0 -779
- package/src/service.d.ts +0 -28
- package/src/service.js +0 -71
- /package/src/{index.d.ts → index.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiyuu/runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"main": "./src/index.
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
6
|
"exports": {
|
|
7
|
-
".": "./src/index.
|
|
8
|
-
"./cli": "./src/cli.
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./cli": "./src/cli.ts"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@geajs/core": "^1.0.12",
|
|
12
|
-
"@fiyuu/core": "0.1.
|
|
12
|
+
"@fiyuu/core": "0.1.1",
|
|
13
13
|
"@fiyuu/db": "0.3.0",
|
|
14
14
|
"@fiyuu/realtime": "0.3.0",
|
|
15
15
|
"chokidar": "^4.0.3",
|
package/src/bundler.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { promises as fs, existsSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { build } from "esbuild";
|
|
5
|
+
import type { FeatureRecord, RenderMode } from "@fiyuu/core";
|
|
6
|
+
|
|
7
|
+
export interface ClientAsset {
|
|
8
|
+
route: string;
|
|
9
|
+
feature: string;
|
|
10
|
+
render: RenderMode;
|
|
11
|
+
bundleFile: string;
|
|
12
|
+
publicPath: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const buildCache = new Map<string, { signature: string; asset: ClientAsset }>();
|
|
16
|
+
|
|
17
|
+
export async function bundleClient(features: FeatureRecord[], outputDirectory: string): Promise<ClientAsset[]> {
|
|
18
|
+
await fs.mkdir(outputDirectory, { recursive: true });
|
|
19
|
+
|
|
20
|
+
const pageFeatures = features.filter((feature) => feature.files["page.tsx"] && feature.render === "csr");
|
|
21
|
+
const assets = await Promise.all(
|
|
22
|
+
pageFeatures.map(async (feature) => {
|
|
23
|
+
const safeFeatureName = feature.feature.length > 0 ? feature.feature.replaceAll("/", "_") : "home";
|
|
24
|
+
const pageFile = feature.files["page.tsx"]!;
|
|
25
|
+
const layoutFiles = resolveLayoutFiles(feature, pageFile);
|
|
26
|
+
const signature = await createBuildSignature([pageFile, ...layoutFiles]);
|
|
27
|
+
const signatureHash = createHash("sha1").update(signature).digest("hex").slice(0, 10);
|
|
28
|
+
const bundleName = `${safeFeatureName}.${signatureHash}.js`;
|
|
29
|
+
const bundleFile = path.join(outputDirectory, bundleName);
|
|
30
|
+
const cacheKey = feature.route;
|
|
31
|
+
const publicPath = `/__fiyuu/client/${bundleName}`;
|
|
32
|
+
const cached = buildCache.get(cacheKey);
|
|
33
|
+
|
|
34
|
+
if (cached && cached.signature === signature && existsSync(cached.asset.bundleFile)) {
|
|
35
|
+
return {
|
|
36
|
+
...cached.asset,
|
|
37
|
+
render: feature.render,
|
|
38
|
+
} satisfies ClientAsset;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await build({
|
|
42
|
+
stdin: {
|
|
43
|
+
contents: createClientEntry(pageFile, layoutFiles),
|
|
44
|
+
resolveDir: path.dirname(pageFile),
|
|
45
|
+
sourcefile: `${feature.feature}-client.tsx`,
|
|
46
|
+
loader: "tsx",
|
|
47
|
+
},
|
|
48
|
+
bundle: true,
|
|
49
|
+
format: "esm",
|
|
50
|
+
jsx: "automatic",
|
|
51
|
+
jsxImportSource: "@geajs/core",
|
|
52
|
+
minify: true,
|
|
53
|
+
outfile: bundleFile,
|
|
54
|
+
platform: "browser",
|
|
55
|
+
sourcemap: false,
|
|
56
|
+
target: ["es2022"],
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const asset = {
|
|
60
|
+
route: feature.route,
|
|
61
|
+
feature: feature.feature,
|
|
62
|
+
render: feature.render,
|
|
63
|
+
bundleFile,
|
|
64
|
+
publicPath,
|
|
65
|
+
} satisfies ClientAsset;
|
|
66
|
+
|
|
67
|
+
if (cached && cached.asset.bundleFile !== asset.bundleFile && existsSync(cached.asset.bundleFile)) {
|
|
68
|
+
try {
|
|
69
|
+
await fs.unlink(cached.asset.bundleFile);
|
|
70
|
+
} catch {
|
|
71
|
+
// ignore stale artifact cleanup failures
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
buildCache.set(cacheKey, { signature, asset });
|
|
76
|
+
return asset;
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return assets;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function createBuildSignature(filePaths: string[]): Promise<string> {
|
|
84
|
+
const signatures = await Promise.all(
|
|
85
|
+
filePaths.map(async (filePath) => {
|
|
86
|
+
const stats = await fs.stat(filePath);
|
|
87
|
+
return `${filePath}:${stats.size}:${Math.floor(stats.mtimeMs)}`;
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return signatures.join("|");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function createClientEntry(pageFile: string, layoutFiles: string[]): string {
|
|
95
|
+
const layoutImports = layoutFiles
|
|
96
|
+
.map((layoutFile, index) => `import * as LayoutModule${index} from ${JSON.stringify(layoutFile)};`)
|
|
97
|
+
.join("\n");
|
|
98
|
+
const layoutWrappers = layoutFiles
|
|
99
|
+
.map((_, index) => `const Layout${index} = LayoutModule${index}.default; if (Layout${index}) { const wrapped = new Layout${index}({ route, children: String(component) }); component = wrapped; }`)
|
|
100
|
+
.reverse()
|
|
101
|
+
.join("\n ");
|
|
102
|
+
|
|
103
|
+
return `
|
|
104
|
+
import { Component } from "@geajs/core";
|
|
105
|
+
import Page from ${JSON.stringify(pageFile)};
|
|
106
|
+
${layoutImports}
|
|
107
|
+
|
|
108
|
+
const data = window.__FIYUU_DATA__ ?? null;
|
|
109
|
+
const route = window.__FIYUU_ROUTE__ ?? "/";
|
|
110
|
+
const intent = window.__FIYUU_INTENT__ ?? "";
|
|
111
|
+
const render = window.__FIYUU_RENDER__ ?? "csr";
|
|
112
|
+
const rootElement = document.getElementById("app");
|
|
113
|
+
const pageProps = { data, route, intent, render };
|
|
114
|
+
if (!(Page && Page.prototype instanceof Component)) {
|
|
115
|
+
throw new Error("Fiyuu Gea mode expects page default export to extend @geajs/core Component.");
|
|
116
|
+
}
|
|
117
|
+
let component = new Page(pageProps);
|
|
118
|
+
${layoutWrappers}
|
|
119
|
+
|
|
120
|
+
if (rootElement) {
|
|
121
|
+
rootElement.innerHTML = "";
|
|
122
|
+
component.render(rootElement);
|
|
123
|
+
|
|
124
|
+
// Re-execute any <script> tags injected by the component.
|
|
125
|
+
// innerHTML assignment does not execute scripts — this is a browser security rule.
|
|
126
|
+
// We collect all script tags and recreate them so they run normally.
|
|
127
|
+
const injectedScripts = rootElement.querySelectorAll("script");
|
|
128
|
+
for (const oldScript of injectedScripts) {
|
|
129
|
+
const newScript = document.createElement("script");
|
|
130
|
+
for (const attr of oldScript.attributes) {
|
|
131
|
+
newScript.setAttribute(attr.name, attr.value);
|
|
132
|
+
}
|
|
133
|
+
newScript.textContent = oldScript.textContent;
|
|
134
|
+
oldScript.parentNode?.replaceChild(newScript, oldScript);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function resolveLayoutFiles(feature: FeatureRecord, pageFile: string): string[] {
|
|
141
|
+
const featureParts = feature.feature ? feature.feature.split("/") : [];
|
|
142
|
+
const featureDirectory = path.dirname(pageFile);
|
|
143
|
+
const appDirectory = featureParts.length > 0
|
|
144
|
+
? path.resolve(featureDirectory, ...Array(featureParts.length).fill(".."))
|
|
145
|
+
: featureDirectory;
|
|
146
|
+
const directories = [appDirectory, ...featureParts.map((_, index) => path.join(appDirectory, ...featureParts.slice(0, index + 1)))];
|
|
147
|
+
|
|
148
|
+
return directories
|
|
149
|
+
.map((directory) => path.join(directory, "layout.tsx"))
|
|
150
|
+
.filter((layoutPath) => existsSync(layoutPath));
|
|
151
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { scanApp } from "@fiyuu/core";
|
|
6
|
+
import { bundleClient } from "./bundler.js";
|
|
7
|
+
|
|
8
|
+
async function main(): Promise<void> {
|
|
9
|
+
const [, , command, rootDirectory] = process.argv;
|
|
10
|
+
|
|
11
|
+
if (command !== "bundle" || !rootDirectory) {
|
|
12
|
+
throw new Error("Usage: runtime bundle <rootDirectory>");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const appDirectory = resolveAppDirectory(rootDirectory);
|
|
16
|
+
const features = await scanApp(appDirectory);
|
|
17
|
+
const outputDirectory = path.join(rootDirectory, ".fiyuu", "client");
|
|
18
|
+
await bundleClient(features, outputDirectory);
|
|
19
|
+
console.log(`Bundled client assets to ${outputDirectory}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveAppDirectory(rootDirectory: string): string {
|
|
23
|
+
const rootAppDirectory = path.join(rootDirectory, "app");
|
|
24
|
+
const exampleAppDirectory = path.join(rootDirectory, "examples", "basic-app", "app");
|
|
25
|
+
|
|
26
|
+
return existsSync(rootAppDirectory) ? rootAppDirectory : exampleAppDirectory;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
main().catch((error: unknown) => {
|
|
30
|
+
console.error(error instanceof Error ? error.message : "Unknown bundle error");
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
});
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Everything is accessible via window.fiyuu in page scripts.
|
|
14
14
|
*/
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
|
|
16
|
+
export function buildClientRuntime(websocketPath: string): string {
|
|
17
|
+
return `(function(){
|
|
17
18
|
window.fiyuu = {
|
|
18
19
|
|
|
19
20
|
// ── Theme ──────────────────────────────────────────────────────────────────
|
package/src/inspector.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { promises as fs, existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { FeatureRecord, FiyuuConfig } from "@fiyuu/core";
|
|
4
|
+
|
|
5
|
+
type InsightCategory = "security" | "performance" | "design" | "architecture";
|
|
6
|
+
type InsightSeverity = "low" | "medium" | "high";
|
|
7
|
+
|
|
8
|
+
export interface InsightItem {
|
|
9
|
+
id: string;
|
|
10
|
+
category: InsightCategory;
|
|
11
|
+
severity: InsightSeverity;
|
|
12
|
+
title: string;
|
|
13
|
+
summary: string;
|
|
14
|
+
recommendation: string;
|
|
15
|
+
route?: string;
|
|
16
|
+
file?: string;
|
|
17
|
+
fixable: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface InsightsReport {
|
|
21
|
+
generatedAt: string;
|
|
22
|
+
summary: {
|
|
23
|
+
total: number;
|
|
24
|
+
high: number;
|
|
25
|
+
medium: number;
|
|
26
|
+
low: number;
|
|
27
|
+
};
|
|
28
|
+
items: InsightItem[];
|
|
29
|
+
assistant: {
|
|
30
|
+
mode: "rule-only";
|
|
31
|
+
status: "ready";
|
|
32
|
+
details: string;
|
|
33
|
+
suggestions: string[];
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface BuildInsightsOptions {
|
|
38
|
+
rootDirectory: string;
|
|
39
|
+
appDirectory: string;
|
|
40
|
+
features: FeatureRecord[];
|
|
41
|
+
config?: FiyuuConfig;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function buildInsightsReport(options: BuildInsightsOptions): Promise<InsightsReport> {
|
|
45
|
+
const items = await collectInsightItems(options);
|
|
46
|
+
const assistant = await buildAssistantOutput(options, items);
|
|
47
|
+
|
|
48
|
+
const summary = {
|
|
49
|
+
total: items.length,
|
|
50
|
+
high: items.filter((item) => item.severity === "high").length,
|
|
51
|
+
medium: items.filter((item) => item.severity === "medium").length,
|
|
52
|
+
low: items.filter((item) => item.severity === "low").length,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
generatedAt: new Date().toISOString(),
|
|
57
|
+
summary,
|
|
58
|
+
items,
|
|
59
|
+
assistant,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function collectInsightItems(options: BuildInsightsOptions): Promise<InsightItem[]> {
|
|
64
|
+
const items: InsightItem[] = [];
|
|
65
|
+
|
|
66
|
+
for (const feature of options.features) {
|
|
67
|
+
items.push(...toFeatureStructureInsights(feature));
|
|
68
|
+
|
|
69
|
+
const fileEntries = Object.entries(feature.files).filter((entry): entry is [string, string] => Boolean(entry[1]));
|
|
70
|
+
for (const [, filePath] of fileEntries) {
|
|
71
|
+
const source = await readFileSafe(filePath);
|
|
72
|
+
if (!source) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (/dangerouslySetInnerHTML/.test(source)) {
|
|
77
|
+
items.push({
|
|
78
|
+
id: `security-dangerous-html-${filePath}`,
|
|
79
|
+
category: "security",
|
|
80
|
+
severity: "high",
|
|
81
|
+
title: "Potential XSS surface detected",
|
|
82
|
+
summary: "`dangerouslySetInnerHTML` is used in a route module.",
|
|
83
|
+
recommendation: "Prefer escaped rendering or sanitize content before passing HTML strings.",
|
|
84
|
+
route: feature.route,
|
|
85
|
+
file: filePath,
|
|
86
|
+
fixable: true,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (/\beval\s*\(|new\s+Function\s*\(/.test(source)) {
|
|
91
|
+
items.push({
|
|
92
|
+
id: `security-dynamic-eval-${filePath}`,
|
|
93
|
+
category: "security",
|
|
94
|
+
severity: "high",
|
|
95
|
+
title: "Dynamic code execution detected",
|
|
96
|
+
summary: "`eval` or `new Function` appears in application logic.",
|
|
97
|
+
recommendation: "Replace dynamic execution with explicit, typed control flow.",
|
|
98
|
+
route: feature.route,
|
|
99
|
+
file: filePath,
|
|
100
|
+
fixable: true,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const lineCount = source.split(/\r?\n/).length;
|
|
105
|
+
if (lineCount > 450) {
|
|
106
|
+
items.push({
|
|
107
|
+
id: `performance-large-module-${filePath}`,
|
|
108
|
+
category: "performance",
|
|
109
|
+
severity: "medium",
|
|
110
|
+
title: "Large route module",
|
|
111
|
+
summary: `Module has ${lineCount} lines and may increase parse and hydration cost.`,
|
|
112
|
+
recommendation: "Split heavy route logic into smaller server/client helpers.",
|
|
113
|
+
route: feature.route,
|
|
114
|
+
file: filePath,
|
|
115
|
+
fixable: true,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (feature.files["meta.ts"]) {
|
|
121
|
+
const metaSource = await readFileSafe(feature.files["meta.ts"]);
|
|
122
|
+
if (metaSource) {
|
|
123
|
+
const seoTitle = extractSeoField(metaSource, "title");
|
|
124
|
+
const seoDescription = extractSeoField(metaSource, "description");
|
|
125
|
+
|
|
126
|
+
if (!seoTitle) {
|
|
127
|
+
items.push({
|
|
128
|
+
id: `design-missing-seo-title-${feature.route}`,
|
|
129
|
+
category: "design",
|
|
130
|
+
severity: "low",
|
|
131
|
+
title: "SEO title missing in meta",
|
|
132
|
+
summary: "`meta.ts` exists but does not define `seo.title`.",
|
|
133
|
+
recommendation: "Add route-specific `seo.title` to improve previews and search snippets.",
|
|
134
|
+
route: feature.route,
|
|
135
|
+
file: feature.files["meta.ts"],
|
|
136
|
+
fixable: true,
|
|
137
|
+
});
|
|
138
|
+
} else if (seoTitle.length > 62) {
|
|
139
|
+
items.push({
|
|
140
|
+
id: `design-seo-title-length-${feature.route}`,
|
|
141
|
+
category: "design",
|
|
142
|
+
severity: "low",
|
|
143
|
+
title: "SEO title is longer than recommended",
|
|
144
|
+
summary: `Current title length is ${seoTitle.length} characters.`,
|
|
145
|
+
recommendation: "Keep seo titles around 45-60 characters to avoid truncation in SERP previews.",
|
|
146
|
+
route: feature.route,
|
|
147
|
+
file: feature.files["meta.ts"],
|
|
148
|
+
fixable: true,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!seoDescription) {
|
|
153
|
+
items.push({
|
|
154
|
+
id: `design-missing-seo-description-${feature.route}`,
|
|
155
|
+
category: "design",
|
|
156
|
+
severity: "medium",
|
|
157
|
+
title: "SEO description missing in meta",
|
|
158
|
+
summary: "`meta.ts` does not define `seo.description`.",
|
|
159
|
+
recommendation: "Add a concise description (90-160 chars) for better search and social previews.",
|
|
160
|
+
route: feature.route,
|
|
161
|
+
file: feature.files["meta.ts"],
|
|
162
|
+
fixable: true,
|
|
163
|
+
});
|
|
164
|
+
} else if (seoDescription.length < 80 || seoDescription.length > 170) {
|
|
165
|
+
items.push({
|
|
166
|
+
id: `design-seo-description-length-${feature.route}`,
|
|
167
|
+
category: "design",
|
|
168
|
+
severity: "low",
|
|
169
|
+
title: "SEO description length can be improved",
|
|
170
|
+
summary: `Current description length is ${seoDescription.length} characters.`,
|
|
171
|
+
recommendation: "Keep seo descriptions in the 90-160 character range for reliable previews.",
|
|
172
|
+
route: feature.route,
|
|
173
|
+
file: feature.files["meta.ts"],
|
|
174
|
+
fixable: true,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
items.push(...(await collectGlobalSecurityInsights(options.appDirectory)));
|
|
182
|
+
items.push(...collectGlobalRenderInsights(options.features));
|
|
183
|
+
return dedupeInsights(items);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function toFeatureStructureInsights(feature: FeatureRecord): InsightItem[] {
|
|
187
|
+
const items: InsightItem[] = [];
|
|
188
|
+
|
|
189
|
+
for (const missing of feature.missingRequiredFiles) {
|
|
190
|
+
items.push({
|
|
191
|
+
id: `architecture-missing-${feature.route}-${missing}`,
|
|
192
|
+
category: "architecture",
|
|
193
|
+
severity: missing === "schema.ts" ? "high" : "medium",
|
|
194
|
+
title: `Required file missing: ${missing}`,
|
|
195
|
+
summary: `Feature ${feature.route} is missing ${missing}.`,
|
|
196
|
+
recommendation: "Complete the feature contract so tooling and AI context stay deterministic.",
|
|
197
|
+
route: feature.route,
|
|
198
|
+
file: path.join(feature.directory, missing),
|
|
199
|
+
fixable: true,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return items;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function collectGlobalSecurityInsights(appDirectory: string): Promise<InsightItem[]> {
|
|
207
|
+
const items: InsightItem[] = [];
|
|
208
|
+
const middlewarePath = path.join(appDirectory, "middleware.ts");
|
|
209
|
+
if (existsSync(middlewarePath)) {
|
|
210
|
+
const middlewareSource = await readFileSafe(middlewarePath);
|
|
211
|
+
if (middlewareSource && /access-control-allow-origin["']?\s*[:,=]\s*["']\*/i.test(middlewareSource)) {
|
|
212
|
+
items.push({
|
|
213
|
+
id: "security-open-cors-middleware",
|
|
214
|
+
category: "security",
|
|
215
|
+
severity: "high",
|
|
216
|
+
title: "Wildcard CORS header in middleware",
|
|
217
|
+
summary: "Middleware appears to allow all origins globally.",
|
|
218
|
+
recommendation: "Restrict allowed origins by environment and keep credentials disabled for public origins.",
|
|
219
|
+
file: middlewarePath,
|
|
220
|
+
fixable: true,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return items;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function collectGlobalRenderInsights(features: FeatureRecord[]): InsightItem[] {
|
|
229
|
+
const csrCount = features.filter((feature) => feature.render === "csr").length;
|
|
230
|
+
if (features.length < 4) {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const ratio = csrCount / features.length;
|
|
235
|
+
if (ratio < 0.6) {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return [
|
|
240
|
+
{
|
|
241
|
+
id: "performance-csr-heavy",
|
|
242
|
+
category: "performance",
|
|
243
|
+
severity: ratio > 0.8 ? "high" : "medium",
|
|
244
|
+
title: "Project is CSR-heavy",
|
|
245
|
+
summary: `${csrCount}/${features.length} routes render in CSR mode.`,
|
|
246
|
+
recommendation: "Move content-driven pages to SSR where possible to reduce blank-first-paint risk.",
|
|
247
|
+
fixable: true,
|
|
248
|
+
},
|
|
249
|
+
];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function buildAssistantOutput(options: BuildInsightsOptions, items: InsightItem[]): Promise<InsightsReport["assistant"]> {
|
|
253
|
+
void options;
|
|
254
|
+
return {
|
|
255
|
+
mode: "rule-only",
|
|
256
|
+
status: "ready",
|
|
257
|
+
details: "Using deterministic project checks (no integrated model).",
|
|
258
|
+
suggestions: createRuleBasedSuggestions(items),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function createRuleBasedSuggestions(items: InsightItem[]): string[] {
|
|
263
|
+
if (items.length === 0) {
|
|
264
|
+
return [
|
|
265
|
+
"Run `fiyuu doctor --fix` after scaffolding new routes to keep contracts deterministic.",
|
|
266
|
+
"Keep SEO descriptions in 12-28 words and route-specific titles for stable search snippets.",
|
|
267
|
+
"Add focused tests around auth, middleware, and API routes before release builds.",
|
|
268
|
+
];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const topItems = items
|
|
272
|
+
.slice()
|
|
273
|
+
.sort((left, right) => severityScore(right.severity) - severityScore(left.severity))
|
|
274
|
+
.slice(0, 4);
|
|
275
|
+
return [
|
|
276
|
+
...topItems.map((item) => `${capitalize(item.category)}: ${item.recommendation}`),
|
|
277
|
+
"Use `fiyuu doctor --fix` for safe auto-fixes (SEO fields, missing execute(), fallback pages, className->class).",
|
|
278
|
+
].slice(0, 6);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function dedupeInsights(items: InsightItem[]): InsightItem[] {
|
|
282
|
+
const seen = new Set<string>();
|
|
283
|
+
const output: InsightItem[] = [];
|
|
284
|
+
for (const item of items) {
|
|
285
|
+
if (seen.has(item.id)) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
seen.add(item.id);
|
|
289
|
+
output.push(item);
|
|
290
|
+
}
|
|
291
|
+
return output;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function readFileSafe(filePath: string): Promise<string | null> {
|
|
295
|
+
try {
|
|
296
|
+
return await fs.readFile(filePath, "utf8");
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function severityScore(severity: InsightSeverity): number {
|
|
303
|
+
if (severity === "high") {
|
|
304
|
+
return 3;
|
|
305
|
+
}
|
|
306
|
+
if (severity === "medium") {
|
|
307
|
+
return 2;
|
|
308
|
+
}
|
|
309
|
+
return 1;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function capitalize(value: string): string {
|
|
313
|
+
return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function extractSeoField(source: string, field: "title" | "description"): string | null {
|
|
317
|
+
const seoBlock = source.match(/seo\s*:\s*\{([\s\S]*?)\}/m);
|
|
318
|
+
if (!seoBlock) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const fieldRegex = new RegExp(`${field}\\s*:\\s*(["'\`])([\\s\\S]*?)\\1`, "m");
|
|
323
|
+
const match = seoBlock[1].match(fieldRegex);
|
|
324
|
+
if (!match) {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return match[2].trim() || null;
|
|
329
|
+
}
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* Dev-only browser script generators for the Fiyuu runtime.
|
|
3
3
|
* These return inline <script> strings injected into the rendered document.
|
|
4
4
|
*/
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
|
|
6
|
+
import type { RenderMode } from "@fiyuu/core";
|
|
7
|
+
|
|
8
|
+
export function renderInsightsPanelScript(): string {
|
|
9
|
+
return `<script type="module">
|
|
7
10
|
const host=document.createElement('div');
|
|
8
11
|
host.style.cssText='position:fixed;left:16px;bottom:16px;z-index:9998;font:12px/1.5 ui-monospace,monospace';
|
|
9
12
|
const toggle=document.createElement('button');
|
|
@@ -56,15 +59,23 @@ host.append(toggle,panel);
|
|
|
56
59
|
document.body.append(host);
|
|
57
60
|
</script>`;
|
|
58
61
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
|
|
63
|
+
export function renderUnifiedToolsScript(input: {
|
|
64
|
+
route: string;
|
|
65
|
+
render: RenderMode;
|
|
66
|
+
renderTimeMs: number;
|
|
67
|
+
warnings: string[];
|
|
68
|
+
requestId: string;
|
|
69
|
+
}): string {
|
|
70
|
+
const metrics = JSON.stringify({
|
|
71
|
+
route: input.route,
|
|
72
|
+
render: input.render,
|
|
73
|
+
renderTimeMs: input.renderTimeMs,
|
|
74
|
+
warnings: input.warnings,
|
|
75
|
+
requestId: input.requestId,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return `<script type="module">(function(){
|
|
68
79
|
const metrics=${metrics};
|
|
69
80
|
const removeLegacyPanels=()=>{const old=[...document.querySelectorAll('button')].filter((button)=>button.textContent==='Fiyuu Devtools'||button.textContent==='Fiyuu Insights');for(const button of old){const host=button.closest('div');if(host&&host.parentNode){host.parentNode.removeChild(host);}}};
|
|
70
81
|
removeLegacyPanels();
|