wao 0.27.1 → 0.27.2

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.
package/esm/encode.js CHANGED
@@ -55,14 +55,16 @@ async function sha256(data) {
55
55
  }
56
56
 
57
57
  function formatFloat(num) {
58
- // Format float in scientific notation with proper padding
59
58
  let exp = num.toExponential(20)
60
- // Replace "1.23e+0" with "1.23e+00"
61
59
  exp = exp.replace(/e\+(\d)$/, "e+0$1")
62
60
  exp = exp.replace(/e-(\d)$/, "e-0$1")
63
61
  return exp
64
62
  }
65
63
 
64
+ function hasNonAscii(str) {
65
+ return /[^\x00-\x7F]/.test(str)
66
+ }
67
+
66
68
  function encodeArrayItem(item) {
67
69
  if (typeof item === "number") {
68
70
  if (Number.isInteger(item)) {
@@ -82,7 +84,6 @@ function encodeArrayItem(item) {
82
84
  } else if (typeof item === "boolean") {
83
85
  return `"(ao-type-atom) \\"${item}\\""`
84
86
  } else if (Array.isArray(item)) {
85
- // Nested array
86
87
  const nestedItems = item
87
88
  .map(nestedItem => {
88
89
  if (typeof nestedItem === "number") {
@@ -95,9 +96,58 @@ function encodeArrayItem(item) {
95
96
  return `\\"${nestedItem}\\"`
96
97
  } else if (nestedItem === null) {
97
98
  return `\\"(ao-type-atom) \\\\\\"null\\\\\\"\\"`
99
+ } else if (nestedItem === undefined) {
100
+ return `\\"(ao-type-atom) \\\\\\"undefined\\\\\\"\\"`
98
101
  } else if (typeof nestedItem === "symbol") {
99
102
  const desc = nestedItem.description || "Symbol.for()"
100
103
  return `\\"(ao-type-atom) \\\\\\"${desc}\\\\\\"\\"`
104
+ } else if (typeof nestedItem === "boolean") {
105
+ return `\\"(ao-type-atom) \\\\\\"${nestedItem}\\\\\\"\\"`
106
+ } else if (Array.isArray(nestedItem)) {
107
+ // Handle nested arrays recursively
108
+ const deeperItems = nestedItem
109
+ .map(deepItem => {
110
+ if (typeof deepItem === "number") {
111
+ if (Number.isInteger(deepItem)) {
112
+ return `\\\\\\"(ao-type-integer) ${deepItem}\\\\\\"`
113
+ } else {
114
+ return `\\\\\\"(ao-type-float) ${formatFloat(deepItem)}\\\\\\"`
115
+ }
116
+ } else if (typeof deepItem === "string") {
117
+ return `\\\\\\"${deepItem}\\\\\\"`
118
+ } else if (Array.isArray(deepItem)) {
119
+ // Even deeper nesting - need to escape more
120
+ const deepestItems = deepItem
121
+ .map(deepestItem => {
122
+ if (typeof deepestItem === "number") {
123
+ if (Number.isInteger(deepestItem)) {
124
+ return `\\\\\\\\\\\\\\"(ao-type-integer) ${deepestItem}\\\\\\\\\\\\\\"`
125
+ } else {
126
+ return `\\\\\\\\\\\\\\"(ao-type-float) ${formatFloat(deepestItem)}\\\\\\\\\\\\\\"`
127
+ }
128
+ } else if (typeof deepestItem === "string") {
129
+ return `\\\\\\\\\\\\\\"${deepestItem}\\\\\\\\\\\\\\"`
130
+ } else {
131
+ return `\\\\\\\\\\\\\\"${String(deepestItem)}\\\\\\\\\\\\\\"`
132
+ }
133
+ })
134
+ .join(", ")
135
+ return `\\\\\\"(ao-type-list) ${deepestItems}\\\\\\"`
136
+ } else if (deepItem === null) {
137
+ return `\\\\\\"(ao-type-atom) \\\\\\\\\\\\\\"null\\\\\\\\\\\\\\"\\\\\\"`
138
+ } else if (deepItem === undefined) {
139
+ return `\\\\\\"(ao-type-atom) \\\\\\\\\\\\\\"undefined\\\\\\\\\\\\\\"\\\\\\"`
140
+ } else if (typeof deepItem === "symbol") {
141
+ const desc = deepItem.description || "Symbol.for()"
142
+ return `\\\\\\"(ao-type-atom) \\\\\\\\\\\\\\"${desc}\\\\\\\\\\\\\\"\\\\\\"`
143
+ } else if (typeof deepItem === "boolean") {
144
+ return `\\\\\\"(ao-type-atom) \\\\\\\\\\\\\\"${deepItem}\\\\\\\\\\\\\\"\\\\\\"`
145
+ } else {
146
+ return `\\\\\\"${String(deepItem)}\\\\\\"`
147
+ }
148
+ })
149
+ .join(", ")
150
+ return `\\"(ao-type-list) ${deeperItems}\\"`
101
151
  } else {
102
152
  return `\\"${String(nestedItem)}\\"`
103
153
  }
@@ -105,12 +155,10 @@ function encodeArrayItem(item) {
105
155
  .join(", ")
106
156
  return `"(ao-type-list) ${nestedItems}"`
107
157
  } else if (isBytes(item)) {
108
- // For empty binaries in arrays, return empty string
109
158
  const buffer = toBuffer(item)
110
159
  if (buffer.length === 0 || buffer.byteLength === 0) {
111
160
  return `""`
112
161
  }
113
- // For non-empty binaries, we can't include them in headers
114
162
  return `"(ao-type-binary)"`
115
163
  } else if (isPojo(item)) {
116
164
  const json = JSON.stringify(item)
@@ -121,67 +169,181 @@ function encodeArrayItem(item) {
121
169
  }
122
170
  }
123
171
 
124
- function needsOwnBodyPart(value) {
125
- if (Array.isArray(value)) return true
126
- if (isBytes(value)) return true
127
- if (isPojo(value)) {
128
- // Check if object has complex fields
129
- return Object.values(value).some(
130
- v =>
131
- Array.isArray(v) ||
132
- isPojo(v) ||
133
- isBytes(v) ||
134
- v === null ||
135
- v === undefined ||
136
- typeof v === "symbol"
137
- )
172
+ function toBuffer(value) {
173
+ if (Buffer.isBuffer(value)) {
174
+ return value
175
+ } else if (
176
+ value &&
177
+ typeof value === "object" &&
178
+ value.type === "Buffer" &&
179
+ Array.isArray(value.data)
180
+ ) {
181
+ return Buffer.from(value.data)
182
+ } else if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
183
+ return Buffer.from(value)
184
+ } else {
185
+ return Buffer.from(value)
138
186
  }
139
- return false
140
187
  }
141
188
 
142
189
  function collectBodyKeys(obj, prefix = "") {
190
+ console.log("=== collectBodyKeys START ===")
191
+ console.log("Input object:", JSON.stringify(obj))
192
+
143
193
  const keys = []
144
194
 
145
195
  function traverse(current, path) {
146
- // Track if current level has simple fields or empty objects
196
+ console.log(`[traverse] Called with path: "${path}"`)
147
197
  let hasSimpleFields = false
148
- // Track nested paths that need body parts
149
198
  const nestedPaths = []
199
+ let hasArraysWithObjects = false
150
200
 
151
201
  for (const [key, value] of Object.entries(current)) {
152
202
  const fullPath = path ? `${path}/${key}` : key
153
203
 
154
204
  if (Array.isArray(value)) {
205
+ console.log(
206
+ `[traverse] Found array at ${fullPath}, length: ${value.length}`
207
+ )
155
208
  const hasObjects = value.some(item => isPojo(item))
156
209
  const hasNonObjects = value.some(item => !isPojo(item))
157
210
 
158
- if (hasObjects) {
159
- // Each object in array gets its own key
160
- value.forEach((item, index) => {
161
- if (isPojo(item)) {
162
- nestedPaths.push(`${fullPath}/${index + 1}`)
163
- }
211
+ if (value.length === 0) {
212
+ console.log(
213
+ `[traverse] Empty array at ${fullPath} - marking parent as having simple fields`
214
+ )
215
+ hasSimpleFields = true
216
+ } else if (hasObjects) {
217
+ hasArraysWithObjects = true
218
+ // Check if we need special handling for mixed arrays
219
+ const hasEmptyStrings = value.some(
220
+ item => typeof item === "string" && item === ""
221
+ )
222
+ const hasEmptyObjects = value.some(
223
+ item => isPojo(item) && Object.keys(item).length === 0
224
+ )
225
+ const hasNonEmptyObjects = value.some(
226
+ item => isPojo(item) && Object.keys(item).length > 0
227
+ )
228
+
229
+ // Check if objects contain only empty values (not empty objects)
230
+ const hasObjectsWithOnlyEmptyValues = value.some(item => {
231
+ if (!isPojo(item) || Object.keys(item).length === 0) return false
232
+ return Object.values(item).every(
233
+ v =>
234
+ (typeof v === "string" && v === "") ||
235
+ (Array.isArray(v) && v.length === 0) ||
236
+ (isPojo(v) && Object.keys(v).length === 0)
237
+ )
164
238
  })
165
239
 
166
- // If array ALSO has non-object items, it needs its own body part
167
- if (hasNonObjects) {
168
- hasSimpleFields = true
240
+ // Only use special handling if we have BOTH empty elements AND non-empty objects
241
+ if ((hasEmptyStrings || hasEmptyObjects) && hasNonEmptyObjects) {
242
+ // Special case: mixed array with empty strings/objects - only non-empty objects get parts
243
+ value.forEach((item, index) => {
244
+ if (isPojo(item) && Object.keys(item).length > 0) {
245
+ const itemPath = `${fullPath}/${index + 1}`
246
+ keys.push(itemPath)
247
+ nestedPaths.push(itemPath)
248
+ }
249
+ })
250
+ if (hasNonObjects) {
251
+ hasSimpleFields = true
252
+ keys.push(fullPath)
253
+ }
254
+ } else if (hasObjectsWithOnlyEmptyValues && !hasNonObjects) {
255
+ // Special case: objects that contain only empty values should get parts
256
+ value.forEach((item, index) => {
257
+ if (isPojo(item)) {
258
+ const itemPath = `${fullPath}/${index + 1}`
259
+ keys.push(itemPath)
260
+ if (Object.keys(item).length > 0) {
261
+ nestedPaths.push(itemPath)
262
+ }
263
+ }
264
+ })
265
+ } else {
266
+ // Normal case: all objects get parts
267
+ value.forEach((item, index) => {
268
+ if (isPojo(item)) {
269
+ const itemPath = `${fullPath}/${index + 1}`
270
+ keys.push(itemPath)
271
+ if (Object.keys(item).length > 0) {
272
+ nestedPaths.push(itemPath)
273
+ }
274
+ }
275
+ })
276
+ if (hasNonObjects) {
277
+ hasSimpleFields = true
278
+ keys.push(fullPath)
279
+ }
169
280
  }
170
281
  } else {
171
- // Simple array - parent needs body part
282
+ console.log(
283
+ `[traverse] Non-empty array without objects at ${fullPath} - marking as simple field`
284
+ )
172
285
  hasSimpleFields = true
173
286
  }
174
287
  } else if (isPojo(value)) {
175
- // Check if this is an empty object
176
288
  if (Object.keys(value).length === 0) {
177
- // Empty objects need a body part
289
+ console.log(
290
+ `[traverse] Empty object at ${fullPath} - marking parent as having simple fields`
291
+ )
178
292
  hasSimpleFields = true
179
293
  } else {
180
- // Non-empty objects are processed recursively
181
- nestedPaths.push(fullPath)
294
+ // Don't traverse into the object if it only contains empty values
295
+ const containsOnlyEmptyCollections = Object.entries(value).every(
296
+ ([k, v]) => {
297
+ return (
298
+ (Array.isArray(v) && v.length === 0) ||
299
+ (isPojo(v) && Object.keys(v).length === 0) ||
300
+ (isBytes(v) && (v.length === 0 || v.byteLength === 0)) ||
301
+ (typeof v === "string" && v.length === 0)
302
+ )
303
+ }
304
+ )
305
+
306
+ if (containsOnlyEmptyCollections && Object.keys(value).length > 0) {
307
+ console.log(
308
+ `[traverse] Object at ${fullPath} contains only empty collections - adding as body key`
309
+ )
310
+ keys.push(fullPath)
311
+ } else {
312
+ // Check if this object contains arrays with only empty elements
313
+ const hasArraysWithOnlyEmptyElements = Object.entries(value).some(
314
+ ([k, v]) => {
315
+ return (
316
+ Array.isArray(v) &&
317
+ v.length > 0 &&
318
+ v.every(
319
+ item =>
320
+ (Array.isArray(item) && item.length === 0) ||
321
+ (isPojo(item) && Object.keys(item).length === 0) ||
322
+ (typeof item === "string" && item === "")
323
+ )
324
+ )
325
+ }
326
+ )
327
+
328
+ if (hasArraysWithOnlyEmptyElements) {
329
+ // This object needs a body part to show its array types
330
+ console.log(
331
+ `[traverse] Object at ${fullPath} has arrays with empty elements - adding as body key`
332
+ )
333
+ keys.push(fullPath)
334
+ }
335
+
336
+ console.log(
337
+ `[traverse] Non-empty object at ${fullPath} - will traverse into it`
338
+ )
339
+ nestedPaths.push(fullPath)
340
+ }
182
341
  }
183
342
  } else if (isBytes(value)) {
184
- hasSimpleFields = true
343
+ const buffer = toBuffer(value)
344
+ if (buffer.length > 0) {
345
+ hasSimpleFields = true
346
+ }
185
347
  } else if (
186
348
  typeof value === "string" ||
187
349
  typeof value === "number" ||
@@ -194,12 +356,38 @@ function collectBodyKeys(obj, prefix = "") {
194
356
  }
195
357
  }
196
358
 
197
- // Add current path if it has simple fields or empty objects
198
359
  if (hasSimpleFields) {
360
+ console.log(`[traverse] Adding "${path}" to keys (has simple fields)`)
199
361
  keys.push(path)
362
+ } else if (hasArraysWithObjects && path) {
363
+ // If the object only contains arrays with objects, we still need to add it as a body key
364
+ console.log(
365
+ `[traverse] Adding "${path}" to keys (contains arrays with objects)`
366
+ )
367
+ keys.push(path)
368
+ }
369
+
370
+ // Check for arrays with only empty elements that need their own body parts
371
+ for (const [key, value] of Object.entries(current)) {
372
+ const fullPath = path ? `${path}/${key}` : key
373
+
374
+ if (Array.isArray(value) && value.length > 0) {
375
+ const hasOnlyEmptyElements = value.every(
376
+ item =>
377
+ (Array.isArray(item) && item.length === 0) ||
378
+ (isPojo(item) && Object.keys(item).length === 0) ||
379
+ (typeof item === "string" && item === "")
380
+ )
381
+
382
+ if (hasOnlyEmptyElements) {
383
+ console.log(
384
+ `[traverse] Array at ${fullPath} has only empty elements - adding as body key`
385
+ )
386
+ keys.push(fullPath)
387
+ }
388
+ }
200
389
  }
201
390
 
202
- // Process nested paths
203
391
  for (const nestedPath of nestedPaths) {
204
392
  const parts = nestedPath.split("/")
205
393
  let nestedObj = obj
@@ -218,71 +406,300 @@ function collectBodyKeys(obj, prefix = "") {
218
406
  }
219
407
  }
220
408
 
221
- // Handle top-level fields
409
+ const objKeys = Object.keys(obj)
410
+
222
411
  for (const [key, value] of Object.entries(obj)) {
223
- if (Array.isArray(value)) {
412
+ console.log(`\n[main loop] Processing key: "${key}"`)
413
+ console.log(
414
+ `[main loop] Value type: ${Array.isArray(value) ? "array" : typeof value}`
415
+ )
416
+ console.log(
417
+ `[main loop] Array length: ${Array.isArray(value) ? value.length : "N/A"}`
418
+ )
419
+
420
+ if (
421
+ (key === "data" || key === "body") &&
422
+ (typeof value === "string" ||
423
+ typeof value === "boolean" ||
424
+ typeof value === "number" ||
425
+ value === null ||
426
+ value === undefined ||
427
+ typeof value === "symbol") &&
428
+ objKeys.length > 1
429
+ ) {
430
+ // Special handling: only add to body keys if there's no other data/body field with an object
431
+ if (
432
+ key === "data" &&
433
+ obj.body &&
434
+ isPojo(obj.body) &&
435
+ Object.keys(obj.body).length > 0
436
+ ) {
437
+ console.log(`[main loop] Skipping special data field`)
438
+ } else if (
439
+ key === "body" &&
440
+ obj.data &&
441
+ isPojo(obj.data) &&
442
+ Object.keys(obj.data).length > 0
443
+ ) {
444
+ console.log(`[main loop] Skipping special body field`)
445
+ } else {
446
+ console.log(`[main loop] Adding special data/body key: "${key}"`)
447
+ keys.push(key)
448
+ }
449
+ } else if (Array.isArray(value)) {
450
+ if (value.length === 0) {
451
+ console.log(`[main loop] SKIPPING empty array for key: "${key}"`)
452
+ continue
453
+ }
454
+
224
455
  const hasObjects = value.some(item => isPojo(item))
225
456
  const hasArrays = value.some(item => Array.isArray(item))
226
457
  const hasNonObjects = value.some(item => !isPojo(item))
227
458
 
228
- if (hasObjects) {
229
- value.forEach((item, index) => {
230
- if (isPojo(item)) {
231
- keys.push(`${key}/${index + 1}`)
232
- // Also need to traverse into nested objects within array items
233
- for (const [nestedKey, nestedValue] of Object.entries(item)) {
234
- if (isPojo(nestedValue)) {
235
- keys.push(`${key}/${index + 1}/${nestedKey}`)
459
+ // Check if this is an array of arrays containing objects
460
+ const hasArraysOfObjects = value.some(
461
+ item => Array.isArray(item) && item.some(subItem => isPojo(subItem))
462
+ )
463
+
464
+ console.log(
465
+ `[main loop] Array analysis: hasObjects=${hasObjects}, hasArrays=${hasArrays}, hasNonObjects=${hasNonObjects}, hasArraysOfObjects=${hasArraysOfObjects}`
466
+ )
467
+
468
+ if (value.length > 0) {
469
+ let bodyPartCounter = 1 // Start counting from 1
470
+
471
+ // Check for special mixed array case
472
+ const hasEmptyStrings = value.some(
473
+ item => typeof item === "string" && item === ""
474
+ )
475
+ const hasEmptyObjects = value.some(
476
+ item => isPojo(item) && Object.keys(item).length === 0
477
+ )
478
+
479
+ // Check for objects that contain only empty values
480
+ const hasObjectsWithOnlyEmptyValues = value.some(item => {
481
+ if (!isPojo(item) || Object.keys(item).length === 0) return false
482
+ return Object.values(item).every(
483
+ v =>
484
+ (typeof v === "string" && v === "") ||
485
+ (Array.isArray(v) && v.length === 0) ||
486
+ (isPojo(v) && Object.keys(v).length === 0)
487
+ )
488
+ })
489
+
490
+ if (hasArraysOfObjects) {
491
+ // Handle arrays of arrays containing objects
492
+ value.forEach((item, index) => {
493
+ if (Array.isArray(item)) {
494
+ item.forEach((subItem, subIndex) => {
495
+ if (isPojo(subItem)) {
496
+ const path = `${key}/${index + 1}/${subIndex + 1}`
497
+ console.log(
498
+ `[main loop] Adding nested object path: "${path}"`
499
+ )
500
+ keys.push(path)
501
+ }
502
+ })
503
+ }
504
+ bodyPartCounter++
505
+ })
506
+ // Always add the main array key
507
+ console.log(`[main loop] ADDING main array key: "${key}"`)
508
+ keys.push(key)
509
+ } else if (
510
+ hasObjects &&
511
+ (hasEmptyStrings || hasEmptyObjects) &&
512
+ !hasObjectsWithOnlyEmptyValues
513
+ ) {
514
+ // Special handling: only non-empty objects get parts
515
+ value.forEach((item, index) => {
516
+ if (isPojo(item) && Object.keys(item).length > 0) {
517
+ const path = `${key}/${bodyPartCounter}`
518
+ console.log(
519
+ `[main loop] Adding non-empty object path: "${path}" (array index ${index})`
520
+ )
521
+ keys.push(path)
522
+ // Add paths for nested objects
523
+ for (const [nestedKey, nestedValue] of Object.entries(item)) {
524
+ if (isPojo(nestedValue)) {
525
+ const nestedPath = `${key}/${bodyPartCounter}/${nestedKey}`
526
+ console.log(
527
+ `[main loop] Adding nested object path: "${nestedPath}"`
528
+ )
529
+ keys.push(nestedPath)
530
+ }
236
531
  }
237
532
  }
533
+ bodyPartCounter++
534
+ })
535
+ // Always add the main array key
536
+ console.log(`[main loop] ADDING main array key: "${key}"`)
537
+ keys.push(key)
538
+ } else if (hasObjects) {
539
+ // Normal handling: all objects get parts (except if parent array has only empty elements)
540
+ let skipEmptyObjects = false
541
+
542
+ // Check if this array contains only empty elements
543
+ const arrayHasOnlyEmptyElements = value.every(
544
+ item =>
545
+ (Array.isArray(item) && item.length === 0) ||
546
+ (isPojo(item) && Object.keys(item).length === 0) ||
547
+ (typeof item === "string" && item === "")
548
+ )
549
+
550
+ if (arrayHasOnlyEmptyElements) {
551
+ skipEmptyObjects = true
238
552
  }
239
- })
240
553
 
241
- // Mixed arrays also need their own body part
242
- if (hasNonObjects) {
243
- keys.push(key)
554
+ value.forEach((item, index) => {
555
+ if (isPojo(item)) {
556
+ // Skip empty objects if array has only empty elements
557
+ if (skipEmptyObjects && Object.keys(item).length === 0) {
558
+ bodyPartCounter++
559
+ return
560
+ }
561
+
562
+ const path = `${key}/${bodyPartCounter}`
563
+ console.log(
564
+ `[main loop] Adding object path: "${path}" (array index ${index}, empty=${Object.keys(item).length === 0})`
565
+ )
566
+ keys.push(path)
567
+ // Add paths for nested objects (but not empty ones)
568
+ if (Object.keys(item).length > 0) {
569
+ for (const [nestedKey, nestedValue] of Object.entries(item)) {
570
+ if (
571
+ isPojo(nestedValue) &&
572
+ Object.keys(nestedValue).length > 0
573
+ ) {
574
+ const nestedPath = `${key}/${bodyPartCounter}/${nestedKey}`
575
+ console.log(
576
+ `[main loop] Adding nested object path: "${nestedPath}"`
577
+ )
578
+ keys.push(nestedPath)
579
+ }
580
+ }
581
+ }
582
+ } else if (typeof item === "string" && item === "") {
583
+ // Empty strings may get parts in some formats
584
+ const path = `${key}/${bodyPartCounter}`
585
+ console.log(
586
+ `[main loop] Adding empty string path: "${path}" (array index ${index})`
587
+ )
588
+ keys.push(path)
589
+ }
590
+ bodyPartCounter++
591
+ })
592
+ // Don't add main array key for arrays with only objects containing empty values
593
+ if (
594
+ !hasObjectsWithOnlyEmptyValues ||
595
+ value.some(item => !isPojo(item))
596
+ ) {
597
+ // Check if array has only empty elements
598
+ const hasOnlyEmptyElements = value.every(
599
+ item =>
600
+ (Array.isArray(item) && item.length === 0) ||
601
+ (isPojo(item) && Object.keys(item).length === 0) ||
602
+ (typeof item === "string" && item === "")
603
+ )
604
+
605
+ if (!hasOnlyEmptyElements) {
606
+ // Always add the main array key
607
+ console.log(`[main loop] ADDING main array key: "${key}"`)
608
+ keys.push(key)
609
+ }
610
+ }
611
+ } else {
612
+ // Check if array has only empty elements
613
+ const hasOnlyEmptyArraysOrObjects = value.every(
614
+ item =>
615
+ (Array.isArray(item) && item.length === 0) ||
616
+ (isPojo(item) && Object.keys(item).length === 0) ||
617
+ (typeof item === "string" && item === "")
618
+ )
619
+
620
+ if (hasOnlyEmptyArraysOrObjects && value.length > 0) {
621
+ // Always add the main array key for arrays with only empty elements
622
+ console.log(
623
+ `[main loop] ADDING main array key for empty elements: "${key}"`
624
+ )
625
+ keys.push(key)
626
+ } else if (!hasOnlyEmptyArraysOrObjects) {
627
+ // Always add the main array key
628
+ console.log(`[main loop] ADDING main array key: "${key}"`)
629
+ keys.push(key)
630
+ }
244
631
  }
245
- } else if (hasArrays) {
246
- // Array containing arrays needs body part
247
- keys.push(key)
248
- } else {
249
- // Simple array at top level - DO NOT add to body keys
250
- // It will go in headers instead
251
632
  }
252
633
  } else if (isPojo(value)) {
253
- // Top-level object that may have nested structures
634
+ console.log(`[main loop] Processing object at key: "${key}"`)
635
+ // Objects should be traversed, not have their fields individually added
254
636
  traverse(value, key)
255
637
  } else if (isBytes(value)) {
256
- // All binary data needs body parts, even empty ones
257
- keys.push(key)
638
+ const buffer = toBuffer(value)
639
+ if (buffer.length > 0) {
640
+ console.log(`[main loop] Adding key for non-empty bytes: "${key}"`)
641
+ keys.push(key)
642
+ }
258
643
  } else if (typeof value === "string" && value.includes("\n")) {
259
- // Multiline string
644
+ console.log(`[main loop] Adding key for string with newline: "${key}"`)
645
+ keys.push(key)
646
+ } else if (typeof value === "string" && hasNonAscii(value)) {
647
+ console.log(`[main loop] Adding key for non-ASCII string: "${key}"`)
260
648
  keys.push(key)
649
+ } else {
650
+ console.log(`[main loop] Skipping key: "${key}" (no match)`)
261
651
  }
262
652
  }
263
653
 
264
- return [...new Set(keys)].filter(k => k !== "")
265
- }
654
+ const result = [...new Set(keys)].filter(k => {
655
+ if (k === "") return false
266
656
 
267
- function toBuffer(value) {
268
- if (Buffer.isBuffer(value)) {
269
- return value
270
- } else if (
271
- value &&
272
- typeof value === "object" &&
273
- value.type === "Buffer" &&
274
- Array.isArray(value.data)
275
- ) {
276
- return Buffer.from(value.data)
277
- } else if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
278
- return Buffer.from(value)
279
- } else {
280
- return Buffer.from(value)
281
- }
657
+ // Check if this is a path to an empty object inside an array with only empty elements
658
+ const parts = k.split("/")
659
+ if (parts.length >= 2 && /^\d+$/.test(parts[parts.length - 1])) {
660
+ // This is an array element path like "maps/1"
661
+ const arrayPath = parts.slice(0, -1).join("/")
662
+ let arrayValue = obj
663
+
664
+ // Navigate to the array
665
+ for (const part of parts.slice(0, -1)) {
666
+ if (/^\d+$/.test(part)) {
667
+ arrayValue = arrayValue[parseInt(part) - 1]
668
+ } else {
669
+ arrayValue = arrayValue[part]
670
+ }
671
+ }
672
+
673
+ // Check if this array contains only empty elements
674
+ if (Array.isArray(arrayValue)) {
675
+ const hasOnlyEmptyElements = arrayValue.every(
676
+ item =>
677
+ (Array.isArray(item) && item.length === 0) ||
678
+ (isPojo(item) && Object.keys(item).length === 0) ||
679
+ (typeof item === "string" && item === "")
680
+ )
681
+
682
+ if (hasOnlyEmptyElements) {
683
+ // Filter out paths to individual empty elements
684
+ console.log(`[filter] Removing path to empty element: "${k}"`)
685
+ return false
686
+ }
687
+ }
688
+ }
689
+
690
+ return true
691
+ })
692
+ console.log("\n=== collectBodyKeys RESULT ===")
693
+ console.log("Final bodyKeys:", JSON.stringify(result))
694
+ console.log("=== collectBodyKeys END ===\n")
695
+
696
+ return result
282
697
  }
283
698
 
284
699
  async function encode(obj = {}) {
285
- // Convert symbols to strings for logging
700
+ console.log("\n=== ENCODE START ===")
701
+ console.log("Encoding object:", JSON.stringify(obj))
702
+
286
703
  const processValue = value => {
287
704
  if (typeof value === "symbol") {
288
705
  return value.description || "Symbol.for()"
@@ -303,231 +720,248 @@ async function encode(obj = {}) {
303
720
  processedObj[k] = processValue(v)
304
721
  }
305
722
 
306
- // Remove debug logging for cleaner output
307
- console.log("[encode] START with obj:", JSON.stringify(processedObj))
308
-
309
723
  if (Object.keys(obj).length === 0) {
310
724
  return { headers: {}, body: undefined }
311
725
  }
312
726
 
313
- // Check for special case: body field with binary + other simple fields or empty binaries
727
+ const objKeys = Object.keys(obj)
728
+
729
+ if (objKeys.length === 1) {
730
+ const fieldName = objKeys[0]
731
+ const fieldValue = obj[fieldName]
732
+
733
+ if (
734
+ isBytes(fieldValue) &&
735
+ (fieldValue.length === 0 || fieldValue.byteLength === 0)
736
+ ) {
737
+ const headers = {}
738
+ headers["ao-types"] = `${fieldName.toLowerCase()}="empty-binary"`
739
+ return { headers, body: undefined }
740
+ }
741
+ }
742
+
743
+ if (
744
+ obj.body &&
745
+ isBytes(obj.body) &&
746
+ (obj.body.length === 0 || obj.body.byteLength === 0) &&
747
+ objKeys.length > 1
748
+ ) {
749
+ }
750
+
314
751
  const hasBodyBinary = obj.body && isBytes(obj.body)
315
752
  const otherFields = Object.keys(obj).filter(k => k !== "body")
316
- const allOthersSimpleOrEmptyBinary = otherFields.every(k => {
317
- const v = obj[k]
318
- // Allow empty binaries as "simple"
319
- if (isBytes(v) && (v.length === 0 || v.byteLength === 0)) return true
320
- return (
321
- !isBytes(v) &&
322
- !isPojo(v) &&
323
- !(Array.isArray(v) && v.some(item => isPojo(item) || isBytes(item)))
324
- )
325
- })
326
753
 
327
- if (hasBodyBinary && allOthersSimpleOrEmptyBinary) {
328
- console.log("[encode] Special case: body with binary + simple fields")
329
- // Special case: body with binary + other simple fields
754
+ if (hasBodyBinary && otherFields.length === 0) {
330
755
  const headers = {}
331
- const headerTypes = []
756
+ const bodyBuffer = toBuffer(obj.body)
757
+ const bodyArrayBuffer = bodyBuffer.buffer.slice(
758
+ bodyBuffer.byteOffset,
759
+ bodyBuffer.byteOffset + bodyBuffer.byteLength
760
+ )
332
761
 
333
- // Process other fields into headers
334
- for (const [key, value] of Object.entries(obj)) {
335
- if (key === "body") continue
336
- console.log(
337
- `[encode] Processing special case field: ${key} = ${JSON.stringify(value)}`
762
+ const contentDigest = await sha256(bodyArrayBuffer)
763
+ const base64 = base64url.toBase64(base64url.encode(contentDigest))
764
+ headers["content-digest"] = `sha-256=:${base64}:`
765
+
766
+ return { headers, body: obj.body }
767
+ }
768
+
769
+ if (objKeys.length === 1) {
770
+ const fieldName = objKeys[0]
771
+ const fieldValue = obj[fieldName]
772
+
773
+ if (isBytes(fieldValue) && fieldValue.length > 0) {
774
+ const headers = {}
775
+ const bodyBuffer = toBuffer(fieldValue)
776
+ const bodyArrayBuffer = bodyBuffer.buffer.slice(
777
+ bodyBuffer.byteOffset,
778
+ bodyBuffer.byteOffset + bodyBuffer.byteLength
338
779
  )
339
780
 
781
+ const contentDigest = await sha256(bodyArrayBuffer)
782
+ const base64 = base64url.toBase64(base64url.encode(contentDigest))
783
+ headers["content-digest"] = `sha-256=:${base64}:`
784
+
785
+ if (fieldName !== "body") {
786
+ headers["inline-body-key"] = fieldName
787
+ }
788
+
789
+ return { headers, body: fieldValue }
790
+ } else if (
791
+ (fieldName === "data" || fieldName === "body") &&
792
+ (typeof fieldValue === "string" ||
793
+ typeof fieldValue === "boolean" ||
794
+ typeof fieldValue === "number" ||
795
+ fieldValue === null ||
796
+ fieldValue === undefined ||
797
+ typeof fieldValue === "symbol")
798
+ ) {
799
+ const headers = {}
800
+
801
+ let bodyContent
802
+ if (typeof fieldValue === "string") {
803
+ bodyContent = fieldValue
804
+ } else if (typeof fieldValue === "boolean") {
805
+ bodyContent = `"${fieldValue}"`
806
+ } else if (typeof fieldValue === "number") {
807
+ bodyContent = String(fieldValue)
808
+ } else if (fieldValue === null) {
809
+ bodyContent = '"null"'
810
+ } else if (fieldValue === undefined) {
811
+ bodyContent = '"undefined"'
812
+ } else if (typeof fieldValue === "symbol") {
813
+ bodyContent = `"${fieldValue.description || "Symbol.for()"}"`
814
+ }
815
+
816
+ const encoder = new TextEncoder()
817
+ const encoded = encoder.encode(bodyContent)
818
+ const contentDigest = await sha256(encoded.buffer)
819
+ const base64 = base64url.toBase64(base64url.encode(contentDigest))
820
+ headers["content-digest"] = `sha-256=:${base64}:`
821
+
822
+ if (
823
+ typeof fieldValue === "boolean" ||
824
+ fieldValue === null ||
825
+ fieldValue === undefined ||
826
+ typeof fieldValue === "symbol"
827
+ ) {
828
+ headers["ao-types"] = `${fieldName.toLowerCase()}="atom"`
829
+ } else if (typeof fieldValue === "number") {
830
+ headers["ao-types"] =
831
+ `${fieldName.toLowerCase()}="${Number.isInteger(fieldValue) ? "integer" : "float"}"`
832
+ }
833
+
834
+ if (fieldName !== "body") {
835
+ headers["inline-body-key"] = fieldName
836
+ }
837
+
838
+ return { headers, body: bodyContent }
839
+ } else if (typeof fieldValue === "string" && hasNonAscii(fieldValue)) {
840
+ const headers = {}
841
+ const encoder = new TextEncoder()
842
+ const encoded = encoder.encode(fieldValue)
843
+ const contentDigest = await sha256(encoded.buffer)
844
+ const base64 = base64url.toBase64(base64url.encode(contentDigest))
845
+ headers["content-digest"] = `sha-256=:${base64}:`
846
+
847
+ if (fieldName !== "body") {
848
+ headers["inline-body-key"] = fieldName
849
+ }
850
+
851
+ return { headers, body: fieldValue }
852
+ }
853
+ }
854
+
855
+ const bodyKeys = collectBodyKeys(obj)
856
+ const headers = {}
857
+ const headerTypes = []
858
+
859
+ for (const [key, value] of Object.entries(obj)) {
860
+ const needsBody =
861
+ bodyKeys.includes(key) || bodyKeys.some(k => k.startsWith(`${key}/`))
862
+
863
+ if (!needsBody) {
340
864
  if (value === null) {
341
865
  headers[key] = '"null"'
342
- headerTypes.push(`${key}="atom"`)
866
+ headerTypes.push(`${key.toLowerCase()}="atom"`)
343
867
  } else if (value === undefined) {
344
868
  headers[key] = '"undefined"'
345
- headerTypes.push(`${key}="atom"`)
869
+ headerTypes.push(`${key.toLowerCase()}="atom"`)
346
870
  } else if (typeof value === "boolean") {
347
871
  headers[key] = `"${value}"`
348
- headerTypes.push(`${key}="atom"`)
872
+ headerTypes.push(`${key.toLowerCase()}="atom"`)
349
873
  } else if (typeof value === "symbol") {
350
874
  headers[key] = `"${value.description || "Symbol.for()"}"`
351
- headerTypes.push(`${key}="atom"`)
875
+ headerTypes.push(`${key.toLowerCase()}="atom"`)
352
876
  } else if (typeof value === "number") {
353
877
  headers[key] = String(value)
354
878
  headerTypes.push(
355
- `${key}="${Number.isInteger(value) ? "integer" : "float"}"`
879
+ `${key.toLowerCase()}="${Number.isInteger(value) ? "integer" : "float"}"`
356
880
  )
357
881
  } else if (typeof value === "string") {
358
882
  if (value.length === 0) {
359
- // Empty strings only go in ao-types, not as headers
360
- console.log(`[encode] Adding empty string type for key: ${key}`)
361
- headerTypes.push(`${key}="empty-binary"`)
883
+ headerTypes.push(`${key.toLowerCase()}="empty-binary"`)
884
+ } else if (hasNonAscii(value)) {
885
+ continue
362
886
  } else {
363
887
  headers[key] = value
364
888
  }
365
889
  } else if (Array.isArray(value) && value.length === 0) {
366
- // Empty array only goes in ao-types, not as a header
367
- headerTypes.push(`${key}="empty-list"`)
890
+ headerTypes.push(`${key.toLowerCase()}="empty-list"`)
368
891
  } else if (Array.isArray(value) && !value.some(item => isPojo(item))) {
369
- const encodedItems = value.map(item => encodeArrayItem(item)).join(", ")
370
- headers[key] = encodedItems
371
- headerTypes.push(`${key}="list"`)
892
+ const hasNonAsciiItems = value.some(
893
+ item => typeof item === "string" && hasNonAscii(item)
894
+ )
895
+ if (!hasNonAsciiItems) {
896
+ const encodedItems = value
897
+ .map(item => encodeArrayItem(item))
898
+ .join(", ")
899
+ headers[key] = encodedItems
900
+ headerTypes.push(`${key.toLowerCase()}="list"`)
901
+ }
372
902
  } else if (
373
903
  isBytes(value) &&
374
904
  (value.length === 0 || value.byteLength === 0)
375
905
  ) {
376
- // Empty binary goes in ao-types only
377
- headerTypes.push(`${key}="empty-binary"`)
378
- }
379
- }
380
-
381
- // Add ao-types if needed
382
- if (headerTypes.length > 0) {
383
- headers["ao-types"] = headerTypes.sort().join(", ")
384
- }
385
-
386
- // Set body to binary
387
- const bodyBuffer = toBuffer(obj.body)
388
- const bodyArrayBuffer = bodyBuffer.buffer.slice(
389
- bodyBuffer.byteOffset,
390
- bodyBuffer.byteOffset + bodyBuffer.byteLength
391
- )
392
-
393
- const contentDigest = await sha256(bodyArrayBuffer)
394
- const base64 = base64url.toBase64(base64url.encode(contentDigest))
395
- headers["content-digest"] = `sha-256=:${base64}:`
396
-
397
- console.log(
398
- "[encode] FINAL (body with binary) - headers:",
399
- headers,
400
- "body:",
401
- obj.body
402
- )
403
- return { headers, body: obj.body }
404
- }
405
-
406
- // Check if single binary field
407
- const objKeys = Object.keys(obj)
408
- if (objKeys.length === 1 && isBytes(obj[objKeys[0]])) {
409
- const fieldName = objKeys[0]
410
- const binaryData = obj[fieldName]
411
-
412
- const headers = {}
413
- const bodyBuffer = toBuffer(binaryData)
414
- const bodyArrayBuffer = bodyBuffer.buffer.slice(
415
- bodyBuffer.byteOffset,
416
- bodyBuffer.byteOffset + bodyBuffer.byteLength
417
- )
418
-
419
- const contentDigest = await sha256(bodyArrayBuffer)
420
- const base64 = base64url.toBase64(base64url.encode(contentDigest))
421
- headers["content-digest"] = `sha-256=:${base64}:`
422
-
423
- // Add inline-body-key header to preserve the field name
424
- if (fieldName !== "body") {
425
- headers["inline-body-key"] = fieldName
426
- }
427
-
428
- console.log(
429
- "[encode] FINAL (simple binary field) - headers:",
430
- headers,
431
- "body:",
432
- binaryData
433
- )
434
- return { headers, body: binaryData }
435
- }
436
-
437
- // Continue with normal multipart processing
438
- const headers = {}
439
- const headerTypes = []
440
-
441
- // Collect all body keys
442
- const bodyKeys = collectBodyKeys(obj)
443
-
444
- // Process simple header fields AND collect types for body fields
445
- for (const [key, value] of Object.entries(obj)) {
446
- console.log(
447
- `[encode] Processing field: ${key} = ${JSON.stringify(value)}, type: ${typeof value}`
448
- )
449
- const needsBody =
450
- bodyKeys.includes(key) || bodyKeys.some(k => k.startsWith(`${key}/`))
451
-
452
- if (!needsBody) {
453
- console.log(`[encode] Field ${key} doesn't need body, adding to headers`)
454
- // Simple value goes in header
455
- if (value === null) {
456
- headers[key] = '"null"'
457
- headerTypes.push(`${key}="atom"`)
458
- } else if (value === undefined) {
459
- headers[key] = '"undefined"'
460
- headerTypes.push(`${key}="atom"`)
461
- } else if (typeof value === "boolean") {
462
- headers[key] = `"${value}"`
463
- headerTypes.push(`${key}="atom"`)
464
- } else if (typeof value === "symbol") {
465
- headers[key] = `"${value.description || "Symbol.for()"}"`
466
- headerTypes.push(`${key}="atom"`)
467
- } else if (typeof value === "number") {
468
- headers[key] = String(value)
469
- headerTypes.push(
470
- `${key}="${Number.isInteger(value) ? "integer" : "float"}"`
471
- )
472
- } else if (typeof value === "string") {
473
- if (value.length === 0) {
474
- headerTypes.push(`${key}="empty-binary"`)
475
- // Don't add empty strings as headers
476
- } else {
477
- headers[key] = value
478
- }
479
- } else if (Array.isArray(value) && !value.some(item => isPojo(item))) {
480
- // Simple array (no objects) goes in header
481
- const encodedItems = value.map(item => encodeArrayItem(item)).join(", ")
482
- headers[key] = encodedItems
483
- headerTypes.push(`${key}="list"`)
906
+ headerTypes.push(`${key.toLowerCase()}="empty-binary"`)
907
+ } else if (isPojo(value) && Object.keys(value).length === 0) {
908
+ headerTypes.push(`${key.toLowerCase()}="empty-message"`)
484
909
  }
485
910
  } else {
486
- // Field needs body - still need to add type info to ao-types
487
911
  if (isBytes(value) && (value.length === 0 || value.byteLength === 0)) {
488
- headerTypes.push(`${key}="empty-binary"`)
912
+ headerTypes.push(`${key.toLowerCase()}="empty-binary"`)
489
913
  } else if (typeof value === "string" && value.length === 0) {
490
- headerTypes.push(`${key}="empty-binary"`)
914
+ headerTypes.push(`${key.toLowerCase()}="empty-binary"`)
491
915
  } else if (Array.isArray(value) && value.length === 0) {
492
- headerTypes.push(`${key}="empty-list"`)
916
+ headerTypes.push(`${key.toLowerCase()}="empty-list"`)
493
917
  } else if (isPojo(value) && Object.keys(value).length === 0) {
494
- headerTypes.push(`${key}="empty-message"`)
918
+ headerTypes.push(`${key.toLowerCase()}="empty-message"`)
919
+ } else if (
920
+ typeof value === "boolean" ||
921
+ value === null ||
922
+ value === undefined ||
923
+ typeof value === "symbol"
924
+ ) {
925
+ headerTypes.push(`${key.toLowerCase()}="atom"`)
926
+ } else if (typeof value === "number") {
927
+ headerTypes.push(
928
+ `${key.toLowerCase()}="${Number.isInteger(value) ? "integer" : "float"}"`
929
+ )
495
930
  }
496
931
  }
497
932
  }
498
933
 
499
- // Add ao-types for arrays that go in body
500
934
  for (const [key, value] of Object.entries(obj)) {
501
935
  if (Array.isArray(value)) {
502
- // Check if this array goes in the body
503
936
  if (
504
937
  bodyKeys.includes(key) ||
505
938
  bodyKeys.some(k => k.startsWith(`${key}/`))
506
939
  ) {
507
- // Don't add list type if it's already been added
508
- if (!headerTypes.some(t => t.startsWith(`${key}=`))) {
509
- headerTypes.push(`${key}="list"`)
940
+ if (!headerTypes.some(t => t.startsWith(`${key.toLowerCase()}=`))) {
941
+ headerTypes.push(`${key.toLowerCase()}="list"`)
510
942
  }
511
943
  }
512
944
  }
513
945
  }
514
946
 
515
- // If no body needed
516
947
  if (bodyKeys.length === 0) {
948
+ console.log("No bodyKeys, returning headers only")
517
949
  if (headerTypes.length > 0) {
518
950
  headers["ao-types"] = headerTypes.sort().join(", ")
519
951
  }
520
- console.log("[encode] FINAL - headers:", headers, "body:", undefined)
521
952
  return { headers, body: undefined }
522
953
  }
523
954
 
524
- // Check if all body keys are for empty binaries - if so, treat as no body needed
525
955
  const allBodyKeysAreEmptyBinaries = bodyKeys.every(key => {
526
956
  const pathParts = key.split("/")
527
957
  let value = obj
528
958
  for (const part of pathParts) {
529
959
  if (/^\d+$/.test(part)) {
530
- value = value[parseInt(part) - 1]
960
+ const index = parseInt(part) - 1
961
+ console.log(
962
+ `[Body part] Getting array element at index ${index} from part ${part}`
963
+ )
964
+ value = value[index]
531
965
  } else {
532
966
  value = value[part]
533
967
  }
@@ -536,29 +970,87 @@ async function encode(obj = {}) {
536
970
  })
537
971
 
538
972
  if (allBodyKeysAreEmptyBinaries) {
539
- // Treat as header-only encoding
540
973
  if (headerTypes.length > 0) {
541
974
  headers["ao-types"] = headerTypes.sort().join(", ")
542
975
  }
543
- console.log(
544
- "[encode] FINAL (all empty binaries) - headers:",
545
- headers,
546
- "body:",
547
- undefined
548
- )
549
976
  return { headers, body: undefined }
550
977
  }
551
978
 
552
- // Sort body keys and add to headers
979
+ if (bodyKeys.length === 1) {
980
+ const singleKey = bodyKeys[0]
981
+ const pathParts = singleKey.split("/")
982
+ let value = obj
983
+ for (const part of pathParts) {
984
+ if (/^\d+$/.test(part)) {
985
+ value = value[parseInt(part) - 1]
986
+ } else {
987
+ value = value[part]
988
+ }
989
+ }
990
+
991
+ const otherFieldsAreEmpty = Object.entries(obj).every(([key, val]) => {
992
+ if (key === singleKey) return true
993
+ return (
994
+ (Array.isArray(val) && val.length === 0) ||
995
+ (isPojo(val) && Object.keys(val).length === 0) ||
996
+ (isBytes(val) && (val.length === 0 || val.byteLength === 0)) ||
997
+ (typeof val === "string" && val.length === 0)
998
+ )
999
+ })
1000
+
1001
+ if (otherFieldsAreEmpty && isBytes(value) && value.length > 0) {
1002
+ const bodyBuffer = toBuffer(value)
1003
+ const bodyArrayBuffer = bodyBuffer.buffer.slice(
1004
+ bodyBuffer.byteOffset,
1005
+ bodyBuffer.byteOffset + bodyBuffer.byteLength
1006
+ )
1007
+
1008
+ const contentDigest = await sha256(bodyArrayBuffer)
1009
+ const base64 = base64url.toBase64(base64url.encode(contentDigest))
1010
+ headers["content-digest"] = `sha-256=:${base64}:`
1011
+
1012
+ if (singleKey !== "body") {
1013
+ headers["inline-body-key"] = singleKey
1014
+ }
1015
+
1016
+ if (headerTypes.length > 0) {
1017
+ headers["ao-types"] = headerTypes.sort().join(", ")
1018
+ }
1019
+
1020
+ return { headers, body: value }
1021
+ }
1022
+ }
1023
+
1024
+ // Sort body keys: main array comes first, then element parts by index
553
1025
  const sortedBodyKeys = bodyKeys.sort((a, b) => {
554
- // Special sorting to ensure parent paths come before child paths
555
- if (a.startsWith(b + "/")) return 1
556
- if (b.startsWith(a + "/")) return -1
1026
+ const aIsArrayElement = /\/\d+$/.test(a)
1027
+ const bIsArrayElement = /\/\d+$/.test(b)
1028
+ const aBase = a.split("/")[0]
1029
+ const bBase = b.split("/")[0]
1030
+
1031
+ // If both are for the same array
1032
+ if (aBase === bBase) {
1033
+ // Main array comes before element parts
1034
+ if (!aIsArrayElement && bIsArrayElement) {
1035
+ return -1 // main array comes first
1036
+ }
1037
+ if (aIsArrayElement && !bIsArrayElement) {
1038
+ return 1 // element parts come after
1039
+ }
1040
+ // Both are elements - sort by index
1041
+ if (aIsArrayElement && bIsArrayElement) {
1042
+ const aIndex = parseInt(a.split("/")[1])
1043
+ const bIndex = parseInt(b.split("/")[1])
1044
+ return aIndex - bIndex
1045
+ }
1046
+ return a.localeCompare(b)
1047
+ }
1048
+
1049
+ // Different arrays, sort by base name
557
1050
  return a.localeCompare(b)
558
1051
  })
559
1052
 
560
- // Special case: if we have both 'data' and 'body' keys where data.body is binary
561
- // then we need special handling per Erlang behavior
1053
+ // Check if we have the special case where data contains body with bytes and body contains data with bytes
562
1054
  const hasSpecialDataBody =
563
1055
  sortedBodyKeys.includes("data") &&
564
1056
  sortedBodyKeys.includes("body") &&
@@ -571,34 +1063,26 @@ async function encode(obj = {}) {
571
1063
 
572
1064
  headers["body-keys"] = sortedBodyKeys.map(k => `"${k}"`).join(", ")
573
1065
 
574
- // Check for inline keys - but not in the special data/body case
575
1066
  if (!hasSpecialDataBody) {
576
- const inlineKey = headers["inline-body-key"]
577
- if (!inlineKey) {
578
- // Only set inline-body-key if we have ONLY body (not data)
579
- if (sortedBodyKeys.includes("body") && !sortedBodyKeys.includes("data")) {
580
- headers["inline-body-key"] = "body"
581
- }
1067
+ if (sortedBodyKeys.includes("body") && sortedBodyKeys.length === 1) {
1068
+ headers["inline-body-key"] = "body"
582
1069
  }
583
1070
  }
584
1071
 
585
- // Add ao-types header if needed
586
1072
  if (headerTypes.length > 0) {
587
1073
  headers["ao-types"] = headerTypes.sort().join(", ")
588
1074
  }
589
1075
 
590
- // Create multipart body parts
591
1076
  const bodyParts = []
592
1077
 
593
1078
  for (const bodyKey of sortedBodyKeys) {
1079
+ console.log(`\n[Body part] Processing bodyKey: ${bodyKey}`)
594
1080
  const lines = []
595
1081
 
596
- // Parse the path to get to the value
597
1082
  const pathParts = bodyKey.split("/")
598
1083
  let value = obj
599
1084
  let parent = null
600
1085
 
601
- // Get the actual value at this path
602
1086
  for (let i = 0; i < pathParts.length; i++) {
603
1087
  parent = value
604
1088
  const part = pathParts[i]
@@ -610,339 +1094,75 @@ async function encode(obj = {}) {
610
1094
  }
611
1095
  }
612
1096
 
613
- console.log(
614
- "[encode] Processing body key:",
615
- bodyKey,
616
- "value type:",
617
- Array.isArray(value) ? "array" : typeof value
618
- )
619
-
620
- // Skip if value is an array with only objects (no content for this body part)
621
- if (Array.isArray(value) && value.every(item => isPojo(item))) {
622
- continue
623
- }
624
-
625
- // Skip if this is an empty object
626
- if (isPojo(value) && Object.keys(value).length === 0) {
627
- continue
628
- }
629
-
630
- // Special handling for the data/body pattern
631
- if (
632
- hasSpecialDataBody &&
633
- bodyKey === "data" &&
634
- isPojo(value) &&
635
- Object.keys(value).length === 1 &&
636
- value.body &&
637
- isBytes(value.body)
638
- ) {
639
- // Skip creating inline content for 'data', will handle data/body separately
640
- continue
641
- }
642
-
643
- console.log(
644
- "[encode] Creating body part for key:",
645
- bodyKey,
646
- "value type:",
647
- typeof value,
648
- "isBytes:",
649
- isBytes(value)
650
- )
1097
+ console.log(`[Body part] Value at ${bodyKey}:`, JSON.stringify(value))
651
1098
 
652
- // Determine content-disposition
653
- const isInline = bodyKey === "body" && headers["inline-body-key"] === "body"
654
- if (isInline) {
655
- lines.push(`content-disposition: inline`)
656
- } else {
1099
+ // Special handling for empty strings in arrays
1100
+ if (typeof value === "string" && value === "" && pathParts.length > 1) {
1101
+ console.log(`[Body part] Creating part for empty string at ${bodyKey}`)
657
1102
  lines.push(`content-disposition: form-data;name="${bodyKey}"`)
1103
+ lines.push("")
1104
+ lines.push("")
1105
+ bodyParts.push(new Blob([lines.join("\r\n")]))
1106
+ continue
658
1107
  }
659
1108
 
660
- console.log("[encode] Value type checks:", {
661
- isBytes: isBytes(value),
662
- isPojo: isPojo(value),
663
- isArray: Array.isArray(value),
664
- valueType: typeof value,
665
- })
666
-
1109
+ // Handle direct binary values
667
1110
  if (isBytes(value)) {
668
- console.log("[encode] Processing binary value for key:", bodyKey)
669
- // Binary data
1111
+ console.log(`[Body part] Creating part for binary at ${bodyKey}`)
1112
+ lines.push(`content-disposition: form-data;name="${bodyKey}"`)
670
1113
  const buffer = toBuffer(value)
1114
+ const headerText = lines.join("\r\n") + "\r\n\r\n"
1115
+ bodyParts.push(new Blob([headerText, buffer]))
1116
+ continue
1117
+ }
671
1118
 
672
- // Check if this is a nested path like "data/body"
673
- if (bodyKey.includes("/")) {
674
- // For nested binary, we need to replace the disposition
675
- lines[lines.length - 1] =
676
- `content-disposition: form-data;name="${bodyKey}"`
677
- lines.push("") // Empty line
678
- lines.push("") // Another empty line before binary
679
- const textPart = lines.join("\r\n")
680
- bodyParts.push(new Blob([textPart, buffer]))
681
- } else {
682
- lines.push("") // Empty line after headers
683
- lines.push("") // Another empty line before binary data
684
- const textPart = lines.join("\r\n")
685
- bodyParts.push(new Blob([textPart, buffer]))
686
- }
687
- } else if (isPojo(value)) {
688
- console.log("[encode] Processing object value")
689
- // Object - only include fields that aren't handled by nested body parts
690
- const objectTypes = []
691
- const fieldLines = []
692
- const binaryFields = []
693
-
694
- for (const [k, v] of Object.entries(value)) {
695
- const childPath = `${bodyKey}/${k}`
696
-
697
- // Skip if this field has its own body part
698
- if (sortedBodyKeys.includes(childPath)) {
699
- continue
700
- }
701
-
702
- // Skip if this is an array of objects (handled separately)
703
- if (Array.isArray(v) && v.some(item => isPojo(item))) {
704
- continue
705
- }
706
-
707
- // Add type info
708
- if (Array.isArray(v)) {
709
- objectTypes.push(`${k}="${v.length === 0 ? "empty-list" : "list"}"`)
710
- } else if (
711
- v === null ||
712
- v === undefined ||
713
- typeof v === "symbol" ||
714
- typeof v === "boolean"
715
- ) {
716
- objectTypes.push(`${k}="atom"`)
717
- } else if (typeof v === "number") {
718
- objectTypes.push(
719
- `${k}="${Number.isInteger(v) ? "integer" : "float"}"`
720
- )
721
- } else if (typeof v === "string" && v.length === 0) {
722
- objectTypes.push(`${k}="empty-binary"`)
723
- } else if (isBytes(v) && (v.length === 0 || v.byteLength === 0)) {
724
- objectTypes.push(`${k}="empty-binary"`)
725
- } else if (isPojo(v) && Object.keys(v).length === 0) {
726
- objectTypes.push(`${k}="empty-message"`)
727
- }
728
-
729
- // Add field value
730
- if (typeof v === "string") {
731
- fieldLines.push(`${k}: ${v}`)
732
- } else if (typeof v === "number") {
733
- fieldLines.push(`${k}: ${v}`)
734
- } else if (typeof v === "boolean") {
735
- fieldLines.push(`${k}: "${v}"`)
736
- } else if (v === null) {
737
- fieldLines.push(`${k}: "null"`)
738
- } else if (v === undefined) {
739
- fieldLines.push(`${k}: "undefined"`)
740
- } else if (typeof v === "symbol") {
741
- fieldLines.push(`${k}: "${v.description || "Symbol.for()"}"`)
742
- } else if (isBytes(v)) {
743
- const buffer = toBuffer(v)
744
- // For inline data/body parts, binary fields get raw bytes
745
- if (isInline) {
746
- // Skip here - will be handled specially
747
- continue
748
- } else {
749
- // For non-inline parts, we need to add raw bytes, not base64
750
- // Store the binary field for later processing
751
- binaryFields.push({ key: k, buffer })
752
- continue
753
- }
754
- } else if (Array.isArray(v)) {
755
- if (v.length === 0) {
756
- fieldLines.push(`${k}: `)
757
- } else {
758
- const encodedItems = v.map(item => encodeArrayItem(item)).join(", ")
759
- fieldLines.push(`${k}: ${encodedItems}`)
760
- }
761
- } else if (isPojo(v) && Object.keys(v).length === 0) {
762
- // Empty object - no content line needed, just ao-type
763
- }
764
- }
765
-
766
- // Check if this object only has empty collections
767
- const onlyEmptyCollections = Object.entries(value).every(([k, v]) => {
768
- const childPath = `${bodyKey}/${k}`
769
- if (sortedBodyKeys.includes(childPath)) return true
770
- if (Array.isArray(v) && v.some(item => isPojo(item))) return true
771
-
772
- return (
773
- (Array.isArray(v) && v.length === 0) ||
774
- (isPojo(v) && Object.keys(v).length === 0) ||
775
- (isBytes(v) && (v.length === 0 || v.byteLength === 0))
1119
+ if (Array.isArray(value)) {
1120
+ const hasOnlyEmptyElements =
1121
+ value.length > 0 &&
1122
+ value.every(
1123
+ item =>
1124
+ (Array.isArray(item) && item.length === 0) ||
1125
+ (isPojo(item) && Object.keys(item).length === 0) ||
1126
+ (typeof item === "string" && item === "")
776
1127
  )
777
- })
778
-
779
- // Special handling for inline body
780
- if (isInline) {
781
- // For inline: fields first, then ao-types, then content-disposition
782
- const orderedLines = []
783
-
784
- // First: field lines
785
- if (!onlyEmptyCollections) {
786
- for (const line of fieldLines) {
787
- orderedLines.push(line)
788
- }
789
- }
790
-
791
- // Then: ao-types
792
- if (objectTypes.length > 0) {
793
- orderedLines.push(`ao-types: ${objectTypes.sort().join(", ")}`)
794
- }
795
-
796
- // Finally: content-disposition
797
- orderedLines.push("content-disposition: inline")
798
-
799
- // Check if this has binary fields
800
- const binaryFields = Object.entries(value)
801
- .filter(
802
- ([k, v]) =>
803
- isBytes(v) && !sortedBodyKeys.includes(`${bodyKey}/${k}`)
804
- )
805
- .map(([k, v]) => ({
806
- key: k,
807
- buffer: toBuffer(v),
808
- }))
809
-
810
- if (binaryFields.length > 0) {
811
- // Build the parts
812
- const parts = []
813
-
814
- // Add the text part
815
- parts.push(Buffer.from(orderedLines.join("\r\n")))
816
-
817
- // Add binary fields with raw bytes
818
- for (const { key, buffer } of binaryFields) {
819
- parts.push(Buffer.from(`\r\n${key}: `))
820
- parts.push(buffer)
821
- }
822
-
823
- // Add trailing \r\n for inline parts with binary
824
- parts.push(Buffer.from("\r\n"))
825
-
826
- const fullBody = Buffer.concat(parts)
827
- bodyParts.push(new Blob([fullBody]))
828
- } else {
829
- orderedLines.push("") // Add empty line for trailing \r\n
830
- bodyParts.push(new Blob([orderedLines.join("\r\n")]))
831
- }
832
- } else {
833
- // Normal handling (non-inline)
834
- // ao-types first if needed
835
- if (objectTypes.length > 0) {
836
- lines.unshift(`ao-types: ${objectTypes.sort().join(", ")}`)
837
- }
838
-
839
- // Only add field lines if not all collections are empty
840
- if (!onlyEmptyCollections) {
841
- for (const line of fieldLines) {
842
- lines.push(line)
843
- }
844
- }
845
-
846
- // Then handle binary fields with raw bytes if any
847
- if (binaryFields && binaryFields.length > 0) {
848
- // Create parts array for proper ordering
849
- const parts = []
850
-
851
- // Add headers and text fields
852
- const headerText = lines.join("\r\n") + "\r\n"
853
- parts.push(Buffer.from(headerText))
854
-
855
- // Add binary fields with raw bytes
856
- for (const { key, buffer } of binaryFields) {
857
- parts.push(Buffer.from(`${key}: `))
858
- parts.push(buffer)
859
- parts.push(Buffer.from("\r\n"))
860
- }
861
-
862
- const fullBody = Buffer.concat(parts)
863
- bodyParts.push(new Blob([fullBody]))
864
- } else {
865
- lines.push("")
866
- bodyParts.push(new Blob([lines.join("\r\n")]))
867
- }
868
- }
869
- } else if (Array.isArray(value)) {
870
- // Array field - check if it's a mixed array or array of arrays
1128
+ const hasOnlyNonEmptyObjects =
1129
+ value.length > 0 &&
1130
+ value.every(item => isPojo(item) && Object.keys(item).length > 0)
1131
+ const hasOnlyEmptyObjects =
1132
+ value.length > 0 &&
1133
+ value.every(item => isPojo(item) && Object.keys(item).length === 0)
871
1134
  const hasObjects = value.some(item => isPojo(item))
872
1135
  const hasArrays = value.some(item => Array.isArray(item))
873
1136
  const nonObjectItems = value
874
1137
  .map((item, index) => ({ item, index: index + 1 }))
875
1138
  .filter(({ item }) => !isPojo(item))
876
1139
 
877
- if (hasObjects && nonObjectItems.length > 0) {
878
- // Mixed array - only include non-object items
1140
+ if (hasOnlyNonEmptyObjects) {
1141
+ continue
1142
+ }
1143
+
1144
+ // For arrays containing only empty elements, we still need to show type info
1145
+ if (hasOnlyEmptyElements) {
879
1146
  const fieldLines = []
880
1147
  const partTypes = []
881
1148
 
882
- for (const { item, index } of nonObjectItems) {
883
- if (typeof item === "number") {
884
- if (Number.isInteger(item)) {
885
- partTypes.push(`${index}="integer"`)
886
- fieldLines.push(`${index}: ${item}`)
887
- } else {
888
- partTypes.push(`${index}="float"`)
889
- fieldLines.push(`${index}: ${formatFloat(item)}`)
890
- }
891
- } else if (typeof item === "string") {
892
- fieldLines.push(`${index}: ${item}`)
893
- } else if (
894
- item === null ||
895
- item === undefined ||
896
- typeof item === "symbol" ||
897
- typeof item === "boolean"
898
- ) {
899
- partTypes.push(`${index}="atom"`)
900
- if (item === null) {
901
- fieldLines.push(`${index}: "null"`)
902
- } else if (item === undefined) {
903
- fieldLines.push(`${index}: "undefined"`)
904
- } else if (typeof item === "symbol") {
905
- fieldLines.push(
906
- `${index}: "${item.description || "Symbol.for()"}"`
907
- )
908
- } else {
909
- fieldLines.push(`${index}: "${item}"`)
910
- }
911
- } else if (isBytes(item)) {
912
- // Binary items in arrays need special handling
913
- const buffer = toBuffer(item)
914
- if (buffer.length === 0) {
915
- partTypes.push(`${index}="empty-binary"`)
916
- }
917
- // For now, skip binary items in mixed arrays
918
- // They should be handled differently
919
- partTypes.push(`${index}="binary"`)
920
- } else if (Array.isArray(item)) {
921
- partTypes.push(`${index}="list"`)
922
- const encodedItems = item
923
- .map(subItem => encodeArrayItem(subItem))
924
- .join(", ")
925
- fieldLines.push(`${index}: ${encodedItems}`)
1149
+ // Process items for type information
1150
+ value.forEach((item, idx) => {
1151
+ const index = idx + 1
1152
+ if (Array.isArray(item) && item.length === 0) {
1153
+ partTypes.push(`${index}="empty-list"`)
1154
+ } else if (isPojo(item) && Object.keys(item).length === 0) {
1155
+ partTypes.push(`${index}="empty-message"`)
1156
+ } else if (typeof item === "string" && item === "") {
1157
+ partTypes.push(`${index}="empty-binary"`)
926
1158
  }
927
- }
1159
+ })
928
1160
 
929
- // For inline arrays, use different order
930
- if (isInline) {
931
- console.log("[encode] Reordering for inline array:", {
932
- bodyKey,
933
- fieldLines,
934
- partTypes,
935
- })
1161
+ const isInline =
1162
+ bodyKey === "body" && headers["inline-body-key"] === "body"
936
1163
 
937
- // Rebuild in correct order: field lines, ao-types, content-disposition
1164
+ if (isInline) {
938
1165
  const orderedLines = []
939
-
940
- // First: field lines
941
- for (const line of fieldLines) {
942
- orderedLines.push(line)
943
- }
944
-
945
- // Then: ao-types
946
1166
  if (partTypes.length > 0) {
947
1167
  orderedLines.push(
948
1168
  `ao-types: ${partTypes
@@ -954,18 +1174,13 @@ async function encode(obj = {}) {
954
1174
  .join(", ")}`
955
1175
  )
956
1176
  }
957
-
958
- // Finally: content-disposition (from lines[0])
959
- orderedLines.push(lines[0])
1177
+ orderedLines.push("content-disposition: inline")
960
1178
  orderedLines.push("")
961
-
962
- console.log("[encode] Ordered lines:", orderedLines)
963
-
964
1179
  bodyParts.push(new Blob([orderedLines.join("\r\n")]))
965
1180
  } else {
966
- // Normal order for non-inline parts
1181
+ const orderedLines = []
967
1182
  if (partTypes.length > 0) {
968
- lines.unshift(
1183
+ orderedLines.push(
969
1184
  `ao-types: ${partTypes
970
1185
  .sort((a, b) => {
971
1186
  const aNum = parseInt(a.split("=")[0])
@@ -975,146 +1190,294 @@ async function encode(obj = {}) {
975
1190
  .join(", ")}`
976
1191
  )
977
1192
  }
1193
+ orderedLines.push(`content-disposition: form-data;name="${bodyKey}"`)
978
1194
 
979
- for (const line of fieldLines) {
980
- lines.push(line)
1195
+ // Check if this is the last body part
1196
+ const isLastBodyPart =
1197
+ sortedBodyKeys.indexOf(bodyKey) === sortedBodyKeys.length - 1
1198
+ const hasOnlyTypes = partTypes.length > 0 && fieldLines.length === 0
1199
+
1200
+ if (isLastBodyPart && hasOnlyTypes) {
1201
+ // Don't add empty line for last part with only types
1202
+ bodyParts.push(new Blob([orderedLines.join("\r\n")]))
1203
+ } else {
1204
+ orderedLines.push("")
1205
+ bodyParts.push(new Blob([orderedLines.join("\r\n")]))
981
1206
  }
1207
+ }
1208
+ continue
1209
+ }
982
1210
 
983
- lines.push("")
984
- bodyParts.push(new Blob([lines.join("\r\n")]))
1211
+ // Build list of which indices have their own parts
1212
+ const indicesWithOwnParts = new Set()
1213
+ sortedBodyKeys.forEach(key => {
1214
+ if (key.startsWith(bodyKey + "/")) {
1215
+ const subPath = key.substring(bodyKey.length + 1)
1216
+ const match = subPath.match(/^(\d+)/)
1217
+ if (match) {
1218
+ indicesWithOwnParts.add(parseInt(match[1]))
1219
+ }
985
1220
  }
986
- } else if (hasArrays || (!hasObjects && value.length > 0)) {
987
- // Array of arrays or simple array - use indexed format
988
- const fieldLines = []
989
- const partTypes = []
1221
+ })
990
1222
 
1223
+ // Check if this array contains sub-arrays with objects that have their own parts
1224
+ const hasNestedObjectParts = sortedBodyKeys.some(
1225
+ key =>
1226
+ key.startsWith(bodyKey + "/") &&
1227
+ key.split("/").length > pathParts.length + 1
1228
+ )
1229
+
1230
+ const fieldLines = []
1231
+ const partTypes = []
1232
+
1233
+ // For arrays that contain sub-arrays with objects, we need to add type info for the sub-arrays
1234
+ if (hasNestedObjectParts) {
991
1235
  value.forEach((item, idx) => {
992
1236
  const index = idx + 1
993
1237
  if (Array.isArray(item)) {
994
- if (item.length === 0) {
995
- partTypes.push(`${index}="empty-list"`)
996
- } else {
997
- partTypes.push(`${index}="list"`)
998
- const encodedItems = item
999
- .map(subItem => encodeArrayItem(subItem))
1000
- .join(", ")
1001
- fieldLines.push(`${index}: ${encodedItems}`)
1002
- }
1003
- } else if (typeof item === "number") {
1004
- if (Number.isInteger(item)) {
1005
- partTypes.push(`${index}="integer"`)
1006
- fieldLines.push(`${index}: ${item}`)
1007
- } else {
1008
- partTypes.push(`${index}="float"`)
1009
- fieldLines.push(`${index}: ${formatFloat(item)}`)
1010
- }
1011
- } else if (typeof item === "string") {
1012
- if (item.length === 0) {
1013
- partTypes.push(`${index}="empty-binary"`)
1014
- }
1015
- fieldLines.push(`${index}: ${item}`)
1016
- } else if (
1017
- item === null ||
1018
- item === undefined ||
1019
- typeof item === "symbol" ||
1020
- typeof item === "boolean"
1021
- ) {
1022
- partTypes.push(`${index}="atom"`)
1023
- if (item === null) {
1024
- fieldLines.push(`${index}: "null"`)
1025
- } else if (item === undefined) {
1026
- fieldLines.push(`${index}: "undefined"`)
1027
- } else if (typeof item === "symbol") {
1028
- fieldLines.push(
1029
- `${index}: "${item.description || "Symbol.for()"}"`
1030
- )
1031
- } else {
1032
- fieldLines.push(`${index}: "${item}"`)
1033
- }
1034
- } else if (isBytes(item)) {
1035
- const buffer = toBuffer(item)
1036
- if (buffer.length === 0) {
1037
- partTypes.push(`${index}="empty-binary"`)
1038
- } else {
1039
- partTypes.push(`${index}="binary"`)
1040
- }
1041
- // For indexed format, we also can't include raw bytes inline
1042
- // This is a limitation of the format
1238
+ partTypes.push(`${index}="list"`)
1043
1239
  }
1044
1240
  })
1241
+ }
1045
1242
 
1046
- // For inline arrays, use different order
1047
- if (isInline) {
1048
- console.log("[encode] Reordering for inline array - indexed format")
1243
+ // Check if this array has mixed content with empty strings/objects
1244
+ const hasEmptyStrings = value.some(
1245
+ item => typeof item === "string" && item === ""
1246
+ )
1247
+ const hasEmptyObjects = value.some(
1248
+ item => isPojo(item) && Object.keys(item).length === 0
1249
+ )
1049
1250
 
1050
- const orderedLines = []
1251
+ // Check if we have objects with only empty values (like {empty: ""})
1252
+ const hasObjectsWithOnlyEmptyValues = value.some(item => {
1253
+ if (!isPojo(item) || Object.keys(item).length === 0) return false
1254
+ return Object.values(item).every(
1255
+ v =>
1256
+ (typeof v === "string" && v === "") ||
1257
+ (Array.isArray(v) && v.length === 0) ||
1258
+ (isPojo(v) && Object.keys(v).length === 0)
1259
+ )
1260
+ })
1051
1261
 
1052
- // First: field lines
1053
- for (const line of fieldLines) {
1054
- orderedLines.push(line)
1055
- }
1262
+ const isMixedArray =
1263
+ hasObjects &&
1264
+ (hasEmptyStrings || hasEmptyObjects) &&
1265
+ !hasObjectsWithOnlyEmptyValues
1056
1266
 
1057
- // Then: ao-types
1058
- if (partTypes.length > 0) {
1059
- orderedLines.push(
1060
- `ao-types: ${partTypes
1061
- .sort((a, b) => {
1062
- const aNum = parseInt(a.split("=")[0])
1063
- const bNum = parseInt(b.split("=")[0])
1064
- return aNum - bNum
1065
- })
1066
- .join(", ")}`
1067
- )
1068
- }
1267
+ // Process ALL items for type information
1268
+ value.forEach((item, idx) => {
1269
+ const index = idx + 1
1069
1270
 
1070
- // Finally: content-disposition
1071
- orderedLines.push(lines[0])
1072
- orderedLines.push("")
1271
+ // Skip type info for elements that have their own parts
1272
+ if (indicesWithOwnParts.has(index)) {
1273
+ return
1274
+ }
1073
1275
 
1074
- console.log("[encode] Final ordered lines:", orderedLines)
1075
- bodyParts.push(new Blob([orderedLines.join("\r\n")]))
1076
- } else {
1077
- // Normal order for non-inline parts
1078
- if (partTypes.length > 0) {
1079
- console.log("[encode] Adding ao-types to beginning of lines array")
1080
- lines.unshift(
1081
- `ao-types: ${partTypes
1082
- .sort((a, b) => {
1083
- const aNum = parseInt(a.split("=")[0])
1084
- const bNum = parseInt(b.split("=")[0])
1085
- return aNum - bNum
1086
- })
1087
- .join(", ")}`
1088
- )
1276
+ // If we have nested object parts and this is an array with objects, skip processing it inline
1277
+ if (
1278
+ hasNestedObjectParts &&
1279
+ Array.isArray(item) &&
1280
+ item.some(subItem => isPojo(subItem))
1281
+ ) {
1282
+ // Type info already added above
1283
+ return
1284
+ }
1285
+
1286
+ // For all arrays (not just mixed ones), we need to process all items
1287
+ if (typeof item === "string" && item === "") {
1288
+ // Empty strings get type annotation but no field line
1289
+ partTypes.push(`${index}="empty-binary"`)
1290
+ } else if (isPojo(item) && Object.keys(item).length === 0) {
1291
+ // Empty objects don't get field lines but do get type annotations
1292
+ partTypes.push(`${index}="empty-message"`)
1293
+ } else if (isPojo(item)) {
1294
+ // Non-empty objects might have parts
1295
+ } else if (Array.isArray(item)) {
1296
+ if (item.length === 0) {
1297
+ // Empty arrays don't get field lines but do get type annotations
1298
+ partTypes.push(`${index}="empty-list"`)
1299
+ } else {
1300
+ partTypes.push(`${index}="list"`)
1301
+ // Encode array
1302
+ const encodedItems = item
1303
+ .map(subItem => {
1304
+ if (typeof subItem === "number") {
1305
+ if (Number.isInteger(subItem)) {
1306
+ return `"(ao-type-integer) ${subItem}"`
1307
+ } else {
1308
+ return `"(ao-type-float) ${formatFloat(subItem)}"`
1309
+ }
1310
+ } else if (typeof subItem === "string") {
1311
+ return `"${subItem}"`
1312
+ } else if (subItem === null) {
1313
+ return `"(ao-type-atom) \\"null\\""`
1314
+ } else if (subItem === undefined) {
1315
+ return `"(ao-type-atom) \\"undefined\\""`
1316
+ } else if (typeof subItem === "symbol") {
1317
+ const desc = subItem.description || "Symbol.for()"
1318
+ return `"(ao-type-atom) \\"${desc}\\""`
1319
+ } else if (typeof subItem === "boolean") {
1320
+ return `"(ao-type-atom) \\"${subItem}\\""`
1321
+ } else if (Array.isArray(subItem)) {
1322
+ // Use the full encodeArrayItem for nested arrays
1323
+ return encodeArrayItem(subItem)
1324
+ } else if (isBytes(subItem)) {
1325
+ const buffer = toBuffer(subItem)
1326
+ if (buffer.length === 0 || buffer.byteLength === 0) {
1327
+ return `""`
1328
+ }
1329
+ return `"(ao-type-binary)"`
1330
+ } else if (isPojo(subItem)) {
1331
+ const json = JSON.stringify(subItem)
1332
+ const escaped = json
1333
+ .replace(/\\/g, "\\\\")
1334
+ .replace(/"/g, '\\"')
1335
+ return `"(ao-type-map) ${escaped}"`
1336
+ } else {
1337
+ return `"${String(subItem)}"`
1338
+ }
1339
+ })
1340
+ .join(", ")
1341
+ fieldLines.push(`${index}: ${encodedItems}`)
1089
1342
  }
1343
+ } else if (typeof item === "number") {
1344
+ if (Number.isInteger(item)) {
1345
+ partTypes.push(`${index}="integer"`)
1346
+ fieldLines.push(`${index}: ${item}`)
1347
+ } else {
1348
+ partTypes.push(`${index}="float"`)
1349
+ fieldLines.push(`${index}: ${formatFloat(item)}`)
1350
+ }
1351
+ } else if (typeof item === "string") {
1352
+ // Non-empty strings just get field lines, no type annotation
1353
+ fieldLines.push(`${index}: ${item}`)
1354
+ } else if (
1355
+ item === null ||
1356
+ item === undefined ||
1357
+ typeof item === "symbol" ||
1358
+ typeof item === "boolean"
1359
+ ) {
1360
+ partTypes.push(`${index}="atom"`)
1361
+ if (item === null) {
1362
+ fieldLines.push(`${index}: null`)
1363
+ } else if (item === undefined) {
1364
+ fieldLines.push(`${index}: undefined`)
1365
+ } else if (typeof item === "symbol") {
1366
+ const desc = item.description || "Symbol.for()"
1367
+ fieldLines.push(`${index}: ${desc}`)
1368
+ } else {
1369
+ fieldLines.push(`${index}: ${item}`)
1370
+ }
1371
+ } else if (isBytes(item)) {
1372
+ const buffer = toBuffer(item)
1373
+ if (buffer.length === 0) {
1374
+ partTypes.push(`${index}="empty-binary"`)
1375
+ } else {
1376
+ partTypes.push(`${index}="binary"`)
1377
+ }
1378
+ }
1379
+ })
1380
+
1381
+ const isInline =
1382
+ bodyKey === "body" && headers["inline-body-key"] === "body"
1383
+
1384
+ if (isInline) {
1385
+ const orderedLines = []
1386
+
1387
+ if (partTypes.length > 0) {
1388
+ orderedLines.push(
1389
+ `ao-types: ${partTypes
1390
+ .sort((a, b) => {
1391
+ const aNum = parseInt(a.split("=")[0])
1392
+ const bNum = parseInt(b.split("=")[0])
1393
+ return aNum - bNum
1394
+ })
1395
+ .join(", ")}`
1396
+ )
1397
+ }
1398
+
1399
+ orderedLines.push("content-disposition: inline")
1090
1400
 
1091
- console.log("[encode] Adding field lines:", fieldLines)
1401
+ if (fieldLines.length > 0) {
1402
+ orderedLines.push("")
1092
1403
  for (const line of fieldLines) {
1093
- lines.push(line)
1404
+ orderedLines.push(line)
1094
1405
  }
1406
+ }
1407
+
1408
+ bodyParts.push(new Blob([orderedLines.join("\r\n") + "\r\n"]))
1409
+ } else {
1410
+ // Put ao-types first, then content-disposition, then field lines
1411
+ const orderedLines = []
1412
+
1413
+ if (partTypes.length > 0) {
1414
+ orderedLines.push(
1415
+ `ao-types: ${partTypes
1416
+ .sort((a, b) => {
1417
+ const aNum = parseInt(a.split("=")[0])
1418
+ const bNum = parseInt(b.split("=")[0])
1419
+ return aNum - bNum
1420
+ })
1421
+ .join(", ")}`
1422
+ )
1423
+ }
1424
+
1425
+ orderedLines.push(`content-disposition: form-data;name="${bodyKey}"`)
1426
+
1427
+ // Add field lines directly without blank line
1428
+ for (const line of fieldLines) {
1429
+ orderedLines.push(line)
1430
+ }
1095
1431
 
1096
- console.log("[encode] Final lines before blob:", lines)
1097
- lines.push("")
1098
- bodyParts.push(new Blob([lines.join("\r\n")]))
1432
+ // Add trailing blank line if we have field lines
1433
+ if (fieldLines.length > 0) {
1434
+ bodyParts.push(new Blob([orderedLines.join("\r\n") + "\r\n"]))
1435
+ } else {
1436
+ bodyParts.push(new Blob([orderedLines.join("\r\n")]))
1099
1437
  }
1100
- } else if (!hasObjects && value.length === 0) {
1101
- // Empty array
1102
- const fieldName = pathParts[pathParts.length - 1]
1103
- const partTypes = [`${fieldName}="empty-list"`]
1104
- lines.unshift(`ao-types: ${partTypes.join(", ")}`)
1105
- lines.push("")
1106
- bodyParts.push(new Blob([lines.join("\r\n")]))
1107
1438
  }
1108
- } else if (typeof value === "string") {
1109
- // String with newlines or too long
1439
+ continue
1440
+ }
1441
+
1442
+ // This should not be reached for binary values as they're handled above
1443
+ const isInline = bodyKey === "body" && headers["inline-body-key"] === "body"
1444
+ if (isInline) {
1445
+ lines.push(`content-disposition: inline`)
1446
+ } else {
1447
+ lines.push(`content-disposition: form-data;name="${bodyKey}"`)
1448
+ }
1449
+
1450
+ if (typeof value === "string") {
1110
1451
  lines.push("")
1111
1452
  lines.push(value)
1453
+ bodyParts.push(new Blob([lines.join("\r\n")]))
1454
+ } else if (
1455
+ typeof value === "boolean" ||
1456
+ typeof value === "number" ||
1457
+ value === null ||
1458
+ value === undefined ||
1459
+ typeof value === "symbol"
1460
+ ) {
1461
+ let content
1462
+ if (typeof value === "boolean") {
1463
+ content = `"${value}"`
1464
+ } else if (typeof value === "number") {
1465
+ content = String(value)
1466
+ } else if (value === null) {
1467
+ content = '"null"'
1468
+ } else if (value === undefined) {
1469
+ content = '"undefined"'
1470
+ } else if (typeof value === "symbol") {
1471
+ content = `"${value.description || "Symbol.for()"}"`
1472
+ }
1473
+
1112
1474
  lines.push("")
1475
+ lines.push(content)
1113
1476
  bodyParts.push(new Blob([lines.join("\r\n")]))
1114
1477
  }
1115
1478
  }
1116
1479
 
1117
- // Special case: add data/body as a separate form-data part if needed
1480
+ // Add the special data/body part if needed
1118
1481
  if (
1119
1482
  hasSpecialDataBody &&
1120
1483
  obj.data &&
@@ -1130,13 +1493,11 @@ async function encode(obj = {}) {
1130
1493
  bodyParts.push(new Blob([specialPart, buffer]))
1131
1494
  }
1132
1495
 
1133
- // Calculate boundary from content
1134
1496
  const partsContent = await Promise.all(bodyParts.map(part => part.text()))
1135
1497
  const allContent = partsContent.join("")
1136
1498
  const boundaryHash = await sha256(new TextEncoder().encode(allContent))
1137
1499
  const boundary = base64url.encode(Buffer.from(boundaryHash))
1138
1500
 
1139
- // Assemble final multipart body - NO newlines after each part except the last
1140
1501
  const finalParts = []
1141
1502
  for (let i = 0; i < bodyParts.length; i++) {
1142
1503
  if (i === 0) {
@@ -1151,46 +1512,17 @@ async function encode(obj = {}) {
1151
1512
  headers["content-type"] = `multipart/form-data; boundary="${boundary}"`
1152
1513
  const body = new Blob(finalParts)
1153
1514
 
1154
- // Calculate content digest
1155
1515
  const finalContent = await body.arrayBuffer()
1156
- const contentDigest = await sha256(finalContent)
1157
- const base64 = base64url.toBase64(base64url.encode(contentDigest))
1158
- headers["content-digest"] = `sha-256=:${base64}:`
1159
- headers["content-length"] = String(finalContent.byteLength)
1160
-
1161
- console.log("[encode] FINAL - headers:", headers, "body:", body)
1162
-
1163
- // Debug: decode the multipart body to verify structure
1164
- const bodyText = await body.text()
1165
- console.log("\n[encode] DEBUG - Full body text:")
1166
- console.log(bodyText)
1167
-
1168
- // Parse multipart body
1169
- const boundaryMatch = headers["content-type"].match(/boundary="([^"]+)"/)
1170
- if (boundaryMatch) {
1171
- const debugBoundary = boundaryMatch[1]
1172
- const parts = bodyText.split(`--${debugBoundary}`)
1173
- console.log("\n[encode] DEBUG - Multipart parts:")
1174
- parts.forEach((part, idx) => {
1175
- console.log(`Part ${idx}:`, JSON.stringify(part))
1176
- })
1177
1516
 
1178
- // Show the actual content part
1179
- if (parts[1]) {
1180
- console.log("\n[encode] DEBUG - Content part structure:")
1181
- const lines = parts[1].trim().split("\r\n")
1182
- lines.forEach((line, idx) => {
1183
- if (line.includes("\u0000")) {
1184
- console.log(
1185
- `Line ${idx}: "${line.substring(0, line.indexOf("\u0000"))}" + [${line.length - line.indexOf("\u0000")} bytes]`
1186
- )
1187
- } else {
1188
- console.log(`Line ${idx}: "${line}"`)
1189
- }
1190
- })
1191
- }
1517
+ if (finalContent.byteLength > 0) {
1518
+ const contentDigest = await sha256(finalContent)
1519
+ const base64 = base64url.toBase64(base64url.encode(contentDigest))
1520
+ headers["content-digest"] = `sha-256=:${base64}:`
1192
1521
  }
1193
1522
 
1523
+ headers["content-length"] = String(finalContent.byteLength)
1524
+
1525
+ console.log("=== ENCODE END ===\n")
1194
1526
  return { headers, body }
1195
1527
  }
1196
1528