UNPKG

@swell/cli

Version:

Swell's command line interface/utility

312 lines (311 loc) 11.7 kB
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)); }