kersiosocket 0.0.0-stage → 0.5.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 (55) hide show
  1. package/BUILD-INFO.md +14 -0
  2. package/CHANGELOG.md +19 -0
  3. package/LICENSE +21 -0
  4. package/README.md +145 -2
  5. package/bench/wabinary.js +31 -0
  6. package/docs/PROTOCOL-ROADMAP.md +43 -0
  7. package/docs/RESEARCH-BASELINE.md +25 -0
  8. package/docs/WABINARY.md +37 -0
  9. package/examples/basic.js +18 -0
  10. package/examples/client-payload.js +21 -0
  11. package/examples/device-props.js +18 -0
  12. package/examples/sessions.js +11 -0
  13. package/examples/wabinary.js +15 -0
  14. package/licenses/THIRD-PARTY-NOTICES.md +7 -0
  15. package/licenses/whatsmeow-MPL-2.0.txt +374 -0
  16. package/package.json +36 -3
  17. package/scripts/import-token-table.js +95 -0
  18. package/src/index.js +25 -0
  19. package/src/pairing/adapter.js +115 -0
  20. package/src/pairing/lifecycle.js +120 -0
  21. package/src/pairing/qr.js +38 -0
  22. package/src/proto/client-payload.js +185 -0
  23. package/src/proto/device-props.js +143 -0
  24. package/src/proto/handshake.js +79 -0
  25. package/src/proto/wire.js +137 -0
  26. package/src/protocol/node-protocol.js +155 -0
  27. package/src/session/codec.js +25 -0
  28. package/src/session/encrypted-store.js +107 -0
  29. package/src/session/manager.js +61 -0
  30. package/src/session/stores.js +154 -0
  31. package/src/socket/connection.js +265 -0
  32. package/src/socket/frame.js +77 -0
  33. package/src/socket/make.js +70 -0
  34. package/src/socket/noise.js +126 -0
  35. package/src/socket/transport.js +39 -0
  36. package/src/utils/crypto.js +52 -0
  37. package/src/utils/latency.js +32 -0
  38. package/src/utils/lru.js +76 -0
  39. package/src/wabinary/codec.js +374 -0
  40. package/src/wabinary/tags.js +25 -0
  41. package/src/wabinary/token-source.js +146 -0
  42. package/src/wabinary/tokens.json +1287 -0
  43. package/test/connection.test.js +153 -0
  44. package/test/device-props.test.js +67 -0
  45. package/test/encrypted-store.test.js +54 -0
  46. package/test/frame.test.js +37 -0
  47. package/test/helpers.js +43 -0
  48. package/test/node-protocol.test.js +90 -0
  49. package/test/noise.test.js +66 -0
  50. package/test/pairing-adapter.test.js +71 -0
  51. package/test/pairing.test.js +32 -0
  52. package/test/proto.test.js +64 -0
  53. package/test/session.test.js +134 -0
  54. package/test/token-source.test.js +35 -0
  55. package/test/wabinary.test.js +147 -0
