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.
package/lib/commands.js CHANGED
@@ -94,6 +94,61 @@ function permLine(label, val) {
94
94
  return `${val ? '✅' : '❌'} ${label}`;
95
95
  }
96
96
 
97
+ // ── Telemetry (konsent ganda: lokal config + flag server) ────────────────────
98
+
99
+ // Label satu baris status konsent ganda. serverOn: true/false/null (tak tahu).
100
+ function telemetryConsentLabel(localOn, serverOn) {
101
+ const l = localOn ? 'lokal ✅' : 'lokal ❌';
102
+ const s = serverOn === true ? 'server ✅' : serverOn === false ? 'server ❌' : 'server ?';
103
+ const on = localOn && serverOn === true;
104
+ return `${l} · ${s} → efektif ${on ? '✅ DIKIRIM' : '❌ tidak dikirim'}`;
105
+ }
106
+
107
+ async function cmdTelemetry(rt, args) {
108
+ const sub = String(args.pos[1] || 'status').toLowerCase();
109
+ if (sub === 'on') {
110
+ rt.cfg.telemetry = true;
111
+ saveConfig(rt.cfg);
112
+ console.log('✅ Telemetry lokal: ON (config.json telemetry=true)');
113
+ console.log('⚡ Kirim hanya setelah flag server ikut ON (wajib dua-duanya).');
114
+ } else if (sub === 'off') {
115
+ rt.cfg.telemetry = false;
116
+ saveConfig(rt.cfg);
117
+ console.log('✅ Telemetry lokal: OFF');
118
+ console.log('🤐 Data run (telemetry.run) TIDAK akan dikirim dari mesin ini.');
119
+ } else if (sub !== 'status') {
120
+ return errExit('Pemakaian: luang-cli telemetry on|off|status');
121
+ }
122
+
123
+ // Flag server: agent_keys.telemetry_enabled (diubah dari dashboard).
124
+ let serverOn = null;
125
+ let note = null;
126
+ if (rt.key) {
127
+ try {
128
+ const me = await apiRequest(rt, 'GET', '/api/v1/agent/me');
129
+ serverOn = ((me && me.data) || {}).telemetry_enabled === true;
130
+ } catch (e) {
131
+ note = e.message;
132
+ }
133
+ } else {
134
+ note = 'belum login — flag server tidak bisa diperiksa (luang-cli login)';
135
+ }
136
+
137
+ console.log('');
138
+ console.log('📊 Telemetry — konsent ganda (opt-in, bukan opt-out)');
139
+ console.log(` Lokal : config.telemetry = ${rt.cfg.telemetry === true ? 'true ✅' : 'false ❌'} (${CONFIG_FILE})`);
140
+ if (serverOn === null) console.log(` Server : ? — ${note || 'gagal cek /api/v1/agent/me'}`);
141
+ else console.log(` Server : telemetry_enabled = ${serverOn ? 'true ✅' : 'false ❌'} (Agents → toggle Telemetry di dashboard)`);
142
+ console.log(` Efektif: ${telemetryConsentLabel(rt.cfg.telemetry === true, serverOn)}`);
143
+ console.log('');
144
+ if (rt.cfg.telemetry === true && serverOn !== true) {
145
+ console.log('ℹ️ Bebannya harus ON bersamaan: lokal di sini, server di dashboard.');
146
+ }
147
+ console.log('🔐 Kirim pakai header X-CLI-Key (bukan JWT) via frame WS telemetry.run.');
148
+ console.log(' Bila salah satu OFF → CLI tidak mengirim apa pun (dan server pun men-drop).');
149
+ return 0;
150
+ }
151
+
97
152
  // ── login (device flow) ──────────────────────────────────────────────────────
98
153
 
