luang-cli 0.1.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/README.md +335 -110
- package/bin/luang-cli.js +48 -17
- package/lib/agent.js +504 -79
- package/lib/commands.js +69 -3
- package/lib/core.js +8 -0
- package/lib/ws.js +552 -0
- package/package.json +4 -1
- package/test/bin/opencode +5 -0
- package/test/http-stub.js +39 -0
- package/test/run-e2e.js +458 -0
- package/test/ws-stub.js +202 -0
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 };
|