UNPKG

@sap/ams-dev

Version:

NodesJS AMS development environment

534 lines (455 loc) 16.2 kB
const fs = require('fs').promises; const fsSync = require('fs'); const path = require('path'); // Load the Go WASM exec helper require('./lib/wasm/wasm_exec.js'); const WASM_PATH = path.join(__dirname, 'lib', 'wasm', 'compiler.wasm'); // The WASM binary is constant and the Go runtime registers its functions on // the global object. Instantiating it more than once would run redundant Go // runtimes that all overwrite the same globals, so we instantiate once and // share it across all DclCompiler instances. `wasmReady` tracks whether the // module has been started; `wasmInitPromise` de-duplicates concurrent async // initializations. let wasmReady = false; let wasmInitPromise = null; // Instantiate and start the Go WASM runtime synchronously from an already-read // buffer. Go's run() registers the wasm* globals before it returns, so no // asynchronous wait is required afterwards. function startWasm(wasmBuffer) { const go = new Go(); const module = new WebAssembly.Module(wasmBuffer); const instance = new WebAssembly.Instance(module, go.importObject); // Start Go runtime (non-blocking); it registers the wasm* globals before returning. go.run(instance); // Verify functions are available if (typeof global.wasmCreateProject !== 'function') { throw new Error('WASM module failed to initialize properly'); } wasmReady = true; } function ensureWasmExists() { if (!fsSync.existsSync(WASM_PATH)) { throw new Error( 'WASM module not found. Run "npm run build:wasm" to build it.' ); } } // Synchronously instantiate the WASM module. Safe to call repeatedly. function initWasmSync() { if (wasmReady) return; ensureWasmExists(); startWasm(fsSync.readFileSync(WASM_PATH)); } // Asynchronously instantiate the WASM module, de-duplicating concurrent calls. function initWasm() { if (wasmReady) return Promise.resolve(); if (wasmInitPromise) return wasmInitPromise; wasmInitPromise = (async () => { ensureWasmExists(); startWasm(await fs.readFile(WASM_PATH)); })(); // Allow a retry on failure rather than caching a rejected promise forever wasmInitPromise.catch(() => { wasmInitPromise = null; }); return wasmInitPromise; } /** * DCL Compiler WASM wrapper for Node.js * * Provides a JavaScript API for the DCL compiler compiled to WebAssembly. * The underlying WASM module is instantiated once and shared across all * instances; project state is isolated per project ID inside the module. * * @example * const { DclCompiler } = require('@sci/dcl-compiler-wasm'); * * const compiler = new DclCompiler(); * await compiler.init(); * * const projectId = compiler.createProject(); * await compiler.addDclSourceFromFile(projectId, 'schema.dcl', './schema.dcl'); * const result = compiler.validate(projectId); */ class DclCompiler { constructor() { this.ready = false; } /** * Initialize the WASM module (async) * Must be called before using any other methods * * @returns {Promise<void>} */ async init() { if (this.ready) return; await initWasm(); this.ready = true; } /** * Initialize the WASM module synchronously. * Alternative to init() for fully synchronous usage. */ initSync() { if (this.ready) return; initWasmSync(); this.ready = true; } /** * Create a new project * * @returns {string} Project ID (UUID) */ createProject() { this._ensureReady(); return global.wasmCreateProject(); } /** * Delete a project and free its memory * * @param {string} projectId - Project ID returned by createProject() */ deleteProject(projectId) { this._ensureReady(); const error = global.wasmDeleteProject(projectId); if (error) { this._throwError(error); } } /** * Add a DCL source file to the project * * @param {string} projectId - Project ID * @param {string} filename - File name (should reflect package structure, e.g., "pkg/policy.dcl") * @param {string} content - DCL source code */ addDclSource(projectId, filename, content) { this._ensureReady(); const error = global.wasmAddDclSource(projectId, filename, content); if (error) { this._throwError(error); } } /** * Add a DCN JSON source file to the project * * @param {string} projectId - Project ID * @param {string} filename - File name * @param {string} content - DCN JSON content (as string) */ addDcnSource(projectId, filename, content) { this._ensureReady(); const error = global.wasmAddDcnSource(projectId, filename, content); if (error) { this._throwError(error); } } /** * Add a DCL source file from filesystem (async) * * @param {string} projectId - Project ID * @param {string} filename - Logical filename (e.g., "pkg/policy.dcl") * @param {string} filePath - Actual file path on filesystem * @returns {Promise<void>} */ async addDclSourceFromFile(projectId, filename, filePath) { const content = await fs.readFile(filePath, 'utf8'); this.addDclSource(projectId, filename, content); } /** * Add a DCN source file from filesystem (async) * * @param {string} projectId - Project ID * @param {string} filename - Logical filename * @param {string} filePath - Actual file path on filesystem * @returns {Promise<void>} */ async addDcnSourceFromFile(projectId, filename, filePath) { const content = await fs.readFile(filePath, 'utf8'); this.addDcnSource(projectId, filename, content); } /** * Load all DCL/DCN files from a directory recursively (async) * * @param {string} projectId - Project ID * @param {string} dirPath - Directory path to scan * @param {Object} options - Options * @param {string} [options.baseDir] - Base directory for relative paths (defaults to dirPath) * @param {string[]} [options.extensions=['.dcl', '.dcn']] - File extensions to include * @param {string[]} [options.exclude=[]] - Patterns to exclude (relative paths) * @returns {Promise<{loaded: number, files: string[]}>} Number of files loaded and their paths */ async loadFromDirectory(projectId, dirPath, options = {}) { const { baseDir = dirPath, extensions = ['.dcl', '.dcn'], exclude = [] } = options; this._ensureReady(); const files = await this._scanDirectory(dirPath, extensions, exclude); const loadedFiles = []; for (const filePath of files) { // Calculate relative path from baseDir const relativePath = path.relative(baseDir, filePath); // Normalize path separators to forward slashes const normalizedPath = relativePath.split(path.sep).join('/'); const ext = path.extname(filePath); try { if (ext === '.dcl') { await this.addDclSourceFromFile(projectId, normalizedPath, filePath); loadedFiles.push(normalizedPath); } else if (ext === '.dcn') { await this.addDcnSourceFromFile(projectId, normalizedPath, filePath); loadedFiles.push(normalizedPath); } } catch (error) { // Add file path to error for better debugging error.message = `Error loading ${normalizedPath}: ${error.message}`; throw error; } } return { loaded: loadedFiles.length, files: loadedFiles }; } /** * Recursively scan directory for files with given extensions * @private */ async _scanDirectory(dirPath, extensions, exclude = []) { const files = []; const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); const relativePath = path.relative(process.cwd(), fullPath); // Check if path should be excluded if (exclude.some(pattern => relativePath.includes(pattern))) { continue; } if (entry.isDirectory()) { // Recursively scan subdirectories const subFiles = await this._scanDirectory(fullPath, extensions, exclude); files.push(...subFiles); } else if (entry.isFile()) { const ext = path.extname(entry.name); if (extensions.includes(ext)) { files.push(fullPath); } } } return files; } /** * Load all DCL/DCN files from a directory recursively (synchronous) * @param {string} projectId - Project ID * @param {string} dirPath - Directory path to scan * @param {Object} options - See loadFromDirectory * @returns {{loaded: number, files: string[]}} */ loadFromDirectorySync(projectId, dirPath, options = {}) { const { baseDir = dirPath, extensions = ['.dcl', '.dcn'], exclude = [] } = options; this._ensureReady(); const files = this._scanDirectorySync(dirPath, extensions, exclude); const loadedFiles = []; for (const filePath of files) { const relativePath = path.relative(baseDir, filePath); const normalizedPath = relativePath.split(path.sep).join('/'); const ext = path.extname(filePath); try { if (ext === '.dcl') { this.addDclSource(projectId, normalizedPath, fsSync.readFileSync(filePath, 'utf8')); loadedFiles.push(normalizedPath); } else if (ext === '.dcn') { this.addDcnSource(projectId, normalizedPath, fsSync.readFileSync(filePath, 'utf8')); loadedFiles.push(normalizedPath); } } catch (error) { error.message = `Error loading ${normalizedPath}: ${error.message}`; throw error; } } return { loaded: loadedFiles.length, files: loadedFiles }; } /** * Recursively scan directory for files with given extensions (synchronous) * @private */ _scanDirectorySync(dirPath, extensions, exclude = []) { const files = []; const entries = fsSync.readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); const relativePath = path.relative(process.cwd(), fullPath); if (exclude.some(pattern => relativePath.includes(pattern))) { continue; } if (entry.isDirectory()) { files.push(...this._scanDirectorySync(fullPath, extensions, exclude)); } else if (entry.isFile()) { const ext = path.extname(entry.name); if (extensions.includes(ext)) { files.push(fullPath); } } } return files; } /** * Validate the project * * @param {string} projectId - Project ID * @param {Object} config - Validation configuration * @param {boolean} [config.tenantPackages=false] - Whether to allow tenant packages * @param {boolean} [config.transitiveErrors=false] - Whether to report transitive errors * @returns {Object} Validation result with hasErrors, highestSeverity, findings */ validate(projectId, config = {}) { this._ensureReady(); const configJson = JSON.stringify({ tenantPackages: config.tenantPackages || false, transitiveErrors: config.transitiveErrors || false }); const resultJson = global.wasmValidate(projectId, configJson); const result = JSON.parse(resultJson); if (result.error) { throw new Error(result.message); } return result; } /** * Encode documents to DCN format * * @param {string} projectId - Project ID * @param {string[]} filters - Document filters: "Valid", "Invalid", "Nonlocal" * @returns {Object} Map of filename → DCN JSON string */ encodeToDcn(projectId, filters = ['Valid']) { this._ensureReady(); const filterJson = JSON.stringify({ filters }); const outputJson = global.wasmEncodeToDcn(projectId, filterJson); const result = JSON.parse(outputJson); if (result.error) { throw new Error(result.message); } return result; } /** * Convenience method: Compile DCL files to DCN * * @param {Object<string, string>} files - Map of filename → DCL content * @param {Object} config - Validation configuration * @returns {Object} Result with success, dcnOutputs, findings */ compileDcl(files, config = {}) { const projectId = this.createProject(); try { // Add all sources for (const [filename, content] of Object.entries(files)) { this.addDclSource(projectId, filename, content); } // Validate const validationResult = this.validate(projectId, config); // Encode if no errors let dcnOutputs = {}; if (!validationResult.hasErrors || config.forceEncoding === true) { dcnOutputs = this.encodeToDcn(projectId, config.forceEncoding === true ? [] : ['Valid']); } return { success: !validationResult.hasErrors, hasErrors: validationResult.hasErrors, highestSeverity: validationResult.highestSeverity, findings: validationResult.findings, dcnOutputs }; } finally { this.deleteProject(projectId); } } /** * Convenience method: Compile DCL directory to DCN (async) * * @param {string} dirPath - Directory containing DCL files * @param {Object} options - Options * @param {Object} [options.config] - Validation configuration * @param {string} [options.baseDir] - Base directory for relative paths * @param {string[]} [options.extensions] - File extensions to include * @param {string[]} [options.exclude] - Patterns to exclude * @param {boolean} [options.forceEncoding] - Also write all dcn files, if there are validation errors * @returns {Promise<Object>} Result with success, dcnOutputs, findings, filesLoaded */ async compileDirectory(dirPath, options = {}) { const { config = { }, ...loadOptions } = options; const projectId = this.createProject(); try { // Load all files from directory const { loaded, files } = await this.loadFromDirectory(projectId, dirPath, loadOptions); // Validate const validationResult = this.validate(projectId, config); // Encode if no errors let dcnOutputs = {}; if (!validationResult.hasErrors || options.forceEncoding === true) { dcnOutputs = this.encodeToDcn(projectId, options.forceEncoding === true ? [] : ['Valid']); } return { success: !validationResult.hasErrors, hasErrors: validationResult.hasErrors, highestSeverity: validationResult.highestSeverity, findings: validationResult.findings, dcnOutputs, filesLoaded: loaded, files }; } finally { this.deleteProject(projectId); } } /** * Convenience method: Compile DCL directory to DCN (synchronous) * * Synchronous counterpart to compileDirectory. Requires the compiler to be * initialized via initSync() or init() beforehand. * * @param {string} dirPath - Directory containing DCL files * @param {Object} options - See compileDirectory * @returns {Object} Result with success, dcnOutputs, findings, filesLoaded */ compileDirectorySync(dirPath, options = {}) { const { config = { }, ...loadOptions } = options; const projectId = this.createProject(); try { const { loaded, files } = this.loadFromDirectorySync(projectId, dirPath, loadOptions); const validationResult = this.validate(projectId, config); let dcnOutputs = {}; if (!validationResult.hasErrors || options.forceEncoding === true) { dcnOutputs = this.encodeToDcn(projectId, options.forceEncoding === true ? [] : ['Valid']); } return { success: !validationResult.hasErrors, hasErrors: validationResult.hasErrors, highestSeverity: validationResult.highestSeverity, findings: validationResult.findings, dcnOutputs, filesLoaded: loaded, files }; } finally { this.deleteProject(projectId); } } _ensureReady() { if (!this.ready) { throw new Error('Compiler not initialized. Call init() first.'); } } _throwError(errorJson) { const error = JSON.parse(errorJson); throw new Error(error.message || error.error); } } module.exports = { DclCompiler };