kersiosocket 0.0.0-stage → 0.6.0-dev

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.
Files changed (70) hide show
  1. package/BUILD-INFO.md +15 -0
  2. package/CHANGELOG.md +30 -0
  3. package/LICENSE +21 -0
  4. package/README.md +191 -2
  5. package/bench/wabinary.js +31 -0
  6. package/bridges/whatsmeow/README.md +42 -0
  7. package/docs/ENGINE-MATRIX.md +23 -0
  8. package/docs/PROTOCOL-ROADMAP.md +74 -0
  9. package/docs/RESEARCH-BASELINE.md +36 -0
  10. package/docs/WABINARY.md +37 -0
  11. package/examples/basic.js +10 -0
  12. package/examples/client-payload.js +21 -0
  13. package/examples/device-props.js +18 -0
  14. package/examples/sessions.js +11 -0
  15. package/examples/wabinary.js +15 -0
  16. package/licenses/THIRD-PARTY-NOTICES.md +12 -0
  17. package/package.json +31 -4
  18. package/scripts/import-token-table.js +95 -0
  19. package/src/backends/baileys.js +121 -0
  20. package/src/backends/base.js +102 -0
  21. package/src/backends/whatsmeow-process.js +202 -0
  22. package/src/backends/zapo.js +123 -0
  23. package/src/client/kersio-client.js +305 -0
  24. package/src/client/session-manager.js +67 -0
  25. package/src/index.js +36 -0
  26. package/src/pairing/adapter.js +115 -0
  27. package/src/pairing/lifecycle.js +120 -0
  28. package/src/pairing/qr.js +38 -0
  29. package/src/pairing/verified-commit.js +46 -0
  30. package/src/proto/client-payload.js +185 -0
  31. package/src/proto/device-props.js +143 -0
  32. package/src/proto/handshake.js +79 -0
  33. package/src/proto/wire.js +137 -0
  34. package/src/protocol/node-protocol.js +155 -0
  35. package/src/session/codec.js +25 -0
  36. package/src/session/encrypted-store.js +107 -0
  37. package/src/session/locking.js +233 -0
  38. package/src/session/manager.js +61 -0
  39. package/src/session/stores.js +154 -0
  40. package/src/socket/connection.js +278 -0
  41. package/src/socket/frame.js +77 -0
  42. package/src/socket/make.js +70 -0
  43. package/src/socket/noise.js +146 -0
  44. package/src/socket/transport.js +39 -0
  45. package/src/utils/crypto.js +52 -0
  46. package/src/utils/latency.js +32 -0
  47. package/src/utils/lru.js +76 -0
  48. package/src/wabinary/codec.js +374 -0
  49. package/src/wabinary/tags.js +25 -0
  50. package/src/wabinary/token-source.js +146 -0
  51. package/src/wabinary/tokens.json +5 -0
  52. package/test/backend-adapters.test.js +59 -0
  53. package/test/connection.test.js +173 -0
  54. package/test/device-props.test.js +67 -0
  55. package/test/encrypted-store.test.js +54 -0
  56. package/test/frame.test.js +37 -0
  57. package/test/helpers.js +43 -0
  58. package/test/locking-archive.test.js +49 -0
  59. package/test/node-protocol.test.js +90 -0
  60. package/test/noise.test.js +88 -0
  61. package/test/pairing-adapter.test.js +71 -0
  62. package/test/pairing.test.js +32 -0
  63. package/test/proto.test.js +64 -0
  64. package/test/session-manager.test.js +34 -0
  65. package/test/session.test.js +134 -0
  66. package/test/token-source.test.js +35 -0
  67. package/test/unified-client.test.js +63 -0
  68. package/test/verified-commit.test.js +43 -0
  69. package/test/wabinary.test.js +147 -0
  70. package/test/whatsmeow-process.test.js +43 -0