@@ -0,0 +1,137 @@
1
+ 'use strict'
2
+ /**
3
+ * Tiny protobuf wire-format codec used by KersioSocket.
4
+ *
5
+ * This intentionally implements the protobuf wire primitives, not a generated
6
+ * copy of another project's WAProto code. Field meanings live in our own schema
7
+ * modules. Supported wire types: varint (0), fixed64 (1), bytes/message (2),
8
+ * and fixed32 (5). No implicit string conversion for binary fields.
9
+ */
10
+
11
+ const MAX_FIELD_NUMBER = 0x1fffffff
12
+
13
+ function asBigInt(value, name = 'value') {
14
+ if (typeof value === 'bigint') {
15
+ if (value < 0n || value > 0xffffffffffffffffn) throw new RangeError(`${name} di luar rentang uint64`)
16
+ return value
17
+ }
18
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} harus integer aman non-negatif`)
19
+ return BigInt(value)
20
+ }
21
+
22
+ function encodeVarint(value) {
23
+ let n = asBigInt(value)
24
+ const out = []
25
+ while (n > 0x7fn) {
26
+ out.push(Number((n & 0x7fn) | 0x80n))
27
+ n >>= 7n
28
+ }
29
+ out.push(Number(n))
30
+ return Buffer.from(out)
31
+ }
32
+
33
+ function tag(fieldNumber, wireType) {
34
+ if (!Number.isInteger(fieldNumber) || fieldNumber < 1 || fieldNumber > MAX_FIELD_NUMBER) {
35
+ throw new RangeError('nomor field protobuf tidak valid')
36
+ }
37
+ if (![0, 1, 2, 5].includes(wireType)) throw new RangeError('wire type protobuf tidak didukung')
38
+ return encodeVarint(BigInt(fieldNumber * 8 + wireType))
39
+ }
40
+
41
+ function fieldVarint(fieldNumber, value) {
42
+ if (value === undefined || value === null) return null
43
+ return Buffer.concat([tag(fieldNumber, 0), encodeVarint(value)])
44
+ }
45
+
46
+ function fieldBool(fieldNumber, value) {
47
+ if (value === undefined || value === null) return null
48
+ if (typeof value !== 'boolean') throw new TypeError(`field ${fieldNumber} harus boolean`)
49
+ return fieldVarint(fieldNumber, value ? 1 : 0)
50
+ }
51
+
52
+ function fieldBytes(fieldNumber, value) {
53
+ if (value === undefined || value === null) return null
54
+ if (!(Buffer.isBuffer(value) || value instanceof Uint8Array)) {
55
+ throw new TypeError(`field ${fieldNumber} harus Buffer/Uint8Array`)
56
+ }
57
+ const bytes = Buffer.from(value.buffer, value.byteOffset, value.byteLength)
58
+ return Buffer.concat([tag(fieldNumber, 2), encodeVarint(bytes.length), bytes])
59
+ }
60
+
61
+ function fieldString(fieldNumber, value) {
62
+ if (value === undefined || value === null) return null
63
+ if (typeof value !== 'string') throw new TypeError(`field ${fieldNumber} harus string`)
64
+ return fieldBytes(fieldNumber, Buffer.from(value, 'utf8'))
65
+ }
66
+
67
+ function fieldMessage(fieldNumber, value) {
68
+ return fieldBytes(fieldNumber, value)
69
+ }
70
+
71
+ function fieldFixed32(fieldNumber, value) {
72
+ if (value === undefined || value === null) return null
73
+ if (!Number.isInteger(value) || value < -0x80000000 || value > 0x7fffffff) {
74
+ throw new RangeError(`field ${fieldNumber} harus signed int32`)
75
+ }
76
+ const data = Buffer.allocUnsafe(4)
77
+ data.writeInt32LE(value)
78
+ return Buffer.concat([tag(fieldNumber, 5), data])
79
+ }
80
+
81
+ function pack(parts) {
82
+ const present = parts.filter((part) => part !== null && part !== undefined)
83
+ return present.length ? Buffer.concat(present) : Buffer.alloc(0)
84
+ }
85
+
86
+ function readVarint(buffer, state) {
87
+ let value = 0n
88
+ let shift = 0n
89
+ for (let count = 0; count < 10; count++) {
90
+ if (state.offset >= buffer.length) throw new Error('protobuf: varint terpotong')
91
+ const byte = buffer[state.offset++]
92
+ value |= BigInt(byte & 0x7f) << shift
93
+ if ((byte & 0x80) === 0) return value
94
+ shift += 7n
95
+ }
96
+ throw new Error('protobuf: varint melebihi 10 byte')
97
+ }
98
+
99
+ /** Decode wire entries without applying any application-specific schema. */
100
+ function parseFields(input) {
101
+ const buffer = Buffer.from(input)
102
+ const state = { offset: 0 }
103
+ const fields = []
104
+ while (state.offset < buffer.length) {
105
+ const rawTag = readVarint(buffer, state)
106
+ const fieldNumber = Number(rawTag >> 3n)
107
+ const wireType = Number(rawTag & 7n)
108
+ if (fieldNumber < 1 || fieldNumber > MAX_FIELD_NUMBER) throw new Error('protobuf: nomor field tidak valid')
109
+ let value
110
+ if (wireType === 0) {
111
+ value = readVarint(buffer, state)
112
+ } else if (wireType === 1) {
113
+ if (state.offset + 8 > buffer.length) throw new Error('protobuf: fixed64 terpotong')
114
+ value = Buffer.from(buffer.subarray(state.offset, state.offset + 8))
115
+ state.offset += 8
116
+ } else if (wireType === 2) {
117
+ const length = readVarint(buffer, state)
118
+ if (length > BigInt(buffer.length - state.offset)) throw new Error('protobuf: panjang field melebihi buffer')
119
+ const end = state.offset + Number(length)
120
+ value = Buffer.from(buffer.subarray(state.offset, end))
121
+ state.offset = end
122
+ } else if (wireType === 5) {
123
+ if (state.offset + 4 > buffer.length) throw new Error('protobuf: fixed32 terpotong')
124
+ value = Buffer.from(buffer.subarray(state.offset, state.offset + 4))
125
+ state.offset += 4
126
+ } else {
127
+ throw new Error(`protobuf: wire type ${wireType} tidak didukung`)
128
+ }
129
+ fields.push({ fieldNumber, wireType, value })
130
+ }
131
+ return fields
132
+ }
133
+
134
+ module.exports = {
135
+ encodeVarint, fieldVarint, fieldBool, fieldBytes, fieldString,
136
+ fieldMessage, fieldFixed32, pack, parseFields
137
+ }
@@ -0,0 +1,155 @@
1
+ 'use strict'
2
+
3
+ const crypto = require('node:crypto')
4
+ const { encodeBinaryNode, decodeBinaryNode, loadTokenTable, makeTokenTables } = require('../wabinary/codec')
5
+
6
+ class QueryTimeoutError extends Error {
7
+ constructor(id, timeoutMs) {
8
+ super(`WABinary IQ ${id} timeout setelah ${timeoutMs} ms`)
9
+ this.name = 'QueryTimeoutError'
10
+ this.code = 'ERR_QUERY_TIMEOUT'
11
+ this.id = id
12
+ this.timeoutMs = timeoutMs
13
+ }
14
+ }
15
+
16
+ class RemoteIQError extends Error {
17
+ constructor(node) {
18
+ const code = node?.attrs?.code ? ` (code ${node.attrs.code})` : ''
19
+ super(`server mengembalikan IQ error${code}`)
20
+ this.name = 'RemoteIQError'
21
+ this.code = 'ERR_REMOTE_IQ'
22
+ this.node = node
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Node-level protocol layer above Noise transport. It does not implement Signal,
28
+ * registration credentials, or authenticated pairing-success verification.
29
+ */
30
+ class NodeProtocol {
31
+ constructor(connection, options = {}) {
32
+ this.connection = connection
33
+ const hasExplicitTokenTable = options.tokens !== undefined && options.tokens !== null
34
+ this.tokens = hasExplicitTokenTable ? options.tokens : loadTokenTable(options.tokenTablePath)
35
+ makeTokenTables(this.tokens) // validate dictionary structure at construction time
36
+ const populated = (this.tokens.single || []).some(Boolean) || (this.tokens.double || []).some((page) => page.some(Boolean))
37
+ if (!populated && options.allowEmptyTokenTable !== true) {
38
+ throw new Error('NodeProtocol membutuhkan token dictionary non-kosong; jalankan npm run refresh:tokens terlebih dahulu (allowEmptyTokenTable hanya untuk tes lokal)')
39
+ }
40
+ if (this.tokens._metadata && this.tokens._metadata.dictVersion !== 3) {
41
+ throw new Error(`NodeProtocol mendukung DictVersion 3, menerima ${this.tokens._metadata.dictVersion}`)
42
+ }
43
+ this.timeoutMs = options.timeoutMs ?? 30000
44
+ this.maxPending = options.maxPending ?? 1024
45
+ this.closeOnDecodeError = options.closeOnDecodeError !== false
46
+ if (!Number.isSafeInteger(this.timeoutMs) || this.timeoutMs < 1) throw new TypeError('timeoutMs harus integer positif')
47
+ if (!Number.isSafeInteger(this.maxPending) || this.maxPending < 1) throw new TypeError('maxPending harus integer positif')
48
+ this.pending = new Map()
49
+ this.closed = false
50
+ this.onFrame = (frame) => this._handleFrame(frame)
51
+ this.onConnectionUpdate = ({ connection: state }) => {
52
+ if (state === 'close') this._rejectAll(new Error('koneksi ditutup sebelum IQ selesai'))
53
+ }
54
+ connection.on('frame', this.onFrame)
55
+ connection.on('connection.update', this.onConnectionUpdate)
56
+ }
57
+
58
+ sendNode(node) {
59
+ if (this.closed) throw new Error('NodeProtocol sudah ditutup')
60
+ const bytes = encodeBinaryNode(node, { tokens: this.tokens })
61
+ this.connection.sendFrame(bytes)
62
+ return bytes.length
63
+ }
64
+
65
+ query(node, options = {}) {
66
+ if (this.closed) return Promise.reject(new Error('NodeProtocol sudah ditutup'))
67
+ if (!node || node.tag !== 'iq') return Promise.reject(new TypeError('query() hanya menerima node IQ'))
68
+ if (this.pending.size >= this.maxPending) return Promise.reject(new Error('batas IQ pending tercapai'))
69
+ const timeoutMs = options.timeoutMs ?? this.timeoutMs
70
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) return Promise.reject(new TypeError('timeoutMs harus integer positif'))
71
+ const attrs = { ...(node.attrs || {}) }
72
+ const id = attrs.id === undefined || attrs.id === null ? this._newId() : String(attrs.id)
73
+ if (this.pending.has(id)) return Promise.reject(new Error(`IQ id duplikat: ${id}`))
74
+ attrs.id = id
75
+ const outgoing = { ...node, attrs }
76
+ if (options.signal?.aborted) return Promise.reject(options.signal.reason || new Error('IQ dibatalkan'))
77
+
78
+ return new Promise((resolve, reject) => {
79
+ const entry = { resolve, reject, timer: null, signal: options.signal, abort: null }
80
+ entry.timer = setTimeout(() => {
81
+ this._dropPending(id)
82
+ reject(new QueryTimeoutError(id, timeoutMs))
83
+ }, timeoutMs)
84
+ if (entry.signal) {
85
+ entry.abort = () => {
86
+ this._dropPending(id)
87
+ reject(entry.signal.reason || new Error('IQ dibatalkan'))
88
+ }
89
+ entry.signal.addEventListener('abort', entry.abort, { once: true })
90
+ }
91
+ this.pending.set(id, entry)
92
+ try { this.sendNode(outgoing) } catch (error) {
93
+ this._dropPending(id)
94
+ reject(error)
95
+ }
96
+ })
97
+ }
98
+
99
+ close(reason = new Error('NodeProtocol ditutup')) {
100
+ if (this.closed) return
101
+ this.closed = true
102
+ this.connection.off('frame', this.onFrame)
103
+ this.connection.off('connection.update', this.onConnectionUpdate)
104
+ this._rejectAll(reason)
105
+ }
106
+
107
+ stats() {
108
+ return { pendingQueries: this.pending.size, maxPending: this.maxPending, closed: this.closed }
109
+ }
110
+
111
+ _newId() {
112
+ return `${Date.now().toString(36)}-${crypto.randomBytes(6).toString('hex')}`
113
+ }
114
+
115
+ _handleFrame(frame) {
116
+ if (this.closed) return
117
+ let node
118
+ try {
119
+ node = decodeBinaryNode(frame, { tokens: this.tokens, requirePrefix: true })
120
+ } catch (error) {
121
+ this.connection._safeEmit('protocol.error', error)
122
+ if (this.closeOnDecodeError) this.connection.failProtocol(error)
123
+ return
124
+ }
125
+ this.connection._safeEmit('node', node)
126
+ this.connection._safeEmit(`node:${node.tag}`, node)
127
+ if (node.tag === 'iq') {
128
+ this.connection._safeEmit('iq', node)
129
+ const id = node.attrs?.id
130
+ const entry = id === undefined ? undefined : this.pending.get(String(id))
131
+ if (entry && (node.attrs?.type === 'result' || node.attrs?.type === 'error')) {
132
+ this._dropPending(String(id))
133
+ if (node.attrs.type === 'error') entry.reject(new RemoteIQError(node))
134
+ else entry.resolve(node)
135
+ }
136
+ }
137
+ }
138
+
139
+ _dropPending(id) {
140
+ const entry = this.pending.get(id)
141
+ if (!entry) return
142
+ clearTimeout(entry.timer)
143
+ if (entry.signal && entry.abort) entry.signal.removeEventListener('abort', entry.abort)
144
+ this.pending.delete(id)
145
+ }
146
+
147
+ _rejectAll(error) {
148
+ for (const [id, entry] of this.pending) {
149
+ this._dropPending(id)
150
+ entry.reject(error)
151
+ }
152
+ }
153
+ }
154
+
155
+ module.exports = { NodeProtocol, QueryTimeoutError, RemoteIQError }
@@ -0,0 +1,25 @@
1
+ 'use strict'
2
+ /**
3
+ * Serialisasi nilai session ke JSON dengan dukungan Buffer/Uint8Array (base64) dan bigint.
4
+ * Dipakai oleh store berbasis teks (file, SQLite, Redis, Mongo).
5
+ */
6
+ function encode(value) {
7
+ return JSON.stringify(value, function (key, v) {
8
+ const raw = this[key] // JSON.stringify sudah memanggil Buffer.toJSON sebelum replacer
9
+ if (raw instanceof Uint8Array) return { $b: Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('base64') }
10
+ if (typeof raw === 'bigint') return { $n: raw.toString() }
11
+ return v
12
+ })
13
+ }
14
+
15
+ function decode(text) {
16
+ return JSON.parse(text, (key, v) => {
17
+ if (v && typeof v === 'object') {
18
+ if (typeof v.$b === 'string' && Object.keys(v).length === 1) return Buffer.from(v.$b, 'base64')
19
+ if (typeof v.$n === 'string' && Object.keys(v).length === 1) return BigInt(v.$n)
20
+ }
21
+ return v
22
+ })
23
+ }
24
+
25
+ module.exports = { encode, decode }
@@ -0,0 +1,107 @@
1
+ 'use strict'
2
+
3
+ const crypto = require('node:crypto')
4
+ const { encode, decode } = require('./codec')
5
+
6
+ const FORMAT = 'kersiosocket:aes-256-gcm:v1'
7
+ const IV_BYTES = 12
8
+ const TAG_BYTES = 16
9
+
10
+ /**
11
+ * Authenticated-encryption wrapper for any compatible session store.
12
+ * Supply a 32-byte master key from a secret manager or environment variable.
13
+ * The wrapper never generates or persists its own master key.
14
+ */
15
+ class EncryptedStore {
16
+ constructor(store, masterKey) {
17
+ if (!store || typeof store.get !== 'function' || typeof store.set !== 'function' || typeof store.delete !== 'function') {
18
+ throw new TypeError('EncryptedStore memerlukan store dengan get/set/delete')
19
+ }
20
+ if (!Buffer.isBuffer(masterKey) && !(masterKey instanceof Uint8Array)) {
21
+ throw new TypeError('masterKey harus Buffer/Uint8Array 32 byte; jangan simpan key di source code')
22
+ }
23
+ const key = Buffer.from(masterKey.buffer, masterKey.byteOffset, masterKey.byteLength)
24
+ if (key.length !== 32) throw new RangeError('masterKey harus tepat 32 byte untuk AES-256-GCM')
25
+ this.store = store
26
+ this.key = Buffer.from(key)
27
+ this.closed = false
28
+ }
29
+
30
+ async get(key) {
31
+ this._assertOpen()
32
+ assertStorageKey(key)
33
+ const envelope = await this.store.get(key)
34
+ if (envelope === undefined) return undefined
35
+ if (!envelope || envelope.format !== FORMAT || typeof envelope.iv !== 'string' || typeof envelope.tag !== 'string' || typeof envelope.data !== 'string') {
36
+ throw new Error(`EncryptedStore: data '${key}' tidak memiliki format terenkripsi yang dikenali; tidak ada fallback plaintext`)
37
+ }
38
+ const iv = decodeBase64Strict(envelope.iv, 'iv')
39
+ const tag = decodeBase64Strict(envelope.tag, 'tag')
40
+ const data = decodeBase64Strict(envelope.data, 'data')
41
+ if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) throw new Error(`EncryptedStore: envelope '${key}' malformed`)
42
+ try {
43
+ const decipher = crypto.createDecipheriv('aes-256-gcm', this.key, iv)
44
+ decipher.setAAD(this._aad(key))
45
+ decipher.setAuthTag(tag)
46
+ const plain = Buffer.concat([decipher.update(data), decipher.final()])
47
+ return decode(plain.toString('utf8'))
48
+ } catch {
49
+ throw new Error(`EncryptedStore: autentikasi/dekripsi gagal untuk '${key}' (key salah atau data diubah)`)
50
+ }
51
+ }
52
+
53
+ async set(key, value) {
54
+ this._assertOpen()
55
+ assertStorageKey(key)
56
+ const iv = crypto.randomBytes(IV_BYTES)
57
+ const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv)
58
+ cipher.setAAD(this._aad(key))
59
+ const plain = Buffer.from(encode(value), 'utf8')
60
+ const data = Buffer.concat([cipher.update(plain), cipher.final()])
61
+ await this.store.set(key, {
62
+ format: FORMAT,
63
+ iv: iv.toString('base64'),
64
+ tag: cipher.getAuthTag().toString('base64'),
65
+ data: data.toString('base64')
66
+ })
67
+ }
68
+
69
+ async delete(key) {
70
+ this._assertOpen()
71
+ assertStorageKey(key)
72
+ await this.store.delete(key)
73
+ }
74
+
75
+ async keys(prefix = '') {
76
+ this._assertOpen()
77
+ if (typeof prefix !== 'string') throw new TypeError('prefix harus string')
78
+ if (typeof this.store.keys !== 'function') throw new Error('store dasar tidak mendukung keys()')
79
+ return this.store.keys(prefix)
80
+ }
81
+
82
+ async close() {
83
+ if (this.closed) return
84
+ this.closed = true
85
+ this.key.fill(0)
86
+ if (typeof this.store.close === 'function') await this.store.close()
87
+ }
88
+
89
+ _assertOpen() {
90
+ if (this.closed) throw new Error('EncryptedStore sudah ditutup')
91
+ }
92
+
93
+ _aad(key) { return Buffer.from(`${FORMAT}\0${key}`, 'utf8') }
94
+ }
95
+
96
+ function assertStorageKey(key) {
97
+ if (typeof key !== 'string' || key.length === 0 || key.includes('\0')) throw new TypeError('key harus string non-kosong tanpa NUL')
98
+ }
99
+
100
+ function decodeBase64Strict(value, name) {
101
+ if (value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
102
+ throw new Error(`EncryptedStore: ${name} bukan base64 valid`)
103
+ }
104
+ return Buffer.from(value, 'base64')
105
+ }
106
+
107
+ module.exports = { EncryptedStore, ENCRYPTED_STORE_FORMAT: FORMAT }
@@ -0,0 +1,61 @@
1
+ 'use strict'
2
+ const { makeSocket } = require('../socket/make')
3
+
4
+ /**
5
+ * Banyak sesi (banyak nomor) dalam satu proses, semuanya memakai satu store.
6
+ *
7
+ * const sessions = createSessionManager({ store: new FileStore('./sessions'), logger })
8
+ * const a = sessions.open('nomor-a') // dibuat sekali, dipakai ulang
9
+ * const b = sessions.open('nomor-b')
10
+ * await sessions.list() // semua sesi yang tersimpan di store
11
+ * sessions.stats() // ringkasan per sesi + total RAM proses
12
+ */
13
+ function createSessionManager({ store, ...defaults } = {}) {
14
+ if (!store) throw new Error('createSessionManager: store wajib diisi')
15
+ const live = new Map()
16
+
17
+ return {
18
+ open(id, overrides = {}) {
19
+ if (!id || /[:/\\]/.test(id)) throw new Error('id sesi tidak valid (tanpa : / \\)')
20
+ let sock = live.get(id)
21
+ if (!sock) {
22
+ sock = makeSocket({ ...defaults, ...overrides, store, sessionId: id })
23
+ live.set(id, sock)
24
+ }
25
+ return sock
26
+ },
27
+
28
+ get: (id) => live.get(id),
29
+
30
+ /** Hentikan koneksi sebuah sesi. Dengan { purge: true } kredensialnya juga dihapus dari store. */
31
+ async close(id, { purge = false } = {}) {
32
+ const sock = live.get(id)
33
+ if (sock) { await sock.ready; sock.end(); live.delete(id) }
34
+ if (purge) await store.delete(`${id}:creds`)
35
+ },
36
+
37
+ async closeAll() {
38
+ await Promise.all([...live.keys()].map((id) => this.close(id)))
39
+ if (store.close) await store.close()
40
+ },
41
+
42
+ async list() {
43
+ if (!store.keys) throw new Error('store ini tidak mendukung keys()')
44
+ const ids = new Set()
45
+ for (const k of await store.keys()) {
46
+ const i = k.indexOf(':')
47
+ if (i > 0 && k.slice(i + 1) === 'creds') ids.add(k.slice(0, i))
48
+ }
49
+ return [...ids]
50
+ },
51
+
52
+ stats() {
53
+ const m = process.memoryUsage()
54
+ const sessions = {}
55
+ for (const [id, s] of live) sessions[id] = s.stats()
56
+ return { sessions, active: live.size, rssMB: +(m.rss / 1048576).toFixed(1), heapUsedMB: +(m.heapUsed / 1048576).toFixed(1) }
57
+ }
58
+ }
59
+ }
60
+
61
+ module.exports = { createSessionManager }
@@ -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 }