@eang/core
Version:
eang - model driven enterprise event processing
415 lines • 16.9 kB
JavaScript
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