99
154
  async function cmdLogin(rt, args) {
@@ -222,6 +277,9 @@ async function cmdWhoami(rt) {
222
277
  console.log(` ${permLine('Bisa update tugas (can_update_tasks)', !!d.can_update_tasks)}`);
223
278
  console.log(` ${permLine('Bisa buat tugas (can_create_tasks)', !!d.can_create_tasks)}`);
224
279
  console.log('');
280
+ console.log(`📊 Telemetry: ${telemetryConsentLabel(rt.cfg.telemetry === true, d.telemetry_enabled === true)}`);
281
+ console.log(' (ubah lokal: luang-cli telemetry on|off · flag server: dashboard Agents)');
282
+ console.log('');
225
283
  console.log(`🛠 Provider terdaftar (server): ${providersLabel(d.providers)}`);
226
284
  return 0;
227
285
  }
@@ -260,22 +318,24 @@ async function cmdProvider(rt, args) {
260
318
  return 0;
261
319
  }
262
320
  printTable(
263
- ['Provider', 'Path', 'Exec Template'],
321
+ ['Provider', 'Path', 'Exec Template', 'Model'],
264
322
  list.map((p) => [
265
323
  (p.is_default ? '★ ' : '') + p.provider_type,
266
324
  p.path || '-',
267
325
  p.exec_template || '-',
326
+ p.model || '-',
268
327
  ])
269
328
  );
270
329
  console.log('');
271
330
  console.log('★ = default provider · deteksi: ~/.claude, ~/.codex, ~/.config/opencode/~/.opencode, ~/.gemini + which(1)');
331
+ console.log(' {model} di exec_template diisi dari kolom Model (atau env LUANG_MODEL) saat mode --exec.');
272
332
  await syncIfKey(rt);
273
333
  return 0;
274
334
  }
275
335
 
