435 lines
18 KiB
JavaScript
435 lines
18 KiB
JavaScript
"use strict";
|
||
Object.defineProperty(exports, "__esModule", { value: true });
|
||
exports.startup = startup;
|
||
const tslib_1 = require("tslib");
|
||
/// <reference path="../typings/polyfill.d.ts" />
|
||
require("./polyfill");
|
||
const http_1 = require("http");
|
||
const path_1 = require("path");
|
||
const koa_1 = tslib_1.__importDefault(require("koa"));
|
||
const koa_router_1 = tslib_1.__importDefault(require("koa-router"));
|
||
const koa_body_1 = tslib_1.__importDefault(require("koa-body"));
|
||
// import logger from 'koa-logger';
|
||
const oak_backend_base_1 = require("oak-backend-base");
|
||
const types_1 = require("oak-domain/lib/types");
|
||
const cluster_adapter_1 = require("@socket.io/cluster-adapter");
|
||
const sticky_1 = require("@socket.io/sticky");
|
||
const redis_adapter_1 = require("@socket.io/redis-adapter");
|
||
const ioredis_1 = tslib_1.__importDefault(require("ioredis"));
|
||
const socket_io_1 = require("socket.io");
|
||
const admin_ui_1 = require("@socket.io/admin-ui");
|
||
const koa_static_1 = tslib_1.__importDefault(require("koa-static"));
|
||
const koa_mount_1 = tslib_1.__importDefault(require("koa-mount"));
|
||
const chalk_1 = tslib_1.__importDefault(require("chalk"));
|
||
const utils_1 = require("../utils");
|
||
const bcryptjs_1 = tslib_1.__importDefault(require("bcryptjs"));
|
||
const polyfill_1 = require("./polyfill");
|
||
(0, utils_1.checkNodeVersion)();
|
||
const socketAdminUI = (0, path_1.join)(__dirname, '../../ui/socket-admin');
|
||
const DATA_SUBSCRIBE_NAMESPACE = '/dsn';
|
||
const SOCKET_NAMESPACE = '/sn';
|
||
const SERVER_SUBSCRIBER_NAMESPACE = process.env.OAK_SSUB_NAMESPACE || '/ssub';
|
||
const ExceptionMask = '内部不可知错误';
|
||
function concat(...paths) {
|
||
return paths.reduce((prev, current) => {
|
||
if (current.startsWith('/')) {
|
||
return `${prev}${current}`;
|
||
}
|
||
return `${prev}/${current}`;
|
||
});
|
||
}
|
||
async function startup(path, connector, omitWatchers, omitTimers, routine) {
|
||
// let errorHandler: InternalErrorHandler<ED, Cxt> | undefined = undefined;
|
||
// try {
|
||
// errorHandler = require(join(
|
||
// path,
|
||
// 'lib',
|
||
// 'configuration',
|
||
// 'exception'
|
||
// )).default;
|
||
// } catch (err) {
|
||
// // 不存在exception配置
|
||
// }
|
||
const serverConfiguration = require((0, path_1.join)(path, 'lib', 'configuration', 'server')).default;
|
||
// 拿到package.json,用作项目的唯一标识,否则无法区分不同项目的Redis+socketIO连接
|
||
const packageJson = require((0, path_1.join)(path, 'package.json'));
|
||
const corsHeaders = [
|
||
'Content-Type',
|
||
'Content-Length',
|
||
'Authorization',
|
||
'Accept',
|
||
'X-Requested-With',
|
||
];
|
||
const corsMethods = ['PUT', 'POST', 'GET', 'DELETE', 'OPTIONS'];
|
||
const koa = new koa_1.default();
|
||
// 使用 koa-logger 中间件打印日志
|
||
// koa.use(logger());
|
||
// socket
|
||
const httpServer = (0, http_1.createServer)(koa.callback());
|
||
const socketPath = connector.getSocketPath();
|
||
const socketOption = {
|
||
path: socketPath,
|
||
cors: ['development', 'staging'].includes(process.env.NODE_ENV)
|
||
? {
|
||
origin: '*',
|
||
allowedHeaders: corsHeaders.concat(connector.getCorsHeader()),
|
||
}
|
||
: serverConfiguration.cors
|
||
? {
|
||
origin: serverConfiguration.cors.origin, //socket.io配置cors origin是支持数组和字符串
|
||
allowedHeaders: [
|
||
...corsHeaders.concat(connector.getCorsHeader()),
|
||
...(serverConfiguration.cors.headers || []),
|
||
],
|
||
}
|
||
: undefined,
|
||
};
|
||
const io = new socket_io_1.Server(httpServer, socketOption);
|
||
const clusterInfo = (0, oak_backend_base_1.getClusterInfo)();
|
||
if (clusterInfo.usingCluster) {
|
||
// 目前只支持单物理结点的pm2模式
|
||
// pm2环境下要接入clusterAdapter
|
||
// https://socket.io/zh-CN/docs/v4/pm2/
|
||
// 在单机的所有实例之间使用pm2的集群适配器
|
||
io.adapter((0, cluster_adapter_1.createAdapter)());
|
||
(0, sticky_1.setupWorker)(io);
|
||
if (clusterInfo.enableRedis) {
|
||
// 在多机器之间使用redis适配器
|
||
const redisConfig = serverConfiguration.redis;
|
||
if (!redisConfig) {
|
||
console.error('未配置Redis连接信息!');
|
||
process.exit(-1);
|
||
}
|
||
const isCluster = Array.isArray(redisConfig);
|
||
// 创建 Redis 客户端
|
||
const pubClient = isCluster ?
|
||
new ioredis_1.default.Cluster(redisConfig.map((config) => ({
|
||
...config,
|
||
lazyConnect: true,
|
||
})))
|
||
: new ioredis_1.default({
|
||
...redisConfig,
|
||
lazyConnect: true,
|
||
});
|
||
const subClient = pubClient.duplicate();
|
||
pubClient.on('connect', () => {
|
||
console.log('PUB已成功连接到Redis服务器');
|
||
});
|
||
pubClient.on('error', (err) => {
|
||
console.error('连接到Redis失败!', err);
|
||
// 如果连接到Redis失败,直接退出
|
||
process.exit(-1);
|
||
});
|
||
subClient.on('connect', () => {
|
||
console.log('SUB已成功连接到Redis服务器');
|
||
});
|
||
subClient.on('error', (err) => {
|
||
console.error('连接到Redis失败!', err);
|
||
// 如果连接到Redis失败,直接退出
|
||
process.exit(-1);
|
||
});
|
||
await Promise.all([
|
||
pubClient.connect(),
|
||
subClient.connect(),
|
||
]);
|
||
io.adapter(
|
||
// 为了使单台Redis可以在多个项目之间复用,需要为每个项目创建一个唯一的key
|
||
(0, redis_adapter_1.createAdapter)(pubClient, subClient, {
|
||
key: `${packageJson.name}-socket.io-${process.env.NODE_ENV || 'development'}`
|
||
.replace(/[^a-zA-Z0-9-_:.]/g, '') // 移除特殊字符(只保留字母、数字、-、_、:、.)
|
||
.replace(/\s+/g, '-') // 将空格替换为-
|
||
.toLowerCase(), // 转换为小写(统一格式)
|
||
}));
|
||
console.log('已启用Redis适配器');
|
||
}
|
||
else {
|
||
// 如果没有启用Redis,不应该出现instanceCount大于实际实例数的情况
|
||
console.warn('正处于单机集群环境,请确保实例数正确!');
|
||
}
|
||
console.log(`以集群模式启动,实例总数『${clusterInfo.instanceCount}』,当前实例号『${clusterInfo.instanceId}』`);
|
||
}
|
||
else {
|
||
console.log('以单实例模式启动');
|
||
}
|
||
const { ui } = serverConfiguration;
|
||
const isPasswordSet = !!(process.env.SOCKET_ADMIN_PASSWORD || ui?.password);
|
||
// 密码使用随机字符串
|
||
const passwordForAdminUI = process.env.SOCKET_ADMIN_PASSWORD || ui?.password || (0, utils_1.randomString)(16);
|
||
if (!ui?.disable) {
|
||
(0, admin_ui_1.instrument)(io, {
|
||
auth: {
|
||
type: "basic", // 使用基本认证,生产建议关闭或换成自定义 auth
|
||
username: ui?.username || "admin",
|
||
password: bcryptjs_1.default.hashSync(passwordForAdminUI, 10), // 必须使用 bcrypt 加密之后的密码
|
||
},
|
||
mode: process.env.NODE_ENV === 'production' ? "production" : "development", // 根据环境设置模式
|
||
});
|
||
}
|
||
const appLoader = clusterInfo.usingCluster
|
||
? new oak_backend_base_1.ClusterAppLoader(path, io.of(DATA_SUBSCRIBE_NAMESPACE), io.of(SOCKET_NAMESPACE), io.of(SERVER_SUBSCRIBER_NAMESPACE), connector.getSocketPath())
|
||
: new oak_backend_base_1.AppLoader(path, io.of(DATA_SUBSCRIBE_NAMESPACE), io.of(SOCKET_NAMESPACE), io.of(SERVER_SUBSCRIBER_NAMESPACE));
|
||
await appLoader.mount();
|
||
await appLoader.execStartRoutines();
|
||
if (routine) {
|
||
// 如果传入了routine,执行完成后就结束
|
||
const result = await appLoader.execRoutine(routine);
|
||
await appLoader.unmount();
|
||
return result;
|
||
}
|
||
// if (errorHandler && typeof errorHandler === 'function') {
|
||
// // polyfillConsole("startup", true, (props) => {
|
||
// // if (props.level === "error") {
|
||
// // appLoader.execRoutine(async (ctx) => {
|
||
// // await errorHandler(props.caller, props.args, ctx);
|
||
// // }).catch((err) => {
|
||
// // console.warn('执行全局错误处理失败:', err);
|
||
// // });
|
||
// // }
|
||
// // return props.args;
|
||
// // });
|
||
// // appLoader.registerInternalErrorHandler(async (ctx, type, msg, err) => {
|
||
// // await errorHandler(ctx, type, msg, err);
|
||
// // });
|
||
// }
|
||
appLoader.regAllExceptionHandler();
|
||
// 否则启动服务器模式
|
||
koa.use(async (ctx, next) => {
|
||
try {
|
||
await next();
|
||
}
|
||
catch (err) {
|
||
console.error(err);
|
||
const { request } = ctx;
|
||
const exception = err instanceof types_1.OakException
|
||
? err
|
||
: new types_1.OakException(serverConfiguration?.internalExceptionMask ||
|
||
ExceptionMask);
|
||
const { body } = connector.serializeException(exception, request.headers, request.body);
|
||
ctx.response.body = body;
|
||
return;
|
||
}
|
||
});
|
||
koa.use((0, koa_body_1.default)(Object.assign({
|
||
multipart: true,
|
||
}, serverConfiguration.koaBody)));
|
||
// 注册自定义中间件
|
||
if (serverConfiguration.middleware) {
|
||
if (Array.isArray(serverConfiguration.middleware)) {
|
||
serverConfiguration.middleware.forEach((mw) => {
|
||
koa.use(mw);
|
||
});
|
||
}
|
||
else if (typeof serverConfiguration.middleware === 'function') {
|
||
const mws = serverConfiguration.middleware(koa);
|
||
if (!Array.isArray(mws)) {
|
||
throw new Error('middleware 配置函数必须返回 Koa.Middleware 数组');
|
||
}
|
||
mws.forEach((mw) => {
|
||
koa.use(mw);
|
||
});
|
||
}
|
||
}
|
||
const router = new koa_router_1.default();
|
||
// 如果是开发环境,允许options
|
||
if (['development', 'staging'].includes(process.env.NODE_ENV)) {
|
||
koa.use(async (ctx, next) => {
|
||
ctx.set('Access-Control-Allow-Origin', '*');
|
||
ctx.set('Access-Control-Allow-Headers', corsHeaders.concat(connector.getCorsHeader()));
|
||
ctx.set('Access-Control-Allow-Methods', corsMethods);
|
||
if (ctx.method == 'OPTIONS') {
|
||
ctx.body = 200;
|
||
}
|
||
else {
|
||
await next();
|
||
}
|
||
});
|
||
}
|
||
else if (serverConfiguration.cors) {
|
||
koa.use(async (ctx, next) => {
|
||
if (serverConfiguration.cors.origin instanceof Array) {
|
||
// 获取到req.headers.origin格式:https://xxx.xx.com 加上端口号
|
||
const origin = ctx.req.headers.origin;
|
||
if (serverConfiguration.cors.origin.includes(origin)) {
|
||
ctx.set('Access-Control-Allow-Origin', origin);
|
||
}
|
||
}
|
||
else {
|
||
ctx.set('Access-Control-Allow-Origin', serverConfiguration.cors.origin);
|
||
}
|
||
ctx.set('Access-Control-Allow-Headers', [
|
||
...corsHeaders.concat(connector.getCorsHeader()),
|
||
...(serverConfiguration.cors.headers || []),
|
||
]);
|
||
ctx.set('Access-Control-Allow-Methods', serverConfiguration.cors.methods || corsMethods);
|
||
if (ctx.method == 'OPTIONS') {
|
||
ctx.body = 200;
|
||
}
|
||
else {
|
||
await next();
|
||
}
|
||
});
|
||
}
|
||
router.post(connector.getRouter(), async (ctx) => {
|
||
const { request } = ctx;
|
||
const { contextString, aspectName, data } = connector.parseRequest(request.headers, request.body, request.files);
|
||
const { result, opRecords, message } = await appLoader.execAspect(aspectName, request.headers, contextString, data);
|
||
const { body, headers } = await connector.serializeResult(result, opRecords, request.headers, request.body, message);
|
||
ctx.response.body = body;
|
||
return;
|
||
});
|
||
// 桥接访问外部资源的入口
|
||
router.get(connector.getBridgeRouter(), async (ctx) => {
|
||
const { request: { querystring }, response, } = ctx;
|
||
const { url, headers } = connector.parseBridgeRequestQuery(querystring);
|
||
// headers待处理
|
||
const res = await fetch(url);
|
||
response.body = res.body;
|
||
return;
|
||
});
|
||
// 外部socket接口
|
||
/**
|
||
* 不用pm2 不用nginx: socket与http同端口
|
||
用pm2 不用nginx: socket监听8080
|
||
不用pm2 用nginx: nginx映射路径到server端口(手动配置),socket与http同端口(加路径)
|
||
用pm2 用nginx: nginx映射路径到8080(手动配置),socket与http同端口(加路径)
|
||
*/
|
||
const { nginx, hostname, port, socket: getSocketConfig } = serverConfiguration;
|
||
const protocol = nginx?.ssl ? 'https://' : 'http://';
|
||
let url = `${protocol}${hostname}`;
|
||
if (nginx) {
|
||
if (nginx.port) {
|
||
url += `:${nginx.port}`;
|
||
}
|
||
}
|
||
else if (clusterInfo.usingCluster) {
|
||
url += `:${process.env.PM2_PORT || 8080}`;
|
||
}
|
||
else {
|
||
url += `:${port}`;
|
||
}
|
||
// Example:
|
||
// import { io } from "socket.io-client";
|
||
// const socket = io('https://example.com/order', {
|
||
// path: '/my-custom-path/',
|
||
// });
|
||
// the Socket instance is attached to the "order" Namespace
|
||
// the HTTP requests will look like: GET https://example.com/my-custom-path/?EIO=4&transport=polling&t=ML4jUwU
|
||
// 文档 https://socket.io/docs/v4/client-options/
|
||
router.get(connector.getSocketPointRouter(), async (ctx) => {
|
||
const { response } = ctx;
|
||
let subscribeUrl = concat(url, DATA_SUBSCRIBE_NAMESPACE);
|
||
let socketUrl = concat(url, SOCKET_NAMESPACE);
|
||
if (typeof getSocketConfig === 'function') {
|
||
const socketConfig = getSocketConfig(ctx);
|
||
const url2 = socketConfig?.url;
|
||
if (url2) {
|
||
subscribeUrl = concat(url2, DATA_SUBSCRIBE_NAMESPACE);
|
||
socketUrl = concat(url2, SOCKET_NAMESPACE);
|
||
}
|
||
}
|
||
response.body = {
|
||
path: (nginx?.socketPath ? `/${nginx.socketPath}` : '') + connector.getSocketPath(),
|
||
socketUrl,
|
||
subscribeUrl,
|
||
};
|
||
return;
|
||
});
|
||
// 注入所有的endpoints
|
||
const endpoints = appLoader.getEndpoints(connector.getEndpointRouter());
|
||
endpoints.forEach(([name, method, url, fn]) => {
|
||
router[method](url, async (ctx) => {
|
||
const { req, request, params, response, res } = ctx;
|
||
const { body, headers, files } = request;
|
||
try {
|
||
const result = await fn(params, headers, req, files ? Object.assign({}, body, files) : body);
|
||
const { headers: headers2, data, statusCode } = result;
|
||
response.body = data;
|
||
if (headers2) {
|
||
Object.keys(headers2).forEach((k) => res.setHeader(k, headers2[k]));
|
||
}
|
||
if (statusCode) {
|
||
res.statusCode = statusCode;
|
||
}
|
||
return;
|
||
}
|
||
catch (err) {
|
||
ctx.response.status = 500;
|
||
return;
|
||
}
|
||
});
|
||
});
|
||
router.get(connector.getEndpointRouter(), async (ctx) => {
|
||
ctx.response.body = endpoints;
|
||
});
|
||
const socketAdminMountRaw = ui?.path || '/socket-admin';
|
||
const socketAdminMount = socketAdminMountRaw.startsWith('/')
|
||
? socketAdminMountRaw : `/${socketAdminMountRaw}`;
|
||
// 注册静态资源
|
||
if (!ui?.disable) {
|
||
koa.use((0, koa_mount_1.default)(socketAdminMount, (0, koa_static_1.default)(socketAdminUI)));
|
||
}
|
||
koa.use(router.routes());
|
||
koa.on('error', (err) => {
|
||
console.error(err);
|
||
throw err;
|
||
});
|
||
httpServer.listen(serverConfiguration.port, () => {
|
||
const protocol = nginx?.ssl ? 'https' : 'http';
|
||
const host = hostname || 'localhost';
|
||
const port = nginx?.port || (clusterInfo.usingCluster ? (process.env.PM2_PORT || 8080) : serverConfiguration.port);
|
||
const baseUrl = `${protocol}://${host}:${port}`;
|
||
const adminUIUrl = `${baseUrl}${socketAdminMount}`;
|
||
console.log(chalk_1.default.greenBright.bold('\n🚀 Server started successfully!\n'));
|
||
console.log(`🔗 ${chalk_1.default.cyan('Server URL')}: ${chalk_1.default.underline(baseUrl)}`);
|
||
// socketio地址
|
||
console.log(`🔗 ${chalk_1.default.cyan('Socket URL')}: ${chalk_1.default.underline(concat(url, socketPath))}\n`);
|
||
if (!ui?.disable) {
|
||
console.log(`🛠️ ${chalk_1.default.magenta('Socket Admin UI')}: ${chalk_1.default.underline(adminUIUrl)}`);
|
||
// 账号密码
|
||
// 是否设置密码
|
||
if (isPasswordSet) {
|
||
console.log(`🔑 ${chalk_1.default.yellow('Socket Admin UI Password has been set, check the config file\n')}`);
|
||
}
|
||
else {
|
||
console.log(chalk_1.default.yellow('Socket Admin UI Password Generated: ') + chalk_1.default.red(passwordForAdminUI));
|
||
console.log(chalk_1.default.yellow('Please set the password when running prod env.\n'));
|
||
}
|
||
}
|
||
});
|
||
if (!omitWatchers) {
|
||
appLoader.startWatchers();
|
||
}
|
||
if (!omitTimers) {
|
||
appLoader.startTimers();
|
||
}
|
||
const shutdown = async () => {
|
||
await httpServer.close();
|
||
await koa.removeAllListeners();
|
||
await appLoader.execStopRoutines();
|
||
await appLoader.unmount();
|
||
(0, polyfill_1.removePolyfill)("startup");
|
||
};
|
||
// 监听终止信号进行优雅关闭
|
||
// SIGINT - Ctrl+C 发送的中断信号
|
||
process.on('SIGINT', async () => {
|
||
await shutdown();
|
||
process.exit(0);
|
||
});
|
||
// SIGTERM - 系统/容器管理器发送的终止信号(Docker、K8s、PM2等)
|
||
process.on('SIGTERM', async () => {
|
||
await shutdown();
|
||
process.exit(0);
|
||
});
|
||
// SIGQUIT - Ctrl+\ 发送的退出信号
|
||
process.on('SIGQUIT', async () => {
|
||
await shutdown();
|
||
process.exit(0);
|
||
});
|
||
// SIGHUP - 终端关闭时发送(可选)
|
||
process.on('SIGHUP', async () => {
|
||
await shutdown();
|
||
process.exit(0);
|
||
});
|
||
return shutdown;
|
||
}
|