hbsig 0.2.8 → 0.3.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.
@@ -94,6 +94,8 @@ export function getValueByPath(obj, path) {
94
94
  }
95
95
 
96
96
  // Get the ao-type for a value
97
+ // Note: dev_codec_structured.erl only supports: integer, float, atom, list, map
98
+ // Do NOT use empty-binary, empty-list, empty-message as they cause binary_to_existing_atom errors
97
99
  export function getAoType(value) {
98
100
  if (
99
101
  typeof value === "boolean" ||
@@ -105,15 +107,19 @@ export function getAoType(value) {
105
107
  } else if (typeof value === "number") {
106
108
  return Number.isInteger(value) ? "integer" : "float"
107
109
  } else if (typeof value === "string" && value.length === 0) {
108
- return "empty-binary"
110
+ // Empty strings become empty binaries naturally - no type annotation needed
111
+ return null
109
112
  } else if (isBytes(value) && (value.length === 0 || value.byteLength === 0)) {
110
- return "empty-binary"
113
+ // Empty buffers become empty binaries naturally - no type annotation needed
114
+ return null
111
115
  } else if (Array.isArray(value) && value.length === 0) {
112
- return "empty-list"
116
+ // Use "list" for empty arrays (structured codec compatible)
117
+ return "list"
113
118
  } else if (Array.isArray(value)) {
114
119
  return "list"
115
120
  } else if (isPojo(value) && Object.keys(value).length === 0) {
116
- return "empty-message"
121
+ // Use "map" for empty objects (structured codec compatible)
122
+ return "map"
117
123
  }
118
124
  return null
119
125
  }
package/esm/encode.js CHANGED
@@ -46,7 +46,16 @@ function handleSingleEmptyBinaryField(obj) {
46
46
  (fieldValue.length === 0 || fieldValue.byteLength === 0)
47
47
  ) {
48
48
  const headers = {}
49
- headers["ao-types"] = `${fieldName.toLowerCase()}="empty-binary"`
49
+ // For 'body' field, we can't send it as a header (reserved name, gets stripped).
50
+ // Use inline-body-key to signal that body was present but empty.
51
+ // The modOut function will reconstruct body: <<>> when it sees inline-body-key: body
52
+ // with no actual body content.
53
+ if (fieldName.toLowerCase() === "body") {
54
+ headers["inline-body-key"] = "body"
55
+ return { headers, body: undefined }
56
+ }
57
+ // Include the key with empty value - empty string becomes empty binary in Erlang
58
+ headers[fieldName.toLowerCase()] = ""
50
59
  return { headers, body: undefined }
51
60
  }
52
61
  }
@@ -208,14 +217,17 @@ function processHeaderFields(obj, bodyKeys, headers, headerTypes) {
208
217
  )