276
336
  if (sub === 'add') {
277
337
  const type = args.pos[2];
278
- if (!type) return errExit('Format: luang-cli provider add <type> [--path P] [--exec_template T]', `type: ${PROVIDER_TYPES.join(', ')}`);
338
+ if (!type) return errExit('Format: luang-cli provider add <type> [--path P] [--exec_template T] [--model M]', `type: ${PROVIDER_TYPES.join(', ')}`);
279
339
  if (!PROVIDER_TYPES.includes(type)) return errExit(`Tipe tidak dikenal: ${type}`, `Pilihan: ${PROVIDER_TYPES.join(', ')}`);
280
340
  const existing = collectProviders(rt.cfg).find((p) => p.provider_type === type) || {};
281
341
  const tPath = flags.path && flags.path !== true ? String(flags.path) : existing.path || null;
@@ -283,16 +343,22 @@ async function cmdProvider(rt, args) {
283
343
  flags.exec_template && flags.exec_template !== true
284
344
  ? String(flags.exec_template)
285
345
  : existing.exec_template || DEFAULT_TEMPLATES[type] || null;
346
+ let tModel = flags.model && flags.model !== true ? String(flags.model).trim() : existing.model || null;
347
+ if (tModel && !/^[\w.:+/-]+$/.test(tModel)) {
348
+ return errExit(`--model tidak valid: ${tModel}`, 'Karakter yang diizinkan: huruf, angka, `.` `_` `:` `+` `-` `/`');
349
+ }
286
350
  rt.cfg.providers[type] = {
287
351
  provider_type: type,
288
352
  display_name: DISPLAY_NAMES[type] || type,
289
353
  path: tPath,
290
354
  exec_template: tTpl,
355
+ ...(tModel ? { model: tModel } : {}),
291
356
  };
292
357
  saveConfig(rt.cfg);
293
358
  console.log(`➕ Provider ${type} tersimpan di config:`);
294
359
  console.log(` Path : ${tPath || '-'}`);
295
360
  console.log(` Exec Template : ${tTpl || '-'}`);
361
+ if (tModel) console.log(` Model : ${tModel}`);
296
362
  if (!tTpl || !tTpl.includes('{task}')) {
297
363
  console.log('⚠️ Exec template tanpa {task} — tugas tidak bisa dieksekusi otomatis.');
298
364
  }
@@ -334,4 +400,4 @@ async function cmdProvider(rt, args) {
334
400
  return errExit(`Sub-command provider tidak dikenal: ${sub}`, 'Gunakan: list | add | remove | default');
335
401
  }
336
402
 
337
- module.exports = { cmdLogin, cmdLogout, cmdWhoami, cmdProvider, errExit, authFailMessage, taskIdOf, statusInputNorm, normalizeTaskList, ask, providersLabel, syncIfKey, permLine };
403
+ module.exports = { cmdLogin, cmdLogout, cmdWhoami, cmdTelemetry, cmdProvider, errExit, authFailMessage, taskIdOf, statusInputNorm, normalizeTaskList, ask, providersLabel, syncIfKey, permLine, telemetryConsentLabel };
package/lib/core.js CHANGED
@@ -20,6 +20,14 @@ const DEFAULT_CONFIG = {
20
20
  key: null,
21
21
  default_provider: null,
22
22
  providers: {},
23
+ // Telemetry konsent (lihat docs/agent-realtime-ws.md):
24
+ // false = jangan kirim apa pun. Kirim hanya bila ini true DAN server flag
25
+ // telemetry_enabled=true (dari GET /agent/me / hello.ok WS).
26
+ telemetry: false,
27
+ // Opsional: jumlah token diisi manual saat exec (jika user tahu) — hanya
28
+ // ikut dikirim bila consent aktif; selain itu biarkan null.
29
+ tokens_in: null,
30
+ tokens_out: null,
23
31
  };
24
32
 
25
33
  // ── Config ───────────────────────────────────────────────────────────────────
package/lib/ws.js ADDED
@@ -0,0 +1,552 @@
1
+ 'use strict';
2
+
3
+ // lib/ws.js — klien WebSocket (RFC 6455) minimal TANPA dependensi (Node >= 18).
4
+ // Dipakai daemon `luang-cli agent` untuk mode realtime. Cakupan kecil namun solid:
5
+ // • handshake GET ws://|wss://… (path + headers, mis. X-CLI-Key) + verifikasi Sec-WebSocket-Accept
6
+ // • semua frame client→server SELALU dimask (wajib RFC); pesan berupa teks (JSON)
7
+ // • baca: text/continuation (join fragmentasi), ping→pong otomatis, pong, close
8
+ // • keepalive: ping berkala + deteksi koneksi mati (tidak ada frame masuk)
9
+ // • auto-reconnect eksponensial 1s→30s (dengan jitter); close beres saat diminta
10
+ // • batas pesan 1 MB (masuk maupun keluar)
11
+ // Tidak diimplementasikan: permessage-deflate, subprotocol, TLS opsi lanjutan.
12
+
13
+ const { EventEmitter } = require('events');
14
+ const crypto = require('crypto');
15
+ const net = require('net');
16
+ const tls = require('tls');
17
+
18
+ const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
19
+ const MAX_PAYLOAD = 1024 * 1024; // 1 MB per pesan
20
+ const HS_MAX_HEAD = 16384; // batas header respons handshake
21
+ const CONNECT_TIMEOUT_MS = 10000;
22
+ const PING_INTERVAL_MS = 25000; // ping keepalive klien
23
+ const DEAD_AFTER_MS = 75000; // tanpa frame masuk (incl. pong) → koneksi dianggap mati
24
+ const BASE_BACKOFF_MS = 1000;
25
+ const MAX_BACKOFF_MS = 30000;
26
+ const CLOSE_TIMEOUT_MS = 1500;
27
+
28
+ const OP = { CONT: 0, TEXT: 1, BINARY: 2, CLOSE: 8, PING: 9, PONG: 10 };
29
+
30
+ // ── Util ─────────────────────────────────────────────────────────────────────
31
+
32
+ function wsTarget(url) {
33
+ let u;
34
+ try {
35
+ u = new URL(String(url));
36
+ } catch (e) {
37
+ throw new Error(`URL WebSocket tidak valid: ${url}`);
38
+ }
39
+ const secure = u.protocol === 'wss:' || u.protocol === 'https:';
40
+ if (!secure && u.protocol !== 'ws:' && u.protocol !== 'http:') {
41
+ throw new Error(`Skema WebSocket tidak didukung: ${u.protocol}`);
42
+ }
43
+ return {
44
+ host: u.hostname,
45
+ port: u.port ? Number(u.port) : secure ? 443 : 80,
46
+ path: (u.pathname || '/') + (u.search || ''),
47
+ secure,
48
+ };
49
+ }
50
+
51
+ function acceptKey(key) {
52
+ return crypto.createHash('sha1').update(String(key) + WS_GUID).digest('base64');
53
+ }
54
+
55
+ function encodeClosePayload(code, reason) {
56
+ const r = Buffer.from(String(reason || '').slice(0, 100), 'utf8');
57
+ const out = Buffer.allocUnsafe(2 + r.length);
58
+ out.writeUInt16BE((code || 1000) & 0xffff, 0);
59
+ r.copy(out, 2);
60
+ return out;
61
+ }
62
+
63
+ // Buat frame client→server: FIN + opcode + MASK (wajib) + payload termask.
64
+ function encodeFrame(opcode, payload) {
65
+ const len = payload.length;
66
+ let header;
67
+ if (len < 126) {
68
+ header = Buffer.allocUnsafe(2);
69
+ header[1] = 0x80 | len;
70
+ } else if (len < 65536) {
71
+ header = Buffer.allocUnsafe(4);
72
+ header[1] = 0x80 | 126;
73
+ header.writeUInt16BE(len, 2);
74
+ } else {
75
+ header = Buffer.allocUnsafe(10);
76
+ header[1] = 0x80 | 127;
77
+ header.writeBigUInt64BE(BigInt(len), 2);
78
+ }
79
+ header[0] = 0x80 | opcode;
80
+ const mask = crypto.randomBytes(4);
81
+ const body = Buffer.from(payload);
82
+ for (let i = 0; i < body.length; i++) body[i] ^= mask[i & 3];
83
+ return Buffer.concat([header, mask, body]);
84
+ }
85
+
86
+ // Ambil satu frame dari buffer. null = data belum lengkap; {error} = protocol error.
87
+ function extractFrame(buf) {
88
+ if (!buf || buf.length < 2) return null;
89
+ const b0 = buf[0];
90
+ const b1 = buf[1];
91
+ const fin = (b0 & 0x80) !== 0;
92
+ const opcode = b0 & 0x0f;
93
+ if (b0 & 0x70) return { error: new Error('Bit RSV ter-set tanpa ekstensi (RFC 6455)' + b0) };
94
+ const masked = (b1 & 0x80) !== 0;
95
+ let len = b1 & 0x7f;
96
+ let off = 2;
97
+ if (len === 126) {
98
+ if (buf.length < 4) return null;
99
+ len = buf.readUInt16BE(2);
100
+ off = 4;
101
+ } else if (len === 127) {
102
+ if (buf.length < 10) return null;
103
+ const big = buf.readBigUInt64BE(2);
104
+ len = Number(big);
105
+ off = 10;
106
+ }
107
+ if (len > MAX_PAYLOAD || !Number.isFinite(len)) return { error: new Error('Pesan melebihi batas 1 MB') };
108
+ const maskLen = masked ? 4 : 0;
109
+ const total = off + maskLen + len;
110
+ if (buf.length < total) return null;
111
+ const payload = Buffer.from(buf.subarray(off + maskLen, total));
112
+ if (masked) {
113
+ const mk = buf.subarray(off, off + 4);
114
+ for (let i = 0; i < payload.length; i++) payload[i] ^= mk[i & 3];
115
+ }
116
+ return { frame: { fin, opcode, payload }, rest: buf.subarray(total) };
117
+ }
118
+
119
+ // ── Klien ────────────────────────────────────────────────────────────────────
120
+
121
+ class WsClient extends EventEmitter {
122
+ constructor(url, opts = {}) {
123
+ super();
124
+ this.url = String(url);
125
+ this.headers = opts.headers || {};
126
+ this.state = 'idle'; // idle | connecting | open | closing | closed | fatal
127
+ this.autoReconnect = opts.autoReconnect !== false;
128
+ this.pingIntervalMs = opts.pingIntervalMs || PING_INTERVAL_MS;
129
+ this.deadAfterMs = opts.deadAfterMs || DEAD_AFTER_MS;
130
+ this.baseBackoffMs = opts.baseBackoffMs || BASE_BACKOFF_MS;
131
+ this.maxBackoffMs = opts.maxBackoffMs || MAX_BACKOFF_MS;
132
+ this._sock = null;
133
+ this._buf = null; // buffer frame masuk
134
+ this._hsBuf = null; // buffer header handshake
135
+ this._frag = null; // pesan terfragmentasi {opcode, parts, size}
136
+ this._queue = []; // antrean kecil saat belum open / sedang reconnect
137
+ this._attempt = 0;
138
+ this._pendingKey = null;
139
+ this._lastRecvAt = 0;
140
+ this._connectErr = null;
141
+ this._pingTimer = null;
142
+ this._reconTimer = null;
143
+ this._closeTimer = null;
144
+ this._userClosed = false;
145
+ }
146
+
147
+ isOpen() {
148
+ return this.state === 'open';
149
+ }
150
+
151
+ connect() {
152
+ if (this._userClosed || this._reconTimer) return; // sudah jalan / dijadwalkan
153
+ if (this.state === 'open' || this.state === 'connecting') return;
154
+ if (this.state === 'fatal') return; // permanen gagal (401/403/404…/URL rusak)
155
+ this._userClosed = false;
156
+ this._connectNow();
157
+ }
158
+
159
+ // Kirim teks/objek (di-JSON-kan). false bila belum open → masuk antrean kecil.
160
+ send(data) {
161
+ let buf;
162
+ if (typeof data === 'string') buf = Buffer.from(data, 'utf8');
163
+ else if (Buffer.isBuffer(data)) buf = data;
164
+ else {
165
+ try {
166
+ buf = Buffer.from(JSON.stringify(data), 'utf8');
167
+ } catch (e) {
168
+ this.emit('error', new Error('Pesan tidak bisa diserialisasi: ' + e.message));
169
+ return false;
170
+ }
171
+ }
172
+ if (buf.length > MAX_PAYLOAD) {
173
+ this.emit('error', new Error(`Pesan ${buf.length} byte melebihi batas 1 MB`));
174
+ return false;
175
+ }
176
+ if (this.state === 'open' && this._sock) {
177
+ this._sendFrame(OP.TEXT, buf);
178
+ return true;
179
+ }
180
+ if (this._queue.length >= 100) this._queue.shift(); // buang terlama (heartbeat lama tak berharga)
181
+ this._queue.push(buf);
182
+ return false;
183
+ }
184
+
185
+ // Close beres: kirim frame close, tunggu sebentar, lalu hancurkan socket.
186
+ close(code, reason) {
187
+ this._userClosed = true;
188
+ if (this._reconTimer) {
189
+ clearTimeout(this._reconTimer);
190
+ this._reconTimer = null;
191
+ }
192
+ const sock = this._sock;
193
+ if (!sock) {
194
+ this._clearPing();
195
+ this.state = 'closed';
196
+ this.emit('close', { code: code || 1000, expected: true });
197
+ return;
198
+ }
199
+ if (this.state === 'open') {
200
+ this.state = 'closing';
201
+ this._sendFrame(OP.CLOSE, encodeClosePayload(code || 1000, reason));
202
+ this._clearPing();
203
+ this._closeTimer = setTimeout(() => {
204
+ try {
205
+ if (this._sock) this._sock.destroy();
206
+ } catch (e) { /* abaikan */ }
207
+ }, CLOSE_TIMEOUT_MS);
208
+ this._closeTimer.unref();
209
+ } else {
210
+ // connecting → bongkar langsung tanpa reconnect
211
+ this._clearPing();
212
+ this.state = 'closed';
213
+ try { sock.destroy(); } catch (e) { /* abaikan */ }
214
+ this.emit('close', { code: code || 1000, expected: true });
215
+ }
216
+ }
217
+
218
+ // ── internal ──
219
+
220
+ _clearPing() {
221
+ if (this._pingTimer) {
222
+ clearInterval(this._pingTimer);
223
+ this._pingTimer = null;
224
+ }
225
+ }
226
+
227
+ _scheduleReconnect(reason) {
228
+ if (this._userClosed || !this.autoReconnect || this._reconTimer) return;
229
+ const n = Math.max(0, this._attempt - 1);
230
+ let delay = Math.min(this.maxBackoffMs, this.baseBackoffMs * 2 ** n);
231
+ delay += Math.floor(Math.random() * 300); // jitter
232
+ this.emit('reconnecting', { attempt: this._attempt, delay, reason });
233
+ this._reconTimer = setTimeout(() => {
234
+ this._reconTimer = null;
235
+ if (!this._userClosed) this._connectNow();
236
+ }, delay);
237
+ }
238
+
239
+ _connectNow() {
240
+ if (this._userClosed || this.state === 'open') return;
241
+ this.state = 'connecting';
242
+ this._connectErr = null;
243
+ let target;
244
+ try {
245
+ target = wsTarget(this.url);
246
+ } catch (e) {
247
+ this.state = 'fatal';
248
+ this.emit('fatal', e);
249
+ return;
250
+ }
251
+ let sock;
252
+ try {
253
+ sock = target.secure
254
+ ? tls.connect({ host: target.host, port: target.port, servername: target.host }, () => this._onConnected(target))
255
+ : net.connect({ host: target.host, port: target.port }, () => this._onConnected(target));
256
+ } catch (e) {
257
+ this.state = 'closed';
258
+ this._connectErr = e;
259
+ this._onSockClose();
260
+ return;
261
+ }
262
+ sock.setNoDelay(true);
263
+ sock.setTimeout(CONNECT_TIMEOUT_MS, () => {
264
+ if (this.state === 'connecting') {
265
+ const err = new Error(`Waktu habis ${CONNECT_TIMEOUT_MS / 1000}s saat handshake`);
266
+ err.handshakeStatus = 0;
267
+ this._connectErr = err;
268
+ try { sock.destroy(); } catch (e2) { /* abaikan */ }
269
+ }
270
+ });
271
+ sock.on('data', (chunk) => this._onData(chunk));
272
+ sock.on('error', (e) => this._onSockError(e));
273
+ sock.on('close', () => this._onSockClose());
274
+ this._sock = sock;
275
+ }
276
+
277
+ _onConnected(target) {
278
+ const sock = this._sock;
279
+ if (!sock || this.state !== 'connecting') return;
280
+ const defaultPort = target.secure ? 443 : 80;
281
+ const hostHeader = target.port === defaultPort ? target.host : `${target.host}:${target.port}`;
282
+ const key = crypto.randomBytes(16).toString('base64');
283
+ this._pendingKey = key;
284
+ const lines = [
285
+ `GET ${target.path} HTTP/1.1`,
286
+ `Host: ${hostHeader}`,
287
+ 'Upgrade: websocket',
288
+ 'Connection: Upgrade',
289
+ `Sec-WebSocket-Key: ${key}`,
290
+ 'Sec-WebSocket-Version: 13',
291
+ ];
292
+ for (const [k, v] of Object.entries(this.headers)) {
293
+ const kl = String(k).toLowerCase();
294
+ if (kl.startsWith('sec-websocket') || kl === 'connection' || kl === 'upgrade' || kl === 'host') continue;
295
+ lines.push(`${k}: ${v}`);
296
+ }
297
+ sock.write(lines.join('\r\n') + '\r\n\r\n', 'utf8');
298
+ }
299
+
300
+ _onData(chunk) {
301
+ if (this.state === 'open') {
302
+ this._lastRecvAt = Date.now();
303
+ this._feed(chunk);
304
+ return;
305
+ }
306
+ // masih handshaking
307
+ this._hsBuf = this._hsBuf ? Buffer.concat([this._hsBuf, chunk]) : chunk;
308
+ const idx = this._hsBuf.indexOf('\r\n\r\n');
309
+ if (idx === -1) {
310
+ if (this._hsBuf.length > HS_MAX_HEAD) {
311
+ const err = new Error('Header handshake terlalu besar');
312
+ err.handshakeStatus = 0;
313
+ this._connectErr = err;
314
+ try { this._sock.destroy(); } catch (e) { /* abaikan */ }
315
+ }
316
+ return;
317
+ }
318
+ const head = this._hsBuf.subarray(0, idx).toString('utf8');
319
+ const rest = this._hsBuf.subarray(idx + 4);
320
+ this._hsBuf = null;
321
+ this._finishHandshake(head, rest);
322
+ }
323
+
324
+ _finishHandshake(head, rest) {
325
+ const lines = head.split('\r\n');
326
+ const m = /^HTTP\/1\.\d\s+(\d{3})(?:\s+(.*))?$/.exec(String(lines[0] || '').trim());
327
+ const status = m ? Number(m[1]) : 0;
328
+ if (status !== 101) {
329
+ const err = new Error(`Handshake WebSocket ditolak (HTTP ${status})`);
330
+ err.handshakeStatus = status;
331
+ this._connectErr = err;
332
+ try { this._sock.destroy(); } catch (e) { /* abaikan */ }
333
+ return;
334
+ }
335
+ const headers = {};
336
+ for (const line of lines.slice(1)) {
337
+ const ci = line.indexOf(':');
338
+ if (ci > 0) headers[line.slice(0, ci).trim().toLowerCase()] = line.slice(ci + 1).trim();
339
+ }
340
+ if (!this._pendingKey || headers['sec-websocket-accept'] !== acceptKey(this._pendingKey)) {
341
+ const err = new Error('Sec-WebSocket-Accept tidak valid — server bukan WebSocket RFC 6455');
342
+ err.handshakeStatus = 101;
343
+ this._connectErr = err;
344
+ try { this._sock.destroy(); } catch (e) { /* abaikan */ }
345
+ return;
346
+ }
347
+ // 101 + accept valid → open
348
+ const sock = this._sock;
349
+ this.state = 'open';
350
+ this._attempt = 0;
351
+ this._frag = null;
352
+ this._pendingKey = null;
353
+ this._lastRecvAt = Date.now();
354
+ if (sock) sock.setTimeout(0);
355
+ this._startPing();
356
+ this.emit('open');
357
+ if (rest && rest.length) this._feed(rest);
358
+ const q = this._queue.splice(0);
359
+ for (const b of q) {
360
+ if (this.state === 'open') this._sendFrame(OP.TEXT, b);
361
+ }
362
+ }
363
+
364
+ _startPing() {
365
+ this._clearPing();
366
+ this._pingTimer = setInterval(() => {
367
+ if (this.state !== 'open' || !this._sock) return;
368
+ const now = Date.now();
369
+ if (this._lastRecvAt && now - this._lastRecvAt > this.deadAfterMs) {
370
+ this.emit('error', new Error(`Koneksi diam > ${Math.round(this.deadAfterMs / 1000)}s tanpa frame — diputus untuk sambung ulang`));
371
+ try { this._sock.destroy(); } catch (e) { /* abaikan */ }
372
+ return;
373
+ }
374
+ this._sendFrame(OP.PING, crypto.randomBytes(4));
375
+ }, this.pingIntervalMs);
376
+ }
377
+
378
+ _onSockError(e) {
379
+ if (this.state === 'open') {
380
+ this.emit('error', e);
381
+ try { this._sock.destroy(); } catch (e2) { /* abaikan */ }
382
+ return;
383
+ }
384
+ if (this.state === 'connecting') {
385
+ if (e && e.handshakeStatus === undefined) {
386
+ const err = e instanceof Error ? e : new Error(String(e));
387
+ err.handshakeStatus = 0;
388
+ this._connectErr = err;
389
+ }
390
+ try { this._sock.destroy(); } catch (e2) { /* abaikan */ }
391
+ }
392
+ // state 'closing'/'closed': socket error diabaikan — kita memang sedang memutus
393
+ }
394
+
395
+ _onSockClose() {
396
+ if (this._closeTimer) {
397
+ clearTimeout(this._closeTimer);
398
+ this._closeTimer = null;
399
+ }
400
+ this._clearPing();
401
+ const was = this.state;
402
+ this.state = 'closed';
403
+ this._sock = null;
404
+ this._buf = null;
405
+ this._hsBuf = null;
406
+ this._frag = null;
407
+ if (this._userClosed || was === 'closing') {
408
+ this.emit('close', { code: 1000, expected: true });
409
+ return;
410
+ }
411
+ if (was === 'open') {
412
+ this.emit('close', { code: 1006, expected: false });
413
+ this._scheduleReconnect('koneksi terputus');
414
+ return;
415
+ }
416
+ if (was === 'connecting') {
417
+ const err = this._connectErr || new Error('Gagal terhubung');
418
+ this._connectErr = null;
419
+ const st = Number(err.handshakeStatus || 0);
420
+ const permanent = st >= 400 && st < 500 && st !== 408 && st !== 429;
421
+ if (permanent) {
422
+ this.state = 'fatal';
423
+ this.emit('fatal', err);
424
+ return;
425
+ }
426
+ this.emit('error', err);
427
+ this._attempt += 1;
428
+ this._scheduleReconnect(err.message);
429
+ }
430
+ }
431
+
432
+ _protocolFail(err) {
433
+ this.emit('error', err);
434
+ const code = /1 MB/.test(err.message) ? 1009 : 1002;
435
+ if (this._sock && this.state === 'open') {
436
+ try { this._sock.write(encodeFrame(OP.CLOSE, encodeClosePayload(code, err.message))); } catch (e) { /* abaikan */ }
437
+ try { this._sock.end(); } catch (e2) { /* abaikan */ }
438
+ setTimeout(() => {
439
+ try { if (this._sock) this._sock.destroy(); } catch (e3) { /* abaikan */ }
440
+ }, 250).unref();
441
+ }
442
+ }
443
+
444
+ _feed(chunk) {
445
+ this._buf = this._buf ? Buffer.concat([this._buf, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]) : chunk;
446
+ for (;;) {
447
+ const r = extractFrame(this._buf);
448
+ if (r === null) break;
449
+ if (r.error) {
450
+ this._protocolFail(r.error);
451
+ return;
452
+ }
453
+ this._buf = r.rest;
454
+ this._onFrame(r.frame);
455
+ if (this.state !== 'open') return;
456
+ }
457
+ if (this._buf && this._buf.length > MAX_PAYLOAD + 16) {
458
+ this._protocolFail(new Error('Buffer frame melebihi batas 1 MB'));
459
+ }
460
+ }
461
+
462
+ _onFrame({ fin, opcode, payload }) {
463
+ switch (opcode) {
464
+ case OP.TEXT:
465
+ case OP.BINARY: {
466
+ if (this._frag) {
467
+ this._protocolFail(new Error('Frame teks baru saat fragmentasi belum selesai'));
468
+ return;
469
+ }
470
+ if (fin) {
471
+ this._deliver(opcode, payload);
472
+ return;
473
+ }
474
+ this._frag = { opcode, parts: [payload], size: payload.length };
475
+ return;
476
+ }
477
+ case OP.CONT: {
478
+ if (!this._frag) {
479
+ this._protocolFail(new Error('Continuation tanpa pesan induk'));
480
+ return;
481
+ }
482
+ this._frag.parts.push(payload);
483
+ this._frag.size += payload.length;
484
+ if (this._frag.size > MAX_PAYLOAD) {
485
+ const err = new Error('Pesan gabungan melebihi batas 1 MB');
486
+ this._frag = null;
487
+ this._protocolFail(err);
488
+ return;
489
+ }
490
+ if (fin) {
491
+ const { opcode: op, parts } = this._frag;
492
+ this._frag = null;
493
+ this._deliver(op, Buffer.concat(parts));
494
+ }
495
+ return;
496
+ }
497
+ case OP.PING: {
498
+ if (payload.length > 125) {
499
+ this._protocolFail(new Error('Ping > 125 byte'));
500
+ return;
501
+ }
502
+ this._sendFrame(OP.PONG, payload);
503
+ return;
504
+ }
505
+ case OP.PONG:
506
+ return;
507
+ case OP.CLOSE: {
508
+ let code = 1005;
509
+ let reason = '';
510
+ if (payload.length >= 2) {
511
+ code = payload.readUInt16BE(0);
512
+ reason = payload.subarray(2).toString('utf8');
513
+ }
514
+ // echo close lalu biarkan event 'close' yang memutus (reconnect bila perlu)
515
+ if (this._sock && this.state === 'open') {
516
+ try { this._sock.write(encodeFrame(OP.CLOSE, encodeClosePayload(code === 1005 ? 1000 : code, ''))); } catch (e) { /* abaikan */ }
517
+ }
518
+ setImmediate(() => {
519
+ try { if (this._sock) this._sock.destroy(); } catch (e) { /* abaikan */ }
520
+ });
521
+ return;
522
+ }
523
+ default:
524
+ this._protocolFail(new Error(`Opcode tidak dikenal: ${opcode}`));
525
+ }
526
+ }
527
+
528
+ _deliver(opcode, payload) {
529
+ if (opcode === OP.TEXT) {
530
+ let s = '';
531
+ try { s = payload.toString('utf8'); } catch (e) { s = ''; }
532
+ this.emit('message', s);
533
+ } else {
534
+ this.emit('binary', payload); // tidak dipakai CLI, tapi tetap tersedia
535
+ }
536
+ }
537
+
538
+ _sendFrame(opcode, payload) {
539
+ const sock = this._sock;
540
+ if (!sock || this.state !== 'open' || !sock.writable) return false;
541
+ try {
542
+ sock.write(encodeFrame(opcode, payload));
543
+ return true;
544
+ } catch (e) {
545
+ this.emit('error', e);
546
+ try { sock.destroy(); } catch (e2) { /* abaikan */ }
547
+ return false;
548
+ }
549
+ }
550
+ }
551
+
552
+ module.exports = { WsClient, MAX_PAYLOAD, acceptKey, encodeFrame, extractFrame, wsTarget };