288 lines
9.7 KiB
TypeScript
288 lines
9.7 KiB
TypeScript
import type { PageAst, ViewNode } from "@wrnexus/syntax";
|
|
|
|
export type RouteExecutionKind =
|
|
| "static"
|
|
| "static-interactive"
|
|
| "request-ssr"
|
|
| "authenticated-ssr"
|
|
| "streaming-ssr"
|
|
| "dynamic";
|
|
|
|
export interface RuntimeRequirements {
|
|
kind: RouteExecutionKind;
|
|
canPrerender: boolean;
|
|
needsClientRuntime: boolean;
|
|
needsServerRuntime: boolean;
|
|
hydrationStrategy: string | null;
|
|
reasons: string[];
|
|
optimization: OptimizationReport;
|
|
cachePolicy: Record<string, string>;
|
|
requiredPermission: string | null;
|
|
}
|
|
|
|
export interface OptimizationReport {
|
|
staticNodes: number;
|
|
reactiveRegions: number;
|
|
eliminatedBranches: number;
|
|
unusedState: string[];
|
|
unusedHandlers: string[];
|
|
constantProps: string[];
|
|
unusedLocalCssClasses: string[];
|
|
batchableStateUpdates: number;
|
|
memoizableComponents: string[];
|
|
preloadDependencies: string[];
|
|
serverOnlyModules: string[];
|
|
}
|
|
|
|
function identifiers(value: string): Set<string> {
|
|
return new Set(value.match(/[A-Za-z_$][\w$]*/g) ?? []);
|
|
}
|
|
|
|
function literalBoolean(expression: string | null): boolean | undefined {
|
|
if (expression === null) return true;
|
|
const value = expression.trim();
|
|
if (value === "true") return true;
|
|
if (
|
|
value === "false" ||
|
|
value === "null" ||
|
|
value === "undefined" ||
|
|
value === "0" ||
|
|
value === "''" ||
|
|
value === '""'
|
|
)
|
|
return false;
|
|
if (/^-?(?:[1-9]\d*|0?\.\d+)$/.test(value) || /^(['"]).+\1$/.test(value)) return true;
|
|
return undefined;
|
|
}
|
|
|
|
function optimizeNodes(nodes: ViewNode[], report: { eliminated: number }): ViewNode[] {
|
|
const output: ViewNode[] = [];
|
|
for (const node of nodes) {
|
|
if (node.type === "element")
|
|
output.push({
|
|
...node,
|
|
attrs: node.attrs.map((attribute) => ({ ...attribute })),
|
|
children: optimizeNodes(node.children, report),
|
|
});
|
|
else if (node.type === "each")
|
|
output.push({
|
|
...node,
|
|
body: optimizeNodes(node.body, report),
|
|
empty: optimizeNodes(node.empty, report),
|
|
});
|
|
else if (node.type === "if") {
|
|
let selected: ViewNode[] | undefined;
|
|
let dynamic = false;
|
|
for (const branch of node.branches) {
|
|
const value = literalBoolean(branch.cond);
|
|
if (value === undefined) {
|
|
dynamic = true;
|
|
break;
|
|
}
|
|
report.eliminated++;
|
|
if (value) {
|
|
selected = branch.body;
|
|
break;
|
|
}
|
|
}
|
|
if (dynamic)
|
|
output.push({
|
|
...node,
|
|
branches: node.branches.map((branch) => ({
|
|
...branch,
|
|
body: optimizeNodes(branch.body, report),
|
|
})),
|
|
});
|
|
else if (selected) output.push(...optimizeNodes(selected, report));
|
|
} else output.push({ ...node });
|
|
}
|
|
return output;
|
|
}
|
|
|
|
/** Safe compile-time folding for literal conditional branches. */
|
|
export function optimizeAst(ast: PageAst): { ast: PageAst; eliminatedBranches: number } {
|
|
const report = { eliminated: 0 };
|
|
return {
|
|
ast: { ...ast, view: optimizeNodes(ast.view, report) },
|
|
eliminatedBranches: report.eliminated,
|
|
};
|
|
}
|
|
|
|
export function analyzeOptimizations(ast: PageAst): OptimizationReport {
|
|
const used = new Set<string>();
|
|
let staticNodes = 0;
|
|
let reactiveRegions = 0;
|
|
const componentNames = new Set<string>();
|
|
const staticClasses = new Set<string>();
|
|
const visit = (nodes: ViewNode[]) => {
|
|
for (const node of nodes) {
|
|
if (node.type === "text") {
|
|
const refs = identifiers(node.value);
|
|
refs.forEach((name) => used.add(name));
|
|
if (node.value.includes("{")) reactiveRegions++;
|
|
else staticNodes++;
|
|
} else if (node.type === "element") {
|
|
if (/^[A-Z]/.test(node.tag)) componentNames.add(node.tag);
|
|
let reactive = false;
|
|
for (const attribute of node.attrs) {
|
|
identifiers(attribute.value).forEach((name) => used.add(name));
|
|
reactive ||= attribute.event || attribute.value.includes("{");
|
|
if (attribute.name === "class" && !attribute.value.includes("{"))
|
|
for (const name of attribute.value.split(/\s+/)) if (name) staticClasses.add(name);
|
|
}
|
|
if (reactive) reactiveRegions++;
|
|
else staticNodes++;
|
|
visit(node.children);
|
|
} else if (node.type === "each") {
|
|
identifiers(`${node.list} ${node.key ?? ""}`).forEach((name) => used.add(name));
|
|
reactiveRegions++;
|
|
visit(node.body);
|
|
visit(node.empty);
|
|
} else {
|
|
for (const branch of node.branches) {
|
|
identifiers(branch.cond ?? "").forEach((name) => used.add(name));
|
|
visit(branch.body);
|
|
}
|
|
reactiveRegions++;
|
|
}
|
|
}
|
|
};
|
|
visit(ast.view);
|
|
const handlerReferences = new Set(used);
|
|
const executable = [
|
|
...ast.runtimeFunctions.map((fn) => fn.body),
|
|
...ast.functions,
|
|
...ast.effects.map((effect) => effect.body),
|
|
...ast.watches.map((watch) => watch.body),
|
|
...ast.actions.map((action) => action.body),
|
|
].join("\n");
|
|
identifiers(executable).forEach((name) => used.add(name));
|
|
const localCss = new Set(
|
|
ast.styles.flatMap((style) =>
|
|
[...style.matchAll(/\.([_a-zA-Z][\w-]*)/g)].map((match) => match[1]!),
|
|
),
|
|
);
|
|
const optimized = optimizeAst(ast);
|
|
const assignmentCounts = ast.runtimeFunctions.map(
|
|
(fn) =>
|
|
ast.states.filter((state) =>
|
|
new RegExp(`\\b${state.name}\\s*(?:[+*/-]?=|\\+\\+|--)`).test(fn.body),
|
|
).length,
|
|
);
|
|
return {
|
|
staticNodes,
|
|
reactiveRegions,
|
|
eliminatedBranches: optimized.eliminatedBranches,
|
|
unusedState: ast.states.filter((state) => !used.has(state.name)).map((state) => state.name),
|
|
unusedHandlers: ast.runtimeFunctions
|
|
.filter((fn) => fn.runtime !== "server" && !handlerReferences.has(fn.name))
|
|
.map((fn) => fn.name),
|
|
constantProps: ast.props
|
|
.filter((prop) =>
|
|
/^(?:-?\d+(?:\.\d+)?|true|false|null|(['"]).*\1)$/.test(prop.default.trim()),
|
|
)
|
|
.map((prop) => prop.name),
|
|
unusedLocalCssClasses: [...localCss].filter((name) => !staticClasses.has(name)).sort(),
|
|
batchableStateUpdates: assignmentCounts
|
|
.filter((count) => count > 1)
|
|
.reduce((sum, count) => sum + count - 1, 0),
|
|
memoizableComponents: [...componentNames].sort(),
|
|
preloadDependencies: ast.structuredImports
|
|
.filter((entry) => !entry.typeOnly && !entry.source.startsWith("node:"))
|
|
.map((entry) => entry.source),
|
|
serverOnlyModules: ast.structuredImports
|
|
.filter((entry) => entry.source.startsWith("node:") || ast.runtime === "server")
|
|
.map((entry) => entry.source),
|
|
};
|
|
}
|
|
|
|
function hasEvent(nodes: ViewNode[]): boolean {
|
|
for (const node of nodes) {
|
|
if (node.type === "element") {
|
|
if (node.attrs.some((attribute) => attribute.event)) return true;
|
|
if (hasEvent(node.children)) return true;
|
|
} else if (node.type === "each") {
|
|
if (hasEvent(node.body) || hasEvent(node.empty)) return true;
|
|
} else if (node.type === "if") {
|
|
if (node.branches.some((branch) => hasEvent(branch.body))) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function analyzeRuntimeRequirements(ast: PageAst): RuntimeRequirements {
|
|
const reasons: string[] = [];
|
|
const clientFunctions = ast.runtimeFunctions.some((fn) => fn.runtime !== "server");
|
|
const clientState = ast.states.some((state) => state.runtime !== "server");
|
|
const interactive =
|
|
clientFunctions ||
|
|
clientState ||
|
|
ast.effects.length > 0 ||
|
|
ast.watches.length > 0 ||
|
|
hasEvent(ast.view);
|
|
if (interactive) reasons.push("client interactivity");
|
|
|
|
const requestData =
|
|
ast.loads.length > 0 ||
|
|
ast.actions.length > 0 ||
|
|
ast.dataApis.length > 0 ||
|
|
ast.apis.length > 0 ||
|
|
ast.realtimes.length > 0 ||
|
|
ast.runtimeFunctions.some((fn) => fn.runtime === "server") ||
|
|
ast.states.some((state) => state.runtime === "server");
|
|
if (requestData) reasons.push("server/request data");
|
|
|
|
const authenticated = /^(?:required|true)$/i.test(ast.security.auth ?? "");
|
|
if (authenticated) reasons.push("authentication required");
|
|
|
|
const streaming = /^(?:true|required)$/i.test(ast.security.streaming ?? "");
|
|
if (streaming) reasons.push("streaming enabled");
|
|
|
|
let kind: RouteExecutionKind;
|
|
if (streaming) kind = "streaming-ssr";
|
|
else if (authenticated) kind = "authenticated-ssr";
|
|
else if (requestData && interactive) kind = "dynamic";
|
|
else if (requestData) kind = "request-ssr";
|
|
else if (interactive) kind = "static-interactive";
|
|
else kind = "static";
|
|
|
|
if (ast.renderMode === "static") {
|
|
kind = "static";
|
|
reasons.push("explicit static rendering");
|
|
} else if (ast.renderMode === "server") {
|
|
kind = requestData ? "request-ssr" : "static";
|
|
reasons.push("explicit server rendering");
|
|
} else if (ast.renderMode === "client") {
|
|
kind = "static-interactive";
|
|
reasons.push("explicit client rendering");
|
|
} else if (ast.renderMode === "partial-static") {
|
|
kind = "streaming-ssr";
|
|
reasons.push("partial-static shell with streamed dynamic regions");
|
|
}
|
|
|
|
const clientDisabled = ast.renderMode === "static" || ast.renderMode === "server";
|
|
const serverDisabled = ast.renderMode === "client";
|
|
|
|
return {
|
|
kind,
|
|
canPrerender: kind === "static" || kind === "static-interactive",
|
|
needsClientRuntime:
|
|
!clientDisabled &&
|
|
(interactive || ast.renderMode === "client") &&
|
|
ast.hydrate !== "none" &&
|
|
ast.runtime !== "server",
|
|
needsServerRuntime:
|
|
!serverDisabled &&
|
|
(requestData ||
|
|
authenticated ||
|
|
streaming ||
|
|
ast.renderMode === "server" ||
|
|
["server", "edge", "worker", "service-worker"].includes(ast.runtime ?? "")),
|
|
hydrationStrategy: clientDisabled ? null : interactive ? (ast.hydrate ?? "load") : null,
|
|
reasons,
|
|
optimization: analyzeOptimizations(ast),
|
|
cachePolicy: { ...(ast.cache ?? {}) },
|
|
requiredPermission: ast.security.permission ?? null,
|
|
};
|
|
}
|