209
218
  } else if (typeof value === "string") {
210
219
  if (value.length === 0) {
211
- headerTypes.push(`${key.toLowerCase()}="empty-binary"`)
220
+ // Empty string becomes empty binary in Erlang - no ao-types needed
221
+ headers[key] = ""
212
222
  } else if (hasNonAscii(value)) {
213
223
  continue
214
224
  } else {
215
225
  headers[key] = value
216
226
  }
217
227
  } else if (Array.isArray(value) && value.length === 0) {
218
- headerTypes.push(`${key.toLowerCase()}="empty-list"`)
228
+ // Empty array - use list type annotation
229
+ headers[key] = ""
230
+ headerTypes.push(`${key.toLowerCase()}="list"`)
219
231
  } else if (Array.isArray(value) && !value.some(item => isPojo(item))) {
220
232
  const hasNonAsciiItems = value.some(
221
233
  item => typeof item === "string" && hasNonAscii(item)
@@ -231,9 +243,18 @@ function processHeaderFields(obj, bodyKeys, headers, headerTypes) {
231
243
  isBytes(value) &&
232
244
  (value.length === 0 || value.byteLength === 0)
233
245
  ) {
234
- headerTypes.push(`${key.toLowerCase()}="empty-binary"`)
246
+ // Empty buffer becomes empty binary in Erlang - no ao-types needed
247
+ // For 'body' field, we can't send it as a header (reserved name, gets stripped).
248
+ // Use inline-body-key to signal that body was present but empty.
249
+ if (key.toLowerCase() === "body") {
250
+ headers["inline-body-key"] = "body"
251
+ } else {
252
+ headers[key] = ""
253
+ }
235
254
  } else if (isPojo(value) && Object.keys(value).length === 0) {
236
- headerTypes.push(`${key.toLowerCase()}="empty-message"`)
255
+ // Empty object - use map type annotation
256
+ headers[key] = ""
257
+ headerTypes.push(`${key.toLowerCase()}="map"`)
237
258
  }
238
259
  } else {
239
260
  // Fields that need body still get type annotations
@@ -290,6 +311,14 @@ async function handleSingleBodyKeyOptimization(
290
311
  headers,
291
312
  headerTypes
292
313
  ) {
314
+ // Skip this optimization if there are other header fields - need full multipart encoding
315
+ // to be compatible with HyperBEAM scheduler endpoint
316
+ const otherFields = Object.keys(obj).filter(k => !bodyKeys.includes(k) && !bodyKeys.some(bk => bk.startsWith(`${k}/`)))
317
+ if (otherFields.length > 0 && bodyKeys.length === 1) {
318
+ // Have other header fields + single body key = need multipart, skip optimization
319
+ return null
320
+ }
321
+
293
322
  if (bodyKeys.length === 1) {
294
323
  const singleKey = bodyKeys[0]
295
324
  const value = getValueByPath(obj, singleKey)
@@ -489,14 +518,16 @@ function processArrayItems(
489
518
  }
490
519
 
491
520
  if (typeof item === "string" && item === "") {
492
- partTypes.push(`${index}="empty-binary"`)
521
+ // Empty strings become empty binaries naturally - no type annotation needed
493
522
  } else if (isPojo(item) && Object.keys(item).length === 0) {
494
- partTypes.push(`${index}="empty-message"`)
523
+ // Use "map" instead of "empty-message" for structured codec compatibility
524
+ partTypes.push(`${index}="map"`)
495
525
  } else if (isPojo(item)) {
496
526
  // Non-empty objects are handled elsewhere
497
527
  } else if (Array.isArray(item)) {
498
528
  if (item.length === 0) {
499
- partTypes.push(`${index}="empty-list"`)
529
+ // Use "list" instead of "empty-list" for structured codec compatibility
530
+ partTypes.push(`${index}="list"`)
500
531
  } else {
501
532
  partTypes.push(`${index}="list"`)
502
533
  const encodedItems = item
@@ -567,7 +598,7 @@ function processArrayItems(
567
598
  } else if (isBytes(item)) {
568
599
  const buffer = toBuffer(item)
569
600
  if (buffer.length === 0) {
570
- partTypes.push(`${index}="empty-binary"`)
601
+ // Empty buffers become empty binaries naturally - no type annotation needed
571
602
  } else {
572
603
  partTypes.push(`${index}="binary"`)
573
604
  }
@@ -656,11 +687,10 @@ function processObjectFields(value, bodyKey, sortedBodyKeys) {
656
687
  const arrayTypes = []
657
688
 
658
689
  // First collect array types
690
+ // Note: Use "list" for both empty and non-empty arrays for structured codec compatibility
659
691
  for (const [k, v] of Object.entries(value)) {
660
692
  if (Array.isArray(v)) {
661
- arrayTypes.push(
662
- `${k.toLowerCase()}="${v.length === 0 ? "empty-list" : "list"}"`
663
- )
693
+ arrayTypes.push(`${k.toLowerCase()}="list"`)
664
694
  }
665
695
  }
666
696
 
@@ -693,11 +723,12 @@ function processObjectFields(value, bodyKey, sortedBodyKeys) {
693
723
  `${k.toLowerCase()}="${Number.isInteger(v) ? "integer" : "float"}"`
694
724
  )
695
725
  } else if (typeof v === "string" && v.length === 0) {
696
- objectTypes.push(`${k.toLowerCase()}="empty-binary"`)
726
+ // Empty strings become empty binaries naturally - no type annotation needed
697
727
  } else if (isBytes(v) && (v.length === 0 || v.byteLength === 0)) {
698
- objectTypes.push(`${k.toLowerCase()}="empty-binary"`)
728
+ // Empty buffers become empty binaries naturally - no type annotation needed
699
729
  } else if (isPojo(v) && Object.keys(v).length === 0) {
700
- objectTypes.push(`${k.toLowerCase()}="empty-message"`)
730
+ // Use "map" instead of "empty-message" for structured codec compatibility
731
+ objectTypes.push(`${k.toLowerCase()}="map"`)
701
732
  }
702
733
 
703
734
  if (typeof v === "string") {
@@ -720,6 +751,12 @@ function processObjectFields(value, bodyKey, sortedBodyKeys) {
720
751
  } else if (isBytes(v)) {
721
752
  const buffer = toBuffer(v)
722
753
  binaryFields.push({ key: k, buffer })
754
+ } else if (Array.isArray(v) && v.length === 0) {
755
+ // Empty array - include the key so Erlang knows it exists
756
+ fieldLines.push(`${k}: `)
757
+ } else if (isPojo(v) && Object.keys(v).length === 0) {
758
+ // Empty object - include the key so Erlang knows it exists
759
+ fieldLines.push(`${k}: `)
723
760
  } else if (Array.isArray(v) && v.length > 0) {
724
761
  const childPath = `${bodyKey}/${k}`
725
762
  if (!sortedBodyKeys.includes(childPath)) {
package/esm/erl_json.js CHANGED
@@ -61,7 +61,10 @@ export function normalize(obj, binaryMode = false) {
61
61
 
62
62
  if (binaryMode) {
63
63
  // In binary mode, convert strings to buffers
64
- return Buffer.from(obj, "utf8")
64
+ // Use binary/latin1 encoding only if all chars are <= 255 (preserves raw bytes)
65
+ // Use UTF-8 for strings with chars > 255 (proper multi-byte encoding)
66
+ const needsUtf8 = [...obj].some(ch => ch.codePointAt(0) > 255)
67
+ return Buffer.from(obj, needsUtf8 ? "utf8" : "binary")
65
68
  } else {
66
69
  // In string mode, strings stay as strings
67
70
  return obj
package/esm/erl_str.js CHANGED
@@ -10,6 +10,11 @@
10
10
  * @returns {*} JavaScript value
11
11
  */
12
12
  export function erl_str_from(str, binaryMode = false) {
13
+ // Handle null/undefined input
14
+ if (str === null || str === undefined) {
15
+ return null
16
+ }
17
+
13
18
  // Handle the new response format
14
19
  if (str.startsWith("#erl_response{")) {
15
20
  const rawMatch = str.match(/#erl_response\{raw=(.*?),formatted=(.*?)\}$/s)
package/esm/flat.js CHANGED
@@ -70,6 +70,57 @@ function pathToParts(path) {
70
70
  throw new Error("Path must be a string or array")
71
71
  }
72
72
 
73
+ /**
74
+ * Convert a value to string format to match Erlang codec behavior
75
+ * Erlang dev_codec_flat only handles binaries, so all leaf values become strings
76
+ * @param {*} value - Value to convert
77
+ * @returns {string|Object} - String for leaf values, or recursively processed object
78
+ */
79
+ function valueToString(value) {
80
+ if (typeof value === "object" && value !== null && !Array.isArray(value) && !Buffer.isBuffer(value)) {
81
+ // Recursively process nested objects
82
+ const result = {}
83
+ for (const [k, v] of Object.entries(value)) {
84
+ result[k] = valueToString(v)
85
+ }
86
+ return result
87
+ }
88
+ if (typeof value === "string") {
89
+ return value
90
+ }
91
+ if (typeof value === "number") {
92
+ return String(value)
93
+ }
94
+ if (typeof value === "boolean") {
95
+ return String(value)
96
+ }
97
+ if (value === null) {
98
+ return "null"
99
+ }
100
+ if (Array.isArray(value)) {
101
+ // Convert array elements to strings first, then format as Erlang list
102
+ // Erlang's io_lib:format("~p", [List]) produces binary syntax like [<<"a">>,<<"b">>]
103
+ const elements = value.map(v => {
104
+ if (typeof v === "string") {
105
+ return `<<"${v}">>`
106
+ } else if (typeof v === "number") {
107
+ return String(v)
108
+ } else if (typeof v === "boolean") {
109
+ return v ? "true" : "false"
110
+ } else if (v === null) {
111
+ return "null"
112
+ } else {
113
+ return String(v)
114
+ }
115
+ })
116
+ return `[${elements.join(",")}]`
117
+ }
118
+ if (Buffer.isBuffer(value)) {
119
+ return value.toString()
120
+ }
121
+ return String(value)
122
+ }
123
+
73
124
  /**
74
125
  * Helper function to inject a value at a specific path in a nested object
75
126
  * @param {Array} pathParts - Array of path parts
@@ -81,6 +132,9 @@ function injectAtPath(pathParts, value, obj) {
81
132
  throw new Error("Path cannot be empty")
82
133
  }
83
134
 
135
+ // Convert value to match Erlang codec behavior
136
+ const convertedValue = valueToString(value)
137
+
84
138
  if (pathParts.length === 1) {
85
139
  const key = pathParts[0]
86
140
 
@@ -91,20 +145,20 @@ function injectAtPath(pathParts, value, obj) {
91
145
  if (
92
146
  typeof existing === "object" &&
93
147
  existing !== null &&
94
- typeof value === "object" &&
95
- value !== null &&
148
+ typeof convertedValue === "object" &&
149
+ convertedValue !== null &&
96
150
  !Array.isArray(existing) &&
97
- !Array.isArray(value)
151
+ !Array.isArray(convertedValue)
98
152
  ) {
99
- obj[key] = { ...existing, ...value }
153
+ obj[key] = { ...existing, ...convertedValue }
100
154
  } else {
101
155
  // Path collision
102
156
  throw new Error(
103
- `Path collision at key: ${key}, existing: ${JSON.stringify(existing)}, value: ${JSON.stringify(value)}`
157
+ `Path collision at key: ${key}, existing: ${JSON.stringify(existing)}, value: ${JSON.stringify(convertedValue)}`
104
158
  )
105
159
  }
106
160
  } else {
107
- obj[key] = value
161
+ obj[key] = convertedValue
108
162
  }
109
163
  return
110
164
  }
@@ -117,7 +171,7 @@ function injectAtPath(pathParts, value, obj) {
117
171
  throw new Error(`Cannot create nested path at non-object key: ${key}`)
118
172
  }
119
173
 
120
- injectAtPath(rest, value, obj[key])
174
+ injectAtPath(rest, convertedValue, obj[key])
121
175
  }
122
176
 
123
177
  /**
package/esm/httpsig.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { hash } from "fast-sha256"
4
4
  import { flat_from, flat_to } from "./flat.js"
5
+ import { structured_from as structuredFrom, structured_to as structuredTo } from "./structured.js"
5
6
 
6
7
  const CRLF = "\r\n"
7
8
  const DOUBLE_CRLF = CRLF + CRLF
@@ -120,17 +121,18 @@ function boundaryFromParts(parts) {
120
121
  return bytesToBase64url(hashBytes)
121
122
  }
122
123
 
123
- // Helper to determine inline key
124
+ // Helper to determine inline key - matches Erlang's inline_key/2
124
125
  function inlineKey(msg) {
125
- const inlineBodyKey = msg["inline-body-key"]
126
- if (inlineBodyKey) {
127
- return [{}, inlineBodyKey]
126
+ // Check for ao-body-key (Erlang uses ao-body-key, not inline-body-key)
127
+ const aoBodyKey = msg["ao-body-key"]
128
+ if (aoBodyKey) {
129
+ return [{}, aoBodyKey]
128
130
  }
129
131
  if ("body" in msg) {
130
132
  return [{}, "body"]
131
133
  }
132
134
  if ("data" in msg) {
133
- return [{ "inline-body-key": "data" }, "data"]
135
+ return [{ "ao-body-key": "data" }, "data"]
134
136
  }
135
137
  return [{}, "body"]
136
138
  }
@@ -182,6 +184,13 @@ function ungroupIds(msg) {
182
184
  return result
183
185
  }
184
186
 
187
+ // Get the size of a map (matches Erlang's maps:size behavior)
188
+ // This counts ALL keys including ao-types - empty means literally {}
189
+ function mapSize(obj) {
190
+ if (typeof obj !== "object" || obj === null) return 0
191
+ return Object.keys(obj).length
192
+ }
193
+
185
194
  // Group maps for body encoding - following Erlang logic exactly
186
195
  function groupMaps(map, parent = "", top = {}) {
187
196
  if (
@@ -210,8 +219,18 @@ function groupMaps(map, parent = "", top = {}) {
210
219
  !Array.isArray(value) &&
211
220
  !Buffer.isBuffer(value)
212
221
  ) {
213
- // Recursively process nested objects
214
- newTop = groupMaps(value, flatK, newTop)
222
+ // Check size of the nested object (including metadata keys like ao-types)
223
+ // Empty means literally {} - a map with only ao-types is NOT empty
224
+ const size = mapSize(value)
225
+
226
+ if (size === 0) {
227
+ // Empty map (no data keys) - add empty-message marker
228
+ // This matches Erlang's group_maps behavior for empty maps
229
+ newTop[flatK] = { "ao-types": "empty-message" }
230
+ } else {
231
+ // Recursively process nested objects
232
+ newTop = groupMaps(value, flatK, newTop)
233
+ }
215
234
  } else if (typeof value === "string" && value.length > MAX_HEADER_LENGTH) {
216
235
  // Value too large for header, lift to top level
217
236
  newTop[flatK] = value
@@ -235,11 +254,27 @@ function groupMaps(map, parent = "", top = {}) {
235
254
  }
236
255
  }
237
256
 
257
+ // Helper to compute content-digest for a body value
258
+ function computePartDigest(bodyValue) {
259
+ let bodyBytes
260
+ if (Buffer.isBuffer(bodyValue)) {
261
+ bodyBytes = new Uint8Array(bodyValue)
262
+ } else if (typeof bodyValue === "string") {
263
+ bodyBytes = stringToBytes(bodyValue, "binary")
264
+ } else {
265
+ bodyBytes = stringToBytes(String(bodyValue), "binary")
266
+ }
267
+ const hashBytes = hash(bodyBytes)
268
+ return `sha-256=:${bytesToBase64(hashBytes)}:`
269
+ }
270
+
238
271
  // Encode multipart body part
272
+ // NOTE: This matches Erlang's encode_body_part/4 which does NOT apply inline_key
273
+ // logic to nested parts. For nested maps, ALL fields become headers (except 'body'
274
+ // which becomes the part body). The inline_key logic is only for top-level messages.
239
275
  function encodeBodyPart(partName, bodyPart, inlineKey) {
240
276
  const disposition =
241
277
  partName === inlineKey ? "inline" : `form-data;name="${partName}"`
242
- const isInline = partName === inlineKey
243
278
 
244
279
  if (
245
280
  typeof bodyPart === "object" &&
@@ -247,101 +282,45 @@ function encodeBodyPart(partName, bodyPart, inlineKey) {
247
282
  !Array.isArray(bodyPart) &&
248
283
  !Buffer.isBuffer(bodyPart)
249
284
  ) {
250
- // Check if this part has ao-types
251
- const hasAoTypes = "ao-types" in bodyPart
252
-
253
- if (hasAoTypes) {
254
- // For parts WITH ao-types: sort all entries alphabetically
255
- const allEntries = []
256
-
257
- // Collect all entries except body
258
- for (const [key, value] of Object.entries(bodyPart)) {
259
- if (key === "body") continue
260
-
261
- if (key === "ao-types") {
262
- // Keep ao-types as-is (Buffer or string)
263
- let valueStr = value
264
- if (Buffer.isBuffer(value)) {
265
- valueStr = value.toString("binary")
266
- }
267
- allEntries.push({ key: "ao-types", line: `ao-types: ${valueStr}` })
268
- } else {
269
- // Handle Buffer values properly
270
- let valueStr = value
271
- if (Buffer.isBuffer(value)) {
272
- // Use binary/latin1 encoding to preserve all byte values 0-255
273
- valueStr = value.toString("binary")
274
- }
275
- allEntries.push({ key: key, line: `${key}: ${valueStr}` })
276
- }
277
- }
278
-
279
- // Add content-disposition
280
- allEntries.push({
281
- key: "content-disposition",
282
- line: `content-disposition: ${disposition}`,
283
- })
285
+ // Collect all headers (everything except 'body' and 'priv')
286
+ const allEntries = []
284
287
 
285
- // Sort alphabetically by key
286
- allEntries.sort((a, b) => a.key.localeCompare(b.key))
288
+ for (const [key, value] of Object.entries(bodyPart)) {
289
+ if (key === "body" || key === "priv") continue
287
290
 
288
- // Build the lines
289
- const lines = allEntries.map(entry => entry.line)
290
-
291
- // Body handling
292
- const body = bodyPart.body || ""
293
- if (body) {
294
- lines.push("") // Always add empty line before body
295
- lines.push(body)
296
- }
297
-
298
- return lines.join(CRLF)
299
- } else {
300
- // For parts WITHOUT ao-types
301
- const allEntries = []
302
-
303
- for (const [key, value] of Object.entries(bodyPart)) {
304
- if (key === "body") continue
305
- // Handle Buffer values properly
306
- let valueStr = value
307
- if (Buffer.isBuffer(value)) {
308
- // Use binary/latin1 encoding to preserve all byte values 0-255
309
- valueStr = value.toString("binary")
310
- }
311
- allEntries.push({ key: key, line: `${key}: ${valueStr}` })
291
+ // Handle Buffer values properly
292
+ let valueStr = value
293
+ if (Buffer.isBuffer(value)) {
294
+ // Use binary/latin1 encoding to preserve all byte values 0-255
295
+ valueStr = value.toString("binary")
312
296
  }
297
+ allEntries.push({ key: key, line: `${key}: ${valueStr}` })
298
+ }
313
299
 
314
- const lines = []
315
-
316
- if (isInline) {
317
- // Inline parts without ao-types: sort ALL fields alphabetically including content-disposition
318
- allEntries.push({
319
- key: "content-disposition",
320
- line: `content-disposition: ${disposition}`,
321
- })
300
+ // Add content-disposition
301
+ allEntries.push({
302
+ key: "content-disposition",
303
+ line: `content-disposition: ${disposition}`,
304
+ })
322
305
 
323
- // Sort by key
324
- allEntries.sort((a, b) => a.key.localeCompare(b.key))
306
+ // Sort all entries by key alphabetically - matches Erlang behavior
307
+ allEntries.sort((a, b) => a.key.localeCompare(b.key))
325
308
 
326
- // Extract the lines
327
- lines.push(...allEntries.map(entry => entry.line))
328
- } else {
329
- // Regular parts: content-disposition first, then fields
330
- lines.push(`content-disposition: ${disposition}`)
331
- lines.push(...allEntries.map(entry => entry.line))
332
- }
309
+ // Build the lines
310
+ const lines = allEntries.map(entry => entry.line)
333
311
 
334
- // Body handling
335
- const body = bodyPart.body || ""
336
- if (body) {
337
- lines.push("") // Always add empty line before body
338
- lines.push(body)
339
- }
340
-
341
- return lines.join(CRLF)
312
+ // Only the 'body' field (if present) becomes the part body
313
+ const body = bodyPart.body
314
+ if (body !== "" && body !== undefined && body !== null) {
315
+ lines.push("") // Always add empty line before body
316
+ lines.push(Buffer.isBuffer(body) ? body.toString("binary") : String(body))
342
317
  }
318
+
319
+ return lines.join(CRLF)
343
320
  } else if (typeof bodyPart === "string" || Buffer.isBuffer(bodyPart)) {
344
- return `content-disposition: ${disposition}${DOUBLE_CRLF}${bodyPart}`
321
+ // Use binary/latin1 encoding to preserve byte values 0-255
322
+ const bodyStr = Buffer.isBuffer(bodyPart) ? bodyPart.toString("binary") : bodyPart
323
+ return `content-disposition: ${disposition}${DOUBLE_CRLF}${bodyStr}`
345
324
  }
346
325
  return ""
347
326
  }
@@ -696,12 +675,20 @@ export function httpsig_from(http) {
696
675
 
697
676
  /**
698
677
  * Convert TABM to HTTP message
678
+ * Implements bundle mode like Erlang's dev_codec_httpsig_conv:to/3 with bundle=true
699
679
  */
700
680
  export function httpsig_to(tabm) {
701
681
  if (typeof tabm === "string") return tabm
702
682
 
683
+ // Bundle logic: TABM → structured → TABM
684
+ // This matches Erlang's behavior when bundle=true:
685
+ // 1. Convert TABM to structured@1.0 (interprets ao-types, decodes to native types)
686
+ // 2. Convert back to TABM (re-encodes with ao-types)
687
+ const structured = structuredTo(tabm)
688
+ const bundledTabm = structuredFrom(structured)
689
+
703
690
  // Group IDs
704
- const withGroupedIds = groupIds(tabm)
691
+ const withGroupedIds = groupIds(bundledTabm)
705
692
 
706
693
  // Remove private and signature-related keys
707
694
  const stripped = { ...withGroupedIds }
@@ -713,26 +700,25 @@ export function httpsig_to(tabm) {
713
700
  const [inlineFieldHdrs, inlineKeyVal] = inlineKey(tabm)
714
701
 
715
702
  // Check if this is a flat structure that should stay as headers
716
- // A flat structure has no nested objects (maps)
717
- const hasNestedMaps = Object.values(stripped).some(
718
- value =>
719
- typeof value === "object" &&
720
- value !== null &&
721
- !Array.isArray(value) &&
722
- !Buffer.isBuffer(value)
723
- )
703
+ // A flat structure has no nested objects (maps), excluding:
704
+ // - Arrays (JS arrays, not numbered maps)
705
+ // - Buffers
706
+ // Note: List-encoded maps (numbered maps with .="list") ARE nested maps
707
+ // and should trigger multipart encoding, matching Erlang's behavior
708
+ const hasNestedMaps = Object.values(stripped).some(value => {
709
+ // Not an object
710
+ if (typeof value !== "object" || value === null) return false
711
+ // Arrays and Buffers are not nested maps
712
+ if (Array.isArray(value) || Buffer.isBuffer(value)) return false
713
+ // Any other object (including list-encoded maps) is a nested map
714
+ return true
715
+ })
724
716
 
725
717
  // If it's just a flat map with strings/primitives, keep as headers
726
718
  // This matches Erlang's behavior where flat maps don't become multipart
727
719
  if (!hasNestedMaps) {
728
720
  // For flat structures, just return with normalized keys
729
- // This matches Erlang which returns the map unchanged
730
- const result = { ...inlineFieldHdrs }
731
-
732
- for (const [key, value] of Object.entries(stripped)) {
733
- // Keep Buffers as Buffers - don't convert to strings
734
- result[key] = value
735
- }
721
+ const result = { ...inlineFieldHdrs, ...stripped }
736
722
 
737
723
  // Handle inline body key - move data from inline key to body
738
724
  if (inlineKeyVal && inlineKeyVal !== "body" && result[inlineKeyVal]) {
@@ -740,9 +726,28 @@ export function httpsig_to(tabm) {
740
726
  delete result[inlineKeyVal]
741
727
  }
742
728
 
743
- // If there's a body, add content-digest
729
+ // If the only field is ao-types (no actual data), return empty object
730
+ // This matches Erlang's behavior where ao-types-only messages become empty
731
+ const dataKeys = Object.keys(result).filter(k =>
732
+ k !== "ao-types" &&
733
+ k !== "ao-ids" &&
734
+ k !== "inline-body-key" &&
735
+ k !== "ao-body-key"
736
+ )
737
+ if (dataKeys.length === 0) {
738
+ return {}
739
+ }
740
+
741
+ // If there's a non-empty body, add content-digest
742
+ // Erlang doesn't add content-digest for empty bodies (<<>>)
744
743
  if (result.body) {
745
- return addContentDigest(result)
744
+ const bodyIsEmpty = Buffer.isBuffer(result.body)
745
+ ? result.body.length === 0
746
+ : (typeof result.body === "string" && result.body.length === 0)
747
+
748
+ if (!bodyIsEmpty) {
749
+ return addContentDigest(result)
750
+ }
746
751
  }
747
752
 
748
753
  return result
@@ -837,7 +842,7 @@ export function httpsig_to(tabm) {
837
842
 
838
843
  const result = {
839
844
  ...headers,
840
- "body-keys": bodyKeysList.map(k => `"${k}"`).join(", "),
845
+ // Note: body-keys is NOT included in httpsig output - it's only used for parsing
841
846
  "content-type": `multipart/form-data; boundary="${boundary}"`,
842
847
  body: finalBody,
843
848
  }