UNPKG

@eang/core

Version:

eang - model driven enterprise event processing

415 lines 16.9 kB
import * as fs from 'fs'; import { join, isAbsolute } from 'path'; import { ErrorEventInstanceObj, FunctionInstanceObj } from './objects.js'; import { NatsStreamService } from './NatsStreamingService.js'; import { filter } from 'rxjs/operators'; import { logger } from './logger.js'; import { FunctionStartContext } from './context.js'; import { EntityEvent } from './entity.js'; import * as v from 'valibot'; // Configuration utility class to centralize config merging logic class ConfBuilder { static buildBaseConfig(conf) { // Handle EANG_SERVERS as JSON array if from environment let servers = conf?.servers; if (!servers && process.env.EANG_SERVERS) { try { const serversArray = JSON.parse(process.env.EANG_SERVERS); if (Array.isArray(serversArray)) { servers = serversArray.join(','); } else { servers = process.env.EANG_SERVERS; } } catch { // Fallback to treating as comma-separated string servers = process.env.EANG_SERVERS; } } const configToValidate = { tenant: conf?.tenant || process.env.EANG_TENANT, organizationalUnit: conf?.organizationalUnit || process.env.EANG_ORGANIZATIONAL_UNIT, servers: servers, user: conf?.user || process.env.EANG_USER, password: conf?.password || process.env.EANG_PASSWORD, secretsFilePath: conf?.secretsFilePath || process.env.EANG_SECRETS_FILE_PATH, logLevel: conf?.logLevel || process.env.EANG_LOG_LEVEL || 'info', secrets: conf?.secrets, secretsSchema: conf?.secretsSchema, env: conf?.env, envSchema: conf?.envSchema }; if (!configToValidate.password) { // Load secrets if password is not provided and no secrets object is given if (!configToValidate.secrets) { const path = configToValidate.secretsFilePath || (fs.existsSync('/run/secrets/secrets.json') ? '/run/secrets/secrets.json' : './secrets/secrets.json'); configToValidate.secretsFilePath = path; const { secrets: loadedSecrets, eangPassword } = this.loadSecrets(path, configToValidate.secretsSchema); if (eangPassword) { configToValidate.password = eangPassword; } configToValidate.secrets = loadedSecrets; } else if (configToValidate.secrets && typeof configToValidate.secrets === 'object' && 'EANG_PASSWORD' in configToValidate.secrets) { configToValidate.password = configToValidate.secrets.EANG_PASSWORD; } } return configToValidate; } static buildFunctionConfig(conf) { const baseConfig = this.buildBaseConfig(conf); return { ...baseConfig, instanceOf: conf?.instanceOf || process.env.EANG_INSTANCE_OF }; } static buildServiceConfig(conf) { const baseConfig = this.buildBaseConfig(conf); let subscriptions = conf?.subscriptions; if (!subscriptions) { const subscriptionString = process.env.EANG_SUBSCRIPTIONS; if (subscriptionString && subscriptionString.length > 0) { // Parse as comma-separated values subscriptions = subscriptionString .split(',') .map((s) => s.trim()) .filter((s) => s.length > 0); } } return { ...baseConfig, instanceOf: conf?.instanceOf || process.env.EANG_INSTANCE_OF, subscriptions: subscriptions || [] }; } static loadSecrets(filePath, schema) { const absolutePath = isAbsolute(filePath) ? filePath : join(process.cwd(), filePath); try { if (fs.existsSync(absolutePath)) { const secretsContent = fs.readFileSync(absolutePath, 'utf-8'); const rawSecrets = JSON.parse(secretsContent); if (!rawSecrets || typeof rawSecrets !== 'object' || Object.keys(rawSecrets).length === 0) { logger.error(`Secrets file '${absolutePath}' is empty or invalid`); } // Extract EANG_PASSWORD before validation const eangPassword = 'EANG_PASSWORD' in rawSecrets ? String(rawSecrets.EANG_PASSWORD) : undefined; // Validate with schema if provided if (schema) { try { const validatedSecrets = v.parse(schema, rawSecrets); return { secrets: validatedSecrets, eangPassword }; } catch (error) { if (error instanceof v.ValiError) { throw this.formatValiError(error, 'Secrets'); } throw error; } } // Ensure all values are strings (default behavior when no schema) const processedSecrets = {}; for (const [key, value] of Object.entries(rawSecrets)) { processedSecrets[key] = String(value); } return { secrets: processedSecrets, eangPassword }; } else { logger.error(`Secrets file '${absolutePath}' not found!`); } return { secrets: undefined, eangPassword: undefined }; } catch (error) { if (error instanceof SyntaxError) { logger.error(error, `Failed to parse secret file '${absolutePath}': Invalid JSON format`); } else { console.log(error); logger.error(error, 'Failed to load secrets: Unknown error'); } return { secrets: undefined, eangPassword: undefined }; } } static formatValiError(error, contextName) { const errorMessages = error.issues .map((issue) => { const path = issue.path?.map((p) => p.key).join('.') || 'root'; return `${path}: ${issue.message}`; }) .join('; '); return new Error(`${contextName} configuration validation failed: ${errorMessages}`); } static validateConfig(schema, config, contextName) { try { return v.parse(schema, config); } catch (error) { if (error instanceof v.ValiError) { throw this.formatValiError(error, contextName); } throw error; } } } // Valibot validation schemas const EangConfigSchema = v.object({ tenant: v.pipe(v.string(), v.minLength(1, 'Tenant cannot be empty')), organizationalUnit: v.optional(v.string()), servers: v.pipe(v.string(), v.minLength(1, 'Servers cannot be empty')), user: v.pipe(v.string(), v.minLength(1, 'User cannot be empty')), password: v.pipe(v.string(), v.minLength(1, 'Password cannot be empty')), secretsFilePath: v.optional(v.pipe(v.string(), v.minLength(1, 'Secrets file path cannot be empty'))), secrets: v.optional(v.any()), secretsSchema: v.optional(v.any()), env: v.optional(v.any()), envSchema: v.optional(v.any()), logLevel: v.pipe(v.picklist(['trace', 'debug', 'info', 'warn', 'error', 'silent'])) }); const EangFunctionConfigSchema = v.object({ ...EangConfigSchema.entries, instanceOf: v.pipe(v.string(), v.minLength(1, 'instanceOf cannot be empty')) }); const EangServiceConfigSchema = v.object({ ...EangFunctionConfigSchema.entries, subscriptions: v.pipe(v.array(v.string()), v.minLength(1, 'At least one subscription is required')) }); export class EangBase { tenant; organizationalUnit; servers; user; _password; _secretsFilePath; _secretsSchema; _logLevel; _secrets; _env; _envSchema; _nss = new NatsStreamService(); _natsConnection; log = logger; get secrets() { if (!this._secrets) { const path = this._secretsFilePath || (fs.existsSync('/run/secrets/secrets.json') ? '/run/secrets/secrets.json' : './secrets/secrets.json'); const { secrets } = ConfBuilder.loadSecrets(path, this._secretsSchema); this._secrets = secrets; } return this._secrets || {}; } get env() { if (!this._env) { // If schema is provided, validate process.env against it if (this._envSchema) { try { this._env = v.parse(this._envSchema, process.env); } catch (error) { if (error instanceof v.ValiError) { throw ConfBuilder.formatValiError(error, 'Environment variables'); } throw error; } } } return this._env || {}; } get natConf() { return { servers: this.servers, user: this.user, pass: this._password }; } constructor(conf) { // After validation, these fields are guaranteed to exist this.tenant = conf.tenant; this.organizationalUnit = conf.organizationalUnit; this.servers = conf.servers; this.user = conf.user; this._password = conf.password; this._secretsFilePath = conf.secretsFilePath; this._secretsSchema = conf.secretsSchema; this._logLevel = conf.logLevel || 'info'; if (conf.secrets) { this._secrets = conf.secrets; } if (conf.env) { this._env = conf.env; } this._envSchema = conf.envSchema; } async ensureStream(streamName, subjects) { if (!this._natsConnection) { const { nc, js, jsm } = await this._nss.initialize(this.natConf); this._natsConnection = { nc, js, jsm }; } const stream = await this._nss.ensureStream(streamName, subjects); return stream; } async publish(events, options) { const opts = options || {}; if (!opts.tenant) { opts.tenant = this.tenant; } if (!opts.organizationalUnit && this.organizationalUnit) { opts.organizationalUnit = this.organizationalUnit; } if (!opts.user) { opts.user = this.user; } if (!this._natsConnection) { this._natsConnection = await this._nss.initialize(this.natConf); } const pubAcks = await this._nss.publish(events, opts); return pubAcks; } async shutdown() { this.log.debug('In eang shutdown'); if (this._natsConnection) { const { nc } = this._natsConnection; await nc.drain(); await nc.close(); this._natsConnection = undefined; } } } export class EangPublisher extends EangBase { constructor(conf) { const confToValidate = ConfBuilder.buildBaseConfig(conf); const validatedConf = ConfBuilder.validateConfig(EangConfigSchema, confToValidate, 'EangPublisher'); // Store secrets, env and schemas separately to maintain type safety const { secrets, secretsSchema, env, envSchema } = confToValidate; const baseConf = { ...validatedConf }; if (secrets !== undefined) baseConf.secrets = secrets; if (secretsSchema !== undefined) baseConf.secretsSchema = secretsSchema; if (env !== undefined) baseConf.env = env; if (envSchema !== undefined) baseConf.envSchema = envSchema; super(baseConf); } } export class EangFunction extends EangBase { instanceOf; _subscription; _handler; constructor(options) { const confToValidate = ConfBuilder.buildFunctionConfig(options.conf); const validatedConf = ConfBuilder.validateConfig(EangFunctionConfigSchema, confToValidate, 'EangFunction'); // Store secrets, env and schemas separately to maintain type safety const { secrets, secretsSchema, env, envSchema } = confToValidate; const baseConf = { ...validatedConf }; if (secrets !== undefined) baseConf.secrets = secrets; if (secretsSchema !== undefined) baseConf.secretsSchema = secretsSchema; if (env !== undefined) baseConf.env = env; if (envSchema !== undefined) baseConf.envSchema = envSchema; super(baseConf); this.instanceOf = validatedConf.instanceOf; this._handler = options.handler; } async start() { if (!this._natsConnection) { this._natsConnection = await this._nss.initialize({ servers: this.servers, user: this.user, pass: this._password }); } const stream = await this._nss.ensureConsumerStream(`${this.tenant}_FunctionInstance_${this.instanceOf}`, [`eang.obj.${this.tenant}.*.*.FunctionInstance.${this.instanceOf}.>`]); this._subscription = stream.event$ .pipe(filter((m) => m.subjectData.eventType === 'start' && m.subjectData.typeOf === 'FunctionInstance')) .subscribe(async (e) => { // let startContext: FunctionStartContext | undefined = undefined if (e.context) { const startContext = new FunctionStartContext(e.context); this.log.debug(startContext); const input = startContext?.data; let stopContext; try { stopContext = await this._handler(input ?? {}, this, startContext); } catch (error) { this.log.error(error, 'Unhandled error during function handler execution:'); stopContext = { errEvents: [ new ErrorEventInstanceObj({ name: 'UnhandledError', instanceOf: `ErrorEvent/${this.instanceOf}`, message: error.message, data: { errorMessage: error.message, stack: error.stack } }) ] }; } this.log.debug(stopContext); const subjectData = e.subjectData; const functionStopEvent = EntityEvent.stop(new FunctionInstanceObj({ key: subjectData.key, instanceOf: subjectData.instanceOf }), stopContext); const pubAcks = await this.publish([functionStopEvent]); this.log.debug(pubAcks); const ackAck = await e.msg.ackAck(); this.log.debug(ackAck); } else { throw new Error('No context found'); } }); return stream.startToConsume(); // return stream } async shutdown() { await super.shutdown(); if (this._subscription) { this._subscription.unsubscribe(); this._subscription = undefined; } } } export class EangService extends EangBase { instanceOf; subscriptions; constructor(conf) { const confToValidate = ConfBuilder.buildServiceConfig(conf); const validatedConf = ConfBuilder.validateConfig(EangServiceConfigSchema, confToValidate, 'EangService'); // Store secrets, env and schemas separately to maintain type safety const { secrets, secretsSchema, env, envSchema } = confToValidate; const baseConf = { ...validatedConf }; if (secrets !== undefined) baseConf.secrets = secrets; if (secretsSchema !== undefined) baseConf.secretsSchema = secretsSchema; if (env !== undefined) baseConf.env = env; if (envSchema !== undefined) baseConf.envSchema = envSchema; super(baseConf); this.instanceOf = validatedConf.instanceOf; this.subscriptions = validatedConf.subscriptions; } async start() { if (!this._natsConnection) { this._natsConnection = await this._nss.initialize(this.natConf); } const stream = await this._nss.ensureConsumerStream(this.instanceOf, this.subscriptions); return stream; } } //# sourceMappingURL=eang.js.map