imean-service-engine
Version:
microservice engine
1,566 lines (1,559 loc) • 53.4 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 { timing } from 'hono/timing';
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 { html } from 'hono/html';
import { jsx, jsxs } from 'hono/jsx/jsx-runtime';
import { customAlphabet } from 'nanoid';
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 paramString = func.slice(func.indexOf("(") + 1, func.indexOf(")"));
const parsedParams = paramString.split(",").map((p) => {
const paramWithoutDefault = p.split("=")[0]?.trim();
return paramWithoutDefault;
}).filter((param) => !!param);
options.params.forEach((param, index) => {
if (index < parsedParams.length) {
const paramName = parsedParams[index].split(":")[0]?.trim();
if (paramName) {
options.params[index] = param.describe(paramName);
}
}
});
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
};
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 @Module 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 imports = [
"// \u8FD9\u4E2A\u6587\u4EF6\u662F\u81EA\u52A8\u751F\u6210\u7684\uFF0C\u8BF7\u4E0D\u8981\u624B\u52A8\u4FEE\u6539",
"",
'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}`;
const paramDef = param._def;
const isOptional = param.isOptional();
const hasDefault = paramDef?.typeName === "ZodDefault";
return `${name2}${isOptional || hasDefault ? "?" : ""}: ${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, ctx) {
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, ctx);
} 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 paramsMap = [];
if (typeof req === "string") {
try {
paramsMap = ejson4.parse(req);
} catch (error) {
throw new Error(`Invalid request body: ${error.message}`);
}
} else {
paramsMap = req;
}
const args = this.metadata.params ? this.metadata.params.map((param, index) => {
try {
return param.parse(paramsMap[index]);
} 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, ctx) {
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 argsWithContext = [...args, ctx];
const result = await this.moduleInstance[this.actionName].apply(
this.moduleInstance,
argsWithContext
);
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";
}
// decorators/page.ts
var PAGE_METADATA = Symbol("page:metadata");
function Page(options) {
return function(_target, context) {
const methodName = context.name;
context.addInitializer(function() {
const prototype = this.constructor.prototype;
const existingMetadata = prototype[PAGE_METADATA] || {};
existingMetadata[methodName] = {
name: methodName,
description: options.description || "",
method: options.method || "get",
path: normalizePath(options.path),
absolutePath: options.absolutePath ?? false,
middlewares: options.middlewares ?? []
};
prototype[PAGE_METADATA] = existingMetadata;
});
};
}
function normalizePath(path) {
if (Array.isArray(path)) {
return path.map((p) => normalizePath(p));
}
if (!path.startsWith("/")) path = "/" + path;
if (path === "/") return "";
return path;
}
function getPageMetadata(target) {
return target.constructor.prototype[PAGE_METADATA] ?? {};
}
var tracer3 = trace.getTracer("page-handler");
var PageHandler = class {
constructor(moduleInstance, options, moduleName) {
this.moduleInstance = moduleInstance;
this.options = options;
this.moduleName = moduleName;
}
async handle(ctx) {
return await tracer3.startActiveSpan(
`handle ${this.moduleName}.${this.options.name}`,
async (span) => {
span.setAttribute("module", this.moduleName);
span.setAttribute("page", this.options.name);
span.setAttribute("path", this.options.path);
try {
const result = await this.moduleInstance[this.options.name].apply(
this.moduleInstance,
[ctx]
);
if (result instanceof Response) {
return result;
}
return ctx.html(result);
} catch (error) {
span.recordException(error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message
});
throw error;
} finally {
span.end();
}
}
);
}
};
var WebSocketHandler = class {
constructor(microservice, ctx, options) {
this.microservice = microservice;
this.ctx = ctx;
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, this.ctx);
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, ctx) {
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, ctx);
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;
statisticsTimer;
wsHandler;
actionHandlers = /* @__PURE__ */ new Map();
pageHandlers = /* @__PURE__ */ new Map();
activeRequests = /* @__PURE__ */ new Map();
status = "running";
modules = /* @__PURE__ */ new Map();
fetch;
options;
cache;
serviceId;
constructor(options) {
this.app = new Hono();
this.app.use(timing());
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: [],
gracefulShutdown: {
timeout: 3e4,
cleanupHooks: []
},
...options
};
this.cache = this.options.cacheAdapter;
this.fetch = this.app.request;
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();
this.app.get(this.options.prefix, (ctx) => {
const name = this.options.name ?? "Microservice";
const version = this.options.version ?? "1.0.0";
return ctx.text(`${name} is ${this.status}. version: ${version}`);
});
}
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 @Module`
);
}
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);
const pages = getPageMetadata(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}`
);
}
for (const [_, page] of Object.entries(pages)) {
const handler = new PageHandler(moduleInstance, page, moduleName);
this.pageHandlers.set(`${moduleName}.${page.name}`, handler);
const paths = Array.isArray(page.path) ? page.path : [page.path];
for (let path of paths) {
path = page.absolutePath ? path : `${this.options.prefix}${path}`;
this.app[page.method](
path,
...page.middlewares ?? [],
(ctx) => handler.handle(ctx)
);
logger_default.info(
`[ \u6CE8\u518C\u9875\u9762 ] ${moduleName}.${page.name} ${page.method.toUpperCase()} ${page.path} ${page.description}`
);
}
}
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}/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),
activeRequests: this.getActiveRequestCount(),
status: this.status
});
});
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.app.get(
`${prefix}/ws`,
this.nodeWebSocket.upgradeWebSocket((ctx) => {
const wsHandler = new WebSocketHandler(this, ctx, {
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.init();
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.shutdown();
});
process.on("SIGTERM", () => {
logger_default.info(`
Received SIGTERM signal`);
this.shutdown();
});
process.on("unhandledrejection", (event) => {
logger_default.error("Unhandled rejection:", event.reason);
});
process.on("error", (event) => {
logger_default.error("Uncaught error:", event.error);
});
}
/**
* 优雅停机
*/
async shutdown() {
if (this.status === "shutting_down") return;
this.status = "shutting_down";
logger_default.info("\nshutdown initiated...");
if (this.statisticsTimer) clearInterval(this.statisticsTimer);
try {
await this.waitForActiveRequests();
await this.executeCleanupHooks();
await this.stop();
logger_default.info("shutdown completed");
} catch (error) {
logger_default.error("Error during shutdown:", error);
process.exit(1);
} finally {
process.exit(0);
}
}
/**
* 等待所有活跃请求完成
*/
async waitForActiveRequests() {
const timeout = this.options.gracefulShutdown?.timeout || 3e4;
const startTime = Date.now();
logger_default.info(
`Waiting for ${this.activeRequests.size} active requests to complete...`
);
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
const elapsed = Date.now() - startTime;
if (this.activeRequests.size === 0) {
clearInterval(checkInterval);
logger_default.info("All active requests completed");
resolve();
} else if (elapsed >= timeout) {
clearInterval(checkInterval);
logger_default.warn(
`Timeout waiting for requests to complete. ${this.activeRequests.size} requests still active`
);
resolve();
} else {
logger_default.info(
`Still waiting for ${this.activeRequests.size} requests... (${elapsed}ms elapsed)`
);
}
}, 1e3);
});
}
/**
* 执行清理hook
*/
async executeCleanupHooks() {
const hooks = this.options.gracefulShutdown?.cleanupHooks || [];
if (hooks.length === 0) {
logger_default.info("No cleanup hooks configured");
return;
}
logger_default.info(`Executing ${hooks.length} cleanup hooks...`);
for (const hook of hooks) {
try {
const timeout = hook.timeout || 5e3;
logger_default.info(`Executing cleanup hook: ${hook.name}`);
await Promise.race([
Promise.resolve(hook.cleanup()),
new Promise(
(_, reject) => setTimeout(
() => reject(new Error(`Cleanup hook ${hook.name} timeout`)),
timeout
)
)
]);
logger_default.info(`Cleanup hook ${hook.name} completed successfully`);
} catch (error) {
logger_default.error(`Cleanup hook ${hook.name} failed:`, error);
}
}
logger_default.info("All cleanup hooks completed");
}
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;
}
/**
* 添加活跃请求跟踪
*/
addActiveRequest(requestId, requestInfo) {
this.activeRequests.set(requestId, requestInfo);
}
/**
* 移除活跃请求跟踪
*/
removeActiveRequest(requestId) {
this.activeRequests.delete(requestId);
}
/**
* 获取当前活跃请求数量
*/
getActiveRequestCount() {
return this.activeRequests.size;
}
/**
* 获取当前活跃请求信息
*/
getActiveRequests() {
return Array.from(this.activeRequests.values());
}
handleRequest = async (ctx) => {
const { moduleName, actionName } = ctx.req.param();
const handler = this.getActionHandler(moduleName, actionName);
const requestId = crypto.randomUUID();
const startTime = Date.now();
if (this.status === "shutting_down") {
return ctx.json(
{
success: false,
error: "Service is shutting down"
},
503
);
}
try {
const paramsText = await ctx.req.text();
this.addActiveRequest(requestId, {
id: requestId,
moduleName,
actionName,
startTime,
params: paramsText
});
const result = await handler.handle(paramsText, ctx);
if (handler.metadata.stream) {
const encoder = new TextEncoder();
const microservice = this;
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();
} finally {
microservice.removeActiveRequest(requestId);
}
}
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive"
}
});
}
this.removeActiveRequest(requestId);
return ctx.text(ejson4.stringify({ success: true, data: result }));
} catch (error) {
this.removeActiveRequest(requestId);
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() {
this.waitingInitialization = this.initialize();
await this.waitingInitialization;
}
};
var DEFAULT_FAVICON = /* @__PURE__ */ jsx(
"link",
{
rel: "icon",
href: "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' width='100' height='100'><defs><linearGradient id='nodeGradient' x1='0%' y1='0%' x2='100%' y2='100%'><stop offset='0%' stop-color='%233498db'/><stop offset='100%' stop-color='%232980b9'/></linearGradient><linearGradient id='centerNodeGradient' x1='0%' y1='0%' x2='100%' y2='100%'><stop offset='0%' stop-color='%232ecc71'/><stop offset='100%' stop-color='%2327ae60'/></linearGradient></defs><circle cx='50' cy='50' r='45' fill='%23f5f7fa'/><path d='M30,30 L50,50' stroke='%23bdc3c7' stroke-width='2' stroke-linecap='round'/><path d='M70,30 L50,50' stroke='%23bdc3c7' stroke-width='2' stroke-linecap='round'/><path d='M30,70 L50,50' stroke='%23bdc3c7' stroke-width='2' stroke-linecap='round'/><path d='M70,70 L50,50' stroke='%23bdc3c7' stroke-width='2' stroke-linecap='round'/><polygon points='30,15 45,25 45,45 30,55 15,45 15,25' fill='url(%23nodeGradient)' stroke='%232980b9' stroke-width='1.5'/><polygon points='70,15 85,25 85,45 70,55 55,45 55,25' fill='url(%23nodeGradient)' stroke='%232980b9' stroke-width='1.5'/><polygon points='30,45 45,55 45,75 30,85 15,75 15,55' fill='url(%23nodeGradient)' stroke='%232980b9' stroke-width='1.5'/><polygon points='70,45 85,55 85,75 70,85 55,75 55,55' fill='url(%23nodeGradient)' stroke='%232980b9' stroke-width='1.5'/><polygon points='50,30 65,40 65,60 50,70 35,60 35,40' fill='url(%23centerNodeGradient)' stroke='%2327ae60' stroke-width='2'/><circle cx='30' cy='30' r='3' fill='%23ffffff'/><circle cx='70' cy='30' r='3' fill='%23ffffff'/><circle cx='30' cy='70' r='3' fill='%23ffffff'/><circle cx='70' cy='70' r='3' fill='%23ffffff'/><circle cx='50' cy='50' r='4' fill='%23ffffff'/></svg>",
type: "image/svg+xml"
}
);
var BaseLayout = (props = {
title: "Microservice Template"
}) => html`<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${props.title}</title>
${props.heads}
</head>
<body>
${props.children}
</body>
</html>`;
var HtmxLayout = (props = {
title: "Microservice Template"
}) => BaseLayout({
title: props.title,
heads: html`
<script src="https://unpkg.com/htmx.org@latest"></script>
<script src="https://unpkg.com/hyperscript.org@latest"></script>
<script src="https://cdn.tailwindcss.com"></script>
${props.favicon || DEFAULT_FAVICON}
`,
children: props.children
});
var InfoCard = ({
icon,
iconColor,
bgColor,
label,
value
}) => /* @__PURE__ */ jsxs("div", { className: `${bgColor} p-4 rounded-lg`, children: [
/* @__PURE__ */ jsxs("div", { className: "flex items-center mb-2", children: [
/* @__PURE__ */ jsx(
"svg",
{
className: `w-5 h-5 ${iconColor} mr-2`,
fill: "none",
stroke: "currentColor",
viewBox: "0 0 24 24",
children: /* @__PURE__ */ jsx(
"path",
{
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: 2,
d: icon
}
)
}
),
/* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-gray-600", children: label })
] }),
/* @__PURE__ */ jsx("p", { className: `text-xl font-semibold text-gray-900`, children: value })
] });
var getEnvironmentBadgeClass = (env) => {
switch (env) {
case "prod":
return "bg-red-100 text-red-800";
case "stg":
return "bg-yellow-100 text-yellow-800";
case "dev":
default:
return "bg-blue-100 text-blue-800";
}
};
var ServiceInfoCards = ({
serviceInfo
}) => {
const infoCards = [
{
icon: "M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2m-9 0h10m-10 0a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V6a2 2 0 00-2-2",
iconColor: "text-blue-600",
bgColor: "bg-blue-50",
label: "\u670D\u52A1\u540D\u79F0",
value: serviceInfo.name
},
{
icon: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1",
iconColor: "text-orange-600",
bgColor: "bg-orange-50",
label: "\u670D\u52A1\u8DEF\u5F84",
value: serviceInfo.prefix || "/",
isMonospace: true
},
{
icon: "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z",
iconColor: "text-green-600",
bgColor: "bg-green-50",
label: "\u8FD0\u884C\u73AF\u5883",
value: /* @__PURE__ */ jsx(
"span",
{
className: `px-2 py-1 rounded-full text-sm ${getEnvironmentBadgeClass(serviceInfo.env ?? "dev")}`,
children: serviceInfo.env ?? "dev"
}
)
},
{
icon: "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z",
iconColor: "text-purple-600",
bgColor: "bg-purple-50",
label: "\u7248\u672C\u53F7",
value: serviceInfo.version || "unknown"
}
];
return /* @__PURE__ */ jsxs("div", { className: "bg-white rounded-lg shadow-md p-6 mb-8", children: [
/* @__PURE__ */ jsxs("h2", { className: "text-2xl font-semibold text-gray-800 mb-6 flex items-center", children: [
/* @__PURE__ */ jsx(
"svg",
{
className: "w-6 h-6 mr-2 text-blue-600",
fill: "none",
stroke: "currentColor",
viewBox: "0 0 24 24",
children: /* @__PURE__ */ jsx(
"path",
{
strokeLinecap: "round",
strokeLinejoin: "round",
strokeWidth: 2,
d: "M13 10V3L4 14h7v7l9-11h-7z"
}
)
}
),
"\