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,76 @@
1
+ 'use strict'
2
+ /**
3
+ * Cache LRU dengan batas jumlah entri, batas ukuran (byte) opsional, dan TTL opsional.
4
+ * Memakai urutan sisip Map, jadi get/set O(1) tanpa struktur tambahan.
5
+ *
6
+ * new LRUCache({ max: 1000, maxBytes: 8 * 1024 * 1024, sizeOf: (v) => v.length, ttl: 60000 })
7
+ */
8
+ class LRUCache {
9
+ constructor({ max = 500, maxBytes = Infinity, sizeOf = null, ttl = 0, onEvict = null } = {}) {
10
+ if (!(max > 0)) throw new Error('LRUCache: max harus > 0')
11
+ this.max = max
12
+ this.maxBytes = maxBytes
13
+ this.sizeOf = sizeOf
14
+ this.ttl = ttl
15
+ this.onEvict = onEvict
16
+ this.map = new Map() // key -> { v, size, exp }
17
+ this.bytes = 0
18
+ this.hits = 0
19
+ this.misses = 0
20
+ }
21
+
22
+ get size() { return this.map.size }
23
+
24
+ get(key) {
25
+ const e = this.map.get(key)
26
+ if (!e) { this.misses++; return undefined }
27
+ if (e.exp && e.exp <= Date.now()) { this._drop(key, e, 'expired'); this.misses++; return undefined }
28
+ this.map.delete(key)
29
+ this.map.set(key, e) // pindah ke ujung = paling baru dipakai
30
+ this.hits++
31
+ return e.v
32
+ }
33
+
34
+ has(key) {
35
+ const e = this.map.get(key)
36
+ if (!e) return false
37
+ if (e.exp && e.exp <= Date.now()) { this._drop(key, e, 'expired'); return false }
38
+ return true
39
+ }
40
+
41
+ set(key, v) {
42
+ const old = this.map.get(key)
43
+ if (old) { this.bytes -= old.size; this.map.delete(key) }
44
+ const size = this.sizeOf ? this.sizeOf(v) : 0
45
+ this.map.set(key, { v, size, exp: this.ttl ? Date.now() + this.ttl : 0 })
46
+ this.bytes += size
47
+ while (this.map.size > this.max || this.bytes > this.maxBytes) {
48
+ const [k, e] = this.map.entries().next().value
49
+ if (k === key && this.map.size === 1) break // satu entri raksasa tetap disimpan
50
+ this._drop(k, e, 'evicted')
51
+ }
52
+ return this
53
+ }
54
+
55
+ delete(key) {
56
+ const e = this.map.get(key)
57
+ if (!e) return false
58
+ this.bytes -= e.size
59
+ return this.map.delete(key)
60
+ }
61
+
62
+ clear() { this.map.clear(); this.bytes = 0 }
63
+
64
+ _drop(key, e, reason) {
65
+ this.map.delete(key)
66
+ this.bytes -= e.size
67
+ if (this.onEvict) this.onEvict(key, e.v, reason)
68
+ }
69
+
70
+ stats() {
71
+ const n = this.hits + this.misses
72
+ return { size: this.map.size, bytes: this.bytes, hits: this.hits, misses: this.misses, hitRate: n ? +(this.hits / n).toFixed(3) : 0 }
73
+ }
74
+ }
75
+
76
+ module.exports = { LRUCache }
@@ -0,0 +1,374 @@
1
+ 'use strict'
2
+
3
+ const zlib = require('node:zlib')
4
+ const fs = require('node:fs')
5
+ const path = require('node:path')
6
+ const { TAGS } = require('./tags')
7
+ const { validateTokenTable } = require('./token-source')
8
+
9
+ const DEFAULT_LIMITS = Object.freeze({ maxDepth: 64, maxNodes: 100000, maxBytes: 16 * 1024 * 1024 })
10
+ const NIBBLE_ALPHABET = '0123456789-.'
11
+ const HEX_ALPHABET = '0123456789ABCDEF'
12
+
13
+ function asBuffer(value, label = 'value') {
14
+ if (Buffer.isBuffer(value)) return value
15
+ if (value instanceof Uint8Array) return Buffer.from(value.buffer, value.byteOffset, value.byteLength)
16
+ throw new TypeError(`${label} harus Buffer atau Uint8Array`)
17
+ }
18
+
19
+ function loadTokenTable(filePath = path.join(__dirname, 'tokens.json')) {
20
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'))
21
+ if (parsed._metadata && parsed._metadata.dictVersion !== 3) {
22
+ throw new Error(`WABinary token DictVersion mismatch: codec supports 3, file declares ${parsed._metadata.dictVersion}`)
23
+ }
24
+ if (parsed._metadata?.upstreamBlobSha1 && !/^[0-9a-f]{40}$/.test(parsed._metadata.upstreamBlobSha1)) {
25
+ throw new Error('WABinary token metadata has an invalid source blob SHA-1')
26
+ }
27
+ makeTokenTables(parsed)
28
+ if (parsed._metadata) {
29
+ validateTokenTable({ dictVersion: parsed._metadata.dictVersion, single: parsed.single, double: parsed.double }, { dictVersion: 3 })
30
+ }
31
+ return parsed
32
+ }
33
+
34
+ function makeTokenTables(tokens = {}) {
35
+ const single = tokens.single || []
36
+ const double = tokens.double || [[], [], [], []]
37
+ if (!Array.isArray(single) || !Array.isArray(double) || double.length !== 4 || double.some((p) => !Array.isArray(p))) {
38
+ throw new TypeError('token table harus { single: string[], double: [string[], string[], string[], string[]] }')
39
+ }
40
+ const singleToId = new Map()
41
+ for (let i = 1; i < single.length && i < TAGS.DICTIONARY_0; i++) {
42
+ if (typeof single[i] === 'string' && single[i] && !singleToId.has(single[i])) singleToId.set(single[i], i)
43
+ }
44
+ const doubleToId = new Map()
45
+ for (let page = 0; page < 4; page++) {
46
+ for (let i = 0; i < double[page].length && i <= 255; i++) {
47
+ const token = double[page][i]
48
+ if (typeof token === 'string' && token && !doubleToId.has(token)) doubleToId.set(token, [page, i])
49
+ }
50
+ }
51
+ return { single, double, singleToId, doubleToId }
52
+ }
53
+
54
+ function pushUInt20(n, out) {
55
+ if (!Number.isInteger(n) || n < 0 || n > 0xfffff) throw new RangeError('panjang BINARY_20 di luar rentang')
56
+ out.push((n >>> 16) & 0x0f, (n >>> 8) & 0xff, n & 0xff)
57
+ }
58
+ function readUInt20(buf, state) {
59
+ need(buf, state, 3)
60
+ const a = buf[state.offset++], b = buf[state.offset++], c = buf[state.offset++]
61
+ return ((a & 0x0f) << 16) | (b << 8) | c
62
+ }
63
+ function need(buf, state, n) {
64
+ if (!Number.isSafeInteger(n) || n < 0 || state.offset + n > buf.length) throw new Error('WABinary: data terpotong')
65
+ }
66
+ function encodeListSize(size, out) {
67
+ if (!Number.isInteger(size) || size < 0 || size > 0xffff) throw new RangeError('ukuran list node di luar rentang')
68
+ if (size === 0) out.push(TAGS.LIST_EMPTY)
69
+ else if (size < 256) out.push(TAGS.LIST_8, size)
70
+ else { out.push(TAGS.LIST_16, (size >>> 8) & 0xff, size & 0xff) }
71
+ }
72
+ function encodeLengthAndBytes(bytes, out) {
73
+ if (bytes.length <= 0xff) out.push(TAGS.BINARY_8, bytes.length)
74
+ else if (bytes.length <= 0xfffff) { out.push(TAGS.BINARY_20); pushUInt20(bytes.length, out) }
75
+ else if (bytes.length <= 0x7fffffff) {
76
+ out.push(TAGS.BINARY_32, (bytes.length >>> 24) & 0xff, (bytes.length >>> 16) & 0xff, (bytes.length >>> 8) & 0xff, bytes.length & 0xff)
77
+ } else throw new RangeError('payload biner terlalu besar')
78
+ for (const b of bytes) out.push(b)
79
+ }
80
+
81
+ function packNibble(value, out) {
82
+ if (value.length > TAGS.PACKED_MAX || !/^[0-9.-]+$/.test(value)) return false
83
+ out.push(TAGS.NIBBLE_8)
84
+ const pairs = Math.ceil(value.length / 2)
85
+ out.push(pairs | (value.length % 2 ? 0x80 : 0))
86
+ for (let i = 0; i < value.length; i += 2) {
87
+ const first = NIBBLE_ALPHABET.indexOf(value[i])
88
+ const second = i + 1 < value.length ? NIBBLE_ALPHABET.indexOf(value[i + 1]) : 15
89
+ out.push((first << 4) | second)
90
+ }
91
+ return true
92
+ }
93
+ function packHex(value, out) {
94
+ if (value.length > TAGS.PACKED_MAX || !/^[0-9A-F]+$/.test(value)) return false
95
+ out.push(TAGS.HEX_8)
96
+ const pairs = Math.ceil(value.length / 2)
97
+ out.push(pairs | (value.length % 2 ? 0x80 : 0))
98
+ for (let i = 0; i < value.length; i += 2) {
99
+ const first = Number.parseInt(value[i], 16)
100
+ const second = i + 1 < value.length ? Number.parseInt(value[i + 1], 16) : 15
101
+ out.push((first << 4) | second)
102
+ }
103
+ return true
104
+ }
105
+
106
+ function parseJid(value) {
107
+ const at = value.indexOf('@')
108
+ if (at < 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return null
109
+ const local = value.slice(0, at)
110
+ const server = value.slice(at + 1)
111
+ const match = /^([^:@_]*)(?:_(\d+))?(?::(\d+))?$/.exec(local)
112
+ if (!match) return null
113
+ const user = match[1]
114
+ const agent = match[2] === undefined ? 0 : Number(match[2])
115
+ const device = match[3] === undefined ? undefined : Number(match[3])
116
+ if (device !== undefined && (!Number.isInteger(device) || device < 0 || device > 255)) return null
117
+ if (!Number.isSafeInteger(agent) || agent < 0 || agent > 255) return null
118
+ return { user, server, agent, device, local }
119
+ }
120
+ function encodeJid(value, out, table) {
121
+ const jid = parseJid(value)
122
+ if (!jid) return false
123
+ if (jid.device !== undefined && ['s.whatsapp.net', 'lid', 'hosted', 'hosted.lid'].includes(jid.server)) {
124
+ const mapped = { 's.whatsapp.net': 0, lid: 1, hosted: 128, 'hosted.lid': 129 }[jid.server]
125
+ const domainType = jid.agent || mapped
126
+ out.push(TAGS.AD_JID, domainType, jid.device)
127
+ encodeString(jid.user, out, table, { jidPairs: false, allowJid: false })
128
+ return true
129
+ }
130
+ // Match the reference's JID_PAIR shape: encode decoded user and server separately;
131
+ // an empty user is represented by LIST_EMPTY, not an empty raw string.
132
+ out.push(TAGS.JID_PAIR)
133
+ if (jid.user.length) encodeString(jid.user, out, table, { jidPairs: false, allowJid: false })
134
+ else out.push(TAGS.LIST_EMPTY)
135
+ encodeString(jid.server, out, table, { jidPairs: false, allowJid: false })
136
+ return true
137
+ }
138
+
139
+ function encodeString(value, out, table, options = {}) {
140
+ if (typeof value !== 'string') throw new TypeError('tag/atribut/string content harus string')
141
+ const token = table.singleToId.get(value)
142
+ if (token !== undefined) { out.push(token); return }
143
+ const double = table.doubleToId.get(value)
144
+ if (double) { out.push(TAGS.DICTIONARY_0 + double[0], double[1]); return }
145
+ if (options.jidPairs !== false && options.allowJid !== false && encodeJid(value, out, table)) return
146
+ if (packNibble(value, out) || packHex(value, out)) return
147
+ encodeLengthAndBytes(Buffer.from(value, 'utf8'), out)
148
+ }
149
+
150
+ function encodeNode(node, out, table, depth, limits, counter) {
151
+ if (depth > limits.maxDepth) throw new RangeError('WABinary: kedalaman node melampaui batas')
152
+ if (++counter.nodes > limits.maxNodes) throw new RangeError('WABinary: jumlah node melampaui batas')
153
+ if (!node || typeof node !== 'object' || Array.isArray(node)) throw new TypeError('node harus objek')
154
+ if (typeof node.tag !== 'string' || node.tag.length === 0) throw new TypeError('node.tag wajib string non-kosong')
155
+ const attrsObject = node.attrs || {}
156
+ if (!attrsObject || typeof attrsObject !== 'object' || Array.isArray(attrsObject)) throw new TypeError('node.attrs harus objek')
157
+ const entries = Object.entries(attrsObject).filter(([, value]) => value !== undefined && value !== null)
158
+ const content = node.content
159
+ const hasContent = content !== undefined && content !== null
160
+ const isChildren = Array.isArray(content)
161
+ if (hasContent && !isChildren && typeof content !== 'string' && !Buffer.isBuffer(content) && !(content instanceof Uint8Array)) {
162
+ throw new TypeError(`WABinary: tipe content node tidak didukung: ${typeof content}`)
163
+ }
164
+ const listSize = 1 + entries.length * 2 + (hasContent ? 1 : 0)
165
+ encodeListSize(listSize, out)
166
+ encodeString(node.tag, out, table)
167
+ for (const [key, value] of entries) {
168
+ if (typeof value !== 'string') throw new TypeError(`atribut ${key} harus string`)
169
+ encodeString(key, out, table)
170
+ encodeString(value, out, table)
171
+ }
172
+ if (!hasContent) return
173
+ if (isChildren) {
174
+ encodeListSize(content.length, out)
175
+ for (const child of content) encodeNode(child, out, table, depth + 1, limits, counter)
176
+ } else if (typeof content === 'string') encodeString(content, out, table)
177
+ else encodeLengthAndBytes(asBuffer(content, 'content'), out)
178
+ }
179
+
180
+ /**
181
+ * Encode a WABinary node. By default the standard 0x00 uncompressed node prefix
182
+ * is included. Set withPrefix:false only for raw codec fixtures, not socket sends.
183
+ * compress:true emits 0x02 followed by a zlib-compressed node body.
184
+ */
185
+ function encodeBinaryNode(node, options = {}) {
186
+ const table = makeTokenTables(options.tokens)
187
+ const limits = { ...DEFAULT_LIMITS, ...(options.limits || {}) }
188
+ validateLimits(limits)
189
+ const out = []
190
+ encodeNode(node, out, table, 0, limits, { nodes: 0 })
191
+ const body = Buffer.from(out)
192
+ if (body.length > limits.maxBytes) throw new RangeError('WABinary: hasil melebihi maxBytes')
193
+ let result
194
+ if (options.compress) result = Buffer.concat([Buffer.from([0x02]), zlib.deflateSync(body, options.zlibOptions)])
195
+ else if (options.withPrefix !== false) result = Buffer.concat([Buffer.from([0x00]), body])
196
+ else result = body
197
+ if (result.length > limits.maxBytes) throw new RangeError('WABinary: hasil melebihi maxBytes')
198
+ return result
199
+ }
200
+
201
+ function validateLimits(limits) {
202
+ for (const name of ['maxDepth', 'maxNodes', 'maxBytes']) {
203
+ if (!Number.isSafeInteger(limits[name]) || limits[name] < 1) throw new TypeError(`WABinary limits.${name} harus integer positif`)
204
+ }
205
+ }
206
+
207
+ function readByte(buf, state) { need(buf, state, 1); return buf[state.offset++] }
208
+ function readUInt16(buf, state) { need(buf, state, 2); const n = buf.readUInt16BE(state.offset); state.offset += 2; return n }
209
+ function readLength(buf, state, marker) {
210
+ if (marker === TAGS.BINARY_8) return readByte(buf, state)
211
+ if (marker === TAGS.BINARY_20) return readUInt20(buf, state)
212
+ if (marker === TAGS.BINARY_32) {
213
+ need(buf, state, 4)
214
+ const n = buf.readUInt32BE(state.offset); state.offset += 4
215
+ if (n > 0x7fffffff) throw new RangeError('WABinary: panjang binary terlalu besar')
216
+ return n
217
+ }
218
+ throw new Error(`WABinary: marker panjang tidak valid ${marker}`)
219
+ }
220
+ function readRawString(buf, state, length) {
221
+ need(buf, state, length)
222
+ const s = buf.toString('utf8', state.offset, state.offset + length)
223
+ state.offset += length
224
+ return s
225
+ }
226
+ function unpackNibble(n) {
227
+ if (n >= 0 && n <= 9) return String(n)
228
+ if (n === 10) return '-'
229
+ if (n === 11) return '.'
230
+ if (n === 15) return '\0'
231
+ throw new Error(`WABinary: nibble tidak valid ${n}`)
232
+ }
233
+ function unpackHex(n) {
234
+ if (n >= 0 && n <= 15) return HEX_ALPHABET[n]
235
+ throw new Error(`WABinary: hex nibble tidak valid ${n}`)
236
+ }
237
+ function readPacked8(buf, state, marker) {
238
+ const packedLength = readByte(buf, state)
239
+ const odd = (packedLength & 0x80) !== 0
240
+ const byteLength = packedLength & 0x7f
241
+ need(buf, state, byteLength)
242
+ let result = ''
243
+ for (let i = 0; i < byteLength; i++) {
244
+ const byte = buf[state.offset++]
245
+ result += marker === TAGS.HEX_8 ? unpackHex(byte >>> 4) : unpackNibble(byte >>> 4)
246
+ const low = byte & 0x0f
247
+ result += marker === TAGS.HEX_8 ? unpackHex(low) : unpackNibble(low)
248
+ }
249
+ if (odd) {
250
+ if (!result.endsWith('\0')) throw new Error('WABinary: filler nibble packed tidak valid')
251
+ result = result.slice(0, -1)
252
+ } else if (result.includes('\0')) throw new Error('WABinary: filler nibble tidak diizinkan di tengah string')
253
+ return result
254
+ }
255
+ function readListSize(buf, state, marker = readByte(buf, state)) {
256
+ if (marker === TAGS.LIST_EMPTY) return 0
257
+ if (marker === TAGS.LIST_8) return readByte(buf, state)
258
+ if (marker === TAGS.LIST_16) return readUInt16(buf, state)
259
+ throw new Error(`WABinary: marker list tidak valid ${marker}`)
260
+ }
261
+ function decodeString(buf, state, table, marker = readByte(buf, state)) {
262
+ if (marker > 0 && marker < TAGS.DICTIONARY_0) {
263
+ const value = table.single[marker]
264
+ if (typeof value !== 'string' || !value) throw new Error(`WABinary: token tunggal ${marker} tidak ada di tabel`)
265
+ return value
266
+ }
267
+ if (marker >= TAGS.DICTIONARY_0 && marker <= TAGS.DICTIONARY_3) {
268
+ const page = marker - TAGS.DICTIONARY_0
269
+ const id = readByte(buf, state)
270
+ const value = table.double[page][id]
271
+ if (typeof value !== 'string' || !value) throw new Error(`WABinary: token dictionary ${page}:${id} tidak ada di tabel`)
272
+ return value
273
+ }
274
+ if (marker === TAGS.LIST_EMPTY) return ''
275
+ if (marker === TAGS.BINARY_8 || marker === TAGS.BINARY_20 || marker === TAGS.BINARY_32) return readRawString(buf, state, readLength(buf, state, marker))
276
+ if (marker === TAGS.JID_PAIR) {
277
+ const user = decodeString(buf, state, table)
278
+ const server = decodeString(buf, state, table)
279
+ if (!server) throw new Error('WABinary: JID_PAIR tanpa server')
280
+ return `${user}@${server}`
281
+ }
282
+ if (marker === TAGS.AD_JID) {
283
+ const domainType = readByte(buf, state)
284
+ const device = readByte(buf, state)
285
+ const user = decodeString(buf, state, table)
286
+ const server = ({ 0: 's.whatsapp.net', 1: 'lid', 128: 'hosted', 129: 'hosted.lid' })[domainType]
287
+ if (!server) throw new Error(`WABinary: domainType AD_JID tidak dikenal ${domainType}`)
288
+ return device === 0 ? `${user}@${server}` : `${user}:${device}@${server}`
289
+ }
290
+ if (marker === TAGS.FB_JID) {
291
+ const user = decodeString(buf, state, table)
292
+ const device = readUInt16(buf, state)
293
+ const server = decodeString(buf, state, table)
294
+ if (!server) throw new Error('WABinary: FB_JID tanpa server')
295
+ return `${user}:${device}@${server}`
296
+ }
297
+ if (marker === TAGS.INTEROP_JID) {
298
+ const user = decodeString(buf, state, table)
299
+ const device = readUInt16(buf, state)
300
+ const integrator = readUInt16(buf, state)
301
+ let server = 'interop'
302
+ // The server suffix is optional in the observed wire format.
303
+ if (state.offset < buf.length) {
304
+ const before = state.offset
305
+ try {
306
+ server = decodeString(buf, state, table)
307
+ } catch {
308
+ state.offset = before
309
+ }
310
+ }
311
+ return `${integrator}-${user}:${device}@${server}`
312
+ }
313
+ if (marker === TAGS.HEX_8 || marker === TAGS.NIBBLE_8) return readPacked8(buf, state, marker)
314
+ throw new Error(`WABinary: string marker tidak dikenal ${marker}`)
315
+ }
316
+ function decodeContent(buf, state, table, depth, limits) {
317
+ const marker = readByte(buf, state)
318
+ if (marker === TAGS.LIST_EMPTY || marker === TAGS.LIST_8 || marker === TAGS.LIST_16) {
319
+ const size = readListSize(buf, state, marker)
320
+ if (state.nodes + size > limits.maxNodes) throw new RangeError('WABinary: jumlah node melampaui batas')
321
+ const children = []
322
+ for (let i = 0; i < size; i++) children.push(decodeNode(buf, state, table, depth + 1, limits))
323
+ return children
324
+ }
325
+ if (marker === TAGS.BINARY_8 || marker === TAGS.BINARY_20 || marker === TAGS.BINARY_32) {
326
+ const length = readLength(buf, state, marker)
327
+ need(buf, state, length)
328
+ const data = Buffer.from(buf.subarray(state.offset, state.offset + length)); state.offset += length
329
+ return data
330
+ }
331
+ return decodeString(buf, state, table, marker)
332
+ }
333
+ function decodeNode(buf, state, table, depth, limits) {
334
+ if (depth > limits.maxDepth) throw new RangeError('WABinary: kedalaman node melampaui batas')
335
+ if (++state.nodes > limits.maxNodes) throw new RangeError('WABinary: jumlah node melampaui batas')
336
+ const size = readListSize(buf, state)
337
+ if (size < 1) throw new Error('WABinary: node list kosong')
338
+ if (size % 2 === 0 && size < 2) throw new Error('WABinary: ukuran node tidak valid')
339
+ const tag = decodeString(buf, state, table)
340
+ if (!tag) throw new Error('WABinary: tag node kosong')
341
+ const attrCount = Math.floor((size - 1) / 2)
342
+ const attrs = Object.create(null)
343
+ for (let i = 0; i < attrCount; i++) {
344
+ const key = decodeString(buf, state, table)
345
+ const value = decodeString(buf, state, table)
346
+ if (Object.hasOwn(attrs, key)) throw new Error(`WABinary: atribut duplikat ${key}`)
347
+ attrs[key] = value
348
+ }
349
+ let content
350
+ if (size % 2 === 0) content = decodeContent(buf, state, table, depth, limits)
351
+ return { tag, attrs, ...(content === undefined ? {} : { content }) }
352
+ }
353
+
354
+ function decodeBinaryNode(input, options = {}) {
355
+ let buf = asBuffer(input, 'input')
356
+ const limits = { ...DEFAULT_LIMITS, ...(options.limits || {}) }
357
+ validateLimits(limits)
358
+ if (buf.length > limits.maxBytes) throw new RangeError('WABinary: input melebihi maxBytes')
359
+ if (!buf.length) throw new Error('WABinary: input kosong')
360
+ if (options.decompress !== false) {
361
+ const prefix = buf[0]
362
+ if (prefix === 0x02) buf = zlib.inflateSync(buf.subarray(1), { maxOutputLength: limits.maxBytes })
363
+ else if (prefix === 0x00) buf = buf.subarray(1)
364
+ else if (options.requirePrefix === true) throw new Error(`WABinary: prefix node tidak dikenal ${prefix}`)
365
+ }
366
+ if (buf.length > limits.maxBytes) throw new RangeError('WABinary: hasil dekompresi melebihi maxBytes')
367
+ const table = makeTokenTables(options.tokens)
368
+ const state = { offset: 0, nodes: 0 }
369
+ const node = decodeNode(buf, state, table, 0, limits)
370
+ if (state.offset !== buf.length) throw new Error(`WABinary: ${buf.length - state.offset} byte tersisa setelah node`)
371
+ return node
372
+ }
373
+
374
+ module.exports = { encodeBinaryNode, decodeBinaryNode, makeTokenTables, loadTokenTable, TAGS, DEFAULT_LIMITS }
@@ -0,0 +1,25 @@
1
+ 'use strict'
2
+
3
+ // WABinary wire markers. Dictionary entries live in the versioned token-data file.
4
+ const TAGS = Object.freeze({
5
+ LIST_EMPTY: 0,
6
+ STREAM_END: 2,
7
+ DICTIONARY_0: 236,
8
+ DICTIONARY_1: 237,
9
+ DICTIONARY_2: 238,
10
+ DICTIONARY_3: 239,
11
+ INTEROP_JID: 245,
12
+ FB_JID: 246,
13
+ AD_JID: 247,
14
+ LIST_8: 248,
15
+ LIST_16: 249,
16
+ JID_PAIR: 250,
17
+ HEX_8: 251,
18
+ BINARY_8: 252,
19
+ BINARY_20: 253,
20
+ BINARY_32: 254,
21
+ NIBBLE_8: 255,
22
+ PACKED_MAX: 127
23
+ })
24
+
25
+ module.exports = { TAGS }
@@ -0,0 +1,146 @@
1
+ 'use strict'
2
+
3
+ // Small parser for the version-pinned token data source. This file does not
4
+ // implement WABinary; it only converts the source's token arrays to JSON data.
5
+ function readGoString(source, start) {
6
+ if (source[start] !== '"') throw new Error('Go token parser: expected string literal')
7
+ let i = start + 1
8
+ let value = ''
9
+ while (i < source.length) {
10
+ const char = source[i++]
11
+ if (char === '"') return { value, next: i }
12
+ if (char !== '\\') {
13
+ value += char
14
+ continue
15
+ }
16
+ if (i >= source.length) throw new Error('Go token parser: incomplete escape')
17
+ const escape = source[i++]
18
+ const simple = {
19
+ a: '\x07', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v',
20
+ '\\': '\\', '"': '"', "'": "'"
21
+ }
22
+ if (Object.hasOwn(simple, escape)) {
23
+ value += simple[escape]
24
+ continue
25
+ }
26
+ if (escape === 'x' || escape === 'u' || escape === 'U') {
27
+ const digits = escape === 'x' ? 2 : escape === 'u' ? 4 : 8
28
+ const chunk = source.slice(i, i + digits)
29
+ if (chunk.length !== digits || !new RegExp(`^[0-9a-fA-F]{${digits}}$`).test(chunk)) {
30
+ throw new Error('Go token parser: invalid hexadecimal escape')
31
+ }
32
+ const codePoint = Number.parseInt(chunk, 16)
33
+ if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) {
34
+ throw new Error('Go token parser: invalid Unicode code point')
35
+ }
36
+ value += String.fromCodePoint(codePoint)
37
+ i += digits
38
+ continue
39
+ }
40
+ if (/[0-7]/.test(escape)) {
41
+ const rest = source.slice(i, i + 2)
42
+ if (rest.length !== 2 || !/^[0-7]{2}$/.test(rest)) throw new Error('Go token parser: invalid octal escape')
43
+ value += String.fromCodePoint(Number.parseInt(escape + rest, 8))
44
+ i += 2
45
+ continue
46
+ }
47
+ throw new Error(`Go token parser: unsupported escape \\${escape}`)
48
+ }
49
+ throw new Error('Go token parser: unterminated string literal')
50
+ }
51
+
52
+ function findAssignmentBrace(source, name) {
53
+ const match = new RegExp(`\\b${name}\\s*=`).exec(source)
54
+ if (!match) throw new Error(`Go token parser: assignment ${name} not found`)
55
+ const brace = source.indexOf('{', match.index + match[0].length)
56
+ if (brace < 0) throw new Error(`Go token parser: array ${name} has no opening brace`)
57
+ return brace
58
+ }
59
+
60
+ function parseSingleArray(source) {
61
+ let i = findAssignmentBrace(source, 'SingleByteTokens') + 1
62
+ let depth = 1
63
+ const values = []
64
+ while (i < source.length && depth > 0) {
65
+ const ch = source[i]
66
+ if (ch === '"') {
67
+ const parsed = readGoString(source, i)
68
+ values.push(parsed.value)
69
+ i = parsed.next
70
+ continue
71
+ }
72
+ if (ch === '{') depth++
73
+ if (ch === '}') depth--
74
+ i++
75
+ }
76
+ if (depth !== 0) throw new Error('Go token parser: unterminated SingleByteTokens array')
77
+ return values
78
+ }
79
+
80
+ function parseDoubleArray(source) {
81
+ let i = findAssignmentBrace(source, 'DoubleByteTokens') + 1
82
+ let depth = 1
83
+ let page = null
84
+ const pages = []
85
+ while (i < source.length && depth > 0) {
86
+ const ch = source[i]
87
+ if (ch === '"') {
88
+ const parsed = readGoString(source, i)
89
+ if (depth === 2) {
90
+ if (!page) throw new Error('Go token parser: token outside dictionary page')
91
+ page.push(parsed.value)
92
+ }
93
+ i = parsed.next
94
+ continue
95
+ }
96
+ if (ch === '{') {
97
+ depth++
98
+ if (depth === 2) page = []
99
+ i++
100
+ continue
101
+ }
102
+ if (ch === '}') {
103
+ if (depth === 2) {
104
+ if (!page) throw new Error('Go token parser: missing dictionary page')
105
+ pages.push(page)
106
+ page = null
107
+ }
108
+ depth--
109
+ i++
110
+ continue
111
+ }
112
+ i++
113
+ }
114
+ if (depth !== 0) throw new Error('Go token parser: unterminated DoubleByteTokens array')
115
+ return pages
116
+ }
117
+
118
+ function parseGoTokenTable(source) {
119
+ if (typeof source !== 'string' || source.length === 0) throw new TypeError('Go token source must be non-empty text')
120
+ const versionMatch = /\bconst\s+DictVersion\s*=\s*(\d+)\b/.exec(source)
121
+ if (!versionMatch) throw new Error('Go token parser: DictVersion not found')
122
+ return {
123
+ dictVersion: Number(versionMatch[1]),
124
+ single: parseSingleArray(source),
125
+ double: parseDoubleArray(source)
126
+ }
127
+ }
128
+
129
+ function validateTokenTable(table, expected = {}) {
130
+ if (!table || !Array.isArray(table.single) || !Array.isArray(table.double)) throw new TypeError('token data is missing single/double arrays')
131
+ if (expected.dictVersion !== undefined && table.dictVersion !== expected.dictVersion) {
132
+ throw new Error(`DictVersion mismatch: expected ${expected.dictVersion}, received ${table.dictVersion}`)
133
+ }
134
+ if (table.single.length !== 236 || table.single[0] !== '') {
135
+ throw new Error(`single-byte token table shape unexpected: expected 236 entries with empty index 0, received ${table.single.length}`)
136
+ }
137
+ if (table.double.length !== 4 || table.double.some((page) => !Array.isArray(page) || page.length !== 256)) {
138
+ throw new Error('double-byte token table shape unexpected: expected four pages of 256 entries')
139
+ }
140
+ if (table.single.some((entry) => typeof entry !== 'string') || table.double.some((page) => page.some((entry) => typeof entry !== 'string'))) {
141
+ throw new TypeError('token table entries must all be strings')
142
+ }
143
+ return true
144
+ }
145
+
146
+ module.exports = { parseGoTokenTable, validateTokenTable }