UNPKG

efriend

Version:
738 lines (737 loc) 33 kB
'use strict'; /** * EFriendExpert Web Service API * * @file packages/EFriendExpert/efriends/efriendWs.ts * @version 0.0.1 * @license GNU General Public License v3.0 * @copyright 2017~2023, EFriendExport Community Team * @author gye hyun james kim <pnuskgh@gmail.com> */ import WebSocket from 'ws'; //--- https://www.npmjs.com/package/ws import moment from 'moment'; //--- 'YYYY-MM-DD HH:mm:ss.SSS ZZ' import crypto from 'node:crypto'; import { BaseError, ERROR_CODE } from '../common/error/index.js'; import { TR_TYPE } from './efriend.type.js'; import EFriend_JSON_TRID from './efriend.constant.js'; import { limit } from './efriend.js'; export class EFriendWs { logger; secret; ws; isOpen; wsInterval; wsIntervalTime; wsKeys; isKeepAlive = true; wsHandlers; constructor({ secret, logger }) { this.logger = logger ?? console; this.secret = secret; //--- Secret this.ws = null; //--- Web Socket this.isOpen = false; //--- true. Web Socket이 동작중 this.wsInterval = null; //--- 주기적으로 Web Socket(this.ws)이 살아 있는지 확인 한다. this.wsIntervalTime = 60 * 1000; //--- Web Socket(this.ws)이 살아 있는지 확인하는 주기 this.wsKeys = {}; //--- 복호화용 AES256 IV(Initialize Vector)와 Key this.wsHandlers = { // 'init': [], //--- Deprecated : ~ 2023.12.31, func(ws: EFriendWs(this), secret: Secret(this.secret)) 'message': [], 'open': [ { name: 'updateSession', handler: this._onOpen_1.bind(this), isInternal: true }, { name: 'updateApi', handler: this._onOpen_2.bind(this), isInternal: true } ], 'close': [ { name: 'updateSession', handler: this._onClose_1.bind(this), isInternal: true }, { name: 'initialize', handler: this._onClose_2.bind(this), isInternal: true } ] }; } /** * @returns {Secret} secret */ getSecret() { return this.secret; } /** * @param {Secret} secret */ setSecret(secret) { this.secret = secret; } // public length(event: string): number { // return this.wsHandlers[event].length; // } // public push(event: string, handler: WEBSOCKET_HANDLER): void { // this.wsHandlers[event].push(handler); // } // public shift(event: string): WEBSOCKET_HANDLER | undefined { // return this.wsHandlers[event].shift(); // } getWebSocketHandlers() { return this.wsHandlers; } setWebSocketHandlers(wsHandlers) { this.wsHandlers = wsHandlers; } addWebSocketHandler(event, name, handler, isInternal = false) { this.wsHandlers[event].push({ name: name, handler: handler, isInternal: isInternal }); } delWebSocketHandler(event, name) { this.wsHandlers[event] = this.wsHandlers[event].filter((wsHandler) => wsHandler.name != name); } // /** // * Deprecated : ~ 2023.12.31 // * @param {function} handler onMessage() 함수에서 handler로 호출할 함수 등록 // */ // public addHandler(handler: WEBSOCKET_HANDLER): void { // this.addWebSocketHandler('onMessage', handler.name, handler.handler); // } // /** // * Deprecated : ~ 2023.12.31 // * @param {function} handler initialize() 함수에서 handler로 호출할 함수 등록 // */ // public addInitHandler(handler: WEBSOCKET_HANDLER): void { // this.addWebSocketHandler('init', handler.name, handler.handler); // } /** * 한국투자증권 Web Socket을 초기화 한다. * * @returns {boolean} */ async initialize() { try { if (this.isKeepAlive == false) { return false; } if (this.ws != null) { return true; } // const wsHandlers = this.wsHandlers['init']; // for (let idx: number = 0; idx < wsHandlers.length; idx++) { // await wsHandlers[idx].handler(this, this.secret); // } if (this.secret.isActual == false) { this.logger.error('Error: Web Socket은 실전투자만 사용 가능 합니다.'); return false; } const trid = 'H0STCNT0'; const metadata = EFriend_JSON_TRID[`${trid}_실전`]; this.isOpen = false; this.ws = new WebSocket(metadata.info.domain, { perMessageDeflate: false }); this.ws.on('open', this.onOpen.bind(this)); this.ws.on('message', this.onMessage.bind(this)); this.ws.on('close', this.onClose.bind(this)); this.ws.on('error', this.onError.bind(this)); this.ws.on('upgrade', this.onUpgrade.bind(this)); this.ws.on('ping', this.onPing.bind(this)); this.ws.on('pong', this.onPong.bind(this)); this.ws.on('unexpected-response', this.onUnexpectedResponse.bind(this)); // this.ws.on('timeout', function() {}); // this.ws.on('response', function(res: http.IncomingMessage) {}); // this.ws.on('upgrade', function(res: http.IncomingMessage, socket: net.Socket, head: Buffer) {}); // this.ws.on('unexpected-response', function(req: any, res: http.IncomingMessage) {}); //--- 최대 10초간 접속 대기 for (let idx = 0; idx < 100; idx++) { if (this.isOpen) { return true; } await limit.sleep(100); } if (this.isOpen == false) { this.ws = null; } return this.isOpen; } catch (ex) { console.error(ex); throw ex; } } /** * Web Socket에서 open event 처리 */ async onOpen() { try { const wsHandlers = this.wsHandlers['open']; for (let idx = 0; idx < wsHandlers.length; idx++) { await wsHandlers[idx].handler(); } } catch (ex) { console.error('Exception onOpen', ex); } } async _onOpen_1() { try { console.log('WebSocket :: open'); this.isOpen = true; if (limit.updateSession(this.secret.userid, 1) == false) { this.logger.error(`${this.secret.userid} Web Socket session limit is over`); } } catch (ex) { console.error('WebSocket onOpen error', ex); } } async sleep(miliseconds) { const promise = new Promise(function (resolve, _reject) { setTimeout(function () { resolve({}); }, miliseconds); }); await promise; } async _onOpen_2() { try { //--- 저장된 실시간 거래와 모니터링 설정 재등록 const limitSaved = limit.getLimit(); for (const item of limitSaved.account[this.secret.account].ws_api.notifications) { await this.webSocket(item.tr_id, TR_TYPE.registration, item.tr_key); //--- https://apiportal.koreainvestment.com/community/10000000-0000-0011-0000-000000000003 await this.sleep(500); } for (const item of limitSaved.account[this.secret.account].ws_api.registrations) { await this.webSocket(item.tr_id, TR_TYPE.registration, item.tr_key); //--- https://apiportal.koreainvestment.com/community/10000000-0000-0011-0000-000000000003 await this.sleep(500); } } catch (ex) { console.error('WebSocket onOpen error', ex); } } /** * Web Socket에서 message event 처리 * * @param {any} data Buffer | ArrayBuffer | Buffer[] * @param {boolean} isBinary * @returns {void} */ async onMessage(data, isBinary = false) { try { const _typeof = (param) => Object.prototype.toString.call(param).replace(/\[object /g, '').replace(/\]/g, '').toLowerCase(); if ((_typeof(data) == 'uint8array') && (isBinary == false)) { data = data.toString(); } if (_typeof(data) == 'string') { if (data.indexOf('PINGPONG') == -1) { console.log('WebSocket :: message', data, isBinary); } if ((data.startsWith('0')) || //--- 0. record로 평문을 받음 (data.startsWith('1'))) { //--- 1. record로 암호문을 받음 const tmpArr = data.split('|'); const isEncrypt = tmpArr[0]; //--- 암호화 여부 : 0. record로 평문을 받음, 1. record로 암호문을 받음 const tr_id = tmpArr[1]; //--- TR ID const count = parseInt(tmpArr[2]); //--- 데이터 코드 let record = tmpArr[3]; //--- 응답 데이터 if (isEncrypt == '1') { //--- 1. record로 암호문을 받음 if ((typeof (this.wsKeys[tr_id]) == 'undefined') || (typeof (this.wsKeys[tr_id].iv) == 'undefined') || //--- 실시간 결과 복호화에 필요한 AES256 IV (Initialize Vector) (typeof (this.wsKeys[tr_id].key) == 'undefined')) { //--- 실시간 결과 복호화에 필요한 AES256 Key console.error(`WebSocket :: Error - 암복호화 키가 없음, tr_id : ${tr_id}`); return; } record = this.decrypt(record, this.wsKeys[tr_id].key, this.wsKeys[tr_id].iv); } const metadata = EFriend_JSON_TRID[`${tr_id.toUpperCase()}_실전`] ?? null; if (metadata == null) { console.error(`WebSocket: ${tr_id} metadata is not exist.`); throw new BaseError({ code: ERROR_CODE.REQUIRED, data: `${tr_id} metadata is not exist.` }); } if (metadata.info.domain.startsWith('ws') == false) { console.error(`WebSocket: ${tr_id} trid is not supported.`); throw new BaseError({ code: ERROR_CODE.REQUIRED, data: `${tr_id} trid is not supported.` }); } const json = []; let pos = 0; const fields = record.split('^'); for (let idx = 0; idx < count; idx++) { const item = {}; for (const field of metadata.response.body) { let value = (typeof (fields[pos]) == 'undefined') ? null : fields[pos]; switch (field.type) { case 'object': case 'array': console.error(`Not allowed field type: ${field.type}`); break; case 'number': if (value != null) { value = (value == '') ? 0 : parseInt(value); } break; case 'string': default: break; } item[field.code] = value; pos = pos + 1; } json.push(item); } // this.checkData(trid, metadata.response.header, null); // this.compareWithMeta(metadata.response.header, null, tr_id); this.checkResponsebody(tr_id, metadata.response.body, json); const wsHandlers = this.wsHandlers['message']; for (let idx = 0; idx < wsHandlers.length; idx++) { await wsHandlers[idx].handler(tr_id, null, json, data, isBinary); } } else { data = data.trim(); if ((data.startsWith('{') == false) || (data.endsWith('}') == false)) { console.error(`WebSocket :: Error - data is not json object - ${data}`); return; } const json = JSON.parse(data); if (typeof (json.header.tr_id) == 'undefined') { console.error(`WebSocket :: Error - 알려지지 않은 데이터 tr_id - ${json.header.tr_id}`); return; } if (json.header.tr_id == 'PINGPONG') { //--- 100초 이내에 응답이 없으면 세션 종료됨 //--- {"header":{"tr_id":"PINGPONG","datetime":"20231004160455"}} this.ws.send(data); return; } //--- json.header: tr_id, tr_key, encrypt //--- 오류 코드는 common/error/error.coinstant.ts 파일 참조 console.log(`WebSocket :: header`, json.header); // console.log(json, 'header', 'tr_id'); //--- 거래ID // console.log(json, 'header', 'tr_key'); //--- 구분값 (종목코드) // console.log(json, 'header', 'encrypt'); //--- Y. 암호문, N. 평문 // console.log(json, 'header', 'datetime'); //--- if (typeof (json.body) != 'undefined') { console.log(`WebSocket :: body`, json.body); // console.log(json, 'body', 'rt_cd'); //--- 응답 코드 // console.log(json, 'body', 'msg_cd'); //--- 응답 메시지 코드 // console.log(json, 'body', 'msg1'); //--- 응답 메시지 // console.log(json, 'body', 'output'); //--- 응답 결과 //--- xx header에 tr_id가 없으면 Web Socket 요청에 대한 응답으로 해석하여 wsKeys를 저장 한다. if (typeof (json.body.output) != 'undefined') { //--- json.body: rt_cd, msg_cd, msg1, output: { iv: '50a65578c4f7500f', key: 'molrrntztvzothjqkzsitawgikersupf' } //--- json.body.output 필드가 있으면 wsKeys (iv, key)로 저장 한다. this.wsKeys[json.header.tr_id] = json.body.output; return; } // if (json.body.rt_cd != '0') { // console.error(`WebSocket :: Error - ${json.body.msg_cd}, ${json.body.msg1}`); // return; // } } const wsHandlers = this.wsHandlers['message']; for (let idx = 0; idx < wsHandlers.length; idx++) { await wsHandlers[idx].handler(json.header.tr_id, json.header ?? null, json.body ?? null, data, isBinary); } } } else { console.error(`WebSocket :: Error - data type is not string ${_typeof(data)} ${data} ${isBinary}`); } } catch (ex) { console.error('Exception onMessage', ex); } } /** * Response data의 값을 검사 한다. * * @param {string} trid 트랜잭션 ID * @param {Array<TRID_FIELD>} fields 필드 목록 * @param {any} data 검사할 데이터 객체 * @throws {any} */ checkResponsebody(trid, fields, data) { if (Array.isArray(data)) { for (const item of data) { this.checkResponsebody(trid, fields, item); } return; } if (typeof (fields) != 'undefined') { fields.forEach(function (field) { if (['array', 'object'].includes(field.type)) { if (Array.isArray(data[field.code])) { data[field.code].forEach(function (dataItem) { this.checkResponsebody(trid, field.fields, dataItem); }.bind(this)); } else { this.checkResponsebody(trid, field.fields, data[field.code]); } } else { this.checkField(field, data, trid, false); } }.bind(this)); } } /** * 필드의 값을 검사 한다. * * @param {TRID_FIELD} field 필드 정보 * @param {any} data field의 값을 포함하는 object * @param {string} trid tr_id * @param {boolean} allowException true. Exception 허용 * @throws {any} */ checkField(field, data, trid, allowException = true) { try { const fieldInfo = `${trid}: ${field.code}(${field.name})`; if ((typeof (data[field.code]) == 'undefined') && (field.required)) { throw new BaseError({ code: ERROR_CODE.REQUIRED, data: fieldInfo }); } if ((typeof (data.custtype) != 'undefined') && (data.custtype == 'B')) { const required = ['personalseckey', 'seq_no', 'phone_number', 'ip_addr', 'gt_uid'].includes(field.code); if ((typeof (data[field.code]) == 'undefined') && required) { throw new BaseError({ code: ERROR_CODE.REQUIRED, data: fieldInfo }); } } if (typeof (data[field.code]) != 'undefined') { if (typeof (field.enum) != 'undefined') { const isExist = field.enum.reduce((prev, curr) => { return (prev || (curr.code == data[field.code])); }, false); if (isExist == false) { this.logger.info(JSON.stringify(field.enum)); throw new BaseError({ code: ERROR_CODE.NOTALLOWED, data: `${fieldInfo}, value - ${data[field.code]}` }); } } switch (field.type) { case 'string': if (['authorization'].includes(field.code) == false) { if (field.length < data[field.code].length) { throw new BaseError({ code: ERROR_CODE.FIELDERROR, data: `${fieldInfo}, length - ${data[field.code].length}` }); } } break; case 'number': // this.logger.info(`${trid}, ${field.code}, ${field.type} is number`); break; default: this.logger.error(`${trid} ---------- field type : ${field.code}, ${field.type}`); break; } } } catch (ex) { if (allowException) { throw ex; } else { if (ex instanceof BaseError) { this.logger.info(`---------- field manage, ${trid}: ${ex.code} - ${ex.message}, ${ex.data}`); } else { this.logger.info(`---------- field manage, ${trid}:, ${JSON.stringify(ex)}`); } } } } /** * Web Socket에서 close event 처리 * * @param {number} code * @param {Buffer} reason */ async onClose(code, reason) { try { const wsHandlers = this.wsHandlers['close']; for (let idx = 0; idx < wsHandlers.length; idx++) { await wsHandlers[idx].handler(code, reason); } } catch (ex) { console.error('Exception onClose', ex); } } _onClose_1(code, reason) { try { console.log('WebSocket :: close', code, reason); this.isOpen = false; limit.updateSession(this.secret.userid, -1); this.ws = null; } catch (ex) { console.error('WebSocket onClose error', ex); } } _onClose_2(_code, _reason) { try { setTimeout(function () { this.initialize(); }.bind(this), 60 * 1000); } catch (ex) { console.error('WebSocket onClose error', ex); } } /** * Web Socket에서 error event 처리 * * @param {Error} err */ onError(err) { console.error(`WebSocket :: error ${JSON.stringify(err)}`); } /** * Web Socket에서 upgrade event 처리 * * @param {any} res */ onUpgrade(res) { //--- 101, Switching Protocols console.log('WebSocket :: upgrade', res.headers); console.log('WebSocket :: status:', res.statusCode, res.statusMessage); } /** * Web Socket에서 ping event 처리 * * @param {Buffer} data */ onPing(data) { console.log('WebSocket :: ping', data); } /** * Web Socket에서 pong event 처리 * * @param {Buffer} data */ onPong(data) { console.log('WebSocket :: pong', data); } /** * Web Socket에서 unexpected-response event 처리 * * @param {unknown} req * @param {unknown} res */ onUnexpectedResponse(req, res) { console.error('WebSocket :: unexpected-response', req, res); } /** * Web Socket의 상태를 확인 한다. * * @returns {Null} */ checkAlive() { if (this.wsInterval == null) { this.wsInterval = setInterval(async function () { try { // if (this.isOperatingTime().code == 0) { if (this.ws == null) { await this.initialize(); return; } this.logger.info(`WebSocket :: ${moment().format('YYYY.MM.DD HH:mm:ss')}, ${this.ws.readyState} (0. 연결중, 1. 연결, 2. 종료중, 3. 종료)`); //--- Protocol에 정의된 PING을 보내면 알수 없는 tr_id를 받았다는 오류 메시지가 오고 연결이 종료 된다. // this.ws.ping(JSON.stringify(msg)); //--- PINGPONG를 보내면 원래 오던 PINGPONG이 오지 않고 10초 후에 PINGPONG이 다시 온다. // const msg = { header: { tr_id: "PINGPONG", datetime: moment().format('YYYYMMDDHHmmss') } }; // this.ws.send(JSON.stringify(msg)); switch (this.ws.readyState) { case 0: //--- 0. CONNECTING, 연결중 break; case 1: //--- 1. OPEN, 연결 //--- To-Do: 실시간 Web Socket 메시지 처리가 끊어졌는지 확인이 필요 한다. 끊어진 경우 재설정 break; case 2: //--- 2. CLOSING, 종료중 case 3: //--- 3. CLOSED, 종료 default: this.logger.error(`WebSocket :: Restart Web Socket.`); this.ws = null; // await this.initialize(); break; } // } } catch (error) { this.logger.error(JSON.stringify(error)); } }.bind(this), this.wsIntervalTime); } } /** * WebSocket 설정 * * @param {string} trid 거래 아이디 * @param {TR_TYPE} tr_type 거래 타입 * @param {string} tr_key 종목코드 또는 HTS ID * @returns {boolean} 처리 결과 */ async webSocket(trid, tr_type, tr_key) { try { const metadata = EFriend_JSON_TRID[`${trid}_${(this.secret.isActual) ? '실전' : '모의'}`] ?? null; if (metadata == null) { throw new BaseError({ code: ERROR_CODE.REQUIRED, data: `${trid} (${this.secret.isActual}) metadata is not exist.` }); } if (metadata.info.domain.startsWith('ws') == false) { throw new BaseError({ code: ERROR_CODE.NOTALLOWED, data: `${trid} trid is not supported.` }); } await this.initialize(); const header = { "content-type": 'application/json; charset=utf-8', custtype: this.secret.custtype, tr_type: TR_TYPE[tr_type] //--- 거래 타입 : 1. 등록, 2. 해제 }; if ((this.secret.approval_key == null) || (typeof (this.secret.approval_key) == 'undefined')) { header.appkey = this.secret.appkey || this.secret.appKey; header.appsecret = this.secret.appsecret || this.secret.appSecret; } else { header.approval_key = this.secret.approval_key; } const body = { input: { tr_id: trid, tr_key: tr_key //--- 종목코드. 주식체결가/호가, hts_id. 주식체결통보 } }; const data = JSON.stringify({ header: header, body: body }); console.log('WebSocket :: send -', data); this.ws.send(data); // WebSocket :: header { tr_id: 'H0STCNT0', tr_key: '015760', encrypt: 'N' } // WebSocket :: body { // rt_cd: '9', // msg_cd: 'OPSP0009', https://apiportal.koreainvestment.com/community/10000000-0000-0011-0000-000000000003 // msg1: 'SUBSCRIBE ERROR : mci send failed' // } //--- To-Do: limit 초과시 오류 처리를 추가할 것 limit.updateWsApi(this.secret.account, trid, TR_TYPE[tr_type], tr_key); this.checkAlive(); return true; } catch (error) { this.logger.error(`WebSocket :: ${JSON.stringify(error)}`); return false; } } /** * WebSocket에서 받은 메시지 처리 */ async onMessageDefault(trid, header, body, _data, _isBinary = false) { try { console.log('--- onMessage ------------------------------------------------'); console.log('trid', trid); console.log('header', header); if (Array.isArray(body)) { body.forEach((item) => { console.log('body item', item); }); } else { console.log('body', body); } console.log(''); } catch (ex) { console.error('Exception onMessageDefault', ex); } } /** * WebSocket에서 받은 메시지 처리 */ async onMessage_001(trid, _header, body, _data, _isBinary = false) { try { if (Array.isArray(body)) { const msgs = []; switch (trid) { case 'H0STASP0': //--- 주식 호가 : 종목 코드 console.log(`WebSocket :: 주식 호가`); body.forEach(item => { msgs.push(`호 가 :: 종목: ${item.MKSC_SHRN_ISCD}`); msgs.push(`시간: ${item.BSOP_HOUR}`); msgs.push(`매도 잔량: ${item.TOTAL_ASKP_RSQN}`); msgs.push(`매수 잔량: ${item.TOTAL_BIDP_RSQN}`); }); break; case 'H0STCNT0': //--- 실시간 주식 체결가: 종목코드 console.log('WebSocket :: 실시간 주식 체결가'); body.forEach(item => { msgs.push(`체결가 :: 종목: ${item.MKSC_SHRN_ISCD}`); msgs.push(`시간: ${item.STCK_CNTG_HOUR}`); msgs.push(`현재가: ${item.STCK_PRPR}`); msgs.push(`체결량: ${item.CNTG_VOL}`); msgs.push(`매도 건수: ${item.SELN_CNTG_CSNU}`); msgs.push(`매수 건수: ${item.SHNU_CNTG_CSNU}`); }); break; case 'H0STCNI0': //--- 실시간 주식 체결통보 : HTS ID case 'H0STCNI9': //--- 실시간 주식 체결통보 (모의투자) : HTS ID console.log('WebSocket :: 실시간 주식 체결통보', ((trid == 'H0STCNI0') ? '' : '(모의투자)')); body.forEach(item => { msgs.push(`체결통보 :: 고객: ${item.CUST_ID}`); msgs.push(`계좌번호: ${item.ACNT_NO}`); msgs.push(`주문: ${item.ODER_NO}`); msgs.push(`시간: ${item.STCK_CNTG_HOUR}`); msgs.push(`구분: ${(item.SELN_BYOV_CLS == '01') ? '매도' : '매수'} ${(item.CNTG_YN == '2') ? '체결' : '기타'}`); msgs.push(`종목: ${item.STCK_SHRN_ISCD} (${item.CNTG_ISNM})`); msgs.push(`수량: ${item.CNTG_QTY}`); msgs.push(`단가: ${item.CNTG_UNPR}`); }); break; default: console.log('WebSocket :: error: 알려지지 않은 데이터'); break; } this.logger.info(msgs.join(', ')); } } catch (ex) { console.error('Exception onMessage_001', ex); } } /** * 한국투자증권의 운영시간 여부를 확인 한다 * * @param {Moment} today * @returns {AJAX_ERROR} */ isOperatingTime(today = moment()) { const day = today.day(); //--- 요일, 0. 일요일, 1. 월요일, ..., 6. 토요일 if ((day < 1) || (5 < day)) { return { code: 1, message: '평일에만 작업 가능 합니다.' }; } const hhmm = today.format('HH:mm'); //--- 시간과 분 if ((hhmm < '09:00') || ('15:30' < hhmm)) { return { code: 2, message: '오전 9시부터 오후 5시 30분까지만 작업 가능 합니다.' }; } return { code: 0, message: '운영 시간' }; } /** * 평문을 암호문으로 변환 * * @param {string} data 평문 * @param {string} key Cipher Key * @param {string} iv Binary Like * @returns {string} 암호문 */ _encrypt(data, key, iv) { const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); const encrypt = cipher.update(data, 'utf8', 'base64'); return encrypt + cipher.final('base64'); } /** * 암호문을 평문으로 변환 * * @param {string} data 암호문 * @param {string} key Cipher Key * @param {string} iv Binary Like * @returns {string} 평문 */ decrypt(data, key, iv) { const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); const decrypt = decipher.update(data, 'base64', 'utf8'); return decrypt + decipher.final('utf8'); } //--- code : 3701 ~ 3799를 사용하자 //--- 1000 <= code <= 1014 (not allow : 1004, 1005, 1006) //--- 3000 <= code <= 4999 close(code, data) { this.isKeepAlive = false; if (this.ws != null) { this.ws.close(code, data); this.ws = null; } } } export default EFriendWs;