@swell/cli
Version:
Swell's command line interface/utility
312 lines (311 loc) • 11.7 kB
JavaScript
import * as fs from 'node:fs/promises';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const ts = require('typescript');
const ORDINARY_TRIGGER_KEYS = ['route', 'model', 'cron'];
const WORKFLOW_FORBIDDEN_KEYS = [
'route',
'model',
'cron',
'extension',
'timeout',
];
export async function analyzeFunctionSource(filePath) {
const sourceText = await fs.readFile(filePath, 'utf8');
return analyzeFunctionSourceText(sourceText, filePath);
}
export function analyzeFunctionSourceText(sourceText, filePath = 'function.ts') {
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, filePath.endsWith('.tsx') || filePath.endsWith('.ts')
? ts.ScriptKind.TS
: ts.ScriptKind.JS);
const diagnostics = [];
const parseDiagnostics = sourceFile
.parseDiagnostics ?? [];
diagnostics.push(...parseDiagnostics.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')));
const configExport = findConfigExport(sourceFile);
let config;
let kind = 'unknown';
if (configExport?.initializer) {
const initializer = unwrapExpression(configExport.initializer);
const kindValue = readConfigKind(initializer);
if (kindValue === 'dynamic') {
diagnostics.push('config.kind must be a static string literal.');
}
const evalResult = evaluateStatic(initializer);
if (evalResult.errors.length > 0) {
if (kindValue === 'workflow') {
diagnostics.push(...evalResult.errors);
}
}
else if (isRecord(evalResult.value)) {
config = evalResult.value;
}
const configKind = config?.kind ?? kindValue;
if (configKind === undefined) {
kind = config ? 'function' : 'unknown';
}
else if (configKind === 'function' || configKind === 'workflow') {
kind = configKind;
}
else if (typeof configKind === 'string') {
diagnostics.push(`Unsupported config.kind "${configKind}".`);
}
}
const analysis = {
config,
kind,
diagnostics,
};
if (kind === 'workflow') {
analysis.defaultExport = validateWorkflowDefaultExport(sourceFile);
analysis.diagnostics.push(...validateWorkflowConfig(config));
if (!analysis.defaultExport.validWorkflowClass) {
analysis.diagnostics.push(analysis.defaultExport.reason ||
'Workflow default export must be a same-file class with a run method.');
}
}
return analysis;
}
export function getFunctionTriggers(config) {
return ORDINARY_TRIGGER_KEYS.filter((key) => Boolean(config[key]));
}
export function hasKindDiagnostic(analysis) {
return analysis.diagnostics.some((diagnostic) => diagnostic === 'config.kind must be a static string literal.' ||
diagnostic.startsWith('Unsupported config.kind '));
}
export function workflowStaticConfigError() {
return new Error('Workflow configs must export a static object literal so the CLI can choose the workflow bundling path before compiling.');
}
function findConfigExport(sourceFile) {
const localVariables = new Map();
let exportedConfigLocalName;
let directExport;
for (const statement of sourceFile.statements) {
if (ts.isVariableStatement(statement)) {
const isExported = hasModifier(statement, ts.SyntaxKind.ExportKeyword);
for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name)) {
continue;
}
localVariables.set(declaration.name.text, declaration.initializer);
if (isExported && declaration.name.text === 'config') {
directExport = { initializer: declaration.initializer };
}
}
}
if (ts.isExportDeclaration(statement) &&
statement.exportClause &&
ts.isNamedExports(statement.exportClause)) {
for (const element of statement.exportClause.elements) {
const exportedName = element.name.text;
if (exportedName === 'config') {
exportedConfigLocalName = (element.propertyName || element.name).text;
}
}
}
}
if (directExport) {
return directExport;
}
if (exportedConfigLocalName) {
return { initializer: localVariables.get(exportedConfigLocalName) };
}
}
function readConfigKind(expression) {
const unwrapped = unwrapExpression(expression);
if (!ts.isObjectLiteralExpression(unwrapped)) {
return undefined;
}
for (const property of unwrapped.properties) {
if (!ts.isPropertyAssignment(property)) {
continue;
}
const name = staticPropertyName(property.name);
if (name !== 'kind') {
continue;
}
const value = unwrapExpression(property.initializer);
return ts.isStringLiteral(value) ? value.text : 'dynamic';
}
}
function evaluateStatic(expression) {
const unwrapped = unwrapExpression(expression);
if (ts.isObjectLiteralExpression(unwrapped)) {
const value = {};
const errors = [];
for (const property of unwrapped.properties) {
if (ts.isSpreadAssignment(property)) {
errors.push('config must not use object spreads.');
continue;
}
if (!ts.isPropertyAssignment(property)) {
errors.push('config must contain only static property assignments.');
continue;
}
const name = staticPropertyName(property.name);
if (!name) {
errors.push('config must not use computed property keys.');
continue;
}
const result = evaluateStatic(property.initializer);
if (result.errors.length > 0) {
errors.push(...result.errors.map((error) => `${name}: ${error}`));
continue;
}
value[name] = result.value;
}
return { errors, value };
}
if (ts.isArrayLiteralExpression(unwrapped)) {
const value = [];
const errors = [];
for (const element of unwrapped.elements) {
if (ts.isSpreadElement(element)) {
errors.push('arrays must not use spreads.');
continue;
}
const result = evaluateStatic(element);
if (result.errors.length > 0) {
errors.push(...result.errors);
continue;
}
value.push(result.value);
}
return { errors, value };
}
if (ts.isStringLiteral(unwrapped) ||
ts.isNoSubstitutionTemplateLiteral(unwrapped)) {
return { errors: [], value: unwrapped.text };
}
if (ts.isNumericLiteral(unwrapped)) {
return { errors: [], value: Number(unwrapped.text) };
}
if (unwrapped.kind === ts.SyntaxKind.TrueKeyword) {
return { errors: [], value: true };
}
if (unwrapped.kind === ts.SyntaxKind.FalseKeyword) {
return { errors: [], value: false };
}
if (unwrapped.kind === ts.SyntaxKind.NullKeyword) {
return { errors: [], value: null };
}
if (ts.isPrefixUnaryExpression(unwrapped) &&
ts.isNumericLiteral(unwrapped.operand) &&
(unwrapped.operator === ts.SyntaxKind.MinusToken ||
unwrapped.operator === ts.SyntaxKind.PlusToken)) {
const value = Number(unwrapped.operand.text);
return {
errors: [],
value: unwrapped.operator === ts.SyntaxKind.MinusToken ? -value : value,
};
}
return { errors: ['value must be static JSON-like metadata.'] };
}
function validateWorkflowConfig(config) {
if (!config) {
return ['Workflow config must be a static object literal.'];
}
const diagnostics = [];
for (const key of WORKFLOW_FORBIDDEN_KEYS) {
if (Object.hasOwn(config, key)) {
diagnostics.push(`Workflow config must not specify ${key}.`);
}
}
return diagnostics;
}
function validateWorkflowDefaultExport(sourceFile) {
const classes = new Map();
const defaultExports = [];
for (const statement of sourceFile.statements) {
if (ts.isClassDeclaration(statement) && statement.name) {
classes.set(statement.name.text, statement);
}
if (ts.isClassDeclaration(statement) &&
hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) {
defaultExports.push(statement);
}
else if (ts.isFunctionDeclaration(statement) &&
hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) {
defaultExports.push(statement);
}
else if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
defaultExports.push(statement);
}
else if (ts.isExportDeclaration(statement) &&
statement.exportClause &&
ts.isNamedExports(statement.exportClause)) {
for (const element of statement.exportClause.elements) {
if (element.name.text === 'default') {
defaultExports.push(statement);
}
}
}
}
if (defaultExports.length !== 1) {
return {
validWorkflowClass: false,
reason: 'Workflow files must have exactly one default export.',
};
}
const defaultExport = defaultExports[0];
let workflowClass;
if (ts.isClassDeclaration(defaultExport)) {
workflowClass = defaultExport;
}
else if (ts.isExportAssignment(defaultExport)) {
const expression = unwrapExpression(defaultExport.expression);
if (ts.isIdentifier(expression)) {
workflowClass = classes.get(expression.text);
}
}
if (!workflowClass) {
return {
validWorkflowClass: false,
reason: 'Workflow default export must resolve to a class declared in the same file.',
};
}
if (!hasInstanceRunMethod(workflowClass)) {
return {
validWorkflowClass: false,
name: workflowClass.name?.text,
reason: 'Workflow default export class must define an instance run method.',
};
}
return {
validWorkflowClass: true,
name: workflowClass.name?.text,
};
}
function hasInstanceRunMethod(classDeclaration) {
return classDeclaration.members.some((member) => {
if (!ts.isMethodDeclaration(member) ||
hasModifier(member, ts.SyntaxKind.StaticKeyword)) {
return false;
}
return staticPropertyName(member.name) === 'run';
});
}
function unwrapExpression(expression) {
let current = expression;
while (ts.isParenthesizedExpression(current) ||
ts.isAsExpression(current) ||
ts.isTypeAssertionExpression(current) ||
ts.isSatisfiesExpression(current)) {
current = current.expression;
}
return current;
}
function staticPropertyName(name) {
if (ts.isIdentifier(name) ||
ts.isStringLiteral(name) ||
ts.isNumericLiteral(name)) {
return name.text;
}
}
function hasModifier(node, kind) {
return Boolean(ts.canHaveModifiers(node) &&
ts.getModifiers(node)?.some((modifier) => modifier.kind === kind));
}
function isRecord(value) {
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
}