@sap/ams-dev
Version:
NodesJS AMS development environment
534 lines (455 loc) • 16.2 kB
JavaScript
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 };