feedback5 R1: startServer and main become wiring — PORT / HOST / TRUST_PROXY and the option picks go to http/config.js, the session stack and the /ws upgrade to http/websocket.js, the request listener and /healthz to http/routes.js (new), the security headers and the clientError 400 to http/common.js, the banner, signals and entry check to http/boot.js
Same order of construction, listeners and headers as before; a scripted fingerprint (export list, 51 HTTP requests' status / header order / body hash, raw upgrade and malformed-request replies) and the process run (banner, port in use, SIGINT) match the pre-split server exactly. test/version.test.js reads the banner and /healthz from their new files; module headers describe each piece.
This commit is contained in:
1 parent
b48a67caec
commit
1b38e30819
11 files changed
+323
-202
No files matched your search
+60
-1
@@ -1,6 +1,16 @@
|
||||
// server/http/boot.js — what the process prints when it boots (moved from server/index.js).
|
||||
// server/http/boot.js — running the server as a process (`node server/index.js`, npm start, scripts/launch.mjs):
|
||||
//
|
||||
// * the boot banner: release version, the Local URL, the LAN URLs when listening on every interface, the
|
||||
// cloudflared command for internet play;
|
||||
// * a server that cannot start exits 1 (with a hint when the port is in use); unhandled rejections and uncaught
|
||||
// exceptions are logged, not fatal;
|
||||
// * graceful shutdown on SIGINT/SIGTERM (rooms get room.closed{reason:'shutdown'}, sockets close 1001); a second
|
||||
// signal exits at once, and the process exits anyway 5 s after the first.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { APP_VERSION } from '../../shared/constants.js';
|
||||
|
||||
/** Non-internal IPv4 addresses as http URLs. @param {number} port */
|
||||
export function lanUrls(port) {
|
||||
@@ -12,3 +22,52 @@ export function lanUrls(port) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Is the module whose `import.meta.url` is `metaUrl` the file node was started with? */
|
||||
export function isProcessEntry(metaUrl) {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(metaUrl));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Print the boot banner of a started server. @param {{ url: string, host: string, port: number }} srv */
|
||||
export function printBanner(srv) {
|
||||
console.log(`\n 卫戍协议:盟约 · Stronghold Protocol: Alliance v${APP_VERSION}`);
|
||||
console.log(` Local: ${srv.url}`);
|
||||
if (srv.host === '0.0.0.0' || srv.host === '::') {
|
||||
for (const u of lanUrls(srv.port)) console.log(` LAN: ${u}`);
|
||||
}
|
||||
console.log(' Internet: cloudflared tunnel --url ' + `http://localhost:${srv.port}` + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The process main: start the server, print the banner, stop gracefully on SIGINT / SIGTERM.
|
||||
* @param {() => Promise<{ url: string, host: string, port: number, close: () => Promise<void> }>} start index.js startServer
|
||||
*/
|
||||
export async function runMain(start) {
|
||||
process.on('unhandledRejection', (e) => console.error('[process] unhandled rejection', e));
|
||||
process.on('uncaughtException', (e) => console.error('[process] uncaught exception', e));
|
||||
let srv;
|
||||
try {
|
||||
srv = await start();
|
||||
} catch (e) {
|
||||
if (e && e.code === 'EADDRINUSE') console.error(`端口已被占用 / port in use: ${e.port ?? process.env.PORT ?? 3000}. Try PORT=3001 npm start`);
|
||||
else console.error('[boot] failed to start', e);
|
||||
process.exit(1);
|
||||
}
|
||||
printBanner(srv);
|
||||
|
||||
let stopping = false;
|
||||
const stop = (signal) => {
|
||||
if (stopping) { console.log('forced exit'); process.exit(1); }
|
||||
stopping = true;
|
||||
console.log(`\n[${signal}] shutting down…`);
|
||||
setTimeout(() => process.exit(0), 5000).unref();
|
||||
srv.close().then(() => process.exit(0), () => process.exit(1));
|
||||
};
|
||||
process.on('SIGINT', () => stop('SIGINT'));
|
||||
process.on('SIGTERM', () => stop('SIGTERM'));
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// server/http/buildTag.js — the build tag of the browser runtime this process serves (/healthz `build`).
|
||||
// Moved from server/index.js.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
+21
-2
@@ -1,5 +1,15 @@
|
||||
// server/http/common.js — the helpers every HTTP route shares: URL split, the error page and JSON replies.
|
||||
// Moved from server/index.js.
|
||||
// server/http/common.js — what every HTTP answer shares:
|
||||
//
|
||||
// * the security headers, set on every response before any route runs (routes.js);
|
||||
// * splitUrl (raw path + query, also from absolute-form URLs), the bilingual error page (sendError) and JSON replies
|
||||
// (sendJson) — both `Cache-Control: no-store`, without a body for HEAD;
|
||||
// * the bare `400 Bad Request` for a request node:http cannot parse (answerClientError).
|
||||
|
||||
/** Headers on every HTTP response. @param {import('node:http').ServerResponse} res */
|
||||
export function setSecurityHeaders(res) {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Referrer-Policy', 'same-origin');
|
||||
}
|
||||
|
||||
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
|
||||
@@ -35,3 +45,12 @@ export function splitUrl(url) {
|
||||
const hashless = (s) => { const h = s.indexOf('#'); return h >= 0 ? s.slice(0, h) : s; };
|
||||
return q >= 0 ? { rawPath: hashless(u.slice(0, q)), query: hashless(u.slice(q + 1)) } : { rawPath: hashless(u), query: '' };
|
||||
}
|
||||
|
||||
/** node:http 'clientError' listener: a reset socket is dropped, any other unparseable request gets a bare 400. */
|
||||
export function answerClientError(err, socket) {
|
||||
if (err && err.code === 'ECONNRESET') { socket.destroy(); return; }
|
||||
try {
|
||||
if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
||||
else socket.destroy();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
+58
-2
@@ -1,5 +1,11 @@
|
||||
// server/http/config.js — the repository root and the settings the server reads from the environment
|
||||
// (TRUST_PROXY → net.js trustProxy, DEBUG → the console logger's debug level). Moved from server/index.js.
|
||||
// server/http/config.js — where the server's settings come from. startServer() options win over the environment:
|
||||
//
|
||||
// * PORT (default 3000), HOST (default 0.0.0.0);
|
||||
// * TRUST_PROXY ('auto' default: honour CF-Connecting-IP / X-Real-IP / X-Forwarded-For only from loopback/private
|
||||
// peers such as a local cloudflared; '1' always; '0' never) → net.js trustProxy;
|
||||
// * DEBUG → the console logger's debug level;
|
||||
// * the served directories (public/, data/, shared/ of this repository unless the options name others), and which
|
||||
// startServer() options are handed on to net.js Network and lobby.js Lobby.
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -9,6 +15,55 @@ export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '
|
||||
|
||||
export const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
|
||||
|
||||
/** startServer() options handed on to net.js Network / lobby.js Lobby (an absent one keeps that module's default). */
|
||||
const NET_OPTION_KEYS = ['reconnectWindowMs', 'heartbeatMs', 'helloTimeoutMs', 'ratePerSec', 'rateBurst', 'maxConnections', 'abuseDropsPerSec',
|
||||
'maxConnectionsPerAddr', 'heavyPerSec', 'heavyBurst', 'trustProxy'];
|
||||
const LOBBY_OPTION_KEYS = ['lobbyGraceMs', 'maxRooms', 'maxRoomsPerAddr', 'maxMatchesPerAddr', 'resyncMinGapMs', 'soloReconnectWindowMs'];
|
||||
|
||||
/**
|
||||
* Where to listen: the `port` / `host` options, else PORT / HOST, else port 3000 on 0.0.0.0.
|
||||
* @param {{ port?: number, host?: string }} opts
|
||||
* @returns {{ port: number, host: string }}
|
||||
* @throws {RangeError} when the port is not an integer in 0…65535
|
||||
*/
|
||||
export function listenAddress(opts) {
|
||||
const port = opts.port ?? (process.env.PORT != null && process.env.PORT !== '' ? Number(process.env.PORT) : 3000);
|
||||
const host = opts.host ?? process.env.HOST ?? '0.0.0.0';
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new RangeError(`invalid PORT ${port}`);
|
||||
return { port, host };
|
||||
}
|
||||
|
||||
/**
|
||||
* The directories the static server reads.
|
||||
* @param {{ publicDir?: string, dataDir?: string, sharedDir?: string }} opts
|
||||
*/
|
||||
export function serveDirs(opts) {
|
||||
return {
|
||||
publicDir: opts.publicDir || path.join(ROOT, 'public'),
|
||||
dataDir: opts.dataDir || path.join(ROOT, 'data'),
|
||||
sharedDir: opts.sharedDir || path.join(ROOT, 'shared'),
|
||||
};
|
||||
}
|
||||
|
||||
/** net.js Network options out of the startServer() options; `trustProxy` falls back to TRUST_PROXY. */
|
||||
export function netOptionsFrom(opts) {
|
||||
const netOptions = {};
|
||||
for (const k of NET_OPTION_KEYS) {
|
||||
if (opts[k] != null) netOptions[k] = opts[k];
|
||||
}
|
||||
if (netOptions.trustProxy == null) netOptions.trustProxy = parseTrustProxy(process.env.TRUST_PROXY);
|
||||
return netOptions;
|
||||
}
|
||||
|
||||
/** lobby.js Lobby options out of the startServer() options. */
|
||||
export function lobbyOptionsFrom(opts) {
|
||||
const lobbyOptions = {};
|
||||
for (const k of LOBBY_OPTION_KEYS) {
|
||||
if (opts[k] != null) lobbyOptions[k] = opts[k];
|
||||
}
|
||||
return lobbyOptions;
|
||||
}
|
||||
|
||||
/** TRUST_PROXY env → net.js trustProxy ('auto' unless explicitly on/off). @param {string | undefined} v */
|
||||
export function parseTrustProxy(v) {
|
||||
const s = String(v ?? '').trim().toLowerCase();
|
||||
@@ -17,6 +72,7 @@ export function parseTrustProxy(v) {
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
/** The console logger (`quiet` → silent; debug lines only with DEBUG set). */
|
||||
export function makeLogger(quiet) {
|
||||
if (quiet) return noopLog;
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// server/http/files.js — one file on disk → an HTTP response (moved from server/index.js):
|
||||
// server/http/files.js — one file on disk → an HTTP response (static.js and media.js decide which file):
|
||||
// MIME types incl. .mjs/.js text/javascript, .skel application/octet-stream, .atlas text/plain;
|
||||
// gzip for text-like types, .skel and uncompressed fonts when the client accepts it (small files are
|
||||
// compressed once and cached in memory); strong ETag + Last-Modified with 304s; Cache-Control
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// server/http/media.js — the extension-less audio route /media/… → public/assets/audio (moved from server/index.js).
|
||||
// server/http/media.js — the extension-less audio route /media/… → public/assets/audio (see serveMedia).
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// server/http/routes.js — the node:http request listener. Every response gets the security headers (common.js), then:
|
||||
//
|
||||
// * a URL longer than 4096 characters → 414; one that does not parse → 400;
|
||||
// * any method but GET / HEAD → 405 with `Allow: GET, HEAD`;
|
||||
// * GET /healthz → JSON status (protocol `version`, release `app`, uptime, the served `build`, sockets, sessions,
|
||||
// rooms, matches), never cached;
|
||||
// * everything else → the static files (static.js).
|
||||
// A route that throws is logged and answers 500.
|
||||
|
||||
import { PROTOCOL_VERSION, APP_VERSION } from '../../shared/constants.js';
|
||||
import { buildTag } from './buildTag.js';
|
||||
import { setSecurityHeaders, sendError, sendJson, splitUrl } from './common.js';
|
||||
|
||||
const MAX_URL_LENGTH = 4096;
|
||||
|
||||
/**
|
||||
* The GET /healthz body.
|
||||
* @param {{ startedAt: number, network: import('../net.js').Network, registry: import('../net.js').SessionRegistry,
|
||||
* lobby: import('../lobby.js').Lobby }} health
|
||||
*/
|
||||
export function healthReport({ startedAt, network, registry, lobby }) {
|
||||
return {
|
||||
ok: true, version: PROTOCOL_VERSION, app: APP_VERSION, uptimeSec: Math.round((Date.now() - startedAt) / 1000),
|
||||
// the runtime the server is serving right now (public/js/ui/buildGuard.js): a page whose own build is
|
||||
// older than this reloads itself, so a deploy reaches clients that never reload
|
||||
build: buildTag(),
|
||||
sockets: network.connectionCount, sessions: registry.size, ...lobby.stats(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The request listener for `http.createServer`.
|
||||
* @param {{ serveStatic: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
|
||||
* rawPath: string, query: string) => Promise<void>,
|
||||
* health: Parameters<typeof healthReport>[0], log: object }} deps
|
||||
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void}
|
||||
*/
|
||||
export function createRequestHandler({ serveStatic, health, log }) {
|
||||
async function handleRequest(req, res) {
|
||||
const url = req.url || '/';
|
||||
if (url.length > MAX_URL_LENGTH) { sendError(req, res, 414, '请求地址过长 · URI too long'); return; }
|
||||
const parts = splitUrl(url);
|
||||
if (!parts) { sendError(req, res, 400, '请求地址无效 · Bad request'); return; }
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.setHeader('Allow', 'GET, HEAD');
|
||||
sendError(req, res, 405, '不支持的请求方法 · Method not allowed');
|
||||
return;
|
||||
}
|
||||
if (parts.rawPath === '/healthz') {
|
||||
sendJson(req, res, 200, healthReport(health));
|
||||
return;
|
||||
}
|
||||
await serveStatic(req, res, parts.rawPath, parts.query);
|
||||
}
|
||||
|
||||
return (req, res) => {
|
||||
setSecurityHeaders(res);
|
||||
handleRequest(req, res).catch((e) => {
|
||||
log.error('[http] request failed', e);
|
||||
sendError(req, res, 500, '服务器内部错误 · Internal error');
|
||||
});
|
||||
};
|
||||
}
|
||||
+22
-19
@@ -1,16 +1,19 @@
|
||||
// server/http/static.js — the static file server (moved from server/index.js):
|
||||
// * node:http static server: / → public/ (index.html for directories)
|
||||
// /data/ → data/ (generated game data)
|
||||
// /shared/ → shared/ (ESM shared with the browser)
|
||||
// /sim/ → server/sim/ (the battle simulation, read-only, `.js` only — client-side
|
||||
// combat, DESIGN §14; the Node-only loader nodeData.js is not served)
|
||||
// /data.js → a generated browser stand-in of server/data.js (the sim's content modules
|
||||
// import `../../../data.js`; in the browser it serves the data injected with
|
||||
// /sim/simdata.js setSimData). No other server file is ever served.
|
||||
// /media/bgm/act1 → public/assets/audio/bgm/act1.mp3 — the same audio files, addressed
|
||||
// **without** an extension so download managers (IDM / 迅雷 …) stop popping a
|
||||
// "下载文件信息" dialog for every BGM track (shared/media.js, public/js/media.js)
|
||||
// traversal & dotfile protection; 404 page. Files go out through files.js serveFile.
|
||||
// server/http/static.js — the static file server (routes.js hands it every GET / HEAD that is not an API route):
|
||||
//
|
||||
// / → public/ (index.html for directories)
|
||||
// /data/ → data/ (generated game data)
|
||||
// /shared/ → shared/ (ESM shared with the browser)
|
||||
// /sim/ → server/sim/ (the battle simulation, read-only, `.js` only — client-side
|
||||
// combat, DESIGN §14; the Node-only loader nodeData.js is not served)
|
||||
// /data.js → a generated browser stand-in of server/data.js (the sim's content modules
|
||||
// import `../../../data.js`; in the browser it serves the data injected with
|
||||
// /sim/simdata.js setSimData). No other server file is ever served.
|
||||
// /media/bgm/act1 → public/assets/audio/bgm/act1.mp3 — the same audio files, addressed
|
||||
// **without** an extension so download managers (IDM / 迅雷 …) stop popping a
|
||||
// "下载文件信息" dialog for every BGM track (media.js, shared/media.js, public/js/media.js)
|
||||
//
|
||||
// Traversal & dotfile protection, a directory without its trailing slash → 301, 404 page; an absent
|
||||
// data/local-assets.json is answered with an empty manifest. Files go out through files.js serveFile.
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -20,7 +23,7 @@ import { sendError } from './common.js';
|
||||
import { MIME, GzipCache, isNotModified, serveFile } from './files.js';
|
||||
import { serveMedia } from './media.js';
|
||||
|
||||
/** Browser stand-in of server/data.js, served at /data.js (see the header). */
|
||||
/** Browser stand-in of server/data.js, served at /data.js (see the header). Served byte for byte: its first line names the entry, server/index.js. */
|
||||
export const DATA_SHIM_JS = `// Generated by server/index.js — browser stand-in for server/data.js (DESIGN §14 client-side combat).
|
||||
// The simulation's content modules (/sim/content/support/index.js) import getData() from here; it returns the game data
|
||||
// the page injected with /sim/simdata.js setSimData(data).
|
||||
@@ -35,15 +38,15 @@ const SIM_PRIVATE = new Set(['nodedata.js']); // lower-case (compared case-insen
|
||||
// Static file handler
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create the static request handler.
|
||||
* @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, log?: object }} dirs
|
||||
* @returns {(req: http.IncomingMessage, res: http.ServerResponse, rawPath: string, query: string) => Promise<void>}
|
||||
*/
|
||||
/** Optional per-machine art manifest (tools/local-extract) and the empty stand-in served when it is absent. */
|
||||
const LOCAL_ART_MANIFEST = 'local-assets.json';
|
||||
const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none', count: 0, groups: {} }));
|
||||
|
||||
/**
|
||||
* Create the static request handler.
|
||||
* @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, log?: object }} dirs
|
||||
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, rawPath: string, query: string) => Promise<void>}
|
||||
*/
|
||||
export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), log = noopLog }) {
|
||||
const mounts = [
|
||||
{ prefix: '/data/', name: 'data', dir: path.resolve(dataDir) },
|
||||
|
||||
@@ -1,4 +1,62 @@
|
||||
// server/http/websocket.js — the WebSocket endpoint (moved from server/index.js).
|
||||
// server/http/websocket.js — the real-time side of the server:
|
||||
//
|
||||
// * session wiring: SessionRegistry (reconnect tokens) → Lobby (rooms, server/lobby.js) → Network (the socket
|
||||
// protocol, server/net.js), built from the startServer() options (config.js decides which go where);
|
||||
// * WebSocket (ws) at /ws, maxPayload 64 KB, no per-message deflate → Network.handleConnection. Refused at the
|
||||
// upgrade: any other path 404; per-network socket limit for internet clients (maxConnectionsPerAddr, see net.js
|
||||
// clientAddress; local/LAN peers are exempt) 429; server full (maxConnections) or shutting down 503.
|
||||
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { Network, SessionRegistry, NET_DEFAULTS } from '../net.js';
|
||||
import { Lobby } from '../lobby.js';
|
||||
import { splitUrl } from './common.js';
|
||||
import { netOptionsFrom, lobbyOptionsFrom } from './config.js';
|
||||
|
||||
/** Inbound WebSocket frame limit (DESIGN §8). */
|
||||
export const WS_MAX_PAYLOAD = 64 * 1024;
|
||||
|
||||
/**
|
||||
* The session stack of one server.
|
||||
* @param {{ MatchClass?: Function, seedFn?: () => number, [option: string]: any }} opts startServer() options
|
||||
* @param {{ data: object, log: object }} deps the game data the lobby's matches use, the logger
|
||||
* @returns {{ registry: SessionRegistry, lobby: Lobby, network: Network }}
|
||||
*/
|
||||
export function createSessionStack(opts, { data, log }) {
|
||||
const netOptions = netOptionsFrom(opts);
|
||||
const registry = new SessionRegistry({ reconnectWindowMs: netOptions.reconnectWindowMs ?? NET_DEFAULTS.reconnectWindowMs });
|
||||
const lobbyOptions = lobbyOptionsFrom(opts);
|
||||
const lobby = new Lobby({ registry, log, MatchClass: opts.MatchClass, getData: () => data, seedFn: opts.seedFn, options: lobbyOptions });
|
||||
const network = new Network({ registry, handler: lobby, log, options: netOptions });
|
||||
return { registry, lobby, network };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the WebSocket endpoint /ws on `server` (its 'upgrade' event).
|
||||
* @param {import('node:http').Server} server
|
||||
* @param {{ network: Network, log: object }} deps
|
||||
* @returns {WebSocketServer}
|
||||
*/
|
||||
export function attachWebSocket(server, { network, log }) {
|
||||
const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD, perMessageDeflate: false, clientTracking: false });
|
||||
wss.on('connection', (ws, req) => network.handleConnection(ws, req));
|
||||
wss.on('error', (e) => log.error('[ws] server error', e));
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
socket.on('error', () => {});
|
||||
const parts = splitUrl(req.url || '/');
|
||||
const reject = (status, text) => {
|
||||
try { socket.end(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); } catch { socket.destroy(); }
|
||||
};
|
||||
if (!parts || parts.rawPath !== '/ws') { reject(404, 'Not Found'); return; }
|
||||
const refused = network.admission(req);
|
||||
if (refused === 'per-address') { reject(429, 'Too Many Requests'); return; }
|
||||
if (refused) { reject(503, 'Service Unavailable'); return; }
|
||||
try {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
|
||||
} catch (e) {
|
||||
log.error('[ws] upgrade failed', e);
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
return wss;
|
||||
}
|
||||
Reference in new issue
Block a user