hbsig 0.0.1

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 (44) hide show
  1. package/cjs/bin_to_str.js +44 -0
  2. package/cjs/collect-body-keys.js +470 -0
  3. package/cjs/encode-array-item.js +110 -0
  4. package/cjs/encode-utils.js +236 -0
  5. package/cjs/encode.js +1318 -0
  6. package/cjs/erl_json.js +317 -0
  7. package/cjs/erl_str.js +1037 -0
  8. package/cjs/flat.js +222 -0
  9. package/cjs/http-message-signatures/httpbis.js +489 -0
  10. package/cjs/http-message-signatures/index.js +25 -0
  11. package/cjs/http-message-signatures/structured-header.js +129 -0
  12. package/cjs/httpsig.js +716 -0
  13. package/cjs/httpsig2.js +1160 -0
  14. package/cjs/id.js +470 -0
  15. package/cjs/index.js +63 -0
  16. package/cjs/send.js +194 -0
  17. package/cjs/signer-utils.js +617 -0
  18. package/cjs/signer.js +606 -0
  19. package/cjs/structured.js +296 -0
  20. package/cjs/test.js +27 -0
  21. package/cjs/utils.js +42 -0
  22. package/esm/bin_to_str.js +46 -0
  23. package/esm/collect-body-keys.js +436 -0
  24. package/esm/encode-array-item.js +112 -0
  25. package/esm/encode-utils.js +185 -0
  26. package/esm/encode.js +1219 -0
  27. package/esm/erl_json.js +289 -0
  28. package/esm/erl_str.js +1139 -0
  29. package/esm/flat.js +196 -0
  30. package/esm/http-message-signatures/httpbis.js +438 -0
  31. package/esm/http-message-signatures/index.js +4 -0
  32. package/esm/http-message-signatures/structured-header.js +105 -0
  33. package/esm/httpsig.js +658 -0
  34. package/esm/httpsig2.js +1097 -0
  35. package/esm/id.js +459 -0
  36. package/esm/index.js +4 -0
  37. package/esm/package.json +3 -0
  38. package/esm/send.js +124 -0
  39. package/esm/signer-utils.js +494 -0
  40. package/esm/signer.js +452 -0
  41. package/esm/structured.js +269 -0
  42. package/esm/test.js +6 -0
  43. package/esm/utils.js +28 -0
  44. package/package.json +28 -0
package/esm/signer.js ADDED
@@ -0,0 +1,452 @@
1
+ import { httpsig_from, httpsig_to } from "./httpsig.js"
2
+ import { structured_from, structured_to } from "./structured.js"
3
+ import { erl_json_from, erl_json_to, normalize } from "./erl_json.js"
4
+ import { enc } from "./encode.js"
5
+ import { isBytes } from "./encode-utils.js"
6
+ import { createSigner } from "@permaweb/aoconnect"
7
+ import { toHttpSigner } from "./send.js"
8
+
9
+ // Export verify from signer-utils.js for compatibility
10
+ export { verify } from "./signer-utils.js"
11
+
12
+ // Helper to check if an array contains binary data
13
+ const arrayHasBinaryData = arr => {
14
+ if (!Array.isArray(arr)) return false
15
+
16
+ return arr.some(item => {
17
+ if (isBytes(item)) return true
18
+ if (Array.isArray(item)) return arrayHasBinaryData(item)
19
+ return false
20
+ })
21
+ }
22
+
23
+ // Helper to check if a string contains non-printable characters
24
+ const hasNonPrintableChars = str => {
25
+ if (typeof str !== "string") return false
26
+
27
+ for (let i = 0; i < str.length; i++) {
28
+ const code = str.charCodeAt(i)
29
+ // Allow only printable ASCII (32-126)
30
+ // Note: tabs (9), newlines (10), and carriage returns (13) are not allowed in HTTP headers
31
+ if (code < 32 || code > 126) {
32
+ return true
33
+ }
34
+ }
35
+ return false
36
+ }
37
+
38
+ const isValid = encoded => {
39
+ if (!encoded || typeof encoded !== "object") return false
40
+
41
+ // Check if all header values are valid for HTTP headers
42
+ for (const [key, value] of Object.entries(encoded)) {
43
+ if (key === "body") {
44
+ // Body can be string, Buffer, or undefined
45
+ if (
46
+ value !== undefined &&
47
+ typeof value !== "string" &&
48
+ !Buffer.isBuffer(value)
49
+ ) {
50
+ return false
51
+ }
52
+ } else {
53
+ // All other fields (headers) must be strings or numbers
54
+ if (typeof value !== "string" && typeof value !== "number") {
55
+ // Check for Buffer in headers - this will fail HTTP signing
56
+ if (Buffer.isBuffer(value) || isBytes(value)) {
57
+ return false
58
+ }
59
+ return false
60
+ }
61
+
62
+ // Check if string contains non-printable characters
63
+ if (typeof value === "string" && hasNonPrintableChars(value)) {
64
+ return false
65
+ }
66
+ }
67
+ }
68
+
69
+ return true
70
+ }
71
+
72
+ // Check if object contains any binary data or arrays with binary data
73
+ const hasBinaryData = obj => {
74
+ for (const [key, value] of Object.entries(obj)) {
75
+ if (key === "path") continue
76
+
77
+ if (isBytes(value)) {
78
+ return true
79
+ } else if (Array.isArray(value) && arrayHasBinaryData(value)) {
80
+ return true
81
+ } else if (
82
+ typeof value === "object" &&
83
+ value !== null &&
84
+ !Array.isArray(value)
85
+ ) {
86
+ // Check nested objects
87
+ for (const [k, v] of Object.entries(value)) {
88
+ if (isBytes(v)) {
89
+ return true
90
+ } else if (Array.isArray(v) && arrayHasBinaryData(v)) {
91
+ return true
92
+ }
93
+ }
94
+ }
95
+ }
96
+ return false
97
+ }
98
+
99
+ // Helper to check if value is a simple array that should use structured fields
100
+ const isSimpleArray = value => {
101
+ if (!Array.isArray(value)) return false
102
+
103
+ return value.every(item => {
104
+ // Simple types that can be in structured field lists
105
+ if (typeof item === "string") return true
106
+ if (typeof item === "number") return true
107
+ if (typeof item === "boolean") return true
108
+ if (isBytes(item)) return true
109
+
110
+ // Complex types cannot be in structured field lists
111
+ if (item && typeof item === "object") return false
112
+
113
+ return true
114
+ })
115
+ }
116
+
117
+ // Helper to encode array as structured field list
118
+ const encodeAsStructuredFieldList = arr => {
119
+ return arr
120
+ .map(item => {
121
+ if (typeof item === "string") {
122
+ // String values are quoted
123
+ return `"${item.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
124
+ } else if (typeof item === "number") {
125
+ // Numbers are bare
126
+ return String(item)
127
+ } else if (typeof item === "boolean") {
128
+ // Booleans use ?0 or ?1
129
+ return item ? "?1" : "?0"
130
+ } else if (isBytes(item)) {
131
+ // Binary data as byte sequences
132
+ const buffer = Buffer.isBuffer(item) ? item : Buffer.from(item)
133
+ return `:${buffer.toString("base64")}:`
134
+ } else {
135
+ // Fallback
136
+ return `"${String(item)}"`
137
+ }
138
+ })
139
+ .join(", ")
140
+ }
141
+
142
+ const smartSign = async (obj, path) => {
143
+ try {
144
+ // Filter out undefined values
145
+ const filtered = filterUndefined(obj)
146
+
147
+ // Check if we can encode everything as headers (no multipart needed)
148
+ let canUseSimpleEncoding = true
149
+ let hasBodyField = false
150
+
151
+ for (const [key, value] of Object.entries(filtered)) {
152
+ if (key === "path") continue
153
+
154
+ // Check if this is the "body" field
155
+ if (key === "body" || key === "data") {
156
+ hasBodyField = true
157
+ // Only use multipart if body/data is actually binary
158
+ if (isBytes(value)) {
159
+ canUseSimpleEncoding = false
160
+ break
161
+ }
162
+ }
163
+
164
+ // Complex nested objects need multipart
165
+ if (
166
+ value &&
167
+ typeof value === "object" &&
168
+ !Array.isArray(value) &&
169
+ !isBytes(value)
170
+ ) {
171
+ if (Object.keys(value).length > 0) {
172
+ canUseSimpleEncoding = false
173
+ break
174
+ }
175
+ }
176
+
177
+ // Arrays with complex items need multipart
178
+ if (Array.isArray(value) && !isSimpleArray(value)) {
179
+ canUseSimpleEncoding = false
180
+ break
181
+ }
182
+ }
183
+
184
+ if (canUseSimpleEncoding) {
185
+ // Build a simple message that won't trigger multipart
186
+ const message = { path: path || filtered.path || "/~wao@1.0/httpsig" }
187
+ const types = []
188
+
189
+ for (const [key, value] of Object.entries(filtered)) {
190
+ if (key === "path") continue
191
+
192
+ if (value === "") {
193
+ types.push(`${key}="empty-binary"`)
194
+ } else if (Array.isArray(value) && value.length === 0) {
195
+ types.push(`${key}="empty-list"`)
196
+ } else if (
197
+ value &&
198
+ typeof value === "object" &&
199
+ Object.keys(value).length === 0
200
+ ) {
201
+ types.push(`${key}="empty-message"`)
202
+ } else if (isSimpleArray(value)) {
203
+ types.push(`${key}="list"`)
204
+ message[key] = encodeAsStructuredFieldList(value)
205
+ } else if (typeof value === "number") {
206
+ types.push(
207
+ `${key}="${Number.isInteger(value) ? "integer" : "float"}"`
208
+ )
209
+ message[key] = String(value)
210
+ } else if (typeof value === "boolean") {
211
+ types.push(`${key}="atom"`)
212
+ message[key] = String(value)
213
+ } else if (value === null || value === undefined) {
214
+ types.push(`${key}="atom"`)
215
+ message[key] = String(value)
216
+ } else if (typeof value === "string") {
217
+ message[key] = value
218
+ }
219
+ }
220
+
221
+ if (types.length > 0) {
222
+ message["ao-types"] = types.join(", ")
223
+ }
224
+
225
+ return httpsig_to(message)
226
+ }
227
+
228
+ // For complex structures that need multipart, use enc()
229
+ const normalized = normalize({
230
+ ...filtered,
231
+ path: path || "/~wao@1.0/httpsig",
232
+ })
233
+ const result = await enc(normalized)
234
+
235
+ // enc() returns { headers: {...}, body: ... }
236
+ // We need to flatten this for httpsig_to
237
+ const flattened = {
238
+ ...result.headers,
239
+ body: result.body,
240
+ path: normalized.path || path || "/~wao@1.0/httpsig",
241
+ }
242
+
243
+ // httpsig_to expects the structured format
244
+ const encoded = httpsig_to(flattened)
245
+
246
+ return encoded
247
+ } catch (error) {
248
+ console.error("Encoding failed:", error)
249
+
250
+ // Fallback: create a simple structure
251
+ const result = { path: path || "/~wao@1.0/httpsig" }
252
+
253
+ for (const [key, value] of Object.entries(obj)) {
254
+ if (key === "path") continue
255
+
256
+ if (!isBytes(value) && value !== undefined) {
257
+ result[key] = value
258
+ }
259
+ }
260
+
261
+ return result
262
+ }
263
+ }
264
+
265
+ // Internal sign function that matches the original signature
266
+ const _sign = async (obj, path) => {
267
+ // Filter out undefined values before processing
268
+ const filtered = filterUndefined(obj)
269
+
270
+ // If object contains binary data, use enc() directly
271
+ if (hasBinaryData(filtered)) {
272
+ return await smartSign(filtered, path)
273
+ }
274
+
275
+ // Otherwise use the standard pipeline
276
+ const encoded = httpsig_to(
277
+ normalize(
278
+ structured_from(
279
+ normalize({ ...filtered, path: path || "/~wao@1.0/httpsig" })
280
+ )
281
+ )
282
+ )
283
+
284
+ // Check if the encoded result is valid for HTTP headers
285
+ if (!isValid(encoded)) {
286
+ return await smartSign(filtered, path)
287
+ }
288
+
289
+ return encoded
290
+ }
291
+
292
+ // Helper to join URL and path
293
+ const joinUrl = ({ url, path }) => {
294
+ if (path.startsWith("http://") || path.startsWith("https://")) return path
295
+ const normalizedPath = path.startsWith("/") ? path : "/" + path
296
+ return url.endsWith("/")
297
+ ? url.slice(0, -1) + normalizedPath
298
+ : url + normalizedPath
299
+ }
300
+
301
+ // Main sign function that matches signer.js API
302
+ export async function sign({ url, path, msg: encoded, jwk, signPath = true }) {
303
+ const signer = createSigner(jwk, url)
304
+ const { body = null, ...headers } = encoded
305
+ let _enc = { headers }
306
+ if (body) _enc.body = new Blob([body])
307
+
308
+ const headersObj = _enc.headers || {}
309
+ const bodyData = _enc.body || undefined
310
+ let url_path = typeof signPath === "string" ? signPath : path
311
+ const _url = joinUrl({ url, path: url_path })
312
+
313
+ headersObj["path"] = path
314
+ if (bodyData && !headersObj["content-length"]) {
315
+ const bodySize = bodyData.size || bodyData.byteLength || 0
316
+ if (bodySize > 0) headersObj["content-length"] = String(bodySize)
317
+ }
318
+
319
+ const lowercaseHeaders = {}
320
+ for (const [key, value] of Object.entries(headersObj)) {
321
+ lowercaseHeaders[key.toLowerCase()] = value
322
+ }
323
+
324
+ const bodyKeys = headersObj["body-keys"]
325
+ ? headersObj["body-keys"]
326
+ .replace(/"/g, "")
327
+ .split(",")
328
+ .map(k => k.trim())
329
+ : []
330
+
331
+ let isPath = false
332
+ const signingFields = Object.keys(lowercaseHeaders).filter(key => {
333
+ if (key === "path") isPath = true
334
+ return key !== "body-keys" && key !== "path" && !bodyKeys.includes(key)
335
+ })
336
+
337
+ if (signPath !== false && isPath) signingFields.push("@path")
338
+
339
+ const signedRequest = await toHttpSigner(signer)({
340
+ request: { url: _url, method: "POST", headers: lowercaseHeaders },
341
+ fields: signingFields,
342
+ })
343
+
344
+ const finalHeaders = {}
345
+ for (const [key, value] of Object.entries(headersObj)) {
346
+ finalHeaders[key] = value
347
+ }
348
+
349
+ finalHeaders["signature"] = signedRequest.headers["signature"]
350
+ finalHeaders["signature-input"] = signedRequest.headers["signature-input"]
351
+
352
+ if (headersObj["body-keys"]) {
353
+ finalHeaders["body-keys"] = headersObj["body-keys"]
354
+ }
355
+
356
+ const result = { url: _url, method: "POST", headers: finalHeaders }
357
+ if (bodyData) result.body = bodyData
358
+
359
+ return result
360
+ }
361
+
362
+ // Helper function to recursively filter out undefined values
363
+ const filterUndefined = obj => {
364
+ if (obj === null || obj === undefined) return obj
365
+ if (Array.isArray(obj)) {
366
+ return obj.map(filterUndefined).filter(item => item !== undefined)
367
+ }
368
+ if (typeof obj === "object" && obj.constructor === Object) {
369
+ const filtered = {}
370
+ for (const [key, value] of Object.entries(obj)) {
371
+ const filteredValue = filterUndefined(value)
372
+ if (filteredValue !== undefined) {
373
+ filtered[key] = filteredValue
374
+ }
375
+ }
376
+ return filtered
377
+ }
378
+ return obj
379
+ }
380
+
381
+ // Signer factory function that matches signer.js API
382
+ export function signer(config) {
383
+ const { signer, url = "http://localhost:10001" } = config
384
+ if (!signer) throw new Error("Signer is required for mainnet mode")
385
+
386
+ return async (
387
+ fields,
388
+ { encoded: _encoded = false, path: _path = true } = {}
389
+ ) => {
390
+ const { path = "/relay/process", method = "POST", ...aoFields } = fields
391
+
392
+ // Filter out undefined values before encoding
393
+ const filteredFields = filterUndefined(aoFields)
394
+ const encoded = _encoded ? filteredFields : await enc(filteredFields)
395
+
396
+ const headersObj = encoded ? encoded.headers : {}
397
+ const body = encoded ? encoded.body : undefined
398
+ let url_path = typeof _path === "string" ? _path : path
399
+ const _url = joinUrl({ url, path: url_path })
400
+
401
+ headersObj["path"] = path
402
+ if (body && !headersObj["content-length"]) {
403
+ const bodySize = body.size || body.byteLength || 0
404
+ if (bodySize > 0) headersObj["content-length"] = String(bodySize)
405
+ }
406
+
407
+ const lowercaseHeaders = {}
408
+ for (const [key, value] of Object.entries(headersObj)) {
409
+ lowercaseHeaders[key.toLowerCase()] = value
410
+ }
411
+
412
+ const bodyKeys = headersObj["body-keys"]
413
+ ? headersObj["body-keys"]
414
+ .replace(/"/g, "")
415
+ .split(",")
416
+ .map(k => k.trim())
417
+ : []
418
+
419
+ let isPath = false
420
+ const signingFields = Object.keys(lowercaseHeaders).filter(key => {
421
+ if (key === "path") isPath = true
422
+ return key !== "body-keys" && key !== "path" && !bodyKeys.includes(key)
423
+ })
424
+
425
+ if (_path !== false && isPath) signingFields.push("@path")
426
+
427
+ const signedRequest = await toHttpSigner(signer)({
428
+ request: { url: _url, method, headers: lowercaseHeaders },
429
+ fields: signingFields,
430
+ })
431
+
432
+ const finalHeaders = {}
433
+ for (const [key, value] of Object.entries(headersObj)) {
434
+ finalHeaders[key] = value
435
+ }
436
+
437
+ finalHeaders["signature"] = signedRequest.headers["signature"]
438
+ finalHeaders["signature-input"] = signedRequest.headers["signature-input"]
439
+
440
+ if (headersObj["body-keys"]) {
441
+ finalHeaders["body-keys"] = headersObj["body-keys"]
442
+ }
443
+
444
+ const result = { url: _url, method, headers: finalHeaders }
445
+ if (body) result.body = body
446
+
447
+ return result
448
+ }
449
+ }
450
+
451
+ // Export the internal sign function for backward compatibility
452
+ export { _sign as signInternal }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Structured field codec for JavaScript-Erlang interoperability
3
+ * Implements the same behavior as dev_codec_structured.erl
4
+ */
5
+
6
+ import { erl_str_from } from "./erl_str.js"
7
+
8
+ /**
9
+ * Convert from structured format
10
+ * @param {string|object} input - Erlang term string or JavaScript object
11
+ * @returns {object} - Processed object
12
+ */
13
+ export function structured_from(input) {
14
+ // If input is a string (Erlang response), parse it
15
+ if (typeof input === "string") {
16
+ return erl_str_from(input, false)
17
+ }
18
+
19
+ // Otherwise process the object like Erlang's from/1
20
+ return from(input)
21
+ }
22
+
23
+ /**
24
+ * Convert to structured format
25
+ * @param {*} obj - JavaScript object
26
+ * @returns {*} - Structured format object
27
+ */
28
+ export function structured_to(obj) {
29
+ // TODO: Implement the inverse of structured_from
30
+ return obj
31
+ }
32
+
33
+ /**
34
+ * Check if an array should be converted to numbered map
35
+ * Rules based on Erlang behavior:
36
+ * 1. Contains any objects/maps → convert
37
+ * 2. Contains empty arrays (but NOT empty buffers) → convert
38
+ * 3. All items are arrays (array of arrays) → convert
39
+ * 4. Otherwise → encode as string
40
+ */
41
+ function shouldConvertToNumberedMap(arr) {
42
+ let allArrays = true
43
+ let hasObjects = false
44
+ let hasEmptyArrays = false
45
+
46
+ for (const item of arr) {
47
+ // Check for objects (not arrays or buffers)
48
+ if (
49
+ typeof item === "object" &&
50
+ item !== null &&
51
+ !Array.isArray(item) &&
52
+ !Buffer.isBuffer(item)
53
+ ) {
54
+ hasObjects = true
55
+ }
56
+ // Check for empty arrays only (NOT empty buffers)
57
+ else if (Array.isArray(item) && item.length === 0) {
58
+ hasEmptyArrays = true
59
+ }
60
+ // Track if all items are arrays
61
+ else if (!Array.isArray(item)) {
62
+ allArrays = false
63
+ }
64
+ }
65
+
66
+ // Convert if: has objects, has empty arrays, or all items are non-empty arrays
67
+ return (
68
+ hasObjects ||
69
+ hasEmptyArrays ||
70
+ (allArrays && arr.length > 0 && arr.every(item => Array.isArray(item)))
71
+ )
72
+ }
73
+
74
+ /**
75
+ * Implementation of Erlang's dev_codec_structured:from/1
76
+ */
77
+ function from(msg) {
78
+ // Handle non-map values
79
+ if (
80
+ msg instanceof Buffer ||
81
+ typeof msg !== "object" ||
82
+ msg === null ||
83
+ Array.isArray(msg)
84
+ ) {
85
+ return msg
86
+ }
87
+
88
+ // Normalize keys first
89
+ const normalizedMap = {}
90
+ for (const [key, value] of Object.entries(msg)) {
91
+ const normKey = key.toLowerCase()
92
+ normalizedMap[normKey] = value
93
+ }
94
+
95
+ // Get sorted keys (normalized)
96
+ const sortedKeys = Object.keys(normalizedMap).sort()
97
+
98
+ const types = []
99
+ const values = []
100
+
101
+ // Process each key in sorted order
102
+ for (const normKey of sortedKeys) {
103
+ const value = normalizedMap[normKey]
104
+
105
+ // Handle empty values
106
+ if (value === "" || (value instanceof Buffer && value.length === 0)) {
107
+ types.push([normKey, "empty-binary"])
108
+ continue
109
+ }
110
+
111
+ if (Array.isArray(value) && value.length === 0) {
112
+ types.push([normKey, "empty-list"])
113
+ continue
114
+ }
115
+
116
+ if (
117
+ typeof value === "object" &&
118
+ value !== null &&
119
+ !Array.isArray(value) &&
120
+ !(value instanceof Buffer) &&
121
+ Object.keys(value).length === 0
122
+ ) {
123
+ types.push([normKey, "empty-message"])
124
+ continue
125
+ }
126
+
127
+ // Handle binary/string values
128
+ if (value instanceof Buffer || value instanceof Uint8Array) {
129
+ // Keep buffers as buffers
130
+ values.push([normKey, value])
131
+ continue
132
+ }
133
+
134
+ if (typeof value === "string") {
135
+ // Keep strings as strings
136
+ values.push([normKey, value])
137
+ continue
138
+ }
139
+
140
+ // Handle nested maps
141
+ if (typeof value === "object" && !Array.isArray(value) && value !== null) {
142
+ values.push([normKey, from(value)])
143
+ continue
144
+ }
145
+
146
+ // Handle arrays
147
+ if (Array.isArray(value) && value.length > 0) {
148
+ if (shouldConvertToNumberedMap(value)) {
149
+ // Convert to numbered map (1-based indexing)
150
+ const numberedMap = {}
151
+ value.forEach((item, idx) => {
152
+ numberedMap[(idx + 1).toString()] = item
153
+ })
154
+ types.push([normKey, "list"])
155
+ values.push([normKey, from(numberedMap)])
156
+ } else {
157
+ // Encode as list string
158
+ const [type, encoded] = encodeValue(value)
159
+ types.push([normKey, type])
160
+ values.push([normKey, encoded])
161
+ }
162
+ continue
163
+ }
164
+
165
+ // Handle typed values (need encoding)
166
+ if (
167
+ typeof value === "symbol" ||
168
+ typeof value === "number" ||
169
+ Array.isArray(value) ||
170
+ typeof value === "boolean" ||
171
+ value === null
172
+ ) {
173
+ const [type, encoded] = encodeValue(value)
174
+ types.push([normKey, type])
175
+ values.push([normKey, encoded])
176
+ continue
177
+ }
178
+ }
179
+
180
+ // Build result
181
+ const result = {}
182
+
183
+ // Add ao-types if present
184
+ if (types.length > 0) {
185
+ result["ao-types"] = types.map(([k, t]) => `${k}="${t}"`).join(", ")
186
+ }
187
+
188
+ // Add values (but NOT empty values)
189
+ for (const [k, v] of values) {
190
+ result[k] = v
191
+ }
192
+
193
+ return result
194
+ }
195
+
196
+ /**
197
+ * Encode a value with its type
198
+ */
199
+ function encodeValue(value) {
200
+ // Null (as atom)
201
+ if (value === null) {
202
+ return ["atom", '"null"']
203
+ }
204
+
205
+ // Integer
206
+ if (typeof value === "number" && Number.isInteger(value)) {
207
+ return ["integer", value.toString()]
208
+ }
209
+
210
+ // Float
211
+ if (typeof value === "number") {
212
+ // Format like Erlang with scientific notation
213
+ let str = value.toExponential(20)
214
+ // Remove trailing zeros but keep at least one
215
+ str = str.replace(/(\.\d*?)0+e/, "$1e").replace(/\.e/, ".0e")
216
+ // Ensure 2-digit exponent
217
+ str = str.replace(/e([+-])(\d)$/, "e$10$2")
218
+ return ["float", str]
219
+ }
220
+
221
+ // Boolean (as atom)
222
+ if (typeof value === "boolean") {
223
+ return ["atom", `"${value}"`]
224
+ }
225
+
226
+ // Symbol (as atom)
227
+ if (typeof value === "symbol") {
228
+ const name = Symbol.keyFor(value) || value.description || ""
229
+ return ["atom", `"${name}"`]
230
+ }
231
+
232
+ // List
233
+ if (Array.isArray(value)) {
234
+ const parts = []
235
+
236
+ for (const item of value) {
237
+ if (item instanceof Buffer) {
238
+ // Empty buffer => empty string
239
+ parts.push(item.length === 0 ? '""' : `"${item.toString()}"`)
240
+ } else if (typeof item === "string") {
241
+ parts.push(`"${item}"`)
242
+ } else {
243
+ const [itemType, itemEncoded] = encodeValue(item)
244
+
245
+ if (itemType === "list") {
246
+ // Escape nested list quotes
247
+ const escaped = itemEncoded
248
+ .replace(/\\/g, "\\\\")
249
+ .replace(/"/g, '\\"')
250
+ parts.push(`"(ao-type-list) ${escaped}"`)
251
+ } else if (itemType === "atom") {
252
+ // Escape atom quotes
253
+ const escaped = itemEncoded.replace(/"/g, '\\"')
254
+ parts.push(`"(ao-type-atom) ${escaped}"`)
255
+ } else {
256
+ parts.push(`"(ao-type-${itemType}) ${itemEncoded}"`)
257
+ }
258
+ }
259
+ }
260
+
261
+ return ["list", parts.join(", ")]
262
+ }
263
+
264
+ if (value instanceof Buffer) {
265
+ return ["binary", value]
266
+ }
267
+
268
+ return ["unknown", String(value)]
269
+ }