"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.startup = startup; const tslib_1 = require("tslib"); /// 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")); const koa_logger_1 = tslib_1.__importDefault(require("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 = undefined; try { errorHandler = require((0, path_1.join)(path, 'lib', 'configuration', 'errors')).default; } catch (err) { // 不存在errors配置 } 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((0, koa_logger_1.default)()); // 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') { (0, polyfill_1.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; }); } // 否则启动服务器模式 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))); 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(); } process.on('SIGINT', async () => { await appLoader.unmount(); process.exit(0); }); const shutdown = async () => { await httpServer.close(); await koa.removeAllListeners(); await appLoader.unmount(); (0, polyfill_1.removePolyfill)("startup"); }; return shutdown; }