@@ -0,0 +1,154 @@
1
+ 'use strict'
2
+ /**
3
+ * Store session. Satu interface untuk semua backend:
4
+ *
5
+ * get(key) -> Promise<value | undefined>
6
+ * set(key, value) -> Promise<void>
7
+ * delete(key) -> Promise<void>
8
+ * keys(prefix?) -> Promise<string[]> (opsional, dipakai daftar sesi)
9
+ * close?() -> Promise<void>
10
+ *
11
+ * Mau backend sendiri? Cukup buat objek dengan get/set/delete.
12
+ */
13
+ const fs = require('node:fs')
14
+ const fsp = fs.promises
15
+ const path = require('node:path')
16
+ const crypto = require('node:crypto')
17
+ const { encode, decode } = require('./codec')
18
+
19
+ /** Store di memori. Untuk test atau sesi sementara. */
20
+ class MemoryStore {
21
+ constructor() { this.m = new Map() }
22
+ async get(key) { const v = this.m.get(key); return v === undefined ? undefined : decode(v) }
23
+ async set(key, value) { this.m.set(key, encode(value)) }
24
+ async delete(key) { this.m.delete(key) }
25
+ async keys(prefix = '') { return [...this.m.keys()].filter((k) => k.startsWith(prefix)) }
26
+ }
27
+
28
+ // Nama file aman di Windows/Linux/Termux, dan bisa dibalik jadi key asli.
29
+ const fileName = (key) => encodeURIComponent(key).replace(/\./g, '%2E').replace(/\*/g, '%2A') + '.json'
30
+ const keyOf = (name) => decodeURIComponent(name.slice(0, -5))
31
+
32
+ /**
33
+ * Store file. Satu key = satu file JSON.
34
+ * Penulisan atomik: tulis ke file sementara, fsync, lalu rename. Bila proses mati di tengah
35
+ * penulisan, file lama tetap utuh. File yang rusak dipindah ke *.corrupt, bukan dihapus.
36
+ */
37
+ class FileStore {
38
+ constructor(dir, { fsync = true } = {}) {
39
+ this.dir = path.resolve(dir)
40
+ this.fsync = fsync
41
+ this.queues = new Map() // key -> promise terakhir; menjaga urutan tulis per key
42
+ fs.mkdirSync(this.dir, { recursive: true })
43
+ for (const f of fs.readdirSync(this.dir)) if (f.endsWith('.tmp')) fs.rmSync(path.join(this.dir, f), { force: true })
44
+ }
45
+
46
+ _file(key) { return path.join(this.dir, fileName(key)) }
47
+
48
+ async get(key) {
49
+ const file = this._file(key)
50
+ let text
51
+ try { text = await fsp.readFile(file, 'utf8') } catch (e) {
52
+ if (e.code === 'ENOENT') return undefined
53
+ throw e
54
+ }
55
+ try { return decode(text) } catch {
56
+ await fsp.rename(file, `${file}.${Date.now()}.corrupt`).catch(() => {})
57
+ return undefined
58
+ }
59
+ }
60
+
61
+ set(key, value) { return this._queue(key, () => this._writeAtomic(this._file(key), encode(value))) }
62
+
63
+ delete(key) { return this._queue(key, () => fsp.rm(this._file(key), { force: true })) }
64
+
65
+ async keys(prefix = '') {
66
+ const names = await fsp.readdir(this.dir)
67
+ return names.filter((n) => n.endsWith('.json')).map(keyOf).filter((k) => k.startsWith(prefix))
68
+ }
69
+
70
+ _queue(key, job) {
71
+ const prev = this.queues.get(key) || Promise.resolve()
72
+ const next = prev.catch(() => {}).then(job)
73
+ this.queues.set(key, next)
74
+ const clean = () => { if (this.queues.get(key) === next) this.queues.delete(key) }
75
+ next.then(clean, clean)
76
+ return next
77
+ }
78
+
79
+ async _writeAtomic(file, text) {
80
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`
81
+ const fh = await fsp.open(tmp, 'w')
82
+ try {
83
+ await fh.writeFile(text)
84
+ if (this.fsync) await fh.sync()
85
+ } finally { await fh.close() }
86
+ try { await fsp.rename(tmp, file) } catch (e) { await fsp.rm(tmp, { force: true }); throw e }
87
+ }
88
+ }
89
+
90
+ /** Store SQLite memakai node:sqlite bawaan Node (>= 22.5). Tanpa dependensi. */
91
+ class SQLiteStore {
92
+ constructor(file = ':memory:') {
93
+ let DatabaseSync
94
+ try { ({ DatabaseSync } = require('node:sqlite')) } catch {
95
+ throw new Error('SQLiteStore butuh node:sqlite (Node >= 22.5)')
96
+ }
97
+ this.db = new DatabaseSync(file)
98
+ this.db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID')
99
+ this._get = this.db.prepare('SELECT v FROM kv WHERE k = ?')
100
+ this._set = this.db.prepare('INSERT INTO kv (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v')
101
+ this._del = this.db.prepare('DELETE FROM kv WHERE k = ?')
102
+ this._keys = this.db.prepare("SELECT k FROM kv WHERE substr(k, 1, ?) = ?")
103
+ }
104
+ async get(key) { const r = this._get.get(key); return r ? decode(r.v) : undefined }
105
+ async set(key, value) { this._set.run(key, encode(value)) }
106
+ async delete(key) { this._del.run(key) }
107
+ async keys(prefix = '') { return this._keys.all(prefix.length, prefix).map((r) => r.k) }
108
+ async close() { this.db.close() }
109
+ }
110
+
111
+ /**
112
+ * Adapter Redis. Berikan klien yang SUDAH dibuat (node-redis v4+ atau ioredis).
113
+ * Library ini tidak ikut membawa dependensi Redis.
114
+ */
115
+ class RedisStore {
116
+ constructor(client, { prefix = 'kersio:' } = {}) { this.c = client; this.prefix = prefix }
117
+ async get(key) { const v = await this.c.get(this.prefix + key); return v == null ? undefined : decode(String(v)) }
118
+ async set(key, value) { await this.c.set(this.prefix + key, encode(value)) }
119
+ async delete(key) { await this.c.del(this.prefix + key) }
120
+ async keys(prefix = '') {
121
+ const pattern = `${this.prefix}${prefix}`.replace(/[\\*?\[\]]/g, '\\$&') + '*'
122
+ const out = []
123
+ if (typeof this.c.scanIterator === 'function') {
124
+ for await (const batch of this.c.scanIterator({ MATCH: pattern, COUNT: 200 })) {
125
+ for (const k of Array.isArray(batch) ? batch : [batch]) out.push(k)
126
+ }
127
+ } else out.push(...(await this.c.keys(pattern)))
128
+ return out.map((k) => k.slice(this.prefix.length))
129
+ }
130
+ }
131
+
132
+ /** Adapter MongoDB. Berikan objek collection dari driver `mongodb`. */
133
+ class MongoStore {
134
+ constructor(collection) { this.col = collection }
135
+ async get(key) { const d = await this.col.findOne({ _id: key }); return d ? decode(d.v) : undefined }
136
+ async set(key, value) { await this.col.replaceOne({ _id: key }, { _id: key, v: encode(value) }, { upsert: true }) }
137
+ async delete(key) { await this.col.deleteOne({ _id: key }) }
138
+ async keys(prefix = '') {
139
+ const re = '^' + prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
140
+ return (await this.col.find({ _id: { $regex: re } }, { projection: { _id: 1 } }).toArray()).map((d) => d._id)
141
+ }
142
+ }
143
+
144
+ /** Bungkus store dengan namespace: semua key diawali `${ns}:`. */
145
+ function scoped(store, ns) {
146
+ const p = `${ns}:`
147
+ return {
148
+ get: (k) => store.get(p + k),
149
+ set: (k, v) => store.set(p + k, v),
150
+ delete: (k) => store.delete(p + k)
151
+ }
152
+ }
153
+
154
+ module.exports = { MemoryStore, FileStore, SQLiteStore, RedisStore, MongoStore, scoped }
@@ -0,0 +1,278 @@
1
+ 'use strict'
2
+ /**
3
+ * Connection: WebSocket + Noise handshake + reconnect (exponential backoff + jitter).
4
+ *
5
+ * Event:
6
+ * connection.update { connection: 'connecting'|'handshaking'|'noise-ready'|'close', ... }
7
+ * frame Buffer plaintext hasil dekripsi Noise
8
+ * connection.error Error protokol/transport
9
+ * handler.error Error sinkron/async dari satu listener; listener lain tetap dipanggil
10
+ */
11
+ const { EventEmitter } = require('node:events')
12
+ const { createTransport } = require('./transport')
13
+ const { FrameDecoder, encodeFrame } = require('./frame')
14
+ const { ClientHandshake, WA_HEADER } = require('./noise')
15
+ const { generateKeyPair, EMPTY } = require('../utils/crypto')
16
+ const { LatencyTracker } = require('../utils/latency')
17
+ const { encodeClientPayload } = require('../proto/client-payload')
18
+
19
+ const noopLog = { debug() {}, info() {}, warn() {}, error() {} }
20
+
21
+ const DEFAULTS = {
22
+ url: 'wss://web.whatsapp.com/ws/chat',
23
+ origin: 'https://web.whatsapp.com',
24
+ clientPayload: EMPTY, // empty buffer is transport smoke-test only; real login needs verified auth payload
25
+ // Fail closed by default: callers must supply a pinned, independently tested server-certificate verifier.
26
+ strictServerCertificateVerification: true,
27
+ verifyServerCertificate: null,
28
+ // stableMs: koneksi baru dianggap 'sehat' (counter backoff direset) kalau bertahan selama ini
29
+ reconnect: { enabled: true, minDelay: 1000, maxDelay: 30000, factor: 2, stableMs: 30000 }
30
+ }
31
+
32
+ class Connection extends EventEmitter {
33
+ constructor(config = {}) {
34
+ super()
35
+ this.config = { ...DEFAULTS, ...config, reconnect: { ...DEFAULTS.reconnect, ...config.reconnect } }
36
+ this.log = config.logger || noopLog
37
+ const requestedPayload = this.config.clientPayload
38
+ if (Buffer.isBuffer(requestedPayload) || requestedPayload instanceof Uint8Array) {
39
+ this.clientPayload = Buffer.from(requestedPayload.buffer, requestedPayload.byteOffset, requestedPayload.byteLength)
40
+ } else if (requestedPayload && typeof requestedPayload === 'object') {
41
+ this.clientPayload = encodeClientPayload(requestedPayload)
42
+ } else {
43
+ throw new TypeError('clientPayload harus Buffer atau objek ClientPayload')
44
+ }
45
+ this.staticKey = config.staticKey || generateKeyPair()
46
+ this.state = 'close'
47
+ this.ended = false
48
+ this.attempt = 0
49
+ this.startedAt = 0
50
+ this.readyAt = 0
51
+ this.counters = { bytesIn: 0, bytesOut: 0, framesIn: 0, framesOut: 0 }
52
+ this.frameLatency = new LatencyTracker() // dekripsi + dispatch per frame
53
+ this.handshakeMs = 0
54
+ this.serverCertificateVerified = false
55
+ this._t = null
56
+ this._decoder = null
57
+ this._hs = null
58
+ this._noise = null
59
+ this._timer = null
60
+ this._generation = 0
61
+ this._closeHandled = false
62
+ }
63
+
64
+ connect() {
65
+ if (this._t) return
66
+ clearTimeout(this._timer)
67
+ this._timer = null
68
+ this.ended = false
69
+ this._setState('connecting')
70
+ const factory = this.config.transportFactory || createTransport
71
+ let t
72
+ try {
73
+ t = factory(this.config.url, { origin: this.config.origin, headers: this.config.headers })
74
+ } catch (err) {
75
+ this._safeEmit('connection.error', err)
76
+ this._setState('close', { error: err })
77
+ this._scheduleReconnect()
78
+ return
79
+ }
80
+ this._t = t
81
+ const generation = ++this._generation
82
+ this._closeHandled = false
83
+ t.on('open', () => {
84
+ if (this._t !== t || generation !== this._generation) return
85
+ try { this._onOpen() } catch (err) { this._abortConnection(err, t, generation) }
86
+ })
87
+ t.on('message', (d) => {
88
+ if (this._t !== t || generation !== this._generation) return
89
+ this._onMessage(d)
90
+ })
91
+ t.on('close', (code, reason) => this._onClose(code, reason, t, generation))
92
+ t.on('transport.error', (err) => {
93
+ if (this._t === t && generation === this._generation) this._safeEmit('connection.error', err)
94
+ })
95
+ }
96
+
97
+ end() {
98
+ this.ended = true
99
+ clearTimeout(this._timer)
100
+ if (this._t) this._t.close()
101
+ }
102
+
103
+ /** Kirim data (plaintext) lewat kanal Noise terenkripsi. */
104
+ sendFrame(plain) {
105
+ if (this.state !== 'noise-ready') throw new Error('koneksi belum siap (handshake belum selesai)')
106
+ this._write(encodeFrame(this._noise.encrypt(plain)))
107
+ }
108
+
109
+ stats() {
110
+ const m = process.memoryUsage()
111
+ return {
112
+ state: this.state,
113
+ uptimeMs: this.startedAt ? Date.now() - this.startedAt : 0,
114
+ ...this.counters,
115
+ handshakeMs: this.handshakeMs,
116
+ frameLatencyMs: this.frameLatency.summary(),
117
+ serverCertificateVerified: this.serverCertificateVerified,
118
+ rssMB: +(m.rss / 1048576).toFixed(1),
119
+ heapUsedMB: +(m.heapUsed / 1048576).toFixed(1)
120
+ }
121
+ }
122
+
123
+ _setState(connection, extra) {
124
+ this.state = connection
125
+ this._safeEmit('connection.update', { connection, ...extra })
126
+ }
127
+
128
+ _write(buf) {
129
+ this.counters.bytesOut += buf.length
130
+ this.counters.framesOut++
131
+ this._t.send(buf)
132
+ }
133
+
134
+ _onOpen() {
135
+ this._decoder = new FrameDecoder()
136
+ this._hs = new ClientHandshake({
137
+ staticKey: this.staticKey,
138
+ verifyServerCertificate: this.config.verifyServerCertificate,
139
+ requireServerCertificateVerification: this.config.strictServerCertificateVerification
140
+ })
141
+ this.startedAt = Date.now()
142
+ this._setState('handshaking')
143
+ this._write(encodeFrame(this._hs.hello(), WA_HEADER))
144
+ }
145
+
146
+ _onMessage(chunk) {
147
+ if (!this._decoder || !this._t) return
148
+ try {
149
+ this.counters.bytesIn += chunk && typeof chunk.length === 'number' ? chunk.length : 0
150
+ const frames = this._decoder.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
151
+ for (const frame of frames) {
152
+ if (!this._t) break
153
+ this.counters.framesIn++
154
+ try {
155
+ this._onFrame(frame)
156
+ } catch (err) {
157
+ this._abortConnection(err, this._t, this._generation)
158
+ break
159
+ }
160
+ }
161
+ } catch (err) {
162
+ this._abortConnection(err, this._t, this._generation)
163
+ }
164
+ }
165
+
166
+ _onFrame(frame) {
167
+ if (this._hs) {
168
+ const hs = this._hs
169
+ const finish = hs.processServerHello(frame, this.clientPayload)
170
+ this._noise = hs.noise
171
+ this.serverCertificateVerified = hs.serverCertificateVerified === true
172
+ this._hs = null
173
+ // Publish the completed Noise state before writing ClientFinish. A fake or
174
+ // custom transport may synchronously deliver the server reply from send().
175
+ this.readyAt = Date.now()
176
+ this.handshakeMs = Date.now() - this.startedAt
177
+ this._setState('noise-ready', {
178
+ serverStatic: hs.serverStatic,
179
+ serverCertificateVerified: this.serverCertificateVerified
180
+ })
181
+ this._write(encodeFrame(finish))
182
+ return
183
+ }
184
+ if (!this._noise) throw new Error('Noise state tidak tersedia di luar handshake')
185
+ const done = this.frameLatency.start()
186
+ try {
187
+ const plain = this._noise.decrypt(frame)
188
+ this._safeEmit('frame', plain)
189
+ } finally {
190
+ done()
191
+ }
192
+ }
193
+
194
+ /** Dispatch each listener independently, so one user callback cannot starve others. */
195
+ _safeEmit(event, ...args) {
196
+ const listeners = this.rawListeners(event)
197
+ if (listeners.length === 0) return false
198
+ for (const listener of listeners) {
199
+ try {
200
+ const result = Reflect.apply(listener, this, args)
201
+ if (result && typeof result.then === 'function') {
202
+ Promise.resolve(result).catch((err) => this._reportHandlerError(err, event))
203
+ }
204
+ } catch (err) {
205
+ this._reportHandlerError(err, event)
206
+ }
207
+ }
208
+ return true
209
+ }
210
+
211
+ _reportHandlerError(error, sourceEvent) {
212
+ const err = error instanceof Error ? error : new Error(String(error))
213
+ this.log.error(`listener '${sourceEvent}' melempar error: ${err.message}`)
214
+ if (sourceEvent === 'handler.error') return
215
+ const listeners = this.rawListeners('handler.error')
216
+ for (const listener of listeners) {
217
+ try {
218
+ const result = Reflect.apply(listener, this, [err, sourceEvent])
219
+ if (result && typeof result.then === 'function') {
220
+ Promise.resolve(result).catch((nested) => this.log.error(`listener 'handler.error' melempar error: ${nested.message}`))
221
+ }
222
+ } catch (nested) {
223
+ this.log.error(`listener 'handler.error' melempar error: ${nested.message}`)
224
+ }
225
+ }
226
+ }
227
+
228
+ _abortConnection(error, transport = this._t, generation = this._generation) {
229
+ const err = error instanceof Error ? error : new Error(String(error))
230
+ this._safeEmit('connection.error', err)
231
+ if (['ERR_NOISE_CERTIFICATE_VERIFIER_MISSING', 'ERR_NOISE_CERTIFICATE_REJECTED'].includes(err.code)) {
232
+ // Configuration/trust failures are not transient network failures. Stop auto-retry.
233
+ this.ended = true
234
+ }
235
+ if (!transport || transport !== this._t || generation !== this._generation) return
236
+ // Transition/cleanup synchronously. The transport's eventual close event is de-duplicated.
237
+ this._onClose(1002, Buffer.from('protocol/transport failure'), transport, generation)
238
+ try { transport.close() } catch (closeError) {
239
+ this.log.warn(`gagal menutup transport: ${closeError.message}`)
240
+ }
241
+ }
242
+
243
+ _onClose(code, reason, source = this._t, generation = this._generation) {
244
+ if (!source || source !== this._t || generation !== this._generation || this._closeHandled) return
245
+ this._closeHandled = true
246
+ const lived = this.readyAt ? Date.now() - this.readyAt : 0
247
+ const wasReady = this.readyAt > 0
248
+ this.readyAt = 0
249
+ this._t = this._hs = this._noise = this._decoder = null
250
+ this._setState('close', { code, reason: Buffer.isBuffer(reason) ? reason.toString('utf8') : reason })
251
+ const r = this.config.reconnect
252
+ if (lived >= r.stableMs) this.attempt = 0
253
+ else if (wasReady && this.clientPayload.length === 0) {
254
+ this.log.warn('server menutup koneksi tepat setelah handshake: clientPayload masih kosong (login/registrasi belum diimplementasi)')
255
+ }
256
+ this._scheduleReconnect()
257
+ }
258
+
259
+ _scheduleReconnect() {
260
+ const r = this.config.reconnect
261
+ if (this.ended || !r.enabled || this._timer) return
262
+ const base = Math.min(r.maxDelay, r.minDelay * Math.pow(r.factor, this.attempt++))
263
+ const delay = Math.round(base * (0.8 + Math.random() * 0.4))
264
+ this.log.info(`reconnect dalam ${delay}ms (percobaan ${this.attempt})`)
265
+ this._timer = setTimeout(() => {
266
+ this._timer = null
267
+ if (!this.ended && !this._t) this.connect()
268
+ }, delay)
269
+ }
270
+
271
+ /** Fail a decrypted protocol frame closed, while preserving configured backoff behavior. */
272
+ failProtocol(error) {
273
+ this._abortConnection(error)
274
+ }
275
+
276
+ }
277
+
278
+ module.exports = { Connection }
@@ -0,0 +1,77 @@
1
+ 'use strict'
2
+ /**
3
+ * Framing WhatsApp: [prefix opsional][panjang 3 byte big-endian][payload].
4
+ * Prefix (WA_HEADER) hanya dikirim di frame pertama.
5
+ */
6
+ const MAX_FRAME = 0xffffff
7
+
8
+ function encodeFrame(payload, prefix) {
9
+ if (payload.length > MAX_FRAME) throw new Error('frame terlalu besar')
10
+ const head = Buffer.allocUnsafe(3)
11
+ head[0] = payload.length >>> 16
12
+ head[1] = (payload.length >>> 8) & 0xff
13
+ head[2] = payload.length & 0xff
14
+ return prefix ? Buffer.concat([prefix, head, payload]) : Buffer.concat([head, payload])
15
+ }
16
+
17
+ /**
18
+ * Penyusun frame dari potongan data WebSocket (frame bisa terbelah/menyatu).
19
+ * Potongan disimpan dalam daftar dan baru digabung SEKALI saat satu frame lengkap,
20
+ * jadi frame besar yang datang bertahap tidak memicu concat berulang (O(n^2)).
21
+ * Sisa data yang belum lengkap disalin supaya tidak menahan buffer induk yang besar.
22
+ */
23
+ class FrameDecoder {
24
+ constructor() {
25
+ this.chunks = []
26
+ this.total = 0
27
+ this.need = 0 // total byte (header + payload) frame yang sedang dirakit; 0 = belum tahu
28
+ }
29
+
30
+ push(chunk) {
31
+ if (chunk.length) { this.chunks.push(chunk); this.total += chunk.length }
32
+ const frames = []
33
+ for (;;) {
34
+ if (this.need === 0) {
35
+ if (this.total < 3) break
36
+ this.need = 3 + ((this._byte(0) << 16) | (this._byte(1) << 8) | this._byte(2))
37
+ }
38
+ if (this.total < this.need) break
39
+ frames.push(this._take(this.need).subarray(3))
40
+ this.need = 0
41
+ }
42
+ if (this.chunks.length === 1 && this.chunks[0].length) this.chunks[0] = Buffer.from(this.chunks[0])
43
+ return frames
44
+ }
45
+
46
+ _byte(i) {
47
+ for (const c of this.chunks) {
48
+ if (i < c.length) return c[i]
49
+ i -= c.length
50
+ }
51
+ return 0
52
+ }
53
+
54
+ /** Ambil n byte dari depan. Tanpa salinan bila seluruhnya ada di satu potongan. */
55
+ _take(n) {
56
+ const first = this.chunks[0]
57
+ this.total -= n
58
+ if (first.length >= n) {
59
+ if (first.length === n) this.chunks.shift()
60
+ else this.chunks[0] = first.subarray(n)
61
+ return first.subarray(0, n)
62
+ }
63
+ const out = Buffer.allocUnsafe(n)
64
+ let off = 0
65
+ while (off < n) {
66
+ const c = this.chunks[0]
67
+ const take = Math.min(c.length, n - off)
68
+ c.copy(out, off, 0, take)
69
+ off += take
70
+ if (take === c.length) this.chunks.shift()
71
+ else this.chunks[0] = c.subarray(take)
72
+ }
73
+ return out
74
+ }
75
+ }
76
+
77
+ module.exports = { encodeFrame, FrameDecoder, MAX_FRAME }
@@ -0,0 +1,70 @@
1
+ 'use strict'
2
+ const { Connection } = require('./connection')
3
+ const { generateKeyPair } = require('../utils/crypto')
4
+ const { scoped } = require('../session/stores')
5
+ const { PairingLifecycle } = require('../pairing/lifecycle')
6
+ const { NodeProtocol } = require('../protocol/node-protocol')
7
+
8
+ /**
9
+ * Buat socket kersiosocket.
10
+ *
11
+ * @param {object} [config]
12
+ * autoConnect true -> sambung otomatis (default)
13
+ * store store session {get,set,delete}; bila ada, kredensial disimpan/dimuat otomatis
14
+ * sessionId nama sesi di dalam store (default 'default')
15
+ * logger, reconnect, url, origin, staticKey, clientPayload
16
+ *
17
+ * Event (sock.ev): connection.update, creds.update, frame, connection.error, handler.error
18
+ * sock.ready: Promise yang selesai setelah kredensial dimuat (dan koneksi dimulai bila autoConnect).
19
+ */
20
+ function makeSocket(config = {}) {
21
+ const conn = new Connection(config)
22
+ const pairing = new PairingLifecycle({ sessionId: config.sessionId || 'default' })
23
+ const protocolConfig = config.nodeProtocol
24
+ const protocol = protocolConfig ? new NodeProtocol(conn, protocolConfig === true ? {} : protocolConfig) : null
25
+ const store = config.store ? scoped(config.store, config.sessionId || 'default') : null
26
+ let creds = null
27
+
28
+ const saveCreds = async () => {
29
+ if (!store || !creds) return
30
+ await store.set('creds', creds)
31
+ conn._safeEmit('creds.update', creds)
32
+ }
33
+
34
+ const start = async () => {
35
+ if (store) {
36
+ creds = (await store.get('creds')) || null
37
+ if (!creds) {
38
+ creds = { staticKey: config.staticKey || generateKeyPair(), createdAt: Date.now() }
39
+ await saveCreds()
40
+ }
41
+ conn.staticKey = creds.staticKey
42
+ }
43
+ if (config.autoConnect !== false) conn.connect()
44
+ }
45
+
46
+ const sock = {
47
+ ev: conn,
48
+ sessionId: config.sessionId || 'default',
49
+ pairing,
50
+ connect: () => conn.connect(),
51
+ end: () => { protocol?.close(); pairing.close(); conn.end() },
52
+ sendFrame: (buf) => conn.sendFrame(buf),
53
+ protocol,
54
+ sendNode: (node) => { if (!protocol) throw new Error('aktifkan nodeProtocol pada makeSocket() untuk sendNode()'); return protocol.sendNode(node) },
55
+ query: (node, options) => { if (!protocol) return Promise.reject(new Error('aktifkan nodeProtocol pada makeSocket() untuk query()')); return protocol.query(node, options) },
56
+ stats: () => conn.stats(),
57
+ saveCreds,
58
+ get creds() { return creds },
59
+ get state() { return conn.state }
60
+ }
61
+
62
+ // tunda satu tick supaya pemanggil sempat memasang listener
63
+ sock.ready = new Promise((resolve) => process.nextTick(resolve))
64
+ .then(start)
65
+ .catch((err) => { conn._safeEmit('connection.error', err) })
66
+
67
+ return sock
68
+ }
69
+
70
+ module.exports = { makeSocket }