mirror of
https://github.com/oblien/openship.git
synced 2026-10-14 05:09:21 +08:00
204 lines
8.6 KiB
JavaScript
204 lines
8.6 KiB
JavaScript
/** Read route declarations without importing handlers or starting providers. */
|
|
import ts from "typescript";
|
|
|
|
const routerMetadata = Symbol("router metadata");
|
|
const methods = new Set(["get", "post", "patch", "put", "delete", "head", "options", "all"]);
|
|
|
|
/** Only resolve static values. Calls, imports and mutable bindings stay unresolved. */
|
|
function value(node, bindings) {
|
|
if (!node) return undefined;
|
|
if (ts.isStringLiteralLike(node)) return node.text;
|
|
if (ts.isNumericLiteral(node)) return Number(node.text);
|
|
if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
if (ts.isIdentifier(node)) return bindings.get(node.text);
|
|
if (ts.isConditionalExpression(node)) {
|
|
const condition = value(node.condition, bindings);
|
|
return typeof condition === "boolean"
|
|
? value(condition ? node.whenTrue : node.whenFalse, bindings)
|
|
: undefined;
|
|
}
|
|
if (
|
|
ts.isAsExpression(node) ||
|
|
ts.isSatisfiesExpression(node) ||
|
|
ts.isParenthesizedExpression(node)
|
|
)
|
|
return value(node.expression, bindings);
|
|
if (ts.isArrayLiteralExpression(node))
|
|
return node.elements.map((entry) => value(entry, bindings));
|
|
if (ts.isTemplateExpression(node)) {
|
|
let text = node.head.text;
|
|
for (const span of node.templateSpans) {
|
|
const part = value(span.expression, bindings);
|
|
if (typeof part !== "string" && typeof part !== "number") return undefined;
|
|
text += part + span.literal.text;
|
|
}
|
|
return text;
|
|
}
|
|
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
const left = value(node.left, bindings);
|
|
const right = value(node.right, bindings);
|
|
if ([left, right].every((part) => typeof part === "string" || typeof part === "number"))
|
|
return left + right;
|
|
}
|
|
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken) {
|
|
const left = value(node.left, bindings);
|
|
const right = value(node.right, bindings);
|
|
return left !== undefined && right !== undefined ? left === right : undefined;
|
|
}
|
|
if (ts.isObjectLiteralExpression(node)) {
|
|
const result = Object.create(null);
|
|
for (const property of node.properties) {
|
|
if (ts.isSpreadAssignment(property)) {
|
|
const spread = value(property.expression, bindings);
|
|
if (!spread || typeof spread !== "object" || Array.isArray(spread)) return undefined;
|
|
Object.assign(result, spread);
|
|
} else {
|
|
const name = ts.isIdentifier(property.name)
|
|
? property.name.text
|
|
: value(property.name, bindings);
|
|
if (typeof name !== "string") return undefined;
|
|
result[name] = value(
|
|
ts.isShorthandPropertyAssignment(property)
|
|
? property.name
|
|
: ts.isPropertyAssignment(property)
|
|
? property.initializer
|
|
: undefined,
|
|
bindings,
|
|
);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function bind(pattern, resolved, bindings) {
|
|
if (ts.isIdentifier(pattern)) bindings.set(pattern.text, resolved);
|
|
else if (ts.isArrayBindingPattern(pattern)) {
|
|
pattern.elements.forEach((element, index) => {
|
|
if (ts.isBindingElement(element))
|
|
bind(
|
|
element.name,
|
|
Array.isArray(resolved) && !element.dotDotDotToken ? resolved[index] : undefined,
|
|
bindings,
|
|
);
|
|
});
|
|
} else if (ts.isObjectBindingPattern(pattern)) {
|
|
// Unsupported bindings still shadow outer names; never borrow another scope's router.
|
|
for (const element of pattern.elements) bind(element.name, undefined, bindings);
|
|
}
|
|
}
|
|
|
|
export function moduleHttpSurface(file, text, mounts = new Map()) {
|
|
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
const routes = [];
|
|
const fail = (message, node) => {
|
|
const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
throw new Error(`${message} in ${file}:${line}: ${node.getText(source)}`);
|
|
};
|
|
|
|
function visit(node, bindings, unresolvedLoop = false) {
|
|
if (ts.isSourceFile(node) || ts.isBlock(node) || ts.isFunctionLike(node))
|
|
bindings = new Map(bindings);
|
|
if (ts.isFunctionLike(node))
|
|
for (const parameter of node.parameters) bind(parameter.name, undefined, bindings);
|
|
|
|
if (ts.isForOfStatement(node)) {
|
|
const declaration =
|
|
ts.isVariableDeclarationList(node.initializer) &&
|
|
node.initializer.flags & ts.NodeFlags.Const &&
|
|
node.initializer.declarations[0];
|
|
const entries = value(node.expression, bindings);
|
|
if (declaration && Array.isArray(entries)) {
|
|
for (const entry of entries) {
|
|
const iteration = new Map(bindings);
|
|
bind(declaration.name, entry, iteration);
|
|
visit(node.statement, iteration, unresolvedLoop);
|
|
}
|
|
} else visit(node.statement, new Map(bindings), true);
|
|
return;
|
|
}
|
|
|
|
if (ts.isVariableDeclaration(node)) {
|
|
const constant =
|
|
ts.isVariableDeclarationList(node.parent) && node.parent.flags & ts.NodeFlags.Const;
|
|
let resolved = constant ? value(node.initializer, bindings) : undefined;
|
|
if (constant && ts.isIdentifier(node.name) && node.initializer) {
|
|
const init = node.initializer;
|
|
if (ts.isCallExpression(init) && init.expression.getText(source) === "secureRouter") {
|
|
const options = value(init.arguments[1], bindings);
|
|
if (
|
|
typeof options?.basePath !== "string" ||
|
|
typeof options?.module !== "string" ||
|
|
!options.module ||
|
|
(Object.hasOwn(options, "localOnly") && typeof options.localOnly !== "boolean")
|
|
)
|
|
fail("Unresolved route metadata", init);
|
|
resolved = { [routerMetadata]: options };
|
|
} else if (
|
|
ts.isNewExpression(init) &&
|
|
init.expression.getText(source) === "Hono" &&
|
|
mounts.has(node.name.text)
|
|
) {
|
|
const basePath = mounts.get(node.name.text);
|
|
resolved = { [routerMetadata]: { basePath, module: basePath.split("/")[2], raw: true } };
|
|
}
|
|
}
|
|
bind(node.name, resolved, bindings);
|
|
}
|
|
|
|
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
const router = value(node.expression.expression, bindings)?.[routerMetadata];
|
|
if (router) {
|
|
const operation = node.expression.name.text;
|
|
const explicitMethod = operation === "public" || operation === "on";
|
|
if (explicitMethod || methods.has(operation)) {
|
|
if (unresolvedLoop) fail("Unresolved route loop", node);
|
|
const declared = explicitMethod ? value(node.arguments[0], bindings) : operation;
|
|
const verbs = Array.isArray(declared) ? declared : [declared];
|
|
if (
|
|
!verbs.length ||
|
|
!verbs.every((verb) => typeof verb === "string" && methods.has(verb.toLowerCase()))
|
|
)
|
|
fail("Unresolved route method", node);
|
|
const path = value(node.arguments[explicitMethod ? 1 : 0], bindings);
|
|
if (typeof path !== "string") fail("Unresolved route path", node);
|
|
const spec = value(node.arguments[explicitMethod ? 2 : 1], bindings);
|
|
const tag = spec?.tag;
|
|
if (!router.raw && operation !== "public" && typeof tag !== "string")
|
|
fail("Unresolved route permission", node);
|
|
if (spec && Object.hasOwn(spec, "localOnly") && typeof spec.localOnly !== "boolean")
|
|
fail("Unresolved route availability", node);
|
|
const internal = node.arguments.some(
|
|
(argument) => argument.getText(source) === "internalAuth",
|
|
);
|
|
for (const verb of verbs)
|
|
routes.push({
|
|
method: verb.toUpperCase(),
|
|
path: (router.basePath + (path === "/" ? "" : path)).replace(/\/$/, "") || "/",
|
|
module: router.module,
|
|
access: internal
|
|
? "Internal operator"
|
|
: (tag ??
|
|
(router.module === "health"
|
|
? "Public"
|
|
: router.module === "images"
|
|
? "Authenticated"
|
|
: "Handler authentication")),
|
|
localOnly: !!(router.localOnly || spec?.localOnly),
|
|
...(typeof spec?.reason === "string" ? { publicReason: spec.reason } : {}),
|
|
...(spec?.mcp ? { mcp: spec.mcp } : {}),
|
|
...(!spec?.mcp && (spec?.mcpExcluded ?? router.mcpExcluded)
|
|
? { mcpExcluded: spec?.mcpExcluded ?? router.mcpExcluded } : {}),
|
|
source: file,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
ts.forEachChild(node, (child) => visit(child, bindings, unresolvedLoop));
|
|
}
|
|
visit(source, new Map());
|
|
return routes;
|
|
}
|