402 lines
10 KiB
TypeScript
402 lines
10 KiB
TypeScript
import { parse } from "@babel/parser";
|
|
import * as t from "@babel/types";
|
|
import * as z from "zod";
|
|
|
|
export type CompiledTemplateSchema = {
|
|
layoutDescription: string;
|
|
layoutId: string;
|
|
layoutName: string;
|
|
schemaJSON: unknown;
|
|
};
|
|
|
|
type ExtractedDeclaration = {
|
|
init: t.Expression;
|
|
initSource: string;
|
|
name: string;
|
|
order: number;
|
|
};
|
|
|
|
const DANGEROUS_MEMBER_NAMES = new Set([
|
|
"__defineGetter__",
|
|
"__defineSetter__",
|
|
"__lookupGetter__",
|
|
"__lookupSetter__",
|
|
"__proto__",
|
|
"apply",
|
|
"bind",
|
|
"call",
|
|
"constructor",
|
|
"eval",
|
|
"prototype",
|
|
]);
|
|
|
|
function normalizeHardcodedBackendUrlsInCode(layoutCode: string): string {
|
|
return layoutCode.replace(
|
|
/https?:\/\/(?:127\.0\.0\.1|localhost|0\.0\.0\.0):(?:8000|5000)(?=\/(?:app_data|static)\/)/g,
|
|
""
|
|
);
|
|
}
|
|
|
|
function unwrapExpression(node: t.Expression): t.Expression {
|
|
if (
|
|
t.isParenthesizedExpression(node) ||
|
|
t.isTSAsExpression(node) ||
|
|
t.isTSTypeAssertion(node) ||
|
|
t.isTSNonNullExpression(node)
|
|
) {
|
|
return unwrapExpression(node.expression as t.Expression);
|
|
}
|
|
|
|
return node;
|
|
}
|
|
|
|
function getRootIdentifier(node: t.Expression): string | null {
|
|
const expression = unwrapExpression(node);
|
|
|
|
if (t.isIdentifier(expression)) {
|
|
return expression.name;
|
|
}
|
|
|
|
if (t.isMemberExpression(expression)) {
|
|
return getRootIdentifier(expression.object as t.Expression);
|
|
}
|
|
|
|
if (t.isCallExpression(expression)) {
|
|
return getRootIdentifier(expression.callee as t.Expression);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getStaticStringValue(node: t.Expression | null | undefined): string | null {
|
|
if (!node) {
|
|
return null;
|
|
}
|
|
|
|
const expression = unwrapExpression(node);
|
|
|
|
if (t.isStringLiteral(expression)) {
|
|
return expression.value;
|
|
}
|
|
|
|
if (t.isTemplateLiteral(expression) && expression.expressions.length === 0) {
|
|
return expression.quasis
|
|
.map((quasi) => quasi.value.cooked ?? quasi.value.raw ?? "")
|
|
.join("");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function extractTopLevelDeclarations(source: string): Map<string, ExtractedDeclaration> {
|
|
const program = parse(source, {
|
|
plugins: ["jsx", "typescript"],
|
|
sourceType: "module",
|
|
}).program;
|
|
|
|
const declarations = new Map<string, ExtractedDeclaration>();
|
|
let order = 0;
|
|
|
|
for (const statement of program.body) {
|
|
const declaration = t.isExportNamedDeclaration(statement)
|
|
? statement.declaration
|
|
: statement;
|
|
|
|
if (!declaration || !t.isVariableDeclaration(declaration)) {
|
|
continue;
|
|
}
|
|
|
|
for (const declarator of declaration.declarations) {
|
|
if (!t.isIdentifier(declarator.id) || !declarator.init) {
|
|
continue;
|
|
}
|
|
|
|
declarations.set(declarator.id.name, {
|
|
init: unwrapExpression(declarator.init as t.Expression),
|
|
initSource: source.slice(declarator.init.start ?? 0, declarator.init.end ?? 0),
|
|
name: declarator.id.name,
|
|
order: order++,
|
|
});
|
|
}
|
|
}
|
|
|
|
return declarations;
|
|
}
|
|
|
|
function readStringDeclaration(
|
|
declarations: Map<string, ExtractedDeclaration>,
|
|
name: string
|
|
): string | null {
|
|
return getStaticStringValue(declarations.get(name)?.init);
|
|
}
|
|
|
|
function isAllowedIdentifier(
|
|
declarations: Map<string, ExtractedDeclaration>,
|
|
name: string
|
|
): boolean {
|
|
return name === "z" || name === "undefined" || declarations.has(name);
|
|
}
|
|
|
|
function assertSafeMemberName(property: t.Identifier): void {
|
|
if (DANGEROUS_MEMBER_NAMES.has(property.name)) {
|
|
throw new Error(`Unsupported member access: ${property.name}`);
|
|
}
|
|
}
|
|
|
|
function collectDependenciesForDeclaration(
|
|
declarations: Map<string, ExtractedDeclaration>,
|
|
currentDeclaration: string,
|
|
expression: t.Expression
|
|
): Set<string> {
|
|
const dependencies = new Set<string>();
|
|
|
|
const addDependency = (name: string) => {
|
|
if (name !== "z" && name !== "undefined" && name !== currentDeclaration) {
|
|
dependencies.add(name);
|
|
}
|
|
};
|
|
|
|
const validateMemberExpression = (node: t.MemberExpression) => {
|
|
if (node.computed || !t.isIdentifier(node.property)) {
|
|
throw new Error("Computed member access is not supported in template schemas");
|
|
}
|
|
|
|
assertSafeMemberName(node.property);
|
|
|
|
const rootIdentifier = getRootIdentifier(node);
|
|
if (!rootIdentifier || !isAllowedIdentifier(declarations, rootIdentifier)) {
|
|
throw new Error(`Unsupported member access root: ${rootIdentifier ?? node.type}`);
|
|
}
|
|
|
|
validateExpression(node.object as t.Expression);
|
|
};
|
|
|
|
const validateCallExpression = (node: t.CallExpression) => {
|
|
const callee = unwrapExpression(node.callee as t.Expression);
|
|
|
|
if (t.isIdentifier(callee)) {
|
|
throw new Error(`Unsupported direct function call: ${callee.name}`);
|
|
}
|
|
|
|
if (t.isMemberExpression(callee)) {
|
|
validateMemberExpression(callee);
|
|
} else if (t.isCallExpression(callee)) {
|
|
validateCallExpression(callee);
|
|
} else {
|
|
throw new Error(`Unsupported callee type: ${callee.type}`);
|
|
}
|
|
|
|
for (const argument of node.arguments) {
|
|
if (t.isSpreadElement(argument)) {
|
|
validateExpression(argument.argument);
|
|
continue;
|
|
}
|
|
|
|
if (!t.isExpression(argument)) {
|
|
throw new Error("Unsupported call argument");
|
|
}
|
|
|
|
validateExpression(argument);
|
|
}
|
|
};
|
|
|
|
const validateObjectProperty = (node: t.ObjectProperty) => {
|
|
if (node.computed) {
|
|
if (!t.isExpression(node.key)) {
|
|
throw new Error("Unsupported computed object key");
|
|
}
|
|
validateExpression(node.key);
|
|
} else if (
|
|
!t.isIdentifier(node.key) &&
|
|
!t.isStringLiteral(node.key) &&
|
|
!t.isNumericLiteral(node.key)
|
|
) {
|
|
throw new Error(`Unsupported object key type: ${node.key.type}`);
|
|
}
|
|
|
|
if (!t.isExpression(node.value)) {
|
|
throw new Error("Unsupported object property value");
|
|
}
|
|
|
|
validateExpression(node.value);
|
|
};
|
|
|
|
const validateExpression = (node: t.Expression) => {
|
|
const expressionNode = unwrapExpression(node);
|
|
|
|
if (
|
|
t.isStringLiteral(expressionNode) ||
|
|
t.isNumericLiteral(expressionNode) ||
|
|
t.isBooleanLiteral(expressionNode) ||
|
|
t.isNullLiteral(expressionNode) ||
|
|
t.isBigIntLiteral(expressionNode) ||
|
|
t.isRegExpLiteral(expressionNode)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (t.isIdentifier(expressionNode)) {
|
|
if (!isAllowedIdentifier(declarations, expressionNode.name)) {
|
|
throw new Error(`Unsupported identifier: ${expressionNode.name}`);
|
|
}
|
|
|
|
addDependency(expressionNode.name);
|
|
return;
|
|
}
|
|
|
|
if (t.isTemplateLiteral(expressionNode)) {
|
|
if (expressionNode.expressions.length > 0) {
|
|
throw new Error("Dynamic template literals are not supported in template schemas");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (t.isArrayExpression(expressionNode)) {
|
|
for (const element of expressionNode.elements) {
|
|
if (!element) {
|
|
continue;
|
|
}
|
|
|
|
if (t.isSpreadElement(element)) {
|
|
validateExpression(element.argument);
|
|
continue;
|
|
}
|
|
|
|
validateExpression(element);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (t.isObjectExpression(expressionNode)) {
|
|
for (const property of expressionNode.properties) {
|
|
if (t.isSpreadElement(property)) {
|
|
validateExpression(property.argument);
|
|
continue;
|
|
}
|
|
|
|
if (!t.isObjectProperty(property)) {
|
|
throw new Error(`Unsupported object property type: ${property.type}`);
|
|
}
|
|
|
|
validateObjectProperty(property);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (t.isMemberExpression(expressionNode)) {
|
|
validateMemberExpression(expressionNode);
|
|
return;
|
|
}
|
|
|
|
if (t.isCallExpression(expressionNode)) {
|
|
validateCallExpression(expressionNode);
|
|
return;
|
|
}
|
|
|
|
if (t.isUnaryExpression(expressionNode)) {
|
|
if (!["!", "+", "-", "void"].includes(expressionNode.operator)) {
|
|
throw new Error(`Unsupported unary operator: ${expressionNode.operator}`);
|
|
}
|
|
|
|
validateExpression(expressionNode.argument);
|
|
return;
|
|
}
|
|
|
|
throw new Error(`Unsupported expression type: ${expressionNode.type}`);
|
|
};
|
|
|
|
validateExpression(expression);
|
|
return dependencies;
|
|
}
|
|
|
|
function buildSchemaRuntimeSource(
|
|
declarations: Map<string, ExtractedDeclaration>
|
|
): string {
|
|
const requiredDeclarations = new Set<string>();
|
|
const visiting = new Set<string>();
|
|
|
|
const visitDeclaration = (name: string) => {
|
|
if (requiredDeclarations.has(name)) {
|
|
return;
|
|
}
|
|
|
|
if (visiting.has(name)) {
|
|
throw new Error(`Circular schema declaration detected: ${name}`);
|
|
}
|
|
|
|
const declaration = declarations.get(name);
|
|
if (!declaration) {
|
|
throw new Error(`Missing declaration: ${name}`);
|
|
}
|
|
|
|
visiting.add(name);
|
|
const dependencies = collectDependenciesForDeclaration(
|
|
declarations,
|
|
name,
|
|
declaration.init
|
|
);
|
|
|
|
for (const dependency of dependencies) {
|
|
visitDeclaration(dependency);
|
|
}
|
|
|
|
visiting.delete(name);
|
|
requiredDeclarations.add(name);
|
|
};
|
|
|
|
visitDeclaration("Schema");
|
|
|
|
return Array.from(declarations.values())
|
|
.filter((declaration) => requiredDeclarations.has(declaration.name))
|
|
.sort((left, right) => left.order - right.order)
|
|
.map(
|
|
(declaration) =>
|
|
`const ${declaration.name} = ${declaration.initSource};`
|
|
)
|
|
.join("\n");
|
|
}
|
|
|
|
function isZodSchema(value: unknown): value is z.ZodTypeAny {
|
|
return (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
typeof (value as z.ZodTypeAny).safeParse === "function"
|
|
);
|
|
}
|
|
|
|
export function compileTemplateSchema(
|
|
layoutCode: string
|
|
): CompiledTemplateSchema | null {
|
|
try {
|
|
const normalizedLayoutCode =
|
|
normalizeHardcodedBackendUrlsInCode(layoutCode);
|
|
const declarations = extractTopLevelDeclarations(normalizedLayoutCode);
|
|
|
|
if (!declarations.has("Schema")) {
|
|
return null;
|
|
}
|
|
|
|
const schemaRuntimeSource = buildSchemaRuntimeSource(declarations);
|
|
const factory = new Function(
|
|
"_z",
|
|
`"use strict"; const z = _z; ${schemaRuntimeSource}\nreturn Schema;`
|
|
);
|
|
const schema = factory(z);
|
|
|
|
if (!isZodSchema(schema)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
layoutDescription:
|
|
readStringDeclaration(declarations, "layoutDescription") ?? "",
|
|
layoutId: readStringDeclaration(declarations, "layoutId") ?? "custom-layout",
|
|
layoutName:
|
|
readStringDeclaration(declarations, "layoutName") ?? "Custom Layout",
|
|
schemaJSON: z.toJSONSchema(schema),
|
|
};
|
|
} catch (error) {
|
|
console.error("Failed to compile template schema", error);
|
|
return null;
|
|
}
|
|
}
|