Files
MikrotikManager/backend/scripts/build-database-operator.mjs
T

109 lines
8.7 KiB
JavaScript

/** Build-time only. Fixed operator artifact; never invoked by server startup. */
import assert from 'node:assert/strict'
import {createHash} from 'node:crypto'
import {isBuiltin} from 'node:module'
import {readFile,writeFile,mkdir,stat,lstat,readdir} from 'node:fs/promises'
import path from 'node:path'
import {fileURLToPath,pathToFileURL} from 'node:url'
import ts from 'typescript'
export const OPERATOR_SOURCES=Object.freeze([
'database-writer-bootstrap-operator.mjs','database-writer-profile-operator.mts',
'writer-profile/operator-files.mjs','writer-profile/operator-contract.mjs','writer-profile/publisher.mjs','writer-profile/context.mjs',
'writer-profile/catalogue.mts','writer-profile/catalogue-value-bridge.ts','writer-profile/catalogue-projection.ts','writer-profile/catalogue-leaves.ts',
'writer-profile/application-writer-profile-catalogue.ts','writer-profile/application-writer-profile-catalogue-values.ts',
'writer-profile/application-writer-profile-catalogue-parent-policy.ts','writer-profile/application-writer-profile-canonical.ts',
'writer-profile/fixed44-build-sql.ts','writer-profile/fixed44-build-definition.ts','writer-profile/expanded44-data-policy.ts',
])
const emitted=source=>source.replace(/\.mts$/,'.mjs').replace(/\.ts$/,'.js')
const digest=value=>createHash('sha256').update(value).digest('hex')
const packageName=specifier=>specifier.startsWith('@')?specifier.split('/').slice(0,2).join('/'):specifier.split('/')[0]
/** The transformer changes only module specifier AST nodes, never SQL/string data. */
export function compileOperatorModule(source,fileName){
const rewrite=specifier=>specifier.replace(/^(\.\.\/|\.\.\/\.\.\/)src\//,'$1dist/')
const result=ts.transpileModule(source,{fileName,reportDiagnostics:true,compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext,verbatimModuleSyntax:true,newLine:ts.NewLineKind.LineFeed},transformers:{before:[context=>{
const visit=node=>{
if(ts.isImportDeclaration(node)&&ts.isStringLiteral(node.moduleSpecifier))return context.factory.updateImportDeclaration(node,node.modifiers,node.importClause,context.factory.createStringLiteral(rewrite(node.moduleSpecifier.text)),node.attributes)
if(ts.isExportDeclaration(node)&&node.moduleSpecifier&&ts.isStringLiteral(node.moduleSpecifier))return context.factory.updateExportDeclaration(node,node.modifiers,node.isTypeOnly,node.exportClause,context.factory.createStringLiteral(rewrite(node.moduleSpecifier.text)),node.attributes)
if(ts.isCallExpression(node)&&node.expression.kind===ts.SyntaxKind.ImportKeyword){
assert(node.arguments.length===1&&ts.isStringLiteral(node.arguments[0]),'Only fixed literal operator dynamic imports are supported')
return context.factory.updateCallExpression(node,node.expression,node.typeArguments,[context.factory.createStringLiteral(rewrite(node.arguments[0].text))])
}
return ts.visitEachChild(node,visit,context)
}
return sourceFile=>ts.visitNode(sourceFile,visit)
}]}})
const errors=result.diagnostics?.filter(item=>item.category===ts.DiagnosticCategory.Error)??[]
assert.equal(errors.length,0,'Operator transpilation diagnostics: '+errors.map(item=>ts.flattenDiagnosticMessageText(item.messageText,' ')).join('; '))
return result.outputText
}
export function moduleSpecifiers(source,fileName){
const parsed=ts.createSourceFile(fileName,source,ts.ScriptTarget.ES2022,true,ts.ScriptKind.JS),specifiers=[]
const visit=node=>{
if((ts.isImportDeclaration(node)||ts.isExportDeclaration(node))&&node.moduleSpecifier){assert(ts.isStringLiteral(node.moduleSpecifier));specifiers.push(node.moduleSpecifier.text)}
if(ts.isCallExpression(node)&&node.expression.kind===ts.SyntaxKind.ImportKeyword){assert(node.arguments.length===1&&ts.isStringLiteral(node.arguments[0]),'Computed runtime import refused');specifiers.push(node.arguments[0].text)}
ts.forEachChild(node,visit)
}
visit(parsed);return specifiers
}
export async function buildDatabaseOperator({backendDirectory=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),outputDirectory=path.join(backendDirectory,'dist-operator')}={}){
const backend=path.resolve(backendDirectory),output=path.resolve(outputDirectory),packageJson=JSON.parse(await readFile(path.join(backend,'package.json'),'utf8'))
const modules=new Map(),allowed=new Set(OPERATOR_SOURCES.map(emitted)),runtimeDependencies=new Set(),distImports=new Set()
const productionDependency=specifier=>{
if(isBuiltin(specifier))return
const dependency=packageName(specifier);assert(Object.hasOwn(packageJson.dependencies,dependency),'Operator runtime dependency must be production: '+dependency);runtimeDependencies.add(dependency)
}
for(const file of OPERATOR_SOURCES){
const raw=await readFile(path.join(backend,'scripts',file)),code=compileOperatorModule(new TextDecoder('utf-8',{fatal:true}).decode(raw),file),target=emitted(file)
const imports=moduleSpecifiers(code,target)
for(const specifier of imports){
assert(!/\.(?:ts|mts)$/.test(specifier)&&!/(?:^|\/)src\//.test(specifier),'Source runtime import refused')
if(specifier.startsWith('.')){
const resolved=path.posix.normalize(path.posix.join(path.posix.dirname(target),specifier))
if(resolved.startsWith('../dist/')){
const relative=resolved.slice('../dist/'.length)
assert(relative.endsWith('.js')&&!relative.split('/').includes('..'),'Fixed compiled backend helper required')
assert((await stat(path.join(backend,'dist',relative))).isFile(),'Run backend tsc before operator build')
distImports.add(relative)
}else assert(allowed.has(resolved),'Operator import outside fixed build graph: '+specifier)
}else productionDependency(specifier)
}
modules.set(target,{code,source:file,sourceSha256:digest(raw),sha256:digest(code),imports})
}
// Validate the referenced compiled helper closure as part of the production
// build, not only in tests. Keep this bounded to the small operator graph.
const backendDistModules=new Map()
async function visitDist(file){
assert(file.endsWith('.js')&&!/[\\:]/.test(file)&&!file.startsWith('/')&&!file.split('/').includes('..')&&!file.split('/').includes('src'),'Compiled helper escaped backend/dist')
if(backendDistModules.has(file))return
assert(backendDistModules.size<64,'Operator compiled helper graph exceeded 64 modules')
const raw=await readFile(path.join(backend,'dist',file)),imports=moduleSpecifiers(new TextDecoder('utf-8',{fatal:true}).decode(raw),file)
backendDistModules.set(file,{file,sha256:digest(raw),imports})
for(const specifier of imports){
assert(!/\.(?:ts|mts)$/.test(specifier)&&!/(?:^|\/)src\//.test(specifier),'Source runtime import refused')
if(specifier.startsWith('.'))await visitDist(path.posix.normalize(path.posix.join(path.posix.dirname(file),specifier)))
else productionDependency(specifier)
}
}
for(const file of distImports)await visitDist(file)
// Refuse stale/unlisted output rather than shipping an extra file (e.g. a key)
// through Docker's directory COPY. Never delete/adopt unknown build contents.
try{
assert((await lstat(output)).isDirectory(),'Ordinary operator output directory required')
for(const item of await readdir(output,{recursive:true,withFileTypes:true})){
const relative=path.relative(output,path.join(item.parentPath,item.name)).split(path.sep).join('/')
assert(item.isDirectory()?relative==='writer-profile':item.isFile()&&(allowed.has(relative)||relative==='build-manifest.json'),'Unexpected operator output entry: '+relative)
}
}catch(error){if(error.code!=='ENOENT')throw error}
// Validate the whole fixed graph before publishing any generated module.
await mkdir(output,{recursive:true})
for(const [file,module] of modules){await mkdir(path.dirname(path.join(output,file)),{recursive:true});await writeFile(path.join(output,file),module.code)}
const manifest={format:'application-database-operator-build-v1',compilerVersion:ts.version,entrypoints:['database-writer-bootstrap-operator.mjs','database-writer-profile-operator.mjs'],modules:[...modules].map(([file,module])=>({file,source:module.source,sourceSha256:module.sourceSha256,sha256:module.sha256,imports:module.imports})),backendDistImports:[...distImports].sort(),backendDistModules:[...backendDistModules.values()].sort((a,b)=>a.file.localeCompare(b.file)),runtimeDependencies:[...runtimeDependencies].sort(),automaticStartup:false,readyAuthorized:false}
await writeFile(path.join(output,'build-manifest.json'),JSON.stringify(manifest,null,2)+'\n')
return manifest
}
if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href){
assert.equal(process.argv.length,2,'Build takes no runtime configuration or key arguments')
const result=await buildDatabaseOperator();console.log(JSON.stringify({event:'database-operator-built',modules:result.modules.length,automaticStartup:false,readyAuthorized:false}))
}