luang-cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,202 @@
1
+ 'use strict';
2
+
3
+ // test/ws-stub.js — stub server WebSocket minimal (sisi server RFC 6455)
4
+ // untuk E2E offline luang-cli. Bukan bagian dari paket npm; hanya alat uji.
5
+ // Menangani: handshake 101 (verifikasi X-CLI-Key), frame client→server
6
+ // (unmask), ping→pong, close; mengirim text frame ke client.
7
+
8
+ const crypto = require('crypto');
9
+ const net = require('net');
10
+
11
+ const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
12
+
13
+ function extractServerFrame(buf) {
14
+ if (!buf || buf.length < 2) return null;
15
+ const b0 = buf[0];
16
+ const b1 = buf[1];
17
+ const fin = (b0 & 0x80) !== 0;
18
+ const opcode = b0 & 0x0f;
19
+ const masked = (b1 & 0x80) !== 0;
20
+ let len = b1 & 0x7f;
21
+ let off = 2;
22
+ if (len === 126) {
23
+ if (buf.length < 4) return null;
24
+ len = buf.readUInt16BE(2);
25
+ off = 4;
26
+ } else if (len === 127) {
27
+ if (buf.length < 10) return null;
28
+ len = Number(buf.readBigUInt64BE(2));
29
+ off = 10;
30
+ }
31
+ const maskLen = masked ? 4 : 0;
32
+ const total = off + maskLen + len;
33
+ if (buf.length < total) return null;
34
+ const payload = Buffer.from(buf.subarray(off + maskLen, total));
35
+ if (masked) {
36
+ const mk = buf.subarray(off, off + 4);
37
+ for (let i = 0; i < payload.length; i++) payload[i] ^= mk[i & 3];
38
+ }
39
+ return { frame: { fin, opcode, payload }, rest: buf.subarray(total) };
40
+ }
41
+
42
+ function encodeServerFrame(opcode, payload) {
43
+ // frame server→client: TANPA mask
44
+ const len = payload.length;
45
+ let header;
46
+ if (len < 126) header = Buffer.allocUnsafe(2);
47
+ else if (len < 65536) {
48
+ header = Buffer.allocUnsafe(4);
49
+ header.writeUInt16BE(len, 2);
50
+ } else {
51
+ header = Buffer.allocUnsafe(10);
52
+ header.writeBigUInt64BE(BigInt(len), 2);
53
+ }
54
+ header[0] = 0x80 | opcode;
55
+ header[1] = len < 126 ? len : len < 65536 ? 126 : 127;
56
+ return Buffer.concat([header, payload]);
57
+ }
58
+
59
+ class StubWsServer {
60
+ constructor({ port, expectedKey, path = '/api/v1/agent/ws' } = {}) {
61
+ this.port = port;
62
+ this.expectedPath = path;
63
+ this.expectedKey = expectedKey || null;
64
+ this.conns = [];
65
+ this.frames = [];
66
+ this.onMsg = null;
67
+ this.onHandshake = null; // (info, rejectFn) → bila rejectFn dipanggil, handshake ditolak
68
+ this.closedConns = 0;
69
+ this.server = null;
70
+ }
71
+
72
+ start() {
73
+ return new Promise((resolve, reject) => {
74
+ const srv = net.createServer((sock) => this._onConn(sock));
75
+ srv.on('error', reject);
76
+ srv.listen(this.port, '127.0.0.1', () => {
77
+ this.server = srv;
78
+ resolve(this);
79
+ });
80
+ });
81
+ }
82
+
83
+ stop() {
84
+ return new Promise((resolve) => {
85
+ for (const c of this.conns) {
86
+ try { c.sock.destroy(); } catch (e) { /* abaikan */ }
87
+ }
88
+ if (!this.server) return resolve();
89
+ this.server.close(() => resolve());
90
+ });
91
+ }
92
+
93
+ broadcastText(text) {
94
+ const buf = encodeServerFrame(0x1, Buffer.from(text, 'utf8'));
95
+ for (const c of this.conns) {
96
+ try { c.sock.write(buf); } catch (e) { /* abaikan */ }
97
+ }
98
+ }
99
+
100
+ sendText(conn, text) {
101
+ try { conn.sock.write(encodeServerFrame(0x1, Buffer.from(text, 'utf8'))); } catch (e) { /* abaikan */ }
102
+ }
103
+
104
+ pingAll() {
105
+ // Biarkan CLI mendemokan rt: protocol ping 5 byte kecil
106
+ for (const c of this.conns) {
107
+ try { c.sock.write(encodeServerFrame(0x9, Buffer.from([0x01, 0x02]))); } catch (e) { /* abaikan */ }
108
+ }
109
+ }
110
+
111
+ killAll() {
112
+ // Putuskan semua koneksi secara mentah (TCP drop) — untuk uji reconnect.
113
+ for (const c of [...this.conns]) {
114
+ try { c.sock.destroy(); } catch (e) { /* abaikan */ }
115
+ }
116
+ }
117
+
118
+ _onConn(sock) {
119
+ const conn = { sock, buf: null, opened: false };
120
+ this.conns.push(conn);
121
+ sock.on('close', () => {
122
+ this.closedConns += 1;
123
+ const i = this.conns.indexOf(conn);
124
+ if (i >= 0) this.conns.splice(i, 1);
125
+ });
126
+ sock.on('data', (chunk) => {
127
+ conn.buf = conn.buf ? Buffer.concat([conn.buf, chunk]) : chunk;
128
+ if (!conn.opened) {
129
+ const idx = conn.buf.indexOf('\r\n\r\n');
130
+ if (idx === -1) return;
131
+ const head = conn.buf.subarray(0, idx).toString('utf8');
132
+ const rest = conn.buf.subarray(idx + 4);
133
+ const lines = head.split('\r\n');
134
+ const headers = {};
135
+ for (const line of lines.slice(1)) {
136
+ const ci = line.indexOf(':');
137
+ if (ci > 0) headers[line.slice(0, ci).trim().toLowerCase()] = line.slice(ci + 1).trim();
138
+ }
139
+ const target = (lines[0] || '').split(' ');
140
+ const reqPath = target[1] || '';
141
+ if (!reqPath.startsWith(this.expectedPath)) {
142
+ sock.write('HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n');
143
+ sock.destroy();
144
+ return;
145
+ }
146
+ if (this.expectedKey && headers['x-cli-key'] !== this.expectedKey) {
147
+ sock.write('HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n');
148
+ sock.destroy();
149
+ return;
150
+ }
151
+ const secKey = headers['sec-websocket-key'];
152
+ if (!secKey) {
153
+ sock.write('HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n');
154
+ sock.destroy();
155
+ return;
156
+ }
157
+ const accept = crypto.createHash('sha1').update(secKey + WS_GUID).digest('base64');
158
+ sock.write(
159
+ 'HTTP/1.1 101 Switching Protocols\r\n' +
160
+ 'Upgrade: websocket\r\n' +
161
+ 'Connection: Upgrade\r\n' +
162
+ `Sec-WebSocket-Accept: ${accept}\r\n` +
163
+ '\r\n'
164
+ );
165
+ conn.opened = true;
166
+ conn.buf = rest && rest.length ? rest : null;
167
+ if (this.onHandshake) this.onHandshake({ headers }, null);
168
+ // lanjut parsing frame yang datang bersamaan dengan handshake
169
+ if (conn.buf) this._drain(conn);
170
+ return;
171
+ }
172
+ this._drain(conn);
173
+ });
174
+ }
175
+
176
+ _drain(conn) {
177
+ for (;;) {
178
+ const r = extractServerFrame(conn.buf);
179
+ if (!r) return;
180
+ conn.buf = r.rest && r.rest.length ? r.rest : null;
181
+ const { opcode, payload } = r.frame;
182
+ this.frames.push({ ts: Date.now(), opcode }); // semua opcode, untuk cek pong
183
+ if (opcode === 0x1 || opcode === 0x2) {
184
+ // payload client frame mungkin terfragmentasi — skenario kita tidak yang fragmentasi
185
+ let text = '';
186
+ try { text = payload.toString('utf8'); } catch (e) { text = ''; }
187
+ let obj = null;
188
+ try { obj = JSON.parse(text); } catch (e) { obj = null; }
189
+ Object.assign(this.frames[this.frames.length - 1], { raw: text, obj });
190
+ if (this.onMsg) this.onMsg({ ts: Date.now(), opcode, raw: text, obj }, conn, this);
191
+ } else if (opcode === 0x9) {
192
+ try { conn.sock.write(encodeServerFrame(0xa, payload)); } catch (e) { /* abaikan */ }
193
+ } else if (opcode === 0xa) {
194
+ // pong dari client — diabaikan
195
+ } else if (opcode === 0x8) {
196
+ try { conn.sock.end(encodeServerFrame(0x8, Buffer.from([0x03, 0xe8]))); } catch (e) { /* abaikan */ }
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ module.exports = { StubWsServer };