/** * WxTransport — 微信小程序 wx.connectSocket 实现的 TerminalTransport。 * * 帧协议与 Web 端 GatewayTransport 完全一致(§8.2): * 入站:{ type: 'init'|'stdin'|'resize'|'control', ... } * 出站:{ type: 'stdout'|'stderr'|'control'|'error', ... } * * 使用方式: * const t = new WxTransport({ gatewayUrl, gatewayToken }); * const off = t.on(event => { ... }); * await t.connect({ host, port, username, password }); * t.send("ls -la\r"); * t.resize(80, 24); * t.disconnect(); */ const { sanitizeTerminalOutput } = require('../../../packages/terminal-core/dist-miniprogram/index'); function buildWsUrl(rawUrl, token) { let base = String(rawUrl || '').trim(); if (!base) throw new Error('WxTransport: 网关地址为空'); if (base.startsWith('http://')) base = 'ws://' + base.slice(7); if (base.startsWith('https://')) base = 'wss://' + base.slice(8); if (!base.startsWith('ws://') && !base.startsWith('wss://')) { base = 'wss://' + base; } base = base.replace(/\/+$/, ''); return base + '/ws/terminal?token=' + encodeURIComponent(String(token || '')); } function WxTransport(options) { const cfg = options || {}; this._gatewayUrl = cfg.gatewayUrl || ''; this._gatewayToken = cfg.gatewayToken || ''; this._state = 'idle'; // SessionState this._sessionId = ''; this._socket = null; // wx.SocketTask this._listeners = []; // Array<(event) => void> this._pingTimer = null; this._pingAt = 0; } // ── 接口实现 ────────────────────────────────────────────────────────────────── /** * 连接网关并发送 init 帧。 * 返回 Promise,在 control.connected 帧到达后 resolve,错误时 reject。 */ WxTransport.prototype.connect = function(params) { const self = this; return new Promise(function(resolve, reject) { if (self._state === 'connecting' || self._state === 'connected') { resolve(); return; } self._state = 'connecting'; const sessionId = 'wx_' + Date.now() + '_' + Math.random().toString(36).slice(2); self._sessionId = sessionId; const url = buildWsUrl(self._gatewayUrl, self._gatewayToken); const task = wx.connectSocket({ url: url, protocols: ['v1.terminal'], success: function() { /* socket created */ }, fail: function(err) { self._state = 'error'; self._emit({ type: 'error', code: 'HOST_UNREACHABLE', message: JSON.stringify(err) }); reject(new Error('wx.connectSocket fail: ' + JSON.stringify(err))); } }); self._socket = task; let resolved = false; task.onOpen(function() { // 发送 init 帧 self._sendFrame({ type: 'init', sessionId: sessionId, params: params }); // 启动心跳 self._startPing(); }); task.onMessage(function(res) { let frame; try { frame = JSON.parse(res.data); } catch(e) { return; } if (frame.type === 'stdout' || frame.type === 'stderr') { const raw = typeof frame.data === 'string' ? frame.data : ''; const sanitized = sanitizeTerminalOutput(raw); if (sanitized) self._emit({ type: frame.type, data: sanitized }); } else if (frame.type === 'control') { if (frame.action === 'connected') { self._state = 'connected'; self._emit({ type: 'control', action: 'connected', data: frame.data }); if (!resolved) { resolved = true; resolve(); } } else if (frame.action === 'disconnect') { self._cleanup(); self._state = 'disconnected'; self._emit({ type: 'control', action: 'disconnect' }); } else if (frame.action === 'pong') { self._emit({ type: 'control', action: 'pong' }); } } else if (frame.type === 'error') { self._state = 'error'; self._emit({ type: 'error', code: frame.code || 'INTERNAL_ERROR', message: frame.message || '' }); if (!resolved) { resolved = true; reject(new Error(frame.message)); } } }); task.onError(function(err) { self._cleanup(); self._state = 'error'; self._emit({ type: 'error', code: 'HOST_UNREACHABLE', message: JSON.stringify(err) }); if (!resolved) { resolved = true; reject(new Error(JSON.stringify(err))); } }); task.onClose(function() { self._cleanup(); if (self._state !== 'disconnected' && self._state !== 'error') { self._state = 'disconnected'; self._emit({ type: 'control', action: 'disconnect' }); } }); }); }; WxTransport.prototype.send = function(data, meta) { if (this._state !== 'connected') return; this._sendFrame({ type: 'stdin', data: data, meta: meta }); }; WxTransport.prototype.resize = function(cols, rows) { if (this._state !== 'connected') return; this._sendFrame({ type: 'resize', cols: cols, rows: rows }); }; WxTransport.prototype.disconnect = function(reason) { this._sendFrame({ type: 'control', action: 'disconnect', reason: reason || 'manual' }); this._cleanup(); this._state = 'disconnected'; }; WxTransport.prototype.on = function(listener) { const self = this; self._listeners.push(listener); return function() { const idx = self._listeners.indexOf(listener); if (idx >= 0) self._listeners.splice(idx, 1); }; }; WxTransport.prototype.getState = function() { return this._state; }; // ── 私有方法 ────────────────────────────────────────────────────────────────── WxTransport.prototype._emit = function(event) { for (let i = 0; i < this._listeners.length; i++) { try { this._listeners[i](event); } catch(e) { /* 单个监听器异常不影响其他 */ } } }; WxTransport.prototype._sendFrame = function(frame) { if (!this._socket) return; try { this._socket.send({ data: JSON.stringify(frame) }); } catch(e) { /* 忽略发送时 socket 已关闭 */ } }; WxTransport.prototype._startPing = function() { const self = this; self._clearPing(); self._pingTimer = setInterval(function() { if (self._state !== 'connected') return; self._pingAt = Date.now(); self._sendFrame({ type: 'control', action: 'ping' }); }, 10000); }; WxTransport.prototype._clearPing = function() { if (this._pingTimer !== null) { clearInterval(this._pingTimer); this._pingTimer = null; } }; WxTransport.prototype._cleanup = function() { this._clearPing(); if (this._socket) { try { this._socket.close(); } catch(e) { /* ignore */ } this._socket = null; } }; module.exports = { WxTransport: WxTransport };