From 0e3da0f40da4c5b2e0b04c9bf041fdb359c52742 Mon Sep 17 00:00:00 2001 From: killagu Date: Tue, 14 Apr 2026 10:54:49 +0800 Subject: [PATCH 1/5] refactor(onerror): inline error page template as string constant Inline the mustache error page template as a string constant in src/lib/onerror_page.ts so plugins can be statically bundled by turbopack. Previously the template was read from disk via `import.meta.dirname + readFileSync`, which breaks when modules are embedded in a single bundled chunk. `templatePath` default is now `''`; if set, the custom template file is still loaded via fs, otherwise the inlined constant is used. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/onerror/package.json | 2 + plugins/onerror/src/app.ts | 3 +- plugins/onerror/src/config/config.default.ts | 8 +- plugins/onerror/src/lib/onerror_page.ts | 1336 ++++++++++++++++++ 4 files changed, 1344 insertions(+), 5 deletions(-) create mode 100644 plugins/onerror/src/lib/onerror_page.ts diff --git a/plugins/onerror/package.json b/plugins/onerror/package.json index 926a44d61e..e21b75a202 100644 --- a/plugins/onerror/package.json +++ b/plugins/onerror/package.json @@ -28,6 +28,7 @@ "./app": "./src/app.ts", "./config/config.default": "./src/config/config.default.ts", "./lib/error_view": "./src/lib/error_view.ts", + "./lib/onerror_page": "./src/lib/onerror_page.ts", "./lib/utils": "./src/lib/utils.ts", "./types": "./src/types.ts", "./package.json": "./package.json" @@ -40,6 +41,7 @@ "./app": "./dist/app.js", "./config/config.default": "./dist/config/config.default.js", "./lib/error_view": "./dist/lib/error_view.js", + "./lib/onerror_page": "./dist/lib/onerror_page.js", "./lib/utils": "./dist/lib/utils.js", "./types": "./dist/types.js", "./package.json": "./package.json" diff --git a/plugins/onerror/src/app.ts b/plugins/onerror/src/app.ts index fe3444c733..a525754d61 100644 --- a/plugins/onerror/src/app.ts +++ b/plugins/onerror/src/app.ts @@ -6,6 +6,7 @@ import { onerror, type OnerrorOptions, type OnerrorError } from 'koa-onerror'; import type { OnerrorConfig } from './config/config.default.ts'; import { ErrorView } from './lib/error_view.ts'; +import { ONERROR_PAGE_TEMPLATE } from './lib/onerror_page.ts'; import { isProd, detectStatus, detectErrorMessage, accepts } from './lib/utils.ts'; export interface OnerrorErrorWithCode extends OnerrorError { @@ -23,7 +24,7 @@ export default class Boot implements ILifecycleBoot { async didLoad(): Promise { // logging error const config = this.app.config.onerror; - const viewTemplate = fs.readFileSync(config.templatePath, 'utf8'); + const viewTemplate = config.templatePath ? fs.readFileSync(config.templatePath, 'utf8') : ONERROR_PAGE_TEMPLATE; const app = this.app; app.on('error', (err, ctx) => { if (!ctx) { diff --git a/plugins/onerror/src/config/config.default.ts b/plugins/onerror/src/config/config.default.ts index 9aa66b0016..dc7a5e5ee5 100644 --- a/plugins/onerror/src/config/config.default.ts +++ b/plugins/onerror/src/config/config.default.ts @@ -1,5 +1,3 @@ -import path from 'node:path'; - import type { Context } from 'egg'; import type { OnerrorError, OnerrorOptions } from 'koa-onerror'; @@ -20,7 +18,9 @@ export interface OnerrorConfig extends OnerrorOptions { */ appErrorFilter?: (err: OnerrorError, ctx: Context) => boolean; /** - * default template path + * Custom template path. If empty, uses the built-in error page template. + * + * Default: `''` */ templatePath: string; } @@ -29,6 +29,6 @@ export default { onerror: { errorPageUrl: '', appErrorFilter: undefined, - templatePath: path.join(import.meta.dirname, '../lib/onerror_page.mustache.html'), + templatePath: '', } as OnerrorConfig, }; diff --git a/plugins/onerror/src/lib/onerror_page.ts b/plugins/onerror/src/lib/onerror_page.ts new file mode 100644 index 0000000000..b045973009 --- /dev/null +++ b/plugins/onerror/src/lib/onerror_page.ts @@ -0,0 +1,1336 @@ +/** Error page template - inlined from onerror_page.mustache.html */ +export const ONERROR_PAGE_TEMPLATE = ` + + + + + + + + + + + +
+
+

{{ status }}

+
+

{{ name }} in {{ request.url }}

+
{{ message }}
+
+ + + +
+ + +
+
+ + +
+ +
+ {{#frames}} {{index}} +
+
{{ file }}:{{ line }}:{{ column }}
+
{{ method }}
+
+ {{ context.pre }} {{ context.line }} {{ context.post }} +
+
+ {{/frames}} +
+
+
+
+ +
+

Request Details

+
+
+
URI
+
{{ request.url }}
+
+ +
+
Request Method
+
{{ request.method }}
+
+ +
+
HTTP Version
+
{{ request.httpVersion }}
+
+ +
+
Connection
+
{{ request.connection }}
+
+
+ +

Headers

+
+ {{#request.headers}} +
+
{{ key }}
+
{{ value }}
+
+ {{/request.headers}} +
+ +

Cookies

+
+ {{#request.cookies}} +
+
{{ key }}
+
{{ value }}
+
+ {{/request.cookies}} +
+

AppInfo

+
+
+
baseDir
+
{{ appInfo.baseDir }}
+
+
+
config
+
+
{{ appInfo.config }}
+
+
+
+
+ + + +
+ + +`; From 20b7b70b9d350f5119532d37551dcbf3ff3a72af Mon Sep 17 00:00:00 2001 From: killa Date: Sat, 25 Apr 2026 20:09:24 +0800 Subject: [PATCH 2/5] fix(onerror): address template review comments --- plugins/onerror/src/app.ts | 5 +- plugins/onerror/src/lib/error_view.ts | 60 +- .../src/lib/onerror_page.mustache.html | 1333 ----------------- plugins/onerror/src/lib/onerror_page.ts | 21 +- plugins/onerror/test/onerror.test.ts | 34 + 5 files changed, 108 insertions(+), 1345 deletions(-) delete mode 100644 plugins/onerror/src/lib/onerror_page.mustache.html diff --git a/plugins/onerror/src/app.ts b/plugins/onerror/src/app.ts index a525754d61..0c9a56763b 100644 --- a/plugins/onerror/src/app.ts +++ b/plugins/onerror/src/app.ts @@ -6,7 +6,6 @@ import { onerror, type OnerrorOptions, type OnerrorError } from 'koa-onerror'; import type { OnerrorConfig } from './config/config.default.ts'; import { ErrorView } from './lib/error_view.ts'; -import { ONERROR_PAGE_TEMPLATE } from './lib/onerror_page.ts'; import { isProd, detectStatus, detectErrorMessage, accepts } from './lib/utils.ts'; export interface OnerrorErrorWithCode extends OnerrorError { @@ -24,7 +23,9 @@ export default class Boot implements ILifecycleBoot { async didLoad(): Promise { // logging error const config = this.app.config.onerror; - const viewTemplate = config.templatePath ? fs.readFileSync(config.templatePath, 'utf8') : ONERROR_PAGE_TEMPLATE; + const viewTemplate = config.templatePath + ? fs.readFileSync(config.templatePath, 'utf8') + : (await import('./lib/onerror_page.ts')).ONERROR_PAGE_TEMPLATE; const app = this.app; app.on('error', (err, ctx) => { if (!ctx) { diff --git a/plugins/onerror/src/lib/error_view.ts b/plugins/onerror/src/lib/error_view.ts index 278d7e6230..0b9e78621f 100644 --- a/plugins/onerror/src/lib/error_view.ts +++ b/plugins/onerror/src/lib/error_view.ts @@ -13,6 +13,18 @@ import stackTrace, { type StackFrame } from 'stack-trace'; import { detectErrorMessage } from './utils.ts'; const startingSlashRegex = /\\|\//; +const defaultConfigIgnoreList: (string | RegExp)[] = [ + 'pass', + 'pwd', + 'passd', + 'passwd', + 'password', + 'keys', + 'masterKey', + 'accessKey', + /secret/i, +]; +const redactedValue = ''; export interface FrameSource { pre: string[]; @@ -302,13 +314,53 @@ export class ErrorView { baseDir: string; config: string; } { - let config = this.app.config; - if ('dumpConfigToObject' in this.app && typeof this.app.dumpConfigToObject === 'function') { - config = this.app.dumpConfigToObject().config.config; - } + const config = this.serializeConfig(); return { baseDir: this.app.config.baseDir as string, config: util.inspect(config) satisfies string as string, }; } + + serializeConfig(): unknown { + if ('dumpConfigToObject' in this.app && typeof this.app.dumpConfigToObject === 'function') { + return this.app.dumpConfigToObject().config.config; + } + + return this.redactConfig(this.app.config, this.getConfigIgnoreList()); + } + + getConfigIgnoreList(): (string | RegExp)[] { + try { + return Array.from(this.app.config.dump.ignore); + } catch { + return defaultConfigIgnoreList; + } + } + + redactConfig(value: unknown, ignoreList: (string | RegExp)[], seen = new WeakSet()): unknown { + if (!value || typeof value !== 'object') { + return value; + } + + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + + if (Array.isArray(value)) { + return value.map((item) => this.redactConfig(item, ignoreList, seen)); + } + + const result: Record = {}; + for (const key of Object.keys(value)) { + result[key] = this.shouldRedactConfigKey(key, ignoreList) + ? redactedValue + : this.redactConfig((value as Record)[key], ignoreList, seen); + } + return result; + } + + shouldRedactConfigKey(key: string, ignoreList: (string | RegExp)[]): boolean { + return ignoreList.some((item) => (typeof item === 'string' ? item === key : item.test(key))); + } } diff --git a/plugins/onerror/src/lib/onerror_page.mustache.html b/plugins/onerror/src/lib/onerror_page.mustache.html deleted file mode 100644 index d872414635..0000000000 --- a/plugins/onerror/src/lib/onerror_page.mustache.html +++ /dev/null @@ -1,1333 +0,0 @@ - - - - - - - - - - - - -
-
-

{{ status }}

-
-

{{ name }} in {{ request.url }}

-
{{ message }}
-
- - - -
- - -
-
- - -
- -
- {{#frames}} {{index}} -
-
{{ file }}:{{ line }}:{{ column }}
-
{{ method }}
-
- {{ context.pre }} {{ context.line }} {{ context.post }} -
-
- {{/frames}} -
-
-
-
- -
-

Request Details

-
-
-
URI
-
{{ request.url }}
-
- -
-
Request Method
-
{{ request.method }}
-
- -
-
HTTP Version
-
{{ request.httpVersion }}
-
- -
-
Connection
-
{{ request.connection }}
-
-
- -

Headers

-
- {{#request.headers}} -
-
{{ key }}
-
{{ value }}
-
- {{/request.headers}} -
- -

Cookies

-
- {{#request.cookies}} -
-
{{ key }}
-
{{ value }}
-
- {{/request.cookies}} -
-

AppInfo

-
-
-
baseDir
-
{{ appInfo.baseDir }}
-
-
-
config
-
-
{{ appInfo.config }}
-
-
-
-
- - - -
- - diff --git a/plugins/onerror/src/lib/onerror_page.ts b/plugins/onerror/src/lib/onerror_page.ts index b045973009..7d2ab4af85 100644 --- a/plugins/onerror/src/lib/onerror_page.ts +++ b/plugins/onerror/src/lib/onerror_page.ts @@ -1,4 +1,4 @@ -/** Error page template - inlined from onerror_page.mustache.html */ +/** Built-in error page template. */ export const ONERROR_PAGE_TEMPLATE = ` @@ -1167,7 +1167,7 @@ export const ONERROR_PAGE_TEMPLATE = ` var u = +o[0], m = +o[1] || u, h = document.createElement('div'); - ((h.textContent = Array(m - u + 2).join(' \\n')), + ((h.textContent = Array(m - u + 2).join('\\n')), h.setAttribute('aria-hidden', 'true'), (h.className = (i || '') + ' line-highlight'), t(e, 'line-numbers') || (h.setAttribute('data-start', u), m > u && h.setAttribute('data-end', m)), @@ -1280,9 +1280,12 @@ export const ONERROR_PAGE_TEMPLATE = ` node.classList.remove('force-show'); }); var activeFrame = $('.frame-row.active'); - if (activeFrame.classList.contains('native-frame')) { + if (activeFrame && activeFrame.classList.contains('native-frame')) { activeFrame.classList.remove('active'); var firstFrame = $$('.frame-row')[0]; + if (!firstFrame) { + return; + } firstFrame.classList.add('active'); showFrameContext(firstFrame); } @@ -1294,7 +1297,13 @@ export const ONERROR_PAGE_TEMPLATE = ` } } function showFrameContext(frame) { - $frameContext = frame.querySelector('.frame-context'); + if (!frame) { + return; + } + const $frameContext = frame.querySelector('.frame-context'); + if (!$frameContext) { + return; + } var $context = $frameContext.innerHTML; $context = $context.trim().length === 0 ? 'Missing stack frames' : $context; var $line = $frameContext.getAttribute('data-line'); @@ -1310,7 +1319,7 @@ export const ONERROR_PAGE_TEMPLATE = ` $('#code-drop').setAttribute('class', 'language-' + $language); $('#code-drop').innerHTML = $context; $('#frame-file').innerHTML = $file; - $('#frame-method').innerHTML = $method + '' + $lineColumn; + $('#frame-method').innerHTML = $method + ' ' + $lineColumn; Prism.highlightAll(); } @@ -1327,7 +1336,7 @@ export const ONERROR_PAGE_TEMPLATE = ` filterFrames(); }; displayFirstView(); - showFrameContext($('.frame-row.active')); + showFrameContext($('.frame-row.active') || $('.frame-row')); })(); diff --git a/plugins/onerror/test/onerror.test.ts b/plugins/onerror/test/onerror.test.ts index 40961496e1..3195acf93c 100644 --- a/plugins/onerror/test/onerror.test.ts +++ b/plugins/onerror/test/onerror.test.ts @@ -7,6 +7,8 @@ import { mm, type MockApplication } from '@eggjs/mock'; import { type Context } from 'egg'; import { describe, it, beforeAll, afterAll, afterEach } from 'vitest'; +import { ErrorView } from '../src/lib/error_view.ts'; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -77,6 +79,38 @@ describe('test/onerror.test.ts', () => { .expect(500); }); + it('should redact sensitive config when serializing app info', () => { + const view = new ErrorView( + { + request: {}, + app: { + config: { + baseDir: '/tmp/app', + keys: 'foo,bar', + mysql: { + password: 'password-value', + secret: 'secret-value', + }, + normal: 'visible', + dump: { + ignore: new Set(['keys', 'password', /secret/i]), + }, + }, + }, + } as any, + new Error('test error') as any, + '', + ); + + const appInfo = view.serializeAppInfo(); + assert.equal(appInfo.baseDir, '/tmp/app'); + assert.match(appInfo.config, /normal: 'visible'/); + assert.match(appInfo.config, //); + assert(!appInfo.config.includes('foo,bar')); + assert(!appInfo.config.includes('password-value')); + assert(!appInfo.config.includes('secret-value')); + }); + it('should handle status:1 as status:500', async () => { await app .httpRequest() From de393deee6f5e608298d7f2befdebd7e78570fa5 Mon Sep 17 00:00:00 2001 From: killa Date: Sat, 25 Apr 2026 20:19:30 +0800 Subject: [PATCH 3/5] fix(onerror): satisfy declaration build --- plugins/onerror/src/lib/error_view.ts | 6 +++++- plugins/onerror/tsdown.config.ts | 9 +-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/plugins/onerror/src/lib/error_view.ts b/plugins/onerror/src/lib/error_view.ts index 0b9e78621f..f5dfa4b18c 100644 --- a/plugins/onerror/src/lib/error_view.ts +++ b/plugins/onerror/src/lib/error_view.ts @@ -337,7 +337,11 @@ export class ErrorView { } } - redactConfig(value: unknown, ignoreList: (string | RegExp)[], seen = new WeakSet()): unknown { + redactConfig( + value: unknown, + ignoreList: (string | RegExp)[], + seen: WeakSet = new WeakSet(), + ): unknown { if (!value || typeof value !== 'object') { return value; } diff --git a/plugins/onerror/tsdown.config.ts b/plugins/onerror/tsdown.config.ts index a05ee5ecad..7f14942071 100644 --- a/plugins/onerror/tsdown.config.ts +++ b/plugins/onerror/tsdown.config.ts @@ -1,10 +1,3 @@ import { defineConfig } from 'tsdown'; -export default defineConfig({ - copy: [ - { - from: 'src/lib/onerror_page.mustache.html', - to: 'dist/lib', - }, - ], -}); +export default defineConfig({}); From 5277c660664e3911289b14bec45f04322cf91879 Mon Sep 17 00:00:00 2001 From: killa Date: Sat, 25 Apr 2026 20:24:07 +0800 Subject: [PATCH 4/5] fix(onerror): handle shared config references --- plugins/onerror/src/lib/error_view.ts | 36 ++++++++++++++++--------- plugins/onerror/src/lib/onerror_page.ts | 4 +-- plugins/onerror/test/onerror.test.ts | 35 ++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/plugins/onerror/src/lib/error_view.ts b/plugins/onerror/src/lib/error_view.ts index f5dfa4b18c..9fa89f68ef 100644 --- a/plugins/onerror/src/lib/error_view.ts +++ b/plugins/onerror/src/lib/error_view.ts @@ -340,28 +340,40 @@ export class ErrorView { redactConfig( value: unknown, ignoreList: (string | RegExp)[], - seen: WeakSet = new WeakSet(), + ancestors: WeakSet = new WeakSet(), ): unknown { if (!value || typeof value !== 'object') { return value; } - if (seen.has(value)) { - return '[Circular]'; + if (value instanceof Date || value instanceof RegExp || value instanceof URL) { + return value.toString(); } - seen.add(value); - if (Array.isArray(value)) { - return value.map((item) => this.redactConfig(item, ignoreList, seen)); + if (Buffer.isBuffer(value)) { + return value; + } + + if (ancestors.has(value)) { + return '[Circular]'; } + ancestors.add(value); - const result: Record = {}; - for (const key of Object.keys(value)) { - result[key] = this.shouldRedactConfigKey(key, ignoreList) - ? redactedValue - : this.redactConfig((value as Record)[key], ignoreList, seen); + try { + if (Array.isArray(value)) { + return value.map((item) => this.redactConfig(item, ignoreList, ancestors)); + } + + const result: Record = {}; + for (const key of Object.keys(value)) { + result[key] = this.shouldRedactConfigKey(key, ignoreList) + ? redactedValue + : this.redactConfig((value as Record)[key], ignoreList, ancestors); + } + return result; + } finally { + ancestors.delete(value); } - return result; } shouldRedactConfigKey(key: string, ignoreList: (string | RegExp)[]): boolean { diff --git a/plugins/onerror/src/lib/onerror_page.ts b/plugins/onerror/src/lib/onerror_page.ts index 7d2ab4af85..570f07e0f4 100644 --- a/plugins/onerror/src/lib/onerror_page.ts +++ b/plugins/onerror/src/lib/onerror_page.ts @@ -1318,8 +1318,8 @@ export const ONERROR_PAGE_TEMPLATE = ` $('#code-drop').parentNode.setAttribute('data-line-offset', Number($start) - 1); $('#code-drop').setAttribute('class', 'language-' + $language); $('#code-drop').innerHTML = $context; - $('#frame-file').innerHTML = $file; - $('#frame-method').innerHTML = $method + ' ' + $lineColumn; + $('#frame-file').innerHTML = $file || ''; + $('#frame-method').innerHTML = [$method, $lineColumn].filter(Boolean).join(' '); Prism.highlightAll(); } diff --git a/plugins/onerror/test/onerror.test.ts b/plugins/onerror/test/onerror.test.ts index 3195acf93c..568263fc86 100644 --- a/plugins/onerror/test/onerror.test.ts +++ b/plugins/onerror/test/onerror.test.ts @@ -111,6 +111,41 @@ describe('test/onerror.test.ts', () => { assert(!appInfo.config.includes('secret-value')); }); + it('should use default config redaction without marking shared values as circular', () => { + const shared = { name: 'shared-value' }; + const circular: Record = { name: 'circular-value' }; + circular.self = circular; + const view = new ErrorView( + { + request: {}, + app: { + config: { + baseDir: '/tmp/app', + keys: 'foo,bar', + password: 'password-value', + token_secret: 'secret-value', + sharedA: shared, + sharedB: shared, + circular, + normal: 'visible', + }, + }, + } as any, + new Error('test error') as any, + '', + ); + + const appInfo = view.serializeAppInfo(); + assert.equal(appInfo.baseDir, '/tmp/app'); + assert.match(appInfo.config, /normal: 'visible'/); + assert.match(appInfo.config, /sharedA: \{ name: 'shared-value' \}/); + assert.match(appInfo.config, /sharedB: \{ name: 'shared-value' \}/); + assert.match(appInfo.config, /self: '\[Circular\]'/); + assert(!appInfo.config.includes('foo,bar')); + assert(!appInfo.config.includes('password-value')); + assert(!appInfo.config.includes('secret-value')); + }); + it('should handle status:1 as status:500', async () => { await app .httpRequest() From 1e98ce1760518e18f330841043ebcb4014be7e4c Mon Sep 17 00:00:00 2001 From: killa Date: Sat, 25 Apr 2026 21:48:57 +0800 Subject: [PATCH 5/5] test: stabilize cluster CI on Node 22 --- packages/cluster/test/app_worker.test.ts | 3 +- packages/egg/test/cluster1/app_worker.test.ts | 32 ++++++++++++------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/cluster/test/app_worker.test.ts b/packages/cluster/test/app_worker.test.ts index b77f906ab8..61a2d0cfa4 100644 --- a/packages/cluster/test/app_worker.test.ts +++ b/packages/cluster/test/app_worker.test.ts @@ -52,7 +52,8 @@ describe.skipIf(process.version.startsWith('v24') || process.platform === 'win32 app // .debug() .expect('code', 1) - .expect('stdout', /\[app_worker] beforeExit success/) + .expect('stderr', /Error: mock error/) + .expect('stderr', /app_worker#1:\d+ start fail/) .end() ); }); diff --git a/packages/egg/test/cluster1/app_worker.test.ts b/packages/egg/test/cluster1/app_worker.test.ts index 75ac319459..aa6445c1cd 100644 --- a/packages/egg/test/cluster1/app_worker.test.ts +++ b/packages/egg/test/cluster1/app_worker.test.ts @@ -30,6 +30,9 @@ describe('test/cluster1/app_worker.test.ts', () => { }); it('should response 400 bad request when HTTP request packet broken', async () => { + // Node.js will emit a clientError when the raw URI in the HTTP request + // packet contains spaces. Send raw packets because modern clients reject + // unescaped paths before they reach the server. const responses = await Promise.all([rawRequest(app.port, '/foo bar'), rawRequest(app.port, '/foo baz')]); for (const response of responses) { @@ -139,9 +142,11 @@ function connect(port: number) { function rawRequest(port: number, path: string) { return new Promise((resolve, reject) => { - const socket = net.createConnection(port, '127.0.0.1'); let response = ''; let settled = false; + const socket = net.createConnection(port, '127.0.0.1', () => { + socket.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: close\r\n\r\n`); + }); function resolveOnce() { if (!settled) { @@ -150,24 +155,27 @@ function rawRequest(port: number, path: string) { } } + function rejectOnce(err: Error) { + if (!settled) { + settled = true; + reject(err); + } + } + socket.setEncoding('utf8'); - socket.on('connect', () => { - socket.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: close\r\n\r\n`); - }); + socket.setTimeout(5000); socket.on('data', (chunk) => { response += chunk; }); + socket.on('timeout', () => { + socket.destroy(new Error('Timed out waiting for raw HTTP response')); + }); socket.on('end', resolveOnce); - socket.on('close', (hasError) => { - if (!hasError) { + socket.on('error', rejectOnce); + socket.on('close', (hadError) => { + if (!hadError) { resolveOnce(); } }); - socket.on('error', (err) => { - if (!settled) { - settled = true; - reject(err); - } - }); }); }