imean-service-engine
Version:
microservice engine
1,356 lines (1,346 loc) • 42 kB
JavaScript
import { z } from 'zod';
export * from 'zod';
import ejson4 from 'ejson';
import { LRUCache } from 'lru-cache';
import { serve } from '@hono/node-server';
import { Etcd3 } from 'etcd3';
import fs from 'fs-extra';
import { Hono } from 'hono';
import { trace, SpanStatusCode } from '@opentelemetry/api';
import winston, { format } from 'winston';
import prettier from 'prettier';
import crypto2 from 'node:crypto';
import { brotliDecompress } from 'node:zlib';
import { brotliCompress, constants } from 'zlib';
import { createNodeWebSocket } from '@hono/node-ws';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js';
import { streamSSE } from 'hono/streaming';
import { ulid } from 'ulid';
export { default as dayjs } from 'dayjs';
// mod.ts
var CacheAdapter = class {
};
var RedisCacheAdapter = class extends CacheAdapter {
constructor(redis) {
super();
this.redis = redis;
}
async get(key) {
const value = await this.redis.get(key);
return value ? ejson4.parse(value) : null;
}
async set(key, value, ttl) {
return this.redis.set(key, ejson4.stringify(value), "EX", ttl / 1e3);
}
};
var MemoryCacheAdapter = class extends CacheAdapter {
cache;
constructor(options) {
super();
this.cache = new LRUCache({
max: 1e3,
ttl: 1e3 * 60 * 10,
...options
});
}
async get(key) {
return this.cache.get(key);
}
async set(key, value, ttl) {
this.cache.set(key, value, { ttl });
}
};
var ACTION_METADATA = Symbol("action:metadata");
function Action(options) {
options.params = options.params || [];
options.returns = options.returns || z.void();
options.cache = options.cache ?? false;
options.ttl = options.ttl ?? 0;
options.stream = options.stream ?? false;
return function(target, context) {
const methodName = context.name;
context.addInitializer(function() {
const prototype = this.constructor.prototype;
const func = target.toString().split("\n")[0];
const params = func.slice(func.indexOf("(") + 1, func.indexOf(")")).split(",").map((p) => p.split("=").shift()).filter((param) => !!param?.trim());
params.forEach((param, index) => {
options.params[index] = options.params[index]?.describe(
param.trim()
);
});
const existingMetadata = prototype[ACTION_METADATA] || {};
existingMetadata[methodName] = {
name: methodName,
description: options.description || "",
params: options.params,
returns: options.returns,
idempotence: options.idempotence ?? false,
printError: options.printError ?? false,
cache: options.cache,
ttl: options.ttl,
stream: options.stream,
mcp: options.mcp
};
prototype[ACTION_METADATA] = existingMetadata;
});
};
}
function getActionMetadata(target) {
return target.constructor.prototype[ACTION_METADATA] ?? {};
}
// decorators/module.ts
var moduleMetadataMap = /* @__PURE__ */ new WeakMap();
function Module(name, options = {}) {
return function(target, _context) {
const metadata = {
name,
options: {
name: options.name || name,
version: options.version || "1.0.0",
description: options.description || "",
printError: options.printError ?? false
}
};
moduleMetadataMap.set(target, metadata);
};
}
function getModuleMetadata(target) {
return moduleMetadataMap.get(target);
}
// core/types.ts
var ScheduleMode = /* @__PURE__ */ ((ScheduleMode2) => {
ScheduleMode2["FIXED_RATE"] = "FIXED_RATE";
ScheduleMode2["FIXED_DELAY"] = "FIXED_DELAY";
return ScheduleMode2;
})(ScheduleMode || {});
var Plugin = class {
};
var logger = winston.createLogger({
level: "info",
transports: [
new winston.transports.Console({
level: "info",
format: format.combine(
format.colorize(),
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }),
format.printf((info) => {
const splat = info[Symbol.for("splat")];
const msg = `${info.level} ${info.timestamp}: ${info.message} ${!!splat ? JSON.stringify(splat) : ""}`;
return msg;
})
)
})
]
});
var logger_default = logger;
// decorators/schedule.ts
var tracer = trace.getTracer("scheduler");
var SCHEDULE_METADATA = Symbol("schedule:metadata");
function Schedule(options) {
return function(_originalMethod, context) {
const methodName = context.name.toString();
context.addInitializer(function() {
const prototype = this.constructor.prototype;
const moduleMetadata = getModuleMetadata(this.constructor);
if (!moduleMetadata) {
throw new Error(
`Class ${this.constructor.name} is not decorated with decorator`
);
}
const existingMetadata = prototype[SCHEDULE_METADATA] || {};
existingMetadata[methodName] = {
name: methodName,
interval: options.interval,
mode: options.mode || "FIXED_RATE" /* FIXED_RATE */
};
prototype[SCHEDULE_METADATA] = existingMetadata;
});
};
}
function getScheduleMetadata(target) {
return target.constructor.prototype[SCHEDULE_METADATA] ?? {};
}
var Scheduler = class {
constructor(etcdClient) {
this.etcdClient = etcdClient;
}
campaigns = /* @__PURE__ */ new Map();
timers = /* @__PURE__ */ new Map();
isLeader = /* @__PURE__ */ new Map();
/**
* 启动调度任务
*/
async startSchedule(serviceId, moduleName, methodName, electionKey, metadata, method) {
const election = this.etcdClient.election(electionKey, 10);
const observe = await election.observe();
observe.on("change", (leader) => {
const isLeader = leader === serviceId;
this.isLeader.set(serviceId, isLeader);
if (!isLeader) {
this.stopTimer(serviceId);
}
});
const campaign = election.campaign(serviceId);
this.campaigns.set(serviceId, campaign);
campaign.on("error", (error) => {
logger_default.error(`Error in campaign for ${moduleName}.${methodName}:`, error);
});
campaign.on("elected", () => {
this.isLeader.set(serviceId, true);
this.startTimer(serviceId, metadata, moduleName, method);
logger_default.info(`become leader for ${moduleName}.${methodName}`);
});
}
/**
* 启动定时器
*/
startTimer(serviceId, metadata, moduleName, method) {
this.stopTimer(serviceId);
const wrappedMethod = async () => {
tracer.startActiveSpan(
`ScheduleTask ${moduleName}.${metadata.name}`,
{ root: true },
async (span) => {
span.setAttribute("serviceId", serviceId);
span.setAttribute("methodName", metadata.name);
span.setAttribute("moduleName", moduleName);
span.setAttribute("interval", metadata.interval);
span.setAttribute("mode", metadata.mode);
try {
await method();
} catch (error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message
});
} finally {
span.setStatus({ code: SpanStatusCode.OK });
span.end();
}
}
);
};
if (metadata.mode === "FIXED_DELAY" /* FIXED_DELAY */) {
const runTask = async () => {
if (!this.isLeader.get(serviceId)) return;
try {
await wrappedMethod();
} finally {
this.timers.set(serviceId, setTimeout(runTask, metadata.interval));
}
};
runTask();
} else {
this.timers.set(
serviceId,
setInterval(async () => {
if (!this.isLeader.get(serviceId)) return;
await wrappedMethod();
}, metadata.interval)
);
}
}
/**
* 停止定时器
*/
stopTimer(serviceId) {
const timer = this.timers.get(serviceId);
if (timer) {
clearTimeout(timer);
clearInterval(timer);
this.timers.delete(serviceId);
}
}
/**
* 停止所有调度任务
*/
async stop() {
for (const serviceId of this.timers.keys()) {
this.stopTimer(serviceId);
}
for (const [serviceId, campaign] of this.campaigns.entries()) {
try {
await campaign.resign().catch(() => {
});
} catch (error) {
console.error(`Error stopping schedule ${serviceId}:`, error);
} finally {
this.campaigns.delete(serviceId);
this.isLeader.delete(serviceId);
}
}
}
};
async function formatCode(code) {
try {
return prettier.format(code, { parser: "typescript" });
} catch {
return code;
}
}
function getZodTypeString(schema, defaultOptional = false) {
function processType(type) {
const def = type._def;
if (def.typeName === "ZodNullable") {
return `${processType(def.innerType)} | null`;
}
if (def.typeName === "ZodOptional") {
return processType(def.innerType);
}
if (def.typeName === "ZodEffects" && def.schema?._def.typeName !== "ZodAny") {
return processType(def.schema);
}
switch (def.typeName) {
case "ZodString": {
return "string";
}
case "ZodNumber": {
return "number";
}
case "ZodBigInt": {
return "bigint";
}
case "ZodBoolean": {
return "boolean";
}
case "ZodArray": {
const elementType = processType(def.type);
return `${elementType}[]`;
}
case "ZodDate": {
return "Date";
}
case "ZodObject": {
const shape = def.shape();
const props = Object.entries(shape).map(([key, value]) => {
if (key.includes("-")) {
key = `'${key}'`;
}
const fieldDef = value._def;
const isOptional = fieldDef.typeName === "ZodOptional";
const isDefault = defaultOptional && fieldDef.typeName === "ZodDefault";
const fieldType = processType(
isOptional ? fieldDef.innerType : value
);
return `${key}${isOptional || isDefault ? "?" : ""}: ${fieldType}`;
}).join("; ");
return `{ ${props} }`;
}
case "ZodUnion": {
return def.options.map((opt) => processType(opt)).join(" | ");
}
case "ZodNull": {
return "null";
}
case "ZodPromise": {
return `Promise<${processType(def.type)}>`;
}
case "ZodVoid": {
return "void";
}
case "ZodRecord": {
return `Record<${processType(def.keyType)}, ${processType(
def.valueType
)}>`;
}
case "ZodMap": {
return `Map<${processType(def.keyType)}, ${processType(
def.valueType
)}>`;
}
case "ZodAny": {
return "any";
}
case "ZodUnknown": {
return "unknown";
}
case "ZodEnum": {
return "(" + def.values.map((opt) => `"${opt}"`).join(" | ") + ")";
}
case "ZodDefault": {
return processType(def.innerType);
}
default: {
if (type.safeParse(new Uint8Array()).success) {
return "Uint8Array";
}
return "unknown";
}
}
}
return processType(schema);
}
async function generateClientCode(modules) {
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
const imports = [
"// \u8FD9\u4E2A\u6587\u4EF6\u662F\u81EA\u52A8\u751F\u6210\u7684\uFF0C\u8BF7\u4E0D\u8981\u624B\u52A8\u4FEE\u6539",
`// Generated at ${timestamp}`,
"",
'import { MicroserviceClient as BaseMicroserviceClient } from "imean-service-client";',
'export * from "imean-service-client";',
""
].join("\n");
const interfaces = Object.entries(modules).map(([name, module]) => {
const methods = Object.entries(module.actions).map(([actionName, action]) => {
if (!action.params) {
throw new Error(`Missing params for action ${actionName}`);
}
const params = action.params.map((param, index) => {
const name2 = param.description || `arg${index}`;
return `${name2}${param.isOptional() ? "?" : ""}: ${getZodTypeString(
param,
true
)}`;
}).join(", ");
const returnType = action.returns ? getZodTypeString(action.returns) : "void";
return `
/**
* ${action.description}
*/
${actionName}: (${params}) => Promise<${action.stream ? `AsyncIterable<${returnType}>` : returnType}>;`;
}).join("\n ");
const capitalizedName = name.charAt(0).toUpperCase() + name.slice(1);
return `export interface ${capitalizedName}Module {
${methods}
}`;
}).join("\n\n");
const clientClass = `export class MicroserviceClient extends BaseMicroserviceClient {
${Object.entries(modules).map(([name, module]) => {
const methods = Object.entries(module.actions).map(([actionName, action]) => {
return `${actionName}: { idempotent: ${!!action.idempotence}, stream: ${!!action.stream} }`;
}).join(",\n ");
return `public readonly ${name} = this.registerModule<${name.charAt(0).toUpperCase() + name.slice(1)}Module>("${name}", {
${methods}
});`;
}).join("\n\n ")}
}`;
return await formatCode([imports, interfaces, clientClass].join("\n\n"));
}
function hashText(text) {
const hash = crypto2.createHash("sha256");
hash.update(text);
return hash.digest("hex");
}
var brotli = {
compress: (data, quality = 6) => {
return new Promise((resolve, reject) => {
brotliCompress(
data,
{ params: { [constants.BROTLI_PARAM_QUALITY]: quality } },
(err, compressed) => {
if (err) reject(err);
else resolve(compressed);
}
);
});
},
decompress: (data) => {
return new Promise((resolve, reject) => {
brotliDecompress(data, (err, decompressed) => {
if (err) reject(err);
else resolve(decompressed);
});
});
}
};
// core/handler.ts
var tracer2 = trace.getTracer("action-handler");
var ActionHandler = class {
constructor(moduleInstance, actionName, metadata, microservice, moduleName) {
this.moduleInstance = moduleInstance;
this.actionName = actionName;
this.metadata = metadata;
this.microservice = microservice;
this.moduleName = moduleName;
}
async handle(req) {
return await tracer2.startActiveSpan(
`handle ${this.moduleName}.${this.actionName}`,
async (span) => {
span.setAttribute("module", this.moduleName);
span.setAttribute("action", this.actionName);
try {
return await this._handle(req);
} catch (error) {
span.recordException(error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message
});
throw error;
} finally {
span.end();
}
}
);
}
async _validate(req) {
const span = tracer2.startSpan("validate");
try {
let args;
if (typeof req === "string") {
try {
args = Object.values(ejson4.parse(req));
} catch (error) {
throw new Error(`Invalid request body: ${error.message}`);
}
} else {
args = req;
}
if (this.metadata.params) {
args = args.map((arg, index) => {
try {
return this.metadata.params[index].parse(arg);
} catch (error) {
throw new Error(
`Invalid argument ${index}: ${error.message}`
);
}
});
}
const requestHash = hashText(ejson4.stringify(args));
span.setAttribute("requestHash", requestHash);
return { args, requestHash };
} finally {
span.end();
}
}
async _handle(req) {
const span = trace.getActiveSpan();
const { args, requestHash } = await this._validate(req);
const cacheHash = typeof this.metadata.cache === "function" ? this.metadata.cache(...args) : requestHash;
const cacheKey = `${this.microservice.options.name}:cache:${this.moduleName}:${this.actionName}:${hashText(ejson4.stringify(cacheHash))}`;
span?.setAttribute("args", JSON.stringify(args));
if (!this.metadata.stream && this.metadata.cache && !this.microservice.options.disableCache) {
const cached = await this.microservice.cache.get(cacheKey);
const now = Date.now();
if (cached !== null && (!this.metadata.ttl || cached?.expireAt > now)) {
span?.setAttribute("cacheHit", true);
span?.setAttribute("cacheKey", cacheKey);
return cached.data;
}
}
try {
const result = await this.moduleInstance[this.actionName].apply(
this.moduleInstance,
args
);
if (this.metadata.stream) {
span?.setAttribute("stream", true);
if (!isAsyncIterable(result)) {
throw new Error("Stream action must return AsyncIterator");
}
let count = 0;
return {
[Symbol.asyncIterator]() {
const iterator = result[Symbol.asyncIterator]();
return {
async next() {
const { value, done } = await iterator.next();
span?.addEvent("stream.next");
if (!done) count++;
if (done) {
span?.setAttribute("streamCount", count);
span?.addEvent("stream.end");
}
return { value, done };
}
};
}
};
}
let parsedResult = result;
if (this.metadata.returns) {
try {
parsedResult = this.metadata.returns.parse(result);
} catch (error) {
throw new Error(`Invalid return value: ${error.message}`);
}
}
if (this.metadata.cache && !this.microservice.options.disableCache) {
const now = Date.now();
this.microservice.cache.set(
cacheKey,
{
data: parsedResult,
expireAt: this.metadata.ttl ? now + this.metadata.ttl : void 0
},
this.metadata.ttl ?? 1e3
);
}
return parsedResult;
} catch (error) {
if (this.metadata.printError !== false && this.microservice.options.printError !== false) {
console.error(`Error in ${this.moduleName}.${this.actionName}:`, error);
}
throw error;
}
}
};
function isAsyncIterable(obj) {
return obj != null && typeof obj[Symbol.asyncIterator] === "function";
}
var WebSocketHandler = class {
constructor(microservice, options) {
this.microservice = microservice;
this.options = {
timeout: options?.timeout || 3e4
};
}
sockets = /* @__PURE__ */ new Set();
pendingRequests = /* @__PURE__ */ new Map();
options;
onOpen(ws) {
this.sockets.add(ws);
}
async encodeMessage(message) {
const jsonStr = ejson4.stringify(message);
const data = new TextEncoder().encode(jsonStr);
const compressed = await brotli.compress(data, 6);
return compressed;
}
async decodeMessage(data) {
try {
const decompressed = await brotli.decompress(new Uint8Array(data));
const jsonStr = new TextDecoder().decode(decompressed);
return ejson4.parse(jsonStr);
} catch (error) {
console.error("Error decoding message", error);
throw error;
}
}
async sendMessage(ws, message) {
const encoded = await this.encodeMessage(message);
ws.send(encoded);
}
async onMessage(event, ws) {
let messageId = "";
try {
const message = await this.decodeMessage(event.data);
messageId = message.id || "";
if (message.type === "ping") {
await this.sendMessage(ws, { type: "pong" });
return;
}
const response = await this.handleRequest(ws, message);
if (response) {
await this.sendMessage(ws, response);
}
} catch (error) {
if (messageId) {
await this.sendMessage(ws, {
id: messageId,
success: false,
error: error.message
});
}
console.error("Failed to handle WebSocket message:", error);
}
}
onClose(ws) {
this.sockets.delete(ws);
for (const [id, pending] of this.pendingRequests.entries()) {
if (pending) {
clearTimeout(pending.timer);
this.pendingRequests.delete(id);
}
}
}
onError(_error, ws) {
this.onClose(ws);
}
async handleRequest(ws, message) {
if (!message.id || !message.module || !message.action) {
throw new Error("Invalid request message");
}
const timer = setTimeout(() => {
const pending = this.pendingRequests.get(message.id);
if (pending) {
this.pendingRequests.delete(message.id);
this.microservice.updateMethodStats(
pending.module,
pending.action,
0,
false
);
ws.send(
ejson4.stringify({
id: message.id,
success: false,
error: "Request timeout"
})
);
}
}, this.options.timeout);
this.pendingRequests.set(message.id, {
timer,
module: message.module,
action: message.action
});
return (async () => {
const startTime = Date.now();
try {
const handler = this.microservice.getActionHandler(
message.module,
message.action
);
const args = message.args ? Object.values(message.args) : [];
const result = await handler.handle(args);
if (handler.metadata.stream) {
try {
for await (const value of result) {
await this.sendMessage(ws, {
id: message.id,
type: "stream",
data: value,
done: false
});
}
await this.sendMessage(ws, {
id: message.id,
type: "stream",
done: true
});
return null;
} catch (error) {
throw error;
}
}
const responseTime = Date.now() - startTime;
this.microservice.updateMethodStats(
message.module,
message.action,
responseTime,
true
);
return {
id: message.id,
success: true,
data: result
};
} catch (error) {
this.microservice.updateMethodStats(
message.module,
message.action,
0,
false
);
return {
id: message.id,
success: false,
error: error.message
};
} finally {
const pending = this.pendingRequests.get(message.id);
if (pending) {
clearTimeout(pending.timer);
this.pendingRequests.delete(message.id);
}
}
})();
}
close() {
for (const socket of this.sockets) {
socket.close();
}
for (const pending of this.pendingRequests.values()) {
clearTimeout(pending.timer);
}
this.sockets.clear();
this.pendingRequests.clear();
}
};
// core/core.ts
var ServiceContext = {};
var Microservice = class {
app;
nodeWebSocket;
codeCache;
waitingInitialization;
etcdClient;
serviceKey;
statsMap = /* @__PURE__ */ new Map();
lease;
scheduler;
abortController;
isShuttingDown = false;
statisticsTimer;
wsHandler;
actionHandlers = /* @__PURE__ */ new Map();
modules = /* @__PURE__ */ new Map();
fetch;
options;
cache;
serviceId;
constructor(options) {
this.app = new Hono();
this.nodeWebSocket = createNodeWebSocket({ app: this.app });
this.serviceId = crypto.randomUUID();
this.options = {
prefix: "/api",
name: "microservice",
version: "0.0.1",
env: "default",
printError: false,
disableCache: false,
generateClient: false,
etcd: false,
events: {},
websocket: { enabled: false },
cacheAdapter: new MemoryCacheAdapter(),
plugins: [],
...options
};
this.cache = this.options.cacheAdapter;
this.fetch = this.app.request;
this.waitingInitialization = this.initialize();
ServiceContext.service = this;
}
async initialize() {
if (this.options.etcd) {
this.initEtcd(this.options.etcd);
}
await this.initModules();
this.initRoutes();
this.initShutdownHandlers();
if (this.options.events?.onStats) {
this.initStatsEventManager();
}
await this.registerService(true);
await this.initPlugins();
}
async initModules() {
for (const ModuleClass of this.options.modules) {
const moduleInstance = new ModuleClass();
const metadata = getModuleMetadata(ModuleClass);
if (!metadata) {
throw new Error(
`Module ${ModuleClass.name} is not decorated with `
);
}
const moduleName = metadata.name;
logger_default.info(`[ \u6CE8\u518C\u6A21\u5757 ] ${moduleName} ${metadata.options.description}`);
this.modules.set(moduleName, moduleInstance);
const actions = getActionMetadata(ModuleClass.prototype);
for (const [actionName, actionMetadata] of Object.entries(actions)) {
const handler = new ActionHandler(
moduleInstance,
actionName,
actionMetadata,
this,
moduleName
);
this.actionHandlers.set(`${moduleName}.${actionName}`, handler);
logger_default.info(
`[ \u6CE8\u518C\u52A8\u4F5C ] ${moduleName}.${actionName} ${actionMetadata.description} ${actionMetadata.mcp ? "MCP:" + actionMetadata.mcp?.type : ""}`
);
}
const schedules = getScheduleMetadata(ModuleClass.prototype);
if (schedules && Object.keys(schedules).length > 0) {
if (!this.scheduler && this.etcdClient) {
this.scheduler = new Scheduler(this.etcdClient);
}
for (const [methodName, metadata2] of Object.entries(schedules)) {
const electionKey = `${this.options.name}/${moduleName}/schedules/${metadata2.name}`;
this.scheduler.startSchedule(
this.serviceId,
moduleName,
methodName,
electionKey,
metadata2,
ModuleClass.prototype[methodName].bind(moduleInstance)
);
logger_default.info(
`[ \u542F\u52A8\u8C03\u5EA6\u4EFB\u52A1 ] ${moduleName}.${metadata2.name} interval = ${metadata2.interval} mode = ${metadata2.mode}`
);
}
}
}
if (this.options.generateClient) {
logger_default.info(`[ \u751F\u6210\u5BA2\u6237\u7AEF\u4EE3\u7801 ] ${this.options.generateClient}`);
await this.generateClientCode();
}
}
initRoutes() {
const startTime = Date.now();
const prefix = this.options.prefix || "/api";
this.app.get(prefix, (ctx) => {
const name = this.options.name ?? "Microservice";
const version = this.options.version ?? "1.0.0";
return ctx.text(`${name} is running. version: ${version}`);
});
this.app.get(`${prefix}/health`, (ctx) => {
return ctx.json({
status: "ok",
uptime: Date.now() - startTime,
timestamp: Date.now()
});
});
this.app.get(`${prefix}/status`, (ctx) => {
return ctx.json({
id: this.serviceId,
name: this.options.name,
version: this.options.version,
env: this.options.env,
modules: this.getModules(),
stats: Object.fromEntries(this.statsMap)
});
});
this.app.get(`${prefix}/client.ts`, async (ctx) => {
ctx.header("Content-Type", "application/typescript; charset=utf-8");
ctx.header("Content-Disposition", "attachment; filename=client.ts");
ctx.header("Cache-Control", "public, max-age=3600");
return ctx.body(await this.clientCode());
});
this.app.post(`${prefix}/:moduleName/:actionName`, this.handleRequest);
if (this.options.websocket?.enabled) {
this.wsHandler = new WebSocketHandler(this);
this.app.get(
`${prefix}/ws`,
this.nodeWebSocket.upgradeWebSocket((_ctx) => {
const wsHandler = new WebSocketHandler(this, {
timeout: this.options.websocket?.timeout
});
return {
onOpen(_event, ws) {
wsHandler.onOpen(ws);
},
onMessage(message, ws) {
wsHandler.onMessage(message, ws);
},
onClose(_evt, ws) {
wsHandler.onClose(ws);
},
onError(error, ws) {
wsHandler.onError(error, ws);
}
};
})
);
}
}
async generateClientCode() {
if (typeof this.options.generateClient === "string" || this.options.generateClient instanceof URL) {
const code = await this.clientCode();
await fs.writeFile(this.options.generateClient, code);
}
}
async clientCode() {
if (!this.codeCache) {
this.codeCache = await formatCode(
await generateClientCode(this.getModules(true))
);
}
return this.codeCache;
}
initEtcd(config) {
this.etcdClient = new Etcd3({
hosts: config.hosts,
auth: config.auth
});
const serviceName = this.options.name || "microservice";
const namespace = config.namespace ? `${config.namespace}/` : "";
this.serviceKey = `${namespace}services/${serviceName}/${this.serviceId}`;
const ttl = config.ttl || 10;
this.lease = this.etcdClient.lease(ttl);
ServiceContext.lease = this.lease;
ServiceContext.etcdClient = this.etcdClient;
}
/**
* 注册服务
*/
async registerService(update = false) {
const serviceInfo = {
id: this.serviceId,
name: this.options.name,
version: this.options.version,
prefix: this.options.prefix,
env: this.options.env,
modules: this.getModules(false)
};
if (update) {
await this.options.events?.onRegister?.(serviceInfo)?.catch((e) => {
logger_default.error(`Failed to emit register event: ${e}`);
});
}
}
/**
* 更新方法统计信息
*/
updateMethodStats(moduleName, methodName, responseTime, success, cacheHit = false) {
const key = `${moduleName}.${methodName}`;
let stats = this.statsMap.get(key);
if (!stats) {
stats = {
totalCalls: 0,
successCalls: 0,
failureCalls: 0,
avgResponseTime: 0,
maxResponseTime: 0,
minResponseTime: Number.MAX_SAFE_INTEGER,
lastUpdateTime: Date.now(),
cacheHit: 0
};
this.statsMap.set(key, stats);
}
stats.totalCalls++;
if (success) {
stats.successCalls++;
} else {
stats.failureCalls++;
}
stats.avgResponseTime = (stats.avgResponseTime * (stats.totalCalls - 1) + responseTime) / stats.totalCalls;
stats.maxResponseTime = Math.max(stats.maxResponseTime, responseTime);
stats.minResponseTime = Math.min(stats.minResponseTime, responseTime);
stats.lastUpdateTime = Date.now();
stats.cacheHit += cacheHit ? 1 : 0;
}
/**
* 获取 Hono 应用实例
*/
getApp() {
return this.app;
}
/**
* 启动服务
*/
async start(port = 3e3, silent = false) {
await this.waitingInitialization;
const prefix = this.options.prefix ?? "/api";
this.abortController = new AbortController();
!silent && console.log("");
const server = serve({ fetch: this.app.fetch, port });
this.nodeWebSocket.injectWebSocket(server);
if (!silent) {
console.log(
`\u{1F680} Microservice is running on http://localhost:${port}${prefix}`
);
console.log(
`\u{1F4DA} Client SDK available at http://localhost:${port}${prefix}/client.ts`
);
this.options.websocket?.enabled && console.info(
`\u{1F517} WebSocket available at http://localhost:${port}${prefix}/ws`
);
}
}
/**
* 获取所有模块的元数据
*/
getModules(withTypes = false) {
const modules = {};
for (const [moduleName, moduleInstance] of this.modules) {
const metadata = getModuleMetadata(moduleInstance.constructor);
if (!metadata) continue;
modules[moduleName] = {
name: metadata.name,
version: metadata.options.version,
description: metadata.options.description,
printError: metadata.options.printError,
actions: Object.fromEntries(
Array.from(this.actionHandlers.entries()).filter(([key]) => key.startsWith(moduleName + ".")).map(([key, handler]) => {
const metadata2 = { ...handler.metadata };
if (!withTypes) {
delete metadata2.params;
delete metadata2.returns;
}
return [key.slice(moduleName.length + 1), metadata2];
})
)
};
}
return modules;
}
/**
* 停止服务
*/
async stop() {
if (this.wsHandler) {
this.wsHandler.close();
}
if (this.abortController) {
this.abortController.abort();
}
if (this.scheduler) {
await this.scheduler.stop();
}
if (this.options.events?.onStats && this.options.events.forceEmitOnShutdown) {
await this.updateStats().catch((e) => {
logger_default.error("Failed to emit final stats:", e);
});
}
if (this.etcdClient && this.serviceKey) {
await this.etcdClient.delete().key(this.serviceKey);
this.etcdClient.close();
}
}
/**
* 初始化停机处理
*/
initShutdownHandlers() {
process.on("SIGINT", () => {
logger_default.info(`
Received SIGINT signal`);
this.gracefulShutdown();
});
process.on("unhandledrejection", (event) => {
logger_default.error("Unhandled rejection:", event.reason);
});
process.on("error", (event) => {
logger_default.error("Uncaught error:", event.error);
});
}
/**
* 优雅停机
*/
async gracefulShutdown() {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
logger_default.info("\nGraceful shutdown initiated...");
if (this.statisticsTimer) clearInterval(this.statisticsTimer);
try {
await this.stop();
logger_default.info("Graceful shutdown completed");
} catch (error) {
logger_default.error("Error during shutdown:", error);
process.exit(1);
} finally {
process.exit(0);
}
}
initStatsEventManager() {
this.statisticsTimer = setInterval(async () => {
await this.updateStats();
}, this.options.events.interval ?? 1e4);
}
async updateStats() {
const now = Date.now();
const { onStats } = this.options.events;
this.registerService(true);
for (const [key, stats] of this.statsMap.entries()) {
if (stats) {
const [moduleName, actionName] = key.split(".");
try {
await onStats?.({
service: {
env: this.options.env,
id: this.serviceId,
name: this.options.name,
version: this.options.version
},
module: moduleName,
action: actionName,
stats,
startTime: stats.lastUpdateTime,
endTime: now
});
this.statsMap.delete(key);
} catch (error) {
logger_default.error(`Failed to emit stats event for ${key}:`, error);
}
}
}
}
getActionHandler(moduleName, actionName) {
const key = `${moduleName}.${actionName}`;
const handler = this.actionHandlers.get(key);
if (!handler) {
throw new Error(`Action ${actionName} not found in module ${moduleName}`);
}
return handler;
}
handleRequest = async (ctx) => {
const { moduleName, actionName } = ctx.req.param();
const handler = this.getActionHandler(moduleName, actionName);
try {
const paramsText = await ctx.req.text();
const result = await handler.handle(paramsText);
if (handler.metadata.stream) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
for await (const value of result) {
const response = { value, done: false };
const chunk = encoder.encode(ejson4.stringify(response) + "\n");
controller.enqueue(chunk);
}
controller.enqueue(
encoder.encode(
ejson4.stringify({ done: true }) + "\n"
)
);
controller.close();
} catch (error) {
const response = {
error: error.message,
done: true
};
controller.enqueue(
encoder.encode(ejson4.stringify(response) + "\n")
);
controller.close();
}
}
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive"
}
});
}
return ctx.text(ejson4.stringify({ success: true, data: result }));
} catch (error) {
return ctx.json({ success: false, error: error.message });
}
};
async initPlugins() {
for (const plugin of this.options.plugins) {
logger_default.info(`[ \u521D\u59CB\u5316\u63D2\u4EF6 ] ${plugin.constructor.name}`);
await plugin.initialize(this).catch((e) => {
logger_default.error(`[ \u521D\u59CB\u5316\u63D2\u4EF6\u5931\u8D25 ] ${plugin.constructor.name}: ${e}`);
});
}
}
async init() {
await this.waitingInitialization;
}
};
// utils/checker.ts
async function startCheck(checkers, pass) {
logger_default.info("[ \u9884\u68C0\u5F00\u59CB ]");
for (const [index, checker] of checkers.entries()) {
const seq = index + 1;
logger_default.info(`${seq}. ${checker.name}`);
try {
if (checker.skip) {
logger_default.warn(`${seq}. ${checker.name} [\u8DF3\u8FC7]`);
continue;
}
await checker.check();
logger_default.info(`${seq}. ${checker.name} [\u6210\u529F]`);
} catch (error) {
logger_default.error(`${seq}. ${checker.name} [\u5931\u8D25]`);
throw error;
}
}
logger_default.info("[ \u9884\u68C0\u5B8C\u6210 ]");
if (pass) await pass();
}
var HonoTransport = class {
constructor(url, stream, closeStream) {
this.url = url;
this.stream = stream;
this.closeStream = closeStream;
this.sessionId = ulid();
}
sessionId;
onclose;
onerror;
onmessage;
async start() {
await this.stream.writeSSE({
event: "endpoint",
data: `${encodeURI(this.url)}?sessionId=${this.sessionId}`
});
this.stream.onAbort(() => {
this.close();
});
}
async handleMessage(message) {
let parsedMessage;
try {
parsedMessage = JSONRPCMessageSchema.parse(message);
} catch (error) {
this.onerror?.(error);
throw error;
}
this.onmessage?.(parsedMessage);
}
async send(message) {
await this.stream.writeln(
`event: message
data: ${JSON.stringify(message)}
`
);
}
async close() {
this.onclose?.();
this.closeStream();
}
};
var ModelContextProtocolPlugin = class extends Plugin {
mcpServer;
transports = {};
registerMcpTools(engine) {
const modules = engine.getModules(true);
for (const module of Object.values(modules)) {
for (const action of Object.values(module.actions)) {
if (action.mcp?.type !== "tool") {
continue;
}
const args = {};
const argsIndex = {};
for (const [index, param] of (action.params ?? []).entries()) {
args[param.description] = param;
argsIndex[param.description] = index;
}
this.mcpServer.tool(
`${module.name}.${action.name}`,
action.description ?? "",
args,
async (params) => {
const argsList = [];
for (const [key, value] of Object.entries(params)) {
argsList[argsIndex[key]] = value;
}
const result = await engine.getActionHandler(module.name, action.name).handle(argsList);
return {
content: [{ type: "text", text: JSON.stringify(result) }]
};
}
);
}
}
}
initialize = async (engine) => {
const app = engine.getApp();
this.mcpServer = new McpServer({
name: engine.options.name,
version: engine.options.version
});
this.registerMcpTools(engine);
app.get(`${engine.options.prefix}/mcp_sse`, async (ctx) => {
return streamSSE(ctx, async (stream) => {
return new Promise(async (resolve) => {
const transport = new HonoTransport(
`${engine.options.prefix}/mcp_messages`,
stream,
() => {
delete this.transports[transport.sessionId];
resolve();
}
);
this.transports[transport.sessionId] = transport;
await this.mcpServer.connect(transport);
});
});
});
app.post(`${engine.options.prefix}/mcp_messages`, async (ctx) => {
const sessionId = ctx.req.query("sessionId");
if (!sessionId) {
return ctx.text("No transport found for sessionId", 400);
}
const transport = this.transports[sessionId];
const message = await ctx.req.json();
await transport.handleMessage(message);
return ctx.text("Accepted", 202);
});
logger_default.info(
`ModelContextProtocolPlugin endpoint: ${engine.options.prefix}/mcp_sse`
);
};
};
export { Action, CacheAdapter, MemoryCacheAdapter, Microservice, ModelContextProtocolPlugin, Module, Plugin, RedisCacheAdapter, Schedule, ScheduleMode, ServiceContext, logger_default as logger, startCheck };