UNPKG

nats-micro

Version:

NATS micro compatible extra-lightweight microservice library

283 lines (236 loc) 9.15 kB
import moment from 'moment'; import { isUndefined } from 'util'; import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JsonSchema7Type } from 'zod-to-json-schema/src/parseDef.js'; import { Broker } from '../broker.js'; import { localConfig } from '../localConfig.js'; import { BaseMethodData, BaseMicroserviceData, Handler, MessageHandler, MethodProfile, MicroserviceConfig, MicroserviceInfo, MicroserviceMethodConfig, MicroservicePing, MicroserviceRegistration, MicroserviceRegistrationSubject, MicroserviceSchema, MicroserviceStats, Request, Response, } from '../types/index.js'; import { randomId, wrapMethod, attachThreadContext, } from '../utils/index.js'; const emptyMethodProfile: MethodProfile = { num_requests: 0, num_errors: 0, last_error: '', processing_time: 0, average_processing_time: 0, }; export type DiscoveryOptions = { transformConfig?: (config: MicroserviceConfig) => MicroserviceConfig; }; export class Discovery { public readonly id: string; public readonly startedAt: Date; public readonly methodStats: Record<string, MethodProfile> = {}; private readonly handleSchemaWrap: MessageHandler<void>; private readonly handleInfoWrap: MessageHandler<void>; private readonly handlePingWrap: MessageHandler<void>; private readonly handleStatsWrap: MessageHandler<void>; private _isStarted = false; constructor( private readonly broker: Broker, private readonly configOrGetter: MicroserviceConfig | (() => MicroserviceConfig), private readonly options: DiscoveryOptions = {}, ) { this.startedAt = new Date(); this.id = randomId(); const wrap = <T, R>(handler: Handler<T, R>, name: string) => wrapMethod( this.broker, attachThreadContext(this.id, handler.bind(this)), { microservice: this.originalConfig.name, method: name }, ); this.handleSchemaWrap = wrap(this.handleSchema, 'handleSchema'); this.handleInfoWrap = wrap(this.handleInfo, 'handleInfo'); this.handlePingWrap = wrap(this.handlePing, 'handlePing'); this.handleStatsWrap = wrap(this.handleStats, 'handleStats'); } public get originalConfig(): MicroserviceConfig { if (typeof (this.configOrGetter) === 'function') return this.configOrGetter(); return this.configOrGetter; } public get config(): MicroserviceConfig { let config = this.originalConfig; if (this.options.transformConfig) config = this.options.transformConfig(config); return config; } public get isStarted(): boolean { return this._isStarted; } public async start(): Promise<this> { this._isStarted = true; this.broker.on('$SRV.SCHEMA', this.handleSchemaWrap); this.broker.on(`$SRV.SCHEMA.${this.config.name}`, this.handleSchemaWrap); this.broker.on(`$SRV.SCHEMA.${this.config.name}.${this.id}`, this.handleSchemaWrap); this.broker.on('$SRV.INFO', this.handleInfoWrap); this.broker.on(`$SRV.INFO.${this.config.name}`, this.handleInfoWrap); this.broker.on(`$SRV.INFO.${this.config.name}.${this.id}`, this.handleInfoWrap); this.broker.on('$SRV.PING', this.handlePingWrap); this.broker.on(`$SRV.PING.${this.config.name}`, this.handlePingWrap); this.broker.on(`$SRV.PING.${this.config.name}.${this.id}`, this.handlePingWrap); this.broker.on('$SRV.STATS', this.handleStatsWrap); this.broker.on(`$SRV.STATS.${this.config.name}`, this.handleStatsWrap); this.broker.on(`$SRV.STATS.${this.config.name}.${this.id}`, this.handleStatsWrap); await this.publish(); return this; } public async stop(): Promise<this> { this._isStarted = false; await this.publishRegistration('down'); this.broker.off('$SRV.SCHEMA', this.handleSchemaWrap); this.broker.off(`$SRV.SCHEMA.${this.config.name}`, this.handleSchemaWrap); this.broker.off(`$SRV.SCHEMA.${this.config.name}.${this.id}`, this.handleSchemaWrap); this.broker.off('$SRV.INFO', this.handleInfoWrap); this.broker.off(`$SRV.INFO.${this.config.name}`, this.handleInfoWrap); this.broker.off(`$SRV.INFO.${this.config.name}.${this.id}`, this.handleInfoWrap); this.broker.off('$SRV.PING', this.handlePingWrap); this.broker.off(`$SRV.PING.${this.config.name}`, this.handlePingWrap); this.broker.off(`$SRV.PING.${this.config.name}.${this.id}`, this.handlePingWrap); this.broker.off('$SRV.STATS', this.handleStatsWrap); this.broker.off(`$SRV.STATS.${this.config.name}`, this.handleStatsWrap); this.broker.off(`$SRV.STATS.${this.config.name}.${this.id}`, this.handleStatsWrap); return this; } public async publish(): Promise<void> { await this.publishRegistration('up'); } private async publishRegistration(state: MicroserviceRegistration['state']): Promise<void> { await this.broker.send( MicroserviceRegistrationSubject, { info: this.makeInfo(), state, } as MicroserviceRegistration, ); } public profileMethod( name: string, error: string | undefined, time: number, ): void { if (!this.methodStats[name]) this.methodStats[name] = { ...emptyMethodProfile }; const method = this.methodStats[name]; method.num_requests++; if (!isUndefined(error)) { method.num_errors++; method.last_error = error; } method.processing_time += time; method.average_processing_time = Math.round(Number(method.processing_time) / method.num_requests); } private makeMicroserviceData(): BaseMicroserviceData { return { name: this.config.name, id: this.id, version: this.config.version, metadata: { '_nats.client.created.library': localConfig.name, '_nats.client.created.version': localConfig.version, '_nats.client.id': String(this.broker.clientId), ...(!isUndefined(this.broker.name) ? { 'nats.micro.ext.v1.service.node': this.broker.name } : {} ), ...this.config.metadata, }, }; } private makeMethodData( name: string, method: MicroserviceMethodConfig<unknown, unknown>, ): BaseMethodData { return { name: name, subject: this.getMethodSubject(name, method), }; } public getMethodSubject<R, T>( name: string, method: MicroserviceMethodConfig<R, T>, ): string { if (method.subject) return method.subject; if (method.local) return `${this.config.name}.${this.id}.${name}`; return `${this.config.name}.${name}`; } public makeInfo(): MicroserviceInfo { return { ...this.makeMicroserviceData(), description: this.config.description, type: 'io.nats.micro.v1.info_response', endpoints: Object.entries(this.config.methods) .map(([n, m]) => { const metadata = { ...m.metadata }; if (m.unbalanced) metadata['nats.micro.ext.v1.method.unbalanced'] = 'true'; if (m.local) metadata['nats.micro.ext.v1.method.local'] = 'true'; return { ...this.makeMethodData(n, m), metadata, // TODO maybe we should send NULL instead of empty object }; }), }; } public getMethodSchema<T, R>( name: string, kind: keyof Pick<MicroserviceMethodConfig<T, R>, 'request' | 'response'>, ): z.ZodType | undefined { const method = this.config.methods[name]; return method ? (method[kind] ?? z.void()) : undefined; } public getMethodJsonSchema<T, R>( name: string, kind: keyof Pick<MicroserviceMethodConfig<T, R>, 'request' | 'response'>, ): JsonSchema7Type | undefined { const schema = this.getMethodSchema(name, kind); return schema ? zodToJsonSchema(schema) : undefined; } private handleSchema(_req: Request<void>, res: Response<MicroserviceSchema>): void { res.send({ ...this.makeMicroserviceData(), type: 'io.nats.micro.v1.schema_response', endpoints: Object.entries(this.config.methods) .map(([n, m]) => ({ ...this.makeMethodData(n, m), schema: { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion request: this.getMethodJsonSchema(n, 'request')!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion response: this.getMethodJsonSchema(n, 'response')!, }, })), }); } private handleInfo(_req: Request<void>, res: Response<MicroserviceInfo>): void { res.send(this.makeInfo()); } private handleStats(_req: Request<void>, res: Response<MicroserviceStats>): void { res.send({ ...this.makeMicroserviceData(), type: 'io.nats.micro.v1.stats_response', started: moment(this.startedAt).toISOString(), endpoints: Object.entries(this.config.methods) .map(([n, m]) => ({ ...this.makeMethodData(n, m), ...(this.methodStats[n] ?? emptyMethodProfile), })), }); } private handlePing(_req: Request<void>, res: Response<MicroservicePing>): void { res.send({ ...this.makeMicroserviceData(), type: 'io.nats.micro.v1.ping_response', }); } }