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/erl_str.js ADDED
@@ -0,0 +1,1139 @@
1
+ /**
2
+ * Erlang term string parser and formatter
3
+ * Converts between Erlang term strings and JavaScript values
4
+ */
5
+
6
+ /**
7
+ * Parse an Erlang term string into JavaScript values
8
+ * @param {string} str - Erlang term string
9
+ * @param {boolean} binaryMode - If true, keep binaries as Buffers; if false, convert to strings
10
+ * @returns {*} JavaScript value
11
+ */
12
+ export function erl_str_from(str, binaryMode = false) {
13
+ // Handle the new response format
14
+ if (str.startsWith("#erl_response{")) {
15
+ const rawMatch = str.match(/#erl_response\{raw=(.*?),formatted=(.*?)\}$/s)
16
+ if (rawMatch && rawMatch[1] && rawMatch[2]) {
17
+ const rawStr = rawMatch[1]
18
+ const formattedStr = rawMatch[2]
19
+
20
+ if (binaryMode) {
21
+ // In binary mode, just parse formatted
22
+ const parser = new ErlangParser(formattedStr, true)
23
+ return parser.parse()
24
+ } else {
25
+ // Build a type map from raw, then parse formatted with type info
26
+ const typeMap = buildTypeMap(rawStr)
27
+ const parser = new TypeAwareParser(formattedStr, typeMap)
28
+ return parser.parse()
29
+ }
30
+ }
31
+ }
32
+
33
+ // Fallback for non-response format
34
+ const parser = new ErlangParser(str, binaryMode)
35
+ return parser.parse()
36
+ }
37
+
38
+ /**
39
+ * Parse body field specially - convert to string only for multipart bodies
40
+ * @param {*} parsed - Parsed Erlang term object
41
+ * @returns {*} Object with body field potentially converted to string
42
+ */
43
+ export function parse_body(parsed) {
44
+ if (!parsed || typeof parsed !== "object") {
45
+ return parsed
46
+ }
47
+
48
+ // If there's a body field
49
+ if ("body" in parsed) {
50
+ if (Buffer.isBuffer(parsed.body)) {
51
+ // Check if this looks like a multipart body
52
+ // Multipart bodies start with "--" boundary
53
+ const bodyStart = parsed.body.toString(
54
+ "binary",
55
+ 0,
56
+ Math.min(100, parsed.body.length)
57
+ )
58
+ if (
59
+ bodyStart.startsWith("--") &&
60
+ parsed["content-type"] &&
61
+ parsed["content-type"].includes("multipart")
62
+ ) {
63
+ // It's a multipart body, convert to string
64
+ return {
65
+ ...parsed,
66
+ body: parsed.body.toString("binary"),
67
+ }
68
+ }
69
+ // Not multipart, keep as Buffer
70
+ return parsed
71
+ }
72
+ // Already a string, return as-is
73
+ return parsed
74
+ }
75
+
76
+ return parsed
77
+ }
78
+
79
+ // Build a map of paths to types by analyzing the raw string
80
+ function buildTypeMap(rawStr) {
81
+ const typeMap = new Map()
82
+
83
+ function detectBinaryType(str, startPos) {
84
+ // Check what follows <<
85
+ let pos = startPos + 2
86
+ while (pos < str.length && /\s/.test(str[pos])) pos++
87
+
88
+ if (pos < str.length && str[pos] === '"') {
89
+ // It looks like a string, but check if it has escape sequences
90
+ // If it has \NNN octal escapes, it's actually a binary that Erlang formatted as string
91
+ let scanPos = pos + 1
92
+ let hasOctalEscapes = false
93
+
94
+ while (
95
+ scanPos < str.length &&
96
+ !(
97
+ str[scanPos] === '"' &&
98
+ str[scanPos + 1] === ">" &&
99
+ str[scanPos + 2] === ">"
100
+ )
101
+ ) {
102
+ if (str[scanPos] === "\\" && scanPos + 1 < str.length) {
103
+ const nextChar = str[scanPos + 1]
104
+ // Check for octal escape \NNN
105
+ if (/[0-7]/.test(nextChar)) {
106
+ hasOctalEscapes = true
107
+ break
108
+ }
109
+ scanPos += 2
110
+ } else {
111
+ scanPos++
112
+ }
113
+ }
114
+
115
+ // If it has octal escapes, treat as binary
116
+ if (hasOctalEscapes) {
117
+ return "binary"
118
+ }
119
+
120
+ return "string"
121
+ }
122
+ return "binary"
123
+ }
124
+
125
+ function scanValue(str, pos, path) {
126
+ while (pos < str.length && /\s/.test(str[pos])) pos++
127
+
128
+ if (pos >= str.length) return pos
129
+
130
+ if (str[pos] === "#" && str[pos + 1] === "{") {
131
+ // Map
132
+ pos += 2
133
+ let mapIndex = 0
134
+
135
+ while (pos < str.length) {
136
+ while (pos < str.length && /\s/.test(str[pos])) pos++
137
+ if (str[pos] === "}") return pos + 1
138
+
139
+ // Parse key
140
+ const keyStart = pos
141
+ if (str[pos] === "<" && str[pos + 1] === "<") {
142
+ // Binary key
143
+ const keyType = detectBinaryType(str, pos)
144
+ let keyEnd = pos + 2
145
+
146
+ if (keyType === "string") {
147
+ keyEnd++ // skip "
148
+ while (
149
+ keyEnd < str.length &&
150
+ !(
151
+ str[keyEnd] === '"' &&
152
+ str[keyEnd + 1] === ">" &&
153
+ str[keyEnd + 2] === ">"
154
+ )
155
+ ) {
156
+ if (str[keyEnd] === "\\") keyEnd++
157
+ keyEnd++
158
+ }
159
+ keyEnd += 3
160
+ } else {
161
+ let depth = 1
162
+ while (depth > 0 && keyEnd < str.length) {
163
+ if (str[keyEnd] === "<" && str[keyEnd + 1] === "<") {
164
+ depth++
165
+ keyEnd += 2
166
+ } else if (str[keyEnd] === ">" && str[keyEnd + 1] === ">") {
167
+ depth--
168
+ keyEnd += 2
169
+ } else {
170
+ keyEnd++
171
+ }
172
+ }
173
+ }
174
+
175
+ pos = keyEnd
176
+ } else {
177
+ // Regular key
178
+ while (pos < str.length && !/[\s=,}]/.test(str[pos])) pos++
179
+ }
180
+
181
+ // Skip =>
182
+ while (pos < str.length && /\s/.test(str[pos])) pos++
183
+ if (str[pos] === "=" && str[pos + 1] === ">") pos += 2
184
+ while (pos < str.length && /\s/.test(str[pos])) pos++
185
+
186
+ // Parse value and record type if binary
187
+ if (str[pos] === "<" && str[pos + 1] === "<") {
188
+ const type = detectBinaryType(str, pos)
189
+ const pathKey = [...path, mapIndex].join(".")
190
+ typeMap.set(pathKey, type)
191
+ }
192
+
193
+ pos = scanValue(str, pos, [...path, mapIndex])
194
+ mapIndex++
195
+
196
+ // Skip comma
197
+ while (pos < str.length && /\s/.test(str[pos])) pos++
198
+ if (str[pos] === ",") pos++
199
+ }
200
+ } else if (str[pos] === "<" && str[pos + 1] === "<") {
201
+ // Binary
202
+ const type = detectBinaryType(str, pos)
203
+ const pathKey = path.join(".")
204
+ typeMap.set(pathKey, type)
205
+
206
+ pos += 2
207
+ if (type === "string" || str[pos] === '"') {
208
+ // Skip past string literal
209
+ if (str[pos] === '"') pos++ // skip opening quote
210
+ while (
211
+ pos < str.length &&
212
+ !(str[pos] === '"' && str[pos + 1] === ">" && str[pos + 2] === ">")
213
+ ) {
214
+ if (str[pos] === "\\") pos++
215
+ pos++
216
+ }
217
+ return pos + 3
218
+ } else {
219
+ let depth = 1
220
+ while (depth > 0 && pos < str.length) {
221
+ if (str[pos] === "<" && str[pos + 1] === "<") {
222
+ depth++
223
+ pos += 2
224
+ } else if (str[pos] === ">" && str[pos + 1] === ">") {
225
+ depth--
226
+ pos += 2
227
+ } else {
228
+ pos++
229
+ }
230
+ }
231
+ return pos
232
+ }
233
+ } else if (str[pos] === "[") {
234
+ // List
235
+ pos++
236
+ let listIndex = 0
237
+
238
+ while (pos < str.length) {
239
+ while (pos < str.length && /\s/.test(str[pos])) pos++
240
+ if (str[pos] === "]") return pos + 1
241
+
242
+ pos = scanValue(str, pos, [...path, listIndex])
243
+ listIndex++
244
+
245
+ while (pos < str.length && /\s/.test(str[pos])) pos++
246
+ if (str[pos] === ",") pos++
247
+ }
248
+ } else {
249
+ // Skip other values
250
+ while (pos < str.length && !/[\s,\]}=>]/.test(str[pos])) {
251
+ pos++
252
+ }
253
+ return pos
254
+ }
255
+
256
+ return pos
257
+ }
258
+
259
+ scanValue(rawStr, 0, [])
260
+ return typeMap
261
+ }
262
+
263
+ // Parser that uses type information
264
+ class TypeAwareParser {
265
+ constructor(str, typeMap) {
266
+ this.str = str
267
+ this.typeMap = typeMap
268
+ this.pos = 0
269
+ this.path = []
270
+ }
271
+
272
+ parse() {
273
+ const result = this.parseValue()
274
+ this.skipWhitespace()
275
+ if (this.pos < this.str.length) {
276
+ throw new Error(`Unexpected content at position ${this.pos}`)
277
+ }
278
+ return result
279
+ }
280
+
281
+ peek(offset = 0) {
282
+ return this.str[this.pos + offset] || ""
283
+ }
284
+
285
+ advance(count = 1) {
286
+ this.pos += count
287
+ }
288
+
289
+ skipWhitespace() {
290
+ while (this.pos < this.str.length && /\s/.test(this.peek())) {
291
+ this.advance()
292
+ }
293
+ }
294
+
295
+ parseValue() {
296
+ this.skipWhitespace()
297
+
298
+ const ch = this.peek()
299
+ const ch2 = this.peek(1)
300
+
301
+ if (ch === "#" && ch2 === "{") {
302
+ return this.parseMap()
303
+ }
304
+
305
+ if (ch === "<" && ch2 === "<") {
306
+ return this.parseBinary()
307
+ }
308
+
309
+ if (ch === "[") {
310
+ return this.parseList()
311
+ }
312
+
313
+ if (ch === "'") {
314
+ return this.parseQuotedAtom()
315
+ }
316
+
317
+ if (ch === '"') {
318
+ return this.parseQuotedString()
319
+ }
320
+
321
+ return this.parseAtomOrNumber()
322
+ }
323
+
324
+ parseMap() {
325
+ this.advance(2) // skip #{
326
+ const map = {}
327
+ let mapIndex = 0
328
+
329
+ while (true) {
330
+ this.skipWhitespace()
331
+
332
+ if (this.peek() === "}") {
333
+ this.advance()
334
+ break
335
+ }
336
+
337
+ this.path.push(mapIndex)
338
+ const key = this.parseValue()
339
+ this.path.pop()
340
+
341
+ this.skipWhitespace()
342
+
343
+ if (this.peek() !== "=" || this.peek(1) !== ">") {
344
+ throw new Error("Expected =>")
345
+ }
346
+ this.advance(2)
347
+
348
+ this.skipWhitespace()
349
+
350
+ this.path.push(mapIndex)
351
+ const value = this.parseValue()
352
+ this.path.pop()
353
+
354
+ const jsKey =
355
+ key instanceof Buffer
356
+ ? key.toString("utf8")
357
+ : typeof key === "symbol"
358
+ ? Symbol.keyFor(key) || String(key)
359
+ : String(key)
360
+
361
+ map[jsKey] = value
362
+ mapIndex++
363
+
364
+ this.skipWhitespace()
365
+ if (this.peek() === ",") {
366
+ this.advance()
367
+ }
368
+ }
369
+
370
+ return map
371
+ }
372
+
373
+ parseBinary() {
374
+ const currentPath = this.path.join(".")
375
+ const type = this.typeMap.get(currentPath) || "binary"
376
+
377
+ // Parse the formatted version (always byte format)
378
+ this.advance(2) // skip <<
379
+
380
+ if (this.peek() === ">" && this.peek(1) === ">") {
381
+ this.advance(2)
382
+ return Buffer.alloc(0)
383
+ }
384
+
385
+ const bytes = []
386
+ while (!(this.peek() === ">" && this.peek(1) === ">")) {
387
+ this.skipWhitespace()
388
+
389
+ let num = ""
390
+ while (/\d/.test(this.peek())) {
391
+ num += this.peek()
392
+ this.advance()
393
+ }
394
+
395
+ if (num) bytes.push(parseInt(num, 10))
396
+
397
+ this.skipWhitespace()
398
+ if (this.peek() === ",") this.advance()
399
+ }
400
+
401
+ this.advance(2) // skip >>
402
+
403
+ const buffer = Buffer.from(bytes)
404
+
405
+ // If it was a string literal in raw, convert to string
406
+ if (type === "string") {
407
+ return buffer.toString("utf8")
408
+ }
409
+
410
+ // For binaries that are not explicitly marked as strings, check if they're valid UTF-8
411
+ // that contains reasonable string content (no control characters except \t, \n, \r)
412
+ try {
413
+ const str = buffer.toString("utf8")
414
+ // Check if the string round-trips correctly
415
+ if (Buffer.from(str, "utf8").equals(buffer)) {
416
+ // It's valid UTF-8, but also check if it contains reasonable characters
417
+ let hasReasonableChars = true
418
+ for (let i = 0; i < str.length; i++) {
419
+ const code = str.charCodeAt(i)
420
+ // Allow printable chars, space, tab, newline, carriage return, and Unicode
421
+ if (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) {
422
+ hasReasonableChars = false
423
+ break
424
+ }
425
+ }
426
+
427
+ if (hasReasonableChars) {
428
+ return str
429
+ }
430
+ }
431
+ } catch (e) {
432
+ // Not valid UTF-8, keep as buffer
433
+ }
434
+
435
+ return buffer
436
+ }
437
+
438
+ parseQuotedString() {
439
+ this.advance() // skip opening "
440
+ let str = ""
441
+
442
+ while (this.peek() !== '"') {
443
+ if (this.peek() === "\\") {
444
+ this.advance()
445
+ const escaped = this.peek()
446
+ switch (escaped) {
447
+ case '"':
448
+ str += '"'
449
+ break
450
+ case "\\":
451
+ str += "\\"
452
+ break
453
+ case "n":
454
+ str += "\n"
455
+ break
456
+ case "r":
457
+ str += "\r"
458
+ break
459
+ case "t":
460
+ str += "\t"
461
+ break
462
+ default:
463
+ str += escaped
464
+ }
465
+ this.advance()
466
+ } else {
467
+ str += this.peek()
468
+ this.advance()
469
+ }
470
+ }
471
+
472
+ this.advance() // skip closing "
473
+ return str
474
+ }
475
+
476
+ parseList() {
477
+ this.advance() // skip [
478
+ const list = []
479
+ let listIndex = 0
480
+
481
+ while (true) {
482
+ this.skipWhitespace()
483
+
484
+ if (this.peek() === "]") {
485
+ this.advance()
486
+ break
487
+ }
488
+
489
+ this.path.push(listIndex)
490
+ list.push(this.parseValue())
491
+ this.path.pop()
492
+
493
+ listIndex++
494
+
495
+ this.skipWhitespace()
496
+ if (this.peek() === ",") {
497
+ this.advance()
498
+ }
499
+ }
500
+
501
+ return list
502
+ }
503
+
504
+ parseQuotedAtom() {
505
+ this.advance() // skip '
506
+
507
+ let atom = ""
508
+ while (this.peek() !== "'") {
509
+ if (this.peek() === "\\") {
510
+ this.advance()
511
+ const escaped = this.peek()
512
+ switch (escaped) {
513
+ case "'":
514
+ atom += "'"
515
+ break
516
+ case "\\":
517
+ atom += "\\"
518
+ break
519
+ case "n":
520
+ atom += "\n"
521
+ break
522
+ case "r":
523
+ atom += "\r"
524
+ break
525
+ case "t":
526
+ atom += "\t"
527
+ break
528
+ default:
529
+ atom += escaped
530
+ }
531
+ this.advance()
532
+ } else {
533
+ atom += this.peek()
534
+ this.advance()
535
+ }
536
+ }
537
+
538
+ this.advance() // skip closing '
539
+
540
+ if (atom === "null") return null
541
+ if (atom === "true") return true
542
+ if (atom === "false") return false
543
+
544
+ return Symbol.for(atom)
545
+ }
546
+
547
+ parseAtomOrNumber() {
548
+ let token = ""
549
+
550
+ // Parse unquoted atom/number
551
+ while (this.pos < this.str.length) {
552
+ const ch = this.peek()
553
+
554
+ if (
555
+ ch === "," ||
556
+ ch === "]" ||
557
+ ch === "}" ||
558
+ ch === ")" ||
559
+ (ch === "=" && this.peek(1) === ">")
560
+ ) {
561
+ break
562
+ }
563
+
564
+ if (/\s/.test(ch)) {
565
+ let i = this.pos
566
+ while (i < this.str.length && /\s/.test(this.str[i])) i++
567
+
568
+ if (i >= this.str.length) break
569
+
570
+ const nextCh = this.str[i]
571
+ if (
572
+ nextCh === "," ||
573
+ nextCh === "]" ||
574
+ nextCh === "}" ||
575
+ nextCh === ")" ||
576
+ (nextCh === "=" && i + 1 < this.str.length && this.str[i + 1] === ">")
577
+ ) {
578
+ break
579
+ }
580
+ }
581
+
582
+ token += ch
583
+ this.advance()
584
+ }
585
+
586
+ token = token.trim()
587
+
588
+ if (token === "null") return null
589
+ if (token === "true") return true
590
+ if (token === "false") return false
591
+
592
+ if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(token)) {
593
+ return parseFloat(token)
594
+ }
595
+
596
+ return Symbol.for(token)
597
+ }
598
+ }
599
+
600
+ // Parser implementation
601
+ class ErlangParser {
602
+ constructor(str, binaryMode = false) {
603
+ this.str = str
604
+ this.pos = 0
605
+ this.binaryMode = binaryMode
606
+ }
607
+
608
+ parse() {
609
+ const result = this.parseValue()
610
+ this.skipWhitespace()
611
+ if (this.pos < this.str.length) {
612
+ throw new Error(
613
+ `Unexpected content at position ${this.pos}: ${this.str.slice(this.pos, this.pos + 50)}`
614
+ )
615
+ }
616
+ return result
617
+ }
618
+
619
+ peek(offset = 0) {
620
+ return this.str[this.pos + offset] || ""
621
+ }
622
+
623
+ advance(count = 1) {
624
+ this.pos += count
625
+ }
626
+
627
+ skipWhitespace() {
628
+ while (this.pos < this.str.length && /\s/.test(this.peek())) {
629
+ this.advance()
630
+ }
631
+ }
632
+
633
+ parseValue() {
634
+ this.skipWhitespace()
635
+
636
+ const ch = this.peek()
637
+ const ch2 = this.peek(1)
638
+
639
+ // Map: #{...}
640
+ if (ch === "#" && ch2 === "{") {
641
+ return this.parseMap()
642
+ }
643
+
644
+ // Binary: <<...>>
645
+ if (ch === "<" && ch2 === "<") {
646
+ return this.parseBinary()
647
+ }
648
+
649
+ // List: [...]
650
+ if (ch === "[") {
651
+ return this.parseList()
652
+ }
653
+
654
+ // Quoted atom: 'atom'
655
+ if (ch === "'") {
656
+ return this.parseQuotedAtom()
657
+ }
658
+
659
+ // Quoted string: "..."
660
+ if (ch === '"') {
661
+ return this.parseQuotedString()
662
+ }
663
+
664
+ // Number or unquoted atom
665
+ return this.parseAtomOrNumber()
666
+ }
667
+
668
+ parseMap() {
669
+ this.advance(2) // skip #{
670
+ const map = {}
671
+
672
+ while (true) {
673
+ this.skipWhitespace()
674
+
675
+ if (this.peek() === "}") {
676
+ this.advance()
677
+ break
678
+ }
679
+
680
+ // Parse key
681
+ const key = this.parseValue()
682
+ this.skipWhitespace()
683
+
684
+ // Expect =>
685
+ if (this.peek() !== "=" || this.peek(1) !== ">") {
686
+ throw new Error(`Expected => at position ${this.pos}`)
687
+ }
688
+ this.advance(2)
689
+
690
+ this.skipWhitespace()
691
+
692
+ // Parse value
693
+ const value = this.parseValue()
694
+
695
+ // Convert key to string
696
+ let jsKey
697
+ if (key instanceof Buffer) {
698
+ jsKey = key.toString("utf8")
699
+ } else if (typeof key === "symbol") {
700
+ jsKey = Symbol.keyFor(key) || key.description || String(key)
701
+ } else {
702
+ jsKey = String(key)
703
+ }
704
+
705
+ map[jsKey] = value
706
+
707
+ this.skipWhitespace()
708
+ if (this.peek() === ",") {
709
+ this.advance()
710
+ }
711
+ }
712
+
713
+ return map
714
+ }
715
+
716
+ parseBinary() {
717
+ this.advance(2) // skip <<
718
+
719
+ // String binary: <<"...">>
720
+ if (this.peek() === '"') {
721
+ this.advance() // skip "
722
+ let content = ""
723
+
724
+ while (
725
+ !(this.peek() === '"' && this.peek(1) === ">" && this.peek(2) === ">")
726
+ ) {
727
+ if (this.pos >= this.str.length) {
728
+ throw new Error("Unterminated string binary")
729
+ }
730
+
731
+ if (this.peek() === "\\") {
732
+ this.advance()
733
+ const escaped = this.peek()
734
+
735
+ // Check for octal escape sequences \NNN
736
+ if (/[0-7]/.test(escaped)) {
737
+ let octal = escaped
738
+ this.advance()
739
+
740
+ // Get up to 2 more octal digits
741
+ if (/[0-7]/.test(this.peek())) {
742
+ octal += this.peek()
743
+ this.advance()
744
+
745
+ if (/[0-7]/.test(this.peek())) {
746
+ octal += this.peek()
747
+ this.advance()
748
+ }
749
+ }
750
+
751
+ // Convert octal to character
752
+ const charCode = parseInt(octal, 8)
753
+ content += String.fromCharCode(charCode)
754
+ } else {
755
+ // Regular escape sequences
756
+ switch (escaped) {
757
+ case '"':
758
+ content += '"'
759
+ break
760
+ case "\\":
761
+ content += "\\"
762
+ break
763
+ case "n":
764
+ content += "\n"
765
+ break
766
+ case "r":
767
+ content += "\r"
768
+ break
769
+ case "t":
770
+ content += "\t"
771
+ break
772
+ default:
773
+ content += escaped
774
+ }
775
+ this.advance()
776
+ }
777
+ } else {
778
+ content += this.peek()
779
+ this.advance()
780
+ }
781
+ }
782
+
783
+ this.advance(3) // skip ">>
784
+
785
+ // Check for structured field format
786
+ if (
787
+ content.startsWith(":") &&
788
+ content.endsWith(":") &&
789
+ content.length >= 2
790
+ ) {
791
+ if (content === "::") {
792
+ return Buffer.alloc(0)
793
+ }
794
+ try {
795
+ const base64 = content.slice(1, -1)
796
+ return Buffer.from(base64, "base64")
797
+ } catch (e) {
798
+ // Not valid base64, treat as regular string
799
+ }
800
+ }
801
+
802
+ // In binary mode, return as Buffer; otherwise check if it's printable
803
+ if (this.binaryMode) {
804
+ return Buffer.from(content, "utf8")
805
+ } else {
806
+ // Check if the content is printable
807
+ const bytes = Buffer.from(content, "utf8")
808
+ const isPrintable = Array.from(bytes).every(b => b >= 32 && b <= 126)
809
+
810
+ // If it contains non-printable characters, return as Buffer
811
+ if (!isPrintable) {
812
+ return bytes
813
+ }
814
+
815
+ // Otherwise return as string
816
+ return content
817
+ }
818
+ }
819
+
820
+ // Empty binary: <<>>
821
+ if (this.peek() === ">" && this.peek(1) === ">") {
822
+ this.advance(2)
823
+ return Buffer.alloc(0)
824
+ }
825
+
826
+ // Byte binary: <<1,2,3>>
827
+ const bytes = []
828
+ while (!(this.peek() === ">" && this.peek(1) === ">")) {
829
+ if (this.pos >= this.str.length) {
830
+ throw new Error("Unterminated byte binary")
831
+ }
832
+
833
+ this.skipWhitespace()
834
+
835
+ let num = ""
836
+ while (/\d/.test(this.peek())) {
837
+ num += this.peek()
838
+ this.advance()
839
+ }
840
+
841
+ if (num) {
842
+ bytes.push(parseInt(num, 10))
843
+ }
844
+
845
+ this.skipWhitespace()
846
+ if (this.peek() === ",") {
847
+ this.advance()
848
+ }
849
+ }
850
+
851
+ this.advance(2) // skip >>
852
+ return Buffer.from(bytes)
853
+ }
854
+
855
+ parseQuotedString() {
856
+ this.advance() // skip "
857
+ let str = ""
858
+
859
+ while (this.peek() !== '"') {
860
+ if (this.peek() === "\\") {
861
+ this.advance()
862
+ const escaped = this.peek()
863
+ switch (escaped) {
864
+ case '"':
865
+ str += '"'
866
+ break
867
+ case "\\":
868
+ str += "\\"
869
+ break
870
+ case "n":
871
+ str += "\n"
872
+ break
873
+ case "r":
874
+ str += "\r"
875
+ break
876
+ case "t":
877
+ str += "\t"
878
+ break
879
+ default:
880
+ str += escaped
881
+ }
882
+ this.advance()
883
+ } else {
884
+ str += this.peek()
885
+ this.advance()
886
+ }
887
+ }
888
+
889
+ this.advance() // skip closing "
890
+ return str
891
+ }
892
+
893
+ parseList() {
894
+ this.advance() // skip [
895
+ const list = []
896
+
897
+ while (true) {
898
+ this.skipWhitespace()
899
+
900
+ if (this.peek() === "]") {
901
+ this.advance()
902
+ break
903
+ }
904
+
905
+ list.push(this.parseValue())
906
+
907
+ this.skipWhitespace()
908
+ if (this.peek() === ",") {
909
+ this.advance()
910
+ }
911
+ }
912
+
913
+ return list
914
+ }
915
+
916
+ parseQuotedAtom() {
917
+ this.advance() // skip '
918
+ let atom = ""
919
+
920
+ while (this.peek() !== "'") {
921
+ if (this.pos >= this.str.length) {
922
+ throw new Error("Unterminated quoted atom")
923
+ }
924
+
925
+ if (this.peek() === "\\") {
926
+ this.advance()
927
+ const escaped = this.peek()
928
+ switch (escaped) {
929
+ case "'":
930
+ atom += "'"
931
+ break
932
+ case "\\":
933
+ atom += "\\"
934
+ break
935
+ case "n":
936
+ atom += "\n"
937
+ break // Literal newline
938
+ case "r":
939
+ atom += "\r"
940
+ break
941
+ case "t":
942
+ atom += "\t"
943
+ break
944
+ default:
945
+ atom += escaped
946
+ }
947
+ this.advance()
948
+ } else {
949
+ atom += this.peek()
950
+ this.advance()
951
+ }
952
+ }
953
+
954
+ this.advance() // skip closing '
955
+
956
+ // Special atoms that become JS primitives
957
+ if (atom === "null") return null
958
+ if (atom === "true") return true
959
+ if (atom === "false") return false
960
+
961
+ return Symbol.for(atom)
962
+ }
963
+
964
+ parseAtomOrNumber() {
965
+ const startPos = this.pos
966
+ let token = ""
967
+
968
+ // Simple approach: collect until we hit a known delimiter
969
+ while (this.pos < this.str.length) {
970
+ const ch = this.peek()
971
+ const ch2 = this.peek(1)
972
+
973
+ // Stop at these delimiters
974
+ if (ch === ",") break
975
+ if (ch === "]") break
976
+ if (ch === "}") break
977
+ if (ch === ")") break
978
+ if (ch === "=" && ch2 === ">") break
979
+
980
+ // Handle backslash specially
981
+ if (ch === "\\") {
982
+ // In unquoted atoms, backslash is just a regular character
983
+ token += ch
984
+ this.advance()
985
+
986
+ // Don't try to interpret the next character as an escape
987
+ if (this.pos < this.str.length) {
988
+ token += this.peek()
989
+ this.advance()
990
+ }
991
+ continue
992
+ }
993
+
994
+ // For whitespace, check if it's trailing
995
+ if (/\s/.test(ch)) {
996
+ // Scan ahead to find next non-whitespace
997
+ let i = this.pos
998
+ while (i < this.str.length && /\s/.test(this.str[i])) {
999
+ i++
1000
+ }
1001
+
1002
+ // Check what comes after whitespace
1003
+ if (i >= this.str.length) {
1004
+ // Hit end of string
1005
+ break
1006
+ }
1007
+
1008
+ const nextCh = this.str[i]
1009
+ if (
1010
+ nextCh === "," ||
1011
+ nextCh === "]" ||
1012
+ nextCh === "}" ||
1013
+ nextCh === ")"
1014
+ ) {
1015
+ // This whitespace is trailing, not part of atom
1016
+ break
1017
+ }
1018
+ if (
1019
+ nextCh === "=" &&
1020
+ i + 1 < this.str.length &&
1021
+ this.str[i + 1] === ">"
1022
+ ) {
1023
+ // Whitespace before =>
1024
+ break
1025
+ }
1026
+ }
1027
+
1028
+ // Include this character in the atom
1029
+ token += ch
1030
+ this.advance()
1031
+
1032
+ // Safety check to prevent infinite loops
1033
+ if (this.pos - startPos > 1000) {
1034
+ throw new Error(`Atom too long at position ${startPos}`)
1035
+ }
1036
+ }
1037
+
1038
+ // Trim only trailing whitespace
1039
+ token = token.trimEnd()
1040
+
1041
+ if (!token) {
1042
+ throw new Error(`Empty atom at position ${this.pos}`)
1043
+ }
1044
+
1045
+ // Handle special atoms
1046
+ if (token === "null") return null
1047
+ if (token === "true") return true
1048
+ if (token === "false") return false
1049
+
1050
+ // Try to parse as number
1051
+ if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(token)) {
1052
+ return parseFloat(token)
1053
+ }
1054
+
1055
+ // Return as atom
1056
+ return Symbol.for(token)
1057
+ }
1058
+ }
1059
+
1060
+ /**
1061
+ * Format JavaScript values as Erlang term strings
1062
+ * @param {*} value - JavaScript value
1063
+ * @returns {string} Erlang term string
1064
+ */
1065
+ export function erl_str_to(value) {
1066
+ return formatValue(value)
1067
+ }
1068
+
1069
+ // Formatter implementation
1070
+ function formatValue(value) {
1071
+ if (value === null) return "null"
1072
+ if (value === undefined) return "undefined"
1073
+ if (typeof value === "boolean") return value.toString()
1074
+
1075
+ if (typeof value === "number") {
1076
+ return value.toString()
1077
+ }
1078
+
1079
+ if (typeof value === "string") {
1080
+ // Format as string binary
1081
+ return `<<"${escapeString(value)}">>`
1082
+ }
1083
+
1084
+ if (typeof value === "symbol") {
1085
+ const key = Symbol.keyFor(value)
1086
+ const name = key || value.description || ""
1087
+
1088
+ // Special symbols
1089
+ if (name === "null") return "null"
1090
+ if (name === "true") return "true"
1091
+ if (name === "false") return "false"
1092
+ if (name === "undefined") return "undefined"
1093
+
1094
+ // Check if needs quoting
1095
+ if (/^[a-z][a-zA-Z0-9_]*$/.test(name)) {
1096
+ return name
1097
+ } else {
1098
+ return `'${escapeString(name)}'`
1099
+ }
1100
+ }
1101
+
1102
+ if (value instanceof Buffer || value instanceof Uint8Array) {
1103
+ const bytes = Array.from(value)
1104
+
1105
+ // Check if it's printable
1106
+ const isPrintable = bytes.every(b => b >= 32 && b <= 126)
1107
+
1108
+ if (isPrintable) {
1109
+ const str = Buffer.from(value).toString()
1110
+ return `<<"${escapeString(str)}">>`
1111
+ } else {
1112
+ return `<<${bytes.join(",")}>>`
1113
+ }
1114
+ }
1115
+
1116
+ if (Array.isArray(value)) {
1117
+ const items = value.map(formatValue)
1118
+ return `[${items.join(",")}]`
1119
+ }
1120
+
1121
+ if (typeof value === "object" && value !== null) {
1122
+ const entries = Object.entries(value).map(([k, v]) => {
1123
+ const key = `<<"${escapeString(k)}">>`
1124
+ return `${key} => ${formatValue(v)}`
1125
+ })
1126
+ return `#{${entries.join(",")}}`
1127
+ }
1128
+
1129
+ return String(value)
1130
+ }
1131
+
1132
+ function escapeString(str) {
1133
+ return str
1134
+ .replace(/\\/g, "\\\\")
1135
+ .replace(/"/g, '\\"')
1136
+ .replace(/\n/g, "\\n")
1137
+ .replace(/\r/g, "\\r")
1138
+ .replace(/\t/g, "\\t")
1139
+ }