wao 0.27.3 → 0.28.0

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
@@ -1,739 +1,39 @@
1
1
  import base64url from "base64url"
2
- import { hash } from "fast-sha256"
3
-
4
- function isBytes(value) {
5
- return (
6
- value instanceof ArrayBuffer ||
7
- ArrayBuffer.isView(value) ||
8
- Buffer.isBuffer(value) ||
9
- (value &&
10
- typeof value === "object" &&
11
- value.type === "Buffer" &&
12
- Array.isArray(value.data))
13
- )
14
- }
15
-
16
- function isPojo(value) {
17
- return (
18
- !isBytes(value) &&
19
- !Array.isArray(value) &&
20
- !(value instanceof Blob) &&
21
- typeof value === "object" &&
22
- value !== null
23
- )
24
- }
25
-
2
+ import {
3
+ getValueByPath,
4
+ getAoType,
5
+ isEmpty,
6
+ encodePrimitiveContent,
7
+ sortTypeAnnotations,
8
+ analyzeArray,
9
+ toBuffer,
10
+ formatFloat,
11
+ hasNonAscii,
12
+ sha256,
13
+ hasNewline,
14
+ isBytes,
15
+ isPojo,
16
+ } from "./encode-utils.js"
17
+ import encodeArrayItem from "./encode-array-item.js"
18
+ import collectBodyKeys from "./collect-body-keys.js"
26
19
  const MAX_HEADER_LENGTH = 4096
27
20
 
28
- async function hasNewline(value) {
29
- if (typeof value === "string") return value.includes("\n")
30
- if (value instanceof Blob) {
31
- value = await value.text()
32
- return value.includes("\n")
33
- }
34
- if (isBytes(value)) return Buffer.from(value).includes("\n")
35
- return false
36
- }
37
-
38
- async function sha256(data) {
39
- let uint8Array
40
- if (data instanceof ArrayBuffer) {
41
- uint8Array = new Uint8Array(data)
42
- } else if (data instanceof Uint8Array) {
43
- uint8Array = data
44
- } else if (ArrayBuffer.isView(data)) {
45
- uint8Array = new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
46
- } else {
47
- throw new Error("sha256 expects ArrayBuffer or ArrayBufferView")
48
- }
49
-
50
- const hashResult = hash(uint8Array)
51
- return hashResult.buffer.slice(
52
- hashResult.byteOffset,
53
- hashResult.byteOffset + hashResult.byteLength
54
- )
55
- }
56
-
57
- function formatFloat(num) {
58
- let exp = num.toExponential(20)
59
- exp = exp.replace(/e\+(\d)$/, "e+0$1")
60
- exp = exp.replace(/e-(\d)$/, "e-0$1")
61
- return exp
62
- }
63
-
64
- function hasNonAscii(str) {
65
- return /[^\x00-\x7F]/.test(str)
66
- }
67
-
68
- function encodeArrayItem(item) {
69
- if (typeof item === "number") {
70
- if (Number.isInteger(item)) {
71
- return `"(ao-type-integer) ${item}"`
72
- } else {
73
- return `"(ao-type-float) ${formatFloat(item)}"`
74
- }
75
- } else if (typeof item === "string") {
76
- return `"${item}"`
77
- } else if (item === null) {
78
- return `"(ao-type-atom) \\"null\\""`
79
- } else if (item === undefined) {
80
- return `"(ao-type-atom) \\"undefined\\""`
81
- } else if (typeof item === "symbol") {
82
- const desc = item.description || "Symbol.for()"
83
- return `"(ao-type-atom) \\"${desc}\\""`
84
- } else if (typeof item === "boolean") {
85
- return `"(ao-type-atom) \\"${item}\\""`
86
- } else if (Array.isArray(item)) {
87
- const nestedItems = item
88
- .map(nestedItem => {
89
- if (typeof nestedItem === "number") {
90
- if (Number.isInteger(nestedItem)) {
91
- return `\\"(ao-type-integer) ${nestedItem}\\"`
92
- } else {
93
- return `\\"(ao-type-float) ${formatFloat(nestedItem)}\\"`
94
- }
95
- } else if (typeof nestedItem === "string") {
96
- return `\\"${nestedItem}\\"`
97
- } else if (nestedItem === null) {
98
- return `\\"(ao-type-atom) \\\\\\"null\\\\\\"\\"`
99
- } else if (nestedItem === undefined) {
100
- return `\\"(ao-type-atom) \\\\\\"undefined\\\\\\"\\"`
101
- } else if (typeof nestedItem === "symbol") {
102
- const desc = nestedItem.description || "Symbol.for()"
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}\\"`
151
- } else {
152
- return `\\"${String(nestedItem)}\\"`
153
- }
154
- })
155
- .join(", ")
156
- return `"(ao-type-list) ${nestedItems}"`
157
- } else if (isBytes(item)) {
158
- const buffer = toBuffer(item)
159
- if (buffer.length === 0 || buffer.byteLength === 0) {
160
- return `""`
161
- }
162
- return `"(ao-type-binary)"`
163
- } else if (isPojo(item)) {
164
- const json = JSON.stringify(item)
165
- const escaped = json.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
166
- return `"(ao-type-map) ${escaped}"`
167
- } else {
168
- return `"${String(item)}"`
169
- }
170
- }
171
-
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)
186
- }
187
- }
188
-
189
- function collectBodyKeys(obj, prefix = "") {
190
- console.log("=== collectBodyKeys START ===")
191
- console.log("Input object:", JSON.stringify(obj))
192
-
193
- const keys = []
194
-
195
- function traverse(current, path) {
196
- console.log(`[traverse] Called with path: "${path}"`)
197
- let hasSimpleFields = false
198
- const nestedPaths = []
199
- let hasArraysWithObjects = false
200
-
201
- for (const [key, value] of Object.entries(current)) {
202
- const fullPath = path ? `${path}/${key}` : key
203
-
204
- if (Array.isArray(value)) {
205
- console.log(
206
- `[traverse] Found array at ${fullPath}, length: ${value.length}`
207
- )
208
- const hasObjects = value.some(item => isPojo(item))
209
- const hasNonObjects = value.some(item => !isPojo(item))
210
-
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
- )
238
- })
239
-
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
- }
280
- }
281
- } else {
282
- console.log(
283
- `[traverse] Non-empty array without objects at ${fullPath} - marking as simple field`
284
- )
285
- hasSimpleFields = true
286
- }
287
- } else if (isPojo(value)) {
288
- if (Object.keys(value).length === 0) {
289
- console.log(
290
- `[traverse] Empty object at ${fullPath} - marking parent as having simple fields`
291
- )
292
- hasSimpleFields = true
293
- } else {
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
- }
341
- }
342
- } else if (isBytes(value) && value.length > 0) {
343
- hasSimpleFields = true
344
- } else if (
345
- typeof value === "string" ||
346
- typeof value === "number" ||
347
- typeof value === "boolean" ||
348
- value === null ||
349
- value === undefined ||
350
- typeof value === "symbol"
351
- ) {
352
- hasSimpleFields = true
353
- }
354
- }
355
-
356
- if (hasSimpleFields) {
357
- console.log(`[traverse] Adding "${path}" to keys (has simple fields)`)
358
- keys.push(path)
359
- } else if (hasArraysWithObjects && path) {
360
- // If the object only contains arrays with objects, we still need to add it as a body key
361
- console.log(
362
- `[traverse] Adding "${path}" to keys (contains arrays with objects)`
363
- )
364
- keys.push(path)
365
- }
366
-
367
- // Check for arrays with only empty elements that need their own body parts
368
- for (const [key, value] of Object.entries(current)) {
369
- const fullPath = path ? `${path}/${key}` : key
370
-
371
- if (Array.isArray(value) && value.length > 0) {
372
- const hasOnlyEmptyElements = value.every(
373
- item =>
374
- (Array.isArray(item) && item.length === 0) ||
375
- (isPojo(item) && Object.keys(item).length === 0) ||
376
- (typeof item === "string" && item === "")
377
- )
378
-
379
- if (hasOnlyEmptyElements) {
380
- console.log(
381
- `[traverse] Array at ${fullPath} has only empty elements - adding as body key`
382
- )
383
- keys.push(fullPath)
384
- }
385
- }
386
- }
387
-
388
- for (const nestedPath of nestedPaths) {
389
- const parts = nestedPath.split("/")
390
- let nestedObj = obj
391
-
392
- for (const part of parts) {
393
- if (/^\d+$/.test(part)) {
394
- nestedObj = nestedObj[parseInt(part) - 1]
395
- } else {
396
- nestedObj = nestedObj[part]
397
- }
398
- }
399
-
400
- if (isPojo(nestedObj)) {
401
- traverse(nestedObj, nestedPath)
402
- }
403
- }
404
- }
405
-
406
- const objKeys = Object.keys(obj)
407
-
408
- for (const [key, value] of Object.entries(obj)) {
409
- console.log(`\n[main loop] Processing key: "${key}"`)
410
- console.log(
411
- `[main loop] Value type: ${Array.isArray(value) ? "array" : typeof value}`
412
- )
413
- console.log(
414
- `[main loop] Array length: ${Array.isArray(value) ? value.length : "N/A"}`
415
- )
416
-
417
- if (
418
- (key === "data" || key === "body") &&
419
- (typeof value === "string" ||
420
- typeof value === "boolean" ||
421
- typeof value === "number" ||
422
- value === null ||
423
- value === undefined ||
424
- typeof value === "symbol") &&
425
- objKeys.length > 1
426
- ) {
427
- // Special handling: only add to body keys if there's no other data/body field with an object
428
- if (
429
- key === "data" &&
430
- obj.body &&
431
- isPojo(obj.body) &&
432
- Object.keys(obj.body).length > 0
433
- ) {
434
- console.log(`[main loop] Skipping special data field`)
435
- } else if (
436
- key === "body" &&
437
- obj.data &&
438
- isPojo(obj.data) &&
439
- Object.keys(obj.data).length > 0
440
- ) {
441
- console.log(`[main loop] Skipping special body field`)
442
- } else {
443
- console.log(`[main loop] Adding special data/body key: "${key}"`)
444
- keys.push(key)
445
- }
446
- } else if (Array.isArray(value)) {
447
- if (value.length === 0) {
448
- console.log(`[main loop] SKIPPING empty array for key: "${key}"`)
449
- continue
450
- }
451
-
452
- const hasObjects = value.some(item => isPojo(item))
453
- const hasArrays = value.some(item => Array.isArray(item))
454
- const hasNonObjects = value.some(item => !isPojo(item))
455
-
456
- // Check if this is an array of arrays containing objects
457
- const hasArraysOfObjects = value.some(
458
- item => Array.isArray(item) && item.some(subItem => isPojo(subItem))
459
- )
460
-
461
- console.log(
462
- `[main loop] Array analysis: hasObjects=${hasObjects}, hasArrays=${hasArrays}, hasNonObjects=${hasNonObjects}, hasArraysOfObjects=${hasArraysOfObjects}`
463
- )
464
-
465
- if (value.length > 0) {
466
- let bodyPartCounter = 1 // Start counting from 1
467
-
468
- // Check for special mixed array case
469
- const hasEmptyStrings = value.some(
470
- item => typeof item === "string" && item === ""
471
- )
472
- const hasEmptyObjects = value.some(
473
- item => isPojo(item) && Object.keys(item).length === 0
474
- )
475
-
476
- // Check for objects that contain only empty values
477
- const hasObjectsWithOnlyEmptyValues = value.some(item => {
478
- if (!isPojo(item) || Object.keys(item).length === 0) return false
479
- return Object.values(item).every(
480
- v =>
481
- (typeof v === "string" && v === "") ||
482
- (Array.isArray(v) && v.length === 0) ||
483
- (isPojo(v) && Object.keys(v).length === 0)
484
- )
485
- })
486
-
487
- if (hasArraysOfObjects) {
488
- // Handle arrays of arrays containing objects
489
- value.forEach((item, index) => {
490
- if (Array.isArray(item)) {
491
- item.forEach((subItem, subIndex) => {
492
- if (isPojo(subItem)) {
493
- const path = `${key}/${index + 1}/${subIndex + 1}`
494
- console.log(
495
- `[main loop] Adding nested object path: "${path}"`
496
- )
497
- keys.push(path)
498
- }
499
- })
500
- }
501
- bodyPartCounter++
502
- })
503
- // Always add the main array key
504
- console.log(`[main loop] ADDING main array key: "${key}"`)
505
- keys.push(key)
506
- } else if (
507
- hasObjects &&
508
- (hasEmptyStrings || hasEmptyObjects) &&
509
- !hasObjectsWithOnlyEmptyValues
510
- ) {
511
- // Special handling: only non-empty objects get parts
512
- value.forEach((item, index) => {
513
- if (isPojo(item) && Object.keys(item).length > 0) {
514
- const path = `${key}/${bodyPartCounter}`
515
- console.log(
516
- `[main loop] Adding non-empty object path: "${path}" (array index ${index})`
517
- )
518
- keys.push(path)
519
- // Add paths for nested objects
520
- for (const [nestedKey, nestedValue] of Object.entries(item)) {
521
- if (isPojo(nestedValue)) {
522
- const nestedPath = `${key}/${bodyPartCounter}/${nestedKey}`
523
- console.log(
524
- `[main loop] Adding nested object path: "${nestedPath}"`
525
- )
526
- keys.push(nestedPath)
527
- }
528
- }
529
- }
530
- bodyPartCounter++
531
- })
532
- // Always add the main array key
533
- console.log(`[main loop] ADDING main array key: "${key}"`)
534
- keys.push(key)
535
- } else if (hasObjects) {
536
- // Normal handling: all objects get parts (except if parent array has only empty elements)
537
- let skipEmptyObjects = false
538
-
539
- // Check if this array contains only empty elements
540
- const arrayHasOnlyEmptyElements = value.every(
541
- item =>
542
- (Array.isArray(item) && item.length === 0) ||
543
- (isPojo(item) && Object.keys(item).length === 0) ||
544
- (typeof item === "string" && item === "")
545
- )
546
-
547
- if (arrayHasOnlyEmptyElements) {
548
- skipEmptyObjects = true
549
- }
550
-
551
- value.forEach((item, index) => {
552
- if (isPojo(item)) {
553
- // Skip empty objects if array has only empty elements
554
- if (skipEmptyObjects && Object.keys(item).length === 0) {
555
- bodyPartCounter++
556
- return
557
- }
558
-
559
- const path = `${key}/${bodyPartCounter}`
560
- console.log(
561
- `[main loop] Adding object path: "${path}" (array index ${index}, empty=${Object.keys(item).length === 0})`
562
- )
563
- keys.push(path)
564
- // Add paths for nested objects (but not empty ones)
565
- if (Object.keys(item).length > 0) {
566
- for (const [nestedKey, nestedValue] of Object.entries(item)) {
567
- if (
568
- isPojo(nestedValue) &&
569
- Object.keys(nestedValue).length > 0
570
- ) {
571
- const nestedPath = `${key}/${bodyPartCounter}/${nestedKey}`
572
- console.log(
573
- `[main loop] Adding nested object path: "${nestedPath}"`
574
- )
575
- keys.push(nestedPath)
576
- }
577
- }
578
- }
579
- } else if (typeof item === "string" && item === "") {
580
- // Empty strings may get parts in some formats
581
- const path = `${key}/${bodyPartCounter}`
582
- console.log(
583
- `[main loop] Adding empty string path: "${path}" (array index ${index})`
584
- )
585
- keys.push(path)
586
- }
587
- bodyPartCounter++
588
- })
589
- // Don't add main array key for arrays with only objects containing empty values
590
- if (
591
- !hasObjectsWithOnlyEmptyValues ||
592
- value.some(item => !isPojo(item))
593
- ) {
594
- // Check if array has only empty elements
595
- const hasOnlyEmptyElements = value.every(
596
- item =>
597
- (Array.isArray(item) && item.length === 0) ||
598
- (isPojo(item) && Object.keys(item).length === 0) ||
599
- (typeof item === "string" && item === "")
600
- )
601
-
602
- if (!hasOnlyEmptyElements) {
603
- // Always add the main array key
604
- console.log(`[main loop] ADDING main array key: "${key}"`)
605
- keys.push(key)
606
- }
607
- }
608
- } else {
609
- // Check if array has only empty elements
610
- const hasOnlyEmptyArraysOrObjects = value.every(
611
- item =>
612
- (Array.isArray(item) && item.length === 0) ||
613
- (isPojo(item) && Object.keys(item).length === 0) ||
614
- (typeof item === "string" && item === "")
615
- )
616
-
617
- if (hasOnlyEmptyArraysOrObjects && value.length > 0) {
618
- // Always add the main array key for arrays with only empty elements
619
- console.log(
620
- `[main loop] ADDING main array key for empty elements: "${key}"`
621
- )
622
- keys.push(key)
623
- } else if (!hasOnlyEmptyArraysOrObjects) {
624
- // Always add the main array key
625
- console.log(`[main loop] ADDING main array key: "${key}"`)
626
- keys.push(key)
627
- }
628
- }
629
- }
630
- } else if (isPojo(value)) {
631
- console.log(`[main loop] Traversing object at key: "${key}"`)
632
- // Check if this object contains arrays with only empty elements
633
- let hasArraysWithOnlyEmptyElements = false
634
- for (const [k, v] of Object.entries(value)) {
635
- if (
636
- Array.isArray(v) &&
637
- v.length > 0 &&
638
- v.every(
639
- item =>
640
- (Array.isArray(item) && item.length === 0) ||
641
- (isPojo(item) && Object.keys(item).length === 0) ||
642
- (typeof item === "string" && item === "")
643
- )
644
- ) {
645
- hasArraysWithOnlyEmptyElements = true
646
- keys.push(`${key}/${k}`)
647
- }
648
- }
649
- traverse(value, key)
650
- } else if (isBytes(value) && value.length > 0) {
651
- console.log(`[main loop] Adding key for non-empty bytes: "${key}"`)
652
- keys.push(key)
653
- } else if (typeof value === "string" && value.includes("\n")) {
654
- console.log(`[main loop] Adding key for string with newline: "${key}"`)
655
- keys.push(key)
656
- } else if (typeof value === "string" && hasNonAscii(value)) {
657
- console.log(`[main loop] Adding key for non-ASCII string: "${key}"`)
658
- keys.push(key)
659
- } else {
660
- console.log(`[main loop] Skipping key: "${key}" (no match)`)
661
- }
662
- }
663
-
664
- const result = [...new Set(keys)].filter(k => {
665
- if (k === "") return false
666
-
667
- // Check if this is a path to an empty object inside an array with only empty elements
668
- const parts = k.split("/")
669
- if (parts.length >= 2 && /^\d+$/.test(parts[parts.length - 1])) {
670
- // This is an array element path like "maps/1"
671
- const arrayPath = parts.slice(0, -1).join("/")
672
- let arrayValue = obj
673
-
674
- // Navigate to the array
675
- for (const part of parts.slice(0, -1)) {
676
- if (/^\d+$/.test(part)) {
677
- arrayValue = arrayValue[parseInt(part) - 1]
678
- } else {
679
- arrayValue = arrayValue[part]
680
- }
681
- }
682
-
683
- // Check if this array contains only empty elements
684
- if (Array.isArray(arrayValue)) {
685
- const hasOnlyEmptyElements = arrayValue.every(
686
- item =>
687
- (Array.isArray(item) && item.length === 0) ||
688
- (isPojo(item) && Object.keys(item).length === 0) ||
689
- (typeof item === "string" && item === "")
690
- )
691
-
692
- if (hasOnlyEmptyElements) {
693
- // Filter out paths to individual empty elements
694
- console.log(`[filter] Removing path to empty element: "${k}"`)
695
- return false
696
- }
697
- }
698
- }
699
-
700
- return true
701
- })
702
- console.log("\n=== collectBodyKeys RESULT ===")
703
- console.log("Final bodyKeys:", JSON.stringify(result))
704
- console.log("=== collectBodyKeys END ===\n")
705
-
706
- return result
21
+ // Step 1: Process and normalize input values (handle symbols, nested objects/arrays)
22
+ function processInputValues(obj) {
23
+ // Currently this is a no-op, but will be used for input validation/normalization
24
+ return obj
707
25
  }
708
26
 
709
- async function encode(obj = {}) {
710
- console.log("\n=== ENCODE START ===")
711
- console.log("Encoding object:", JSON.stringify(obj))
712
-
713
- const processValue = value => {
714
- if (typeof value === "symbol") {
715
- return value.description || "Symbol.for()"
716
- } else if (Array.isArray(value)) {
717
- return value.map(processValue)
718
- } else if (isPojo(value)) {
719
- const result = {}
720
- for (const [k, v] of Object.entries(value)) {
721
- result[k] = processValue(v)
722
- }
723
- return result
724
- }
725
- return value
726
- }
727
-
728
- const processedObj = {}
729
- for (const [k, v] of Object.entries(obj)) {
730
- processedObj[k] = processValue(v)
731
- }
732
-
27
+ // Step 2: Handle empty object case
28
+ function handleEmptyObject(obj) {
733
29
  if (Object.keys(obj).length === 0) {
734
30
  return { headers: {}, body: undefined }
735
31
  }
32
+ return null
33
+ }
736
34
 
35
+ // Step 3: Handle single field with empty binary
36
+ function handleSingleEmptyBinaryField(obj) {
737
37
  const objKeys = Object.keys(obj)
738
38
 
739
39
  if (objKeys.length === 1) {
@@ -750,14 +50,11 @@ async function encode(obj = {}) {
750
50
  }
751
51
  }
752
52
 
753
- if (
754
- obj.body &&
755
- isBytes(obj.body) &&
756
- (obj.body.length === 0 || obj.body.byteLength === 0) &&
757
- objKeys.length > 1
758
- ) {
759
- }
53
+ return null
54
+ }
760
55
 
56
+ // Step 4: Handle single field with binary data
57
+ async function handleSingleBinaryField(obj) {
761
58
  const hasBodyBinary = obj.body && isBytes(obj.body)
762
59
  const otherFields = Object.keys(obj).filter(k => k !== "body")
763
60
 
@@ -776,28 +73,18 @@ async function encode(obj = {}) {
776
73
  return { headers, body: obj.body }
777
74
  }
778
75
 
76
+ return null
77
+ }
78
+
79
+ // Step 5: Handle single field with primitive value (string/number/boolean/null/undefined/symbol)
80
+ async function handleSinglePrimitiveField(obj) {
81
+ const objKeys = Object.keys(obj)
82
+
779
83
  if (objKeys.length === 1) {
780
84
  const fieldName = objKeys[0]
781
85
  const fieldValue = obj[fieldName]
782
86
 
783
- if (isBytes(fieldValue) && fieldValue.length > 0) {
784
- const headers = {}
785
- const bodyBuffer = toBuffer(fieldValue)
786
- const bodyArrayBuffer = bodyBuffer.buffer.slice(
787
- bodyBuffer.byteOffset,
788
- bodyBuffer.byteOffset + bodyBuffer.byteLength
789
- )
790
-
791
- const contentDigest = await sha256(bodyArrayBuffer)
792
- const base64 = base64url.toBase64(base64url.encode(contentDigest))
793
- headers["content-digest"] = `sha-256=:${base64}:`
794
-
795
- if (fieldName !== "body") {
796
- headers["inline-body-key"] = fieldName
797
- }
798
-
799
- return { headers, body: fieldValue }
800
- } else if (
87
+ if (
801
88
  (fieldName === "data" || fieldName === "body") &&
802
89
  (typeof fieldValue === "string" ||
803
90
  typeof fieldValue === "boolean" ||
@@ -807,21 +94,7 @@ async function encode(obj = {}) {
807
94
  typeof fieldValue === "symbol")
808
95
  ) {
809
96
  const headers = {}
810
-
811
- let bodyContent
812
- if (typeof fieldValue === "string") {
813
- bodyContent = fieldValue
814
- } else if (typeof fieldValue === "boolean") {
815
- bodyContent = `"${fieldValue}"`
816
- } else if (typeof fieldValue === "number") {
817
- bodyContent = String(fieldValue)
818
- } else if (fieldValue === null) {
819
- bodyContent = '"null"'
820
- } else if (fieldValue === undefined) {
821
- bodyContent = '"undefined"'
822
- } else if (typeof fieldValue === "symbol") {
823
- bodyContent = `"${fieldValue.description || "Symbol.for()"}"`
824
- }
97
+ const bodyContent = encodePrimitiveContent(fieldValue)
825
98
 
826
99
  const encoder = new TextEncoder()
827
100
  const encoded = encoder.encode(bodyContent)
@@ -829,16 +102,9 @@ async function encode(obj = {}) {
829
102
  const base64 = base64url.toBase64(base64url.encode(contentDigest))
830
103
  headers["content-digest"] = `sha-256=:${base64}:`
831
104
 
832
- if (
833
- typeof fieldValue === "boolean" ||
834
- fieldValue === null ||
835
- fieldValue === undefined ||
836
- typeof fieldValue === "symbol"
837
- ) {
838
- headers["ao-types"] = `${fieldName.toLowerCase()}="atom"`
839
- } else if (typeof fieldValue === "number") {
840
- headers["ao-types"] =
841
- `${fieldName.toLowerCase()}="${Number.isInteger(fieldValue) ? "integer" : "float"}"`
105
+ const aoType = getAoType(fieldValue)
106
+ if (aoType === "atom" || aoType === "integer" || aoType === "float") {
107
+ headers["ao-types"] = `${fieldName.toLowerCase()}="${aoType}"`
842
108
  }
843
109
 
844
110
  if (fieldName !== "body") {
@@ -846,7 +112,52 @@ async function encode(obj = {}) {
846
112
  }
847
113
 
848
114
  return { headers, body: bodyContent }
849
- } else if (typeof fieldValue === "string" && hasNonAscii(fieldValue)) {
115
+ }
116
+ }
117
+
118
+ return null
119
+ }
120
+
121
+ // Step 6a: Handle single field with non-empty binary (not body field)
122
+ async function handleSingleNonEmptyBinaryField(obj) {
123
+ const objKeys = Object.keys(obj)
124
+
125
+ if (objKeys.length === 1) {
126
+ const fieldName = objKeys[0]
127
+ const fieldValue = obj[fieldName]
128
+
129
+ if (isBytes(fieldValue) && fieldValue.length > 0) {
130
+ const headers = {}
131
+ const bodyBuffer = toBuffer(fieldValue)
132
+ const bodyArrayBuffer = bodyBuffer.buffer.slice(
133
+ bodyBuffer.byteOffset,
134
+ bodyBuffer.byteOffset + bodyBuffer.byteLength
135
+ )
136
+
137
+ const contentDigest = await sha256(bodyArrayBuffer)
138
+ const base64 = base64url.toBase64(base64url.encode(contentDigest))
139
+ headers["content-digest"] = `sha-256=:${base64}:`
140
+
141
+ if (fieldName !== "body") {
142
+ headers["inline-body-key"] = fieldName
143
+ }
144
+
145
+ return { headers, body: fieldValue }
146
+ }
147
+ }
148
+
149
+ return null
150
+ }
151
+
152
+ // Step 6: Handle single field with non-ASCII string
153
+ async function handleSingleNonAsciiStringField(obj) {
154
+ const objKeys = Object.keys(obj)
155
+
156
+ if (objKeys.length === 1) {
157
+ const fieldName = objKeys[0]
158
+ const fieldValue = obj[fieldName]
159
+
160
+ if (typeof fieldValue === "string" && hasNonAscii(fieldValue)) {
850
161
  const headers = {}
851
162
  const encoder = new TextEncoder()
852
163
  const encoded = encoder.encode(fieldValue)
@@ -862,11 +173,16 @@ async function encode(obj = {}) {
862
173
  }
863
174
  }
864
175
 
865
- const headers = {}
866
- const headerTypes = []
176
+ return null
177
+ }
867
178
 
868
- const bodyKeys = collectBodyKeys(obj)
179
+ // Step 7: Collect all keys that need to go in body
180
+ function collectBodyKeysStep(obj) {
181
+ return collectBodyKeys(obj)
182
+ }
869
183
 
184
+ // Step 8: Process fields that can go in headers
185
+ function processHeaderFields(obj, bodyKeys, headers, headerTypes) {
870
186
  for (const [key, value] of Object.entries(obj)) {
871
187
  const needsBody =
872
188
  bodyKeys.includes(key) || bodyKeys.some(k => k.startsWith(`${key}/`))
@@ -919,29 +235,15 @@ async function encode(obj = {}) {
919
235
  headerTypes.push(`${key.toLowerCase()}="empty-message"`)
920
236
  }
921
237
  } else {
922
- if (isBytes(value) && (value.length === 0 || value.byteLength === 0)) {
923
- headerTypes.push(`${key.toLowerCase()}="empty-binary"`)
924
- } else if (typeof value === "string" && value.length === 0) {
925
- headerTypes.push(`${key.toLowerCase()}="empty-binary"`)
926
- } else if (Array.isArray(value) && value.length === 0) {
927
- headerTypes.push(`${key.toLowerCase()}="empty-list"`)
928
- } else if (isPojo(value) && Object.keys(value).length === 0) {
929
- headerTypes.push(`${key.toLowerCase()}="empty-message"`)
930
- } else if (
931
- typeof value === "boolean" ||
932
- value === null ||
933
- value === undefined ||
934
- typeof value === "symbol"
935
- ) {
936
- headerTypes.push(`${key.toLowerCase()}="atom"`)
937
- } else if (typeof value === "number") {
938
- headerTypes.push(
939
- `${key.toLowerCase()}="${Number.isInteger(value) ? "integer" : "float"}"`
940
- )
238
+ // Fields that need body still get type annotations
239
+ const aoType = getAoType(value)
240
+ if (aoType) {
241
+ headerTypes.push(`${key.toLowerCase()}="${aoType}"`)
941
242
  }
942
243
  }
943
244
  }
944
245
 
246
+ // Second pass for array type annotations
945
247
  for (const [key, value] of Object.entries(obj)) {
946
248
  if (Array.isArray(value)) {
947
249
  if (
@@ -954,9 +256,11 @@ async function encode(obj = {}) {
954
256
  }
955
257
  }
956
258
  }
259
+ }
957
260
 
261
+ // Step 9: Handle case where all body keys are empty binaries
262
+ function handleAllEmptyBinaryBodyKeys(obj, bodyKeys, headers, headerTypes) {
958
263
  if (bodyKeys.length === 0) {
959
- console.log("No bodyKeys, returning headers only")
960
264
  if (headerTypes.length > 0) {
961
265
  headers["ao-types"] = headerTypes.sort().join(", ")
962
266
  }
@@ -964,19 +268,7 @@ async function encode(obj = {}) {
964
268
  }
965
269
 
966
270
  const allBodyKeysAreEmptyBinaries = bodyKeys.every(key => {
967
- const pathParts = key.split("/")
968
- let value = obj
969
- for (const part of pathParts) {
970
- if (/^\d+$/.test(part)) {
971
- const index = parseInt(part) - 1
972
- console.log(
973
- `[Body part] Getting array element at index ${index} from part ${part}`
974
- )
975
- value = value[index]
976
- } else {
977
- value = value[part]
978
- }
979
- }
271
+ const value = getValueByPath(obj, key)
980
272
  return isBytes(value) && (value.length === 0 || value.byteLength === 0)
981
273
  })
982
274
 
@@ -987,36 +279,43 @@ async function encode(obj = {}) {
987
279
  return { headers, body: undefined }
988
280
  }
989
281
 
282
+ return null
283
+ }
284
+
285
+ // Step 10: Handle single body key optimization
286
+ async function handleSingleBodyKeyOptimization(
287
+ obj,
288
+ bodyKeys,
289
+ headers,
290
+ headerTypes
291
+ ) {
990
292
  if (bodyKeys.length === 1) {
991
293
  const singleKey = bodyKeys[0]
992
- const pathParts = singleKey.split("/")
993
- let value = obj
994
- for (const part of pathParts) {
995
- if (/^\d+$/.test(part)) {
996
- value = value[parseInt(part) - 1]
294
+ const value = getValueByPath(obj, singleKey)
295
+
296
+ // Apply optimization for binary data OR strings with newlines
297
+ if (
298
+ (isBytes(value) && value.length > 0) ||
299
+ (typeof value === "string" && value.includes("\n"))
300
+ ) {
301
+ let contentToHash
302
+ let bodyContent = value
303
+
304
+ if (isBytes(value)) {
305
+ const bodyBuffer = toBuffer(value)
306
+ contentToHash = bodyBuffer.buffer.slice(
307
+ bodyBuffer.byteOffset,
308
+ bodyBuffer.byteOffset + bodyBuffer.byteLength
309
+ )
997
310
  } else {
998
- value = value[part]
311
+ // For strings, encode to UTF-8 for hashing
312
+ const encoder = new TextEncoder()
313
+ const encoded = encoder.encode(value)
314
+ contentToHash = encoded.buffer
315
+ bodyContent = value
999
316
  }
1000
- }
1001
317
 
1002
- const otherFieldsAreEmpty = Object.entries(obj).every(([key, val]) => {
1003
- if (key === singleKey) return true
1004
- return (
1005
- (Array.isArray(val) && val.length === 0) ||
1006
- (isPojo(val) && Object.keys(val).length === 0) ||
1007
- (isBytes(val) && (val.length === 0 || val.byteLength === 0)) ||
1008
- (typeof val === "string" && val.length === 0)
1009
- )
1010
- })
1011
-
1012
- if (otherFieldsAreEmpty && isBytes(value) && value.length > 0) {
1013
- const bodyBuffer = toBuffer(value)
1014
- const bodyArrayBuffer = bodyBuffer.buffer.slice(
1015
- bodyBuffer.byteOffset,
1016
- bodyBuffer.byteOffset + bodyBuffer.byteLength
1017
- )
1018
-
1019
- const contentDigest = await sha256(bodyArrayBuffer)
318
+ const contentDigest = await sha256(contentToHash)
1020
319
  const base64 = base64url.toBase64(base64url.encode(contentDigest))
1021
320
  headers["content-digest"] = `sha-256=:${base64}:`
1022
321
 
@@ -1028,27 +327,27 @@ async function encode(obj = {}) {
1028
327
  headers["ao-types"] = headerTypes.sort().join(", ")
1029
328
  }
1030
329
 
1031
- return { headers, body: value }
330
+ return { headers, body: bodyContent }
1032
331
  }
1033
332
  }
1034
333
 
1035
- // Sort body keys: main array comes first, then element parts by index
1036
- const sortedBodyKeys = bodyKeys.sort((a, b) => {
334
+ return null
335
+ }
336
+
337
+ // Step 11: Sort body keys
338
+ function sortBodyKeys(bodyKeys) {
339
+ return bodyKeys.sort((a, b) => {
1037
340
  const aIsArrayElement = /\/\d+$/.test(a)
1038
341
  const bIsArrayElement = /\/\d+$/.test(b)
1039
342
  const aBase = a.split("/")[0]
1040
343
  const bBase = b.split("/")[0]
1041
-
1042
- // If both are for the same array
1043
344
  if (aBase === bBase) {
1044
- // Main array comes before element parts
1045
345
  if (!aIsArrayElement && bIsArrayElement) {
1046
- return -1 // main array comes first
346
+ return -1
1047
347
  }
1048
348
  if (aIsArrayElement && !bIsArrayElement) {
1049
- return 1 // element parts come after
349
+ return 1
1050
350
  }
1051
- // Both are elements - sort by index
1052
351
  if (aIsArrayElement && bIsArrayElement) {
1053
352
  const aIndex = parseInt(a.split("/")[1])
1054
353
  const bIndex = parseInt(b.split("/")[1])
@@ -1056,13 +355,13 @@ async function encode(obj = {}) {
1056
355
  }
1057
356
  return a.localeCompare(b)
1058
357
  }
1059
-
1060
- // Different arrays, sort by base name
1061
358
  return a.localeCompare(b)
1062
359
  })
360
+ }
1063
361
 
1064
- // Check if we have the special case where data contains body with bytes and body contains data with bytes
1065
- const hasSpecialDataBody =
362
+ // Step 12: Check for special data/body case
363
+ function checkSpecialDataBodyCase(obj, sortedBodyKeys) {
364
+ return (
1066
365
  sortedBodyKeys.includes("data") &&
1067
366
  sortedBodyKeys.includes("body") &&
1068
367
  obj.data &&
@@ -1071,771 +370,698 @@ async function encode(obj = {}) {
1071
370
  obj.body &&
1072
371
  obj.body.data &&
1073
372
  isBytes(obj.body.data)
373
+ )
374
+ }
1074
375
 
1075
- headers["body-keys"] = sortedBodyKeys.map(k => `"${k}"`).join(", ")
1076
-
1077
- if (!hasSpecialDataBody) {
1078
- if (sortedBodyKeys.includes("body") && sortedBodyKeys.length === 1) {
1079
- headers["inline-body-key"] = "body"
1080
- }
376
+ // Step 13.2.2: Handle empty string in nested path
377
+ function handleEmptyStringInNestedPath(bodyKey, value, pathParts) {
378
+ if (typeof value === "string" && value === "" && pathParts.length > 1) {
379
+ const lines = []
380
+ lines.push(`content-disposition: form-data;name="${bodyKey}"`)
381
+ lines.push("")
382
+ lines.push("")
383
+ return new Blob([lines.join("\r\n")])
1081
384
  }
385
+ return null
386
+ }
1082
387
 
1083
- if (headerTypes.length > 0) {
1084
- headers["ao-types"] = headerTypes.sort().join(", ")
1085
- }
388
+ // Step 13.2.3.2: Handle arrays with only empty elements
389
+ function handleArrayWithOnlyEmptyElements(
390
+ bodyKey,
391
+ value,
392
+ headers,
393
+ sortedBodyKeys
394
+ ) {
395
+ const fieldLines = []
396
+ const partTypes = []
397
+
398
+ value.forEach((item, idx) => {
399
+ const index = idx + 1
400
+ const itemType = getAoType(item)
401
+ if (itemType) {
402
+ partTypes.push(`${index}="${itemType}"`)
403
+ }
404
+ })
1086
405
 
1087
- const bodyParts = []
406
+ const isInline = bodyKey === "body" && headers["inline-body-key"] === "body"
1088
407
 
1089
- for (const bodyKey of sortedBodyKeys) {
1090
- console.log(`\n[Body part] Processing bodyKey: ${bodyKey}`)
1091
- const lines = []
408
+ if (isInline) {
409
+ const orderedLines = []
410
+ if (partTypes.length > 0) {
411
+ orderedLines.push(
412
+ `ao-types: ${sortTypeAnnotations(partTypes).join(", ")}`
413
+ )
414
+ }
415
+ orderedLines.push("content-disposition: inline")
416
+ orderedLines.push("")
417
+ return new Blob([orderedLines.join("\r\n")])
418
+ } else {
419
+ const orderedLines = []
420
+ if (partTypes.length > 0) {
421
+ orderedLines.push(
422
+ `ao-types: ${sortTypeAnnotations(partTypes).join(", ")}`
423
+ )
424
+ }
425
+ orderedLines.push(`content-disposition: form-data;name="${bodyKey}"`)
1092
426
 
1093
- const pathParts = bodyKey.split("/")
1094
- let value = obj
1095
- let parent = null
427
+ const isLastBodyPart =
428
+ sortedBodyKeys.indexOf(bodyKey) === sortedBodyKeys.length - 1
429
+ const hasOnlyTypes = partTypes.length > 0 && fieldLines.length === 0
1096
430
 
1097
- for (let i = 0; i < pathParts.length; i++) {
1098
- parent = value
1099
- const part = pathParts[i]
431
+ if (isLastBodyPart && hasOnlyTypes) {
432
+ return new Blob([orderedLines.join("\r\n")])
433
+ } else {
434
+ orderedLines.push("")
435
+ return new Blob([orderedLines.join("\r\n")])
436
+ }
437
+ }
438
+ }
1100
439
 
1101
- if (/^\d+$/.test(part)) {
1102
- value = value[parseInt(part) - 1]
1103
- } else {
1104
- value = value[part]
440
+ // Step 13.2.3.3: Build indices with own parts
441
+ function buildIndicesWithOwnParts(bodyKey, sortedBodyKeys) {
442
+ const indicesWithOwnParts = new Set()
443
+ sortedBodyKeys.forEach(key => {
444
+ if (key.startsWith(bodyKey + "/")) {
445
+ const subPath = key.substring(bodyKey.length + 1)
446
+ const match = subPath.match(/^(\d+)/)
447
+ if (match) {
448
+ indicesWithOwnParts.add(parseInt(match[1]))
1105
449
  }
1106
450
  }
451
+ })
452
+ return indicesWithOwnParts
453
+ }
1107
454
 
1108
- console.log(`[Body part] Value at ${bodyKey}:`, JSON.stringify(value))
455
+ // Step 13.2.3.4: Process array items
456
+ function processArrayItems(
457
+ value,
458
+ indicesWithOwnParts,
459
+ hasNestedObjectParts,
460
+ pathParts
461
+ ) {
462
+ const fieldLines = []
463
+ const partTypes = []
464
+
465
+ if (hasNestedObjectParts) {
466
+ value.forEach((item, idx) => {
467
+ const index = idx + 1
468
+ if (Array.isArray(item)) {
469
+ partTypes.push(`${index}="list"`)
470
+ }
471
+ })
472
+ }
1109
473
 
1110
- // Special handling for empty strings in arrays
1111
- if (typeof value === "string" && value === "" && pathParts.length > 1) {
1112
- console.log(`[Body part] Creating part for empty string at ${bodyKey}`)
1113
- lines.push(`content-disposition: form-data;name="${bodyKey}"`)
1114
- lines.push("")
1115
- lines.push("")
1116
- bodyParts.push(new Blob([lines.join("\r\n")]))
1117
- continue
474
+ value.forEach((item, idx) => {
475
+ const index = idx + 1
476
+
477
+ if (indicesWithOwnParts.has(index)) {
478
+ return
479
+ }
480
+ if (
481
+ hasNestedObjectParts &&
482
+ Array.isArray(item) &&
483
+ item.some(subItem => isPojo(subItem))
484
+ ) {
485
+ return
1118
486
  }
1119
487
 
1120
- if (Array.isArray(value)) {
1121
- const hasOnlyEmptyElements =
1122
- value.length > 0 &&
1123
- value.every(
1124
- item =>
1125
- (Array.isArray(item) && item.length === 0) ||
1126
- (isPojo(item) && Object.keys(item).length === 0) ||
1127
- (typeof item === "string" && item === "")
1128
- )
1129
- const hasOnlyNonEmptyObjects =
1130
- value.length > 0 &&
1131
- value.every(item => isPojo(item) && Object.keys(item).length > 0)
1132
- const hasOnlyEmptyObjects =
1133
- value.length > 0 &&
1134
- value.every(item => isPojo(item) && Object.keys(item).length === 0)
1135
- const hasObjects = value.some(item => isPojo(item))
1136
- const hasArrays = value.some(item => Array.isArray(item))
1137
- const nonObjectItems = value
1138
- .map((item, index) => ({ item, index: index + 1 }))
1139
- .filter(({ item }) => !isPojo(item))
1140
-
1141
- if (hasOnlyNonEmptyObjects) {
1142
- continue
488
+ if (typeof item === "string" && item === "") {
489
+ partTypes.push(`${index}="empty-binary"`)
490
+ } else if (isPojo(item) && Object.keys(item).length === 0) {
491
+ partTypes.push(`${index}="empty-message"`)
492
+ } else if (isPojo(item)) {
493
+ // Non-empty objects are handled elsewhere
494
+ } else if (Array.isArray(item)) {
495
+ if (item.length === 0) {
496
+ partTypes.push(`${index}="empty-list"`)
497
+ } else {
498
+ partTypes.push(`${index}="list"`)
499
+ const encodedItems = item
500
+ .map(subItem => {
501
+ if (typeof subItem === "number") {
502
+ if (Number.isInteger(subItem)) {
503
+ return `"(ao-type-integer) ${subItem}"`
504
+ } else {
505
+ return `"(ao-type-float) ${formatFloat(subItem)}"`
506
+ }
507
+ } else if (typeof subItem === "string") {
508
+ return `"${subItem}"`
509
+ } else if (subItem === null) {
510
+ return `"(ao-type-atom) \\"null\\""`
511
+ } else if (subItem === undefined) {
512
+ return `"(ao-type-atom) \\"undefined\\""`
513
+ } else if (typeof subItem === "symbol") {
514
+ const desc = subItem.description || "Symbol.for()"
515
+ return `"(ao-type-atom) \\"${desc}\\""`
516
+ } else if (typeof subItem === "boolean") {
517
+ return `"(ao-type-atom) \\"${subItem}\\""`
518
+ } else if (Array.isArray(subItem)) {
519
+ return encodeArrayItem(subItem)
520
+ } else if (isBytes(subItem)) {
521
+ const buffer = toBuffer(subItem)
522
+ if (buffer.length === 0 || buffer.byteLength === 0) {
523
+ return `""`
524
+ }
525
+ return `"(ao-type-binary)"`
526
+ } else if (isPojo(subItem)) {
527
+ const json = JSON.stringify(subItem)
528
+ const escaped = json.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
529
+ return `"(ao-type-map) ${escaped}"`
530
+ } else {
531
+ return `"${String(subItem)}"`
532
+ }
533
+ })
534
+ .join(", ")
535
+ fieldLines.push(`${index}: ${encodedItems}`)
1143
536
  }
1144
-
1145
- // For arrays containing only empty elements, we still need to show type info
1146
- if (hasOnlyEmptyElements) {
1147
- const fieldLines = []
1148
- const partTypes = []
1149
-
1150
- // Process items for type information
1151
- value.forEach((item, idx) => {
1152
- const index = idx + 1
1153
- if (Array.isArray(item) && item.length === 0) {
1154
- partTypes.push(`${index}="empty-list"`)
1155
- } else if (isPojo(item) && Object.keys(item).length === 0) {
1156
- partTypes.push(`${index}="empty-message"`)
1157
- } else if (typeof item === "string" && item === "") {
1158
- partTypes.push(`${index}="empty-binary"`)
1159
- }
1160
- })
1161
-
1162
- const isInline =
1163
- bodyKey === "body" && headers["inline-body-key"] === "body"
1164
-
1165
- if (isInline) {
1166
- const orderedLines = []
1167
- if (partTypes.length > 0) {
1168
- orderedLines.push(
1169
- `ao-types: ${partTypes
1170
- .sort((a, b) => {
1171
- const aNum = parseInt(a.split("=")[0])
1172
- const bNum = parseInt(b.split("=")[0])
1173
- return aNum - bNum
1174
- })
1175
- .join(", ")}`
1176
- )
1177
- }
1178
- orderedLines.push("content-disposition: inline")
1179
- orderedLines.push("")
1180
- bodyParts.push(new Blob([orderedLines.join("\r\n")]))
1181
- } else {
1182
- const orderedLines = []
1183
- if (partTypes.length > 0) {
1184
- orderedLines.push(
1185
- `ao-types: ${partTypes
1186
- .sort((a, b) => {
1187
- const aNum = parseInt(a.split("=")[0])
1188
- const bNum = parseInt(b.split("=")[0])
1189
- return aNum - bNum
1190
- })
1191
- .join(", ")}`
1192
- )
1193
- }
1194
- orderedLines.push(`content-disposition: form-data;name="${bodyKey}"`)
1195
-
1196
- // Check if this is the last body part
1197
- const isLastBodyPart =
1198
- sortedBodyKeys.indexOf(bodyKey) === sortedBodyKeys.length - 1
1199
- const hasOnlyTypes = partTypes.length > 0 && fieldLines.length === 0
1200
-
1201
- if (isLastBodyPart && hasOnlyTypes) {
1202
- // Don't add empty line for last part with only types
1203
- bodyParts.push(new Blob([orderedLines.join("\r\n")]))
1204
- } else {
1205
- orderedLines.push("")
1206
- bodyParts.push(new Blob([orderedLines.join("\r\n")]))
1207
- }
1208
- }
1209
- continue
537
+ } else if (typeof item === "number") {
538
+ if (Number.isInteger(item)) {
539
+ partTypes.push(`${index}="integer"`)
540
+ fieldLines.push(`${index}: ${item}`)
541
+ } else {
542
+ partTypes.push(`${index}="float"`)
543
+ fieldLines.push(`${index}: ${formatFloat(item)}`)
1210
544
  }
545
+ } else if (typeof item === "string") {
546
+ fieldLines.push(`${index}: ${item}`)
547
+ } else if (
548
+ item === null ||
549
+ item === undefined ||
550
+ typeof item === "symbol" ||
551
+ typeof item === "boolean"
552
+ ) {
553
+ partTypes.push(`${index}="atom"`)
554
+ if (item === null) {
555
+ fieldLines.push(`${index}: null`)
556
+ } else if (item === undefined) {
557
+ fieldLines.push(`${index}: undefined`)
558
+ } else if (typeof item === "symbol") {
559
+ const desc = item.description || "Symbol.for()"
560
+ fieldLines.push(`${index}: ${desc}`)
561
+ } else {
562
+ fieldLines.push(`${index}: ${item}`)
563
+ }
564
+ } else if (isBytes(item)) {
565
+ const buffer = toBuffer(item)
566
+ if (buffer.length === 0) {
567
+ partTypes.push(`${index}="empty-binary"`)
568
+ } else {
569
+ partTypes.push(`${index}="binary"`)
570
+ }
571
+ }
572
+ })
1211
573
 
1212
- // Build list of which indices have their own parts
1213
- const indicesWithOwnParts = new Set()
1214
- sortedBodyKeys.forEach(key => {
1215
- if (key.startsWith(bodyKey + "/")) {
1216
- const subPath = key.substring(bodyKey.length + 1)
1217
- const match = subPath.match(/^(\d+)/)
1218
- if (match) {
1219
- indicesWithOwnParts.add(parseInt(match[1]))
1220
- }
1221
- }
1222
- })
574
+ return { fieldLines, partTypes }
575
+ }
1223
576
 
1224
- // Check if this array contains sub-arrays with objects that have their own parts
1225
- const hasNestedObjectParts = sortedBodyKeys.some(
1226
- key =>
1227
- key.startsWith(bodyKey + "/") &&
1228
- key.split("/").length > pathParts.length + 1
1229
- )
577
+ // Step 13.2.3.5: Create array body part
578
+ function createArrayBodyPart(bodyKey, fieldLines, partTypes, headers) {
579
+ const isInline = bodyKey === "body" && headers["inline-body-key"] === "body"
1230
580
 
1231
- const fieldLines = []
1232
- const partTypes = []
1233
-
1234
- // For arrays that contain sub-arrays with objects, we need to add type info for the sub-arrays
1235
- if (hasNestedObjectParts) {
1236
- value.forEach((item, idx) => {
1237
- const index = idx + 1
1238
- if (Array.isArray(item)) {
1239
- partTypes.push(`${index}="list"`)
1240
- }
1241
- })
1242
- }
1243
-
1244
- // Check if this array has mixed content with empty strings/objects
1245
- const hasEmptyStrings = value.some(
1246
- item => typeof item === "string" && item === ""
581
+ if (isInline) {
582
+ const orderedLines = []
583
+ if (partTypes.length > 0) {
584
+ orderedLines.push(
585
+ `ao-types: ${sortTypeAnnotations(partTypes).join(", ")}`
1247
586
  )
1248
- const hasEmptyObjects = value.some(
1249
- item => isPojo(item) && Object.keys(item).length === 0
587
+ }
588
+ orderedLines.push("content-disposition: inline")
589
+ if (fieldLines.length > 0) {
590
+ orderedLines.push("")
591
+ for (const line of fieldLines) {
592
+ orderedLines.push(line)
593
+ }
594
+ }
595
+ return new Blob([orderedLines.join("\r\n") + "\r\n"])
596
+ } else {
597
+ const orderedLines = []
598
+ if (partTypes.length > 0) {
599
+ orderedLines.push(
600
+ `ao-types: ${sortTypeAnnotations(partTypes).join(", ")}`
1250
601
  )
602
+ }
603
+ orderedLines.push(`content-disposition: form-data;name="${bodyKey}"`)
604
+ for (const line of fieldLines) {
605
+ orderedLines.push(line)
606
+ }
607
+ if (fieldLines.length > 0) {
608
+ return new Blob([orderedLines.join("\r\n") + "\r\n"])
609
+ } else {
610
+ return new Blob([orderedLines.join("\r\n")])
611
+ }
612
+ }
613
+ }
1251
614
 
1252
- // Check if we have objects with only empty values (like {empty: ""})
1253
- const hasObjectsWithOnlyEmptyValues = value.some(item => {
1254
- if (!isPojo(item) || Object.keys(item).length === 0) return false
1255
- return Object.values(item).every(
1256
- v =>
1257
- (typeof v === "string" && v === "") ||
1258
- (Array.isArray(v) && v.length === 0) ||
1259
- (isPojo(v) && Object.keys(v).length === 0)
1260
- )
1261
- })
1262
-
1263
- const isMixedArray =
1264
- hasObjects &&
1265
- (hasEmptyStrings || hasEmptyObjects) &&
1266
- !hasObjectsWithOnlyEmptyValues
1267
-
1268
- // Process ALL items for type information
1269
- value.forEach((item, idx) => {
1270
- const index = idx + 1
1271
-
1272
- // Skip type info for elements that have their own parts
1273
- if (indicesWithOwnParts.has(index)) {
1274
- return
1275
- }
1276
-
1277
- // If we have nested object parts and this is an array with objects, skip processing it inline
1278
- if (
1279
- hasNestedObjectParts &&
1280
- Array.isArray(item) &&
1281
- item.some(subItem => isPojo(subItem))
1282
- ) {
1283
- // Type info already added above
1284
- return
1285
- }
615
+ // Step 13.2.3: Handle array values
616
+ function handleArrayValue(bodyKey, value, headers, sortedBodyKeys, pathParts) {
617
+ const arrayInfo = analyzeArray(value)
1286
618
 
1287
- // For all arrays (not just mixed ones), we need to process all items
1288
- if (typeof item === "string" && item === "") {
1289
- // Empty strings get type annotation but no field line
1290
- partTypes.push(`${index}="empty-binary"`)
1291
- } else if (isPojo(item) && Object.keys(item).length === 0) {
1292
- // Empty objects don't get field lines but do get type annotations
1293
- partTypes.push(`${index}="empty-message"`)
1294
- } else if (isPojo(item)) {
1295
- // Non-empty objects might have parts
1296
- } else if (Array.isArray(item)) {
1297
- if (item.length === 0) {
1298
- // Empty arrays don't get field lines but do get type annotations
1299
- partTypes.push(`${index}="empty-list"`)
1300
- } else {
1301
- partTypes.push(`${index}="list"`)
1302
- // Encode array
1303
- const encodedItems = item
1304
- .map(subItem => {
1305
- if (typeof subItem === "number") {
1306
- if (Number.isInteger(subItem)) {
1307
- return `"(ao-type-integer) ${subItem}"`
1308
- } else {
1309
- return `"(ao-type-float) ${formatFloat(subItem)}"`
1310
- }
1311
- } else if (typeof subItem === "string") {
1312
- return `"${subItem}"`
1313
- } else if (subItem === null) {
1314
- return `"(ao-type-atom) \\"null\\""`
1315
- } else if (subItem === undefined) {
1316
- return `"(ao-type-atom) \\"undefined\\""`
1317
- } else if (typeof subItem === "symbol") {
1318
- const desc = subItem.description || "Symbol.for()"
1319
- return `"(ao-type-atom) \\"${desc}\\""`
1320
- } else if (typeof subItem === "boolean") {
1321
- return `"(ao-type-atom) \\"${subItem}\\""`
1322
- } else if (Array.isArray(subItem)) {
1323
- // Use the full encodeArrayItem for nested arrays
1324
- return encodeArrayItem(subItem)
1325
- } else if (isBytes(subItem)) {
1326
- const buffer = toBuffer(subItem)
1327
- if (buffer.length === 0 || buffer.byteLength === 0) {
1328
- return `""`
1329
- }
1330
- return `"(ao-type-binary)"`
1331
- } else if (isPojo(subItem)) {
1332
- const json = JSON.stringify(subItem)
1333
- const escaped = json
1334
- .replace(/\\/g, "\\\\")
1335
- .replace(/"/g, '\\"')
1336
- return `"(ao-type-map) ${escaped}"`
1337
- } else {
1338
- return `"${String(subItem)}"`
1339
- }
1340
- })
1341
- .join(", ")
1342
- fieldLines.push(`${index}: ${encodedItems}`)
1343
- }
1344
- } else if (typeof item === "number") {
1345
- if (Number.isInteger(item)) {
1346
- partTypes.push(`${index}="integer"`)
1347
- fieldLines.push(`${index}: ${item}`)
1348
- } else {
1349
- partTypes.push(`${index}="float"`)
1350
- fieldLines.push(`${index}: ${formatFloat(item)}`)
1351
- }
1352
- } else if (typeof item === "string") {
1353
- // Non-empty strings just get field lines, no type annotation
1354
- fieldLines.push(`${index}: ${item}`)
1355
- } else if (
1356
- item === null ||
1357
- item === undefined ||
1358
- typeof item === "symbol" ||
1359
- typeof item === "boolean"
1360
- ) {
1361
- partTypes.push(`${index}="atom"`)
1362
- if (item === null) {
1363
- fieldLines.push(`${index}: null`)
1364
- } else if (item === undefined) {
1365
- fieldLines.push(`${index}: undefined`)
1366
- } else if (typeof item === "symbol") {
1367
- const desc = item.description || "Symbol.for()"
1368
- fieldLines.push(`${index}: ${desc}`)
1369
- } else {
1370
- fieldLines.push(`${index}: ${item}`)
1371
- }
1372
- } else if (isBytes(item)) {
1373
- const buffer = toBuffer(item)
1374
- if (buffer.length === 0) {
1375
- partTypes.push(`${index}="empty-binary"`)
1376
- } else {
1377
- partTypes.push(`${index}="binary"`)
1378
- }
1379
- }
1380
- })
1381
-
1382
- const isInline =
1383
- bodyKey === "body" && headers["inline-body-key"] === "body"
1384
-
1385
- if (isInline) {
1386
- const orderedLines = []
1387
-
1388
- if (partTypes.length > 0) {
1389
- orderedLines.push(
1390
- `ao-types: ${partTypes
1391
- .sort((a, b) => {
1392
- const aNum = parseInt(a.split("=")[0])
1393
- const bNum = parseInt(b.split("=")[0])
1394
- return aNum - bNum
1395
- })
1396
- .join(", ")}`
1397
- )
1398
- }
619
+ if (arrayInfo.hasOnlyNonEmptyObjects) {
620
+ return null
621
+ }
1399
622
 
1400
- orderedLines.push("content-disposition: inline")
623
+ if (arrayInfo.hasOnlyEmptyElements) {
624
+ return handleArrayWithOnlyEmptyElements(
625
+ bodyKey,
626
+ value,
627
+ headers,
628
+ sortedBodyKeys
629
+ )
630
+ }
1401
631
 
1402
- if (fieldLines.length > 0) {
1403
- orderedLines.push("")
1404
- for (const line of fieldLines) {
1405
- orderedLines.push(line)
1406
- }
1407
- }
632
+ const indicesWithOwnParts = buildIndicesWithOwnParts(bodyKey, sortedBodyKeys)
633
+ const hasNestedObjectParts = sortedBodyKeys.some(
634
+ key =>
635
+ key.startsWith(bodyKey + "/") &&
636
+ key.split("/").length > pathParts.length + 1
637
+ )
1408
638
 
1409
- bodyParts.push(new Blob([orderedLines.join("\r\n") + "\r\n"]))
1410
- } else {
1411
- // Put ao-types first, then content-disposition, then field lines
1412
- const orderedLines = []
1413
-
1414
- if (partTypes.length > 0) {
1415
- orderedLines.push(
1416
- `ao-types: ${partTypes
1417
- .sort((a, b) => {
1418
- const aNum = parseInt(a.split("=")[0])
1419
- const bNum = parseInt(b.split("=")[0])
1420
- return aNum - bNum
1421
- })
1422
- .join(", ")}`
1423
- )
1424
- }
639
+ const { fieldLines, partTypes } = processArrayItems(
640
+ value,
641
+ indicesWithOwnParts,
642
+ hasNestedObjectParts,
643
+ pathParts
644
+ )
645
+ return createArrayBodyPart(bodyKey, fieldLines, partTypes, headers)
646
+ }
1425
647
 
1426
- orderedLines.push(`content-disposition: form-data;name="${bodyKey}"`)
648
+ // Step 13.2.4.3: Process object fields
649
+ function processObjectFields(value, bodyKey, sortedBodyKeys) {
650
+ const objectTypes = []
651
+ const fieldLines = []
652
+ const binaryFields = []
653
+ const arrayTypes = []
654
+
655
+ // First collect array types
656
+ for (const [k, v] of Object.entries(value)) {
657
+ if (Array.isArray(v)) {
658
+ arrayTypes.push(
659
+ `${k.toLowerCase()}="${v.length === 0 ? "empty-list" : "list"}"`
660
+ )
661
+ }
662
+ }
1427
663
 
1428
- // Add field lines directly without blank line
1429
- for (const line of fieldLines) {
1430
- orderedLines.push(line)
1431
- }
664
+ // Then process other fields
665
+ for (const [k, v] of Object.entries(value)) {
666
+ const childPath = `${bodyKey}/${k}`
1432
667
 
1433
- // Add trailing blank line if we have field lines
1434
- if (fieldLines.length > 0) {
1435
- bodyParts.push(new Blob([orderedLines.join("\r\n") + "\r\n"]))
1436
- } else {
1437
- bodyParts.push(new Blob([orderedLines.join("\r\n")]))
1438
- }
1439
- }
668
+ if (sortedBodyKeys.includes(childPath)) {
1440
669
  continue
1441
670
  }
1442
671
 
1443
- if (isPojo(value)) {
1444
- // Handle non-empty objects - skip empty ones
1445
- if (Object.keys(value).length === 0) {
1446
- // Check if this is a parent array context where empty objects should get parts
1447
- const parentPath = pathParts.slice(0, -1).join("/")
1448
- let parentValue = obj
1449
- for (const part of pathParts.slice(0, -1)) {
1450
- if (/^\d+$/.test(part)) {
1451
- parentValue = parentValue[parseInt(part) - 1]
1452
- } else {
1453
- parentValue = parentValue[part]
1454
- }
1455
- }
1456
-
1457
- if (Array.isArray(parentValue)) {
1458
- // Check if parent array has mixed content
1459
- const hasEmptyStrings = parentValue.some(
1460
- item => typeof item === "string" && item === ""
1461
- )
1462
- const hasEmptyObjects = parentValue.some(
1463
- item => isPojo(item) && Object.keys(item).length === 0
1464
- )
1465
- const hasObjects = parentValue.some(item => isPojo(item))
1466
-
1467
- if (hasObjects && (hasEmptyStrings || hasEmptyObjects)) {
1468
- // Special mixed array case - empty objects don't get parts
1469
- console.log(
1470
- `[Body part] Skipping empty object in mixed array at ${bodyKey}`
1471
- )
1472
- continue
1473
- }
1474
- }
1475
-
1476
- // Normal case - empty objects might get parts
1477
- console.log(`[Body part] Empty object at ${bodyKey}`)
1478
- // For now, skip empty objects
672
+ if (Array.isArray(v) && v.some(item => isPojo(item))) {
673
+ const hasOnlyEmpty = v.every(item => isEmpty(item))
674
+ if (hasOnlyEmpty) {
1479
675
  continue
1480
676
  }
677
+ }
1481
678
 
1482
- // Skip the special case where bodyKey is "data" and has only body:Buffer
1483
- if (
1484
- hasSpecialDataBody &&
1485
- bodyKey === "data" &&
1486
- Object.keys(value).length === 1 &&
1487
- value.body &&
1488
- isBytes(value.body)
1489
- ) {
1490
- continue
1491
- }
679
+ if (Array.isArray(v)) {
680
+ // Type already added in arrayTypes
681
+ } else if (
682
+ v === null ||
683
+ v === undefined ||
684
+ typeof v === "symbol" ||
685
+ typeof v === "boolean"
686
+ ) {
687
+ objectTypes.push(`${k.toLowerCase()}="atom"`)
688
+ } else if (typeof v === "number") {
689
+ objectTypes.push(
690
+ `${k.toLowerCase()}="${Number.isInteger(v) ? "integer" : "float"}"`
691
+ )
692
+ } else if (typeof v === "string" && v.length === 0) {
693
+ objectTypes.push(`${k.toLowerCase()}="empty-binary"`)
694
+ } else if (isBytes(v) && (v.length === 0 || v.byteLength === 0)) {
695
+ objectTypes.push(`${k.toLowerCase()}="empty-binary"`)
696
+ } else if (isPojo(v) && Object.keys(v).length === 0) {
697
+ objectTypes.push(`${k.toLowerCase()}="empty-message"`)
698
+ }
1492
699
 
1493
- // Handle non-empty objects
1494
- const isInline =
1495
- bodyKey === "body" && headers["inline-body-key"] === "body"
1496
- if (isInline) {
1497
- lines.push(`content-disposition: inline`)
700
+ if (typeof v === "string") {
701
+ if (v.length === 0) {
702
+ fieldLines.push(`${k}: `)
1498
703
  } else {
1499
- lines.push(`content-disposition: form-data;name="${bodyKey}"`)
704
+ fieldLines.push(`${k}: ${v}`)
1500
705
  }
1501
-
1502
- // Continue with object processing...
1503
- const objectTypes = []
1504
- const fieldLines = []
1505
- const binaryFields = []
1506
-
1507
- // First check if this object only contains empty collections
1508
- const hasOnlyEmptyCollections = Object.entries(value).every(([k, v]) => {
1509
- return (
1510
- (Array.isArray(v) && v.length === 0) ||
1511
- (isPojo(v) && Object.keys(v).length === 0) ||
1512
- (isBytes(v) && (v.length === 0 || v.byteLength === 0)) ||
1513
- (typeof v === "string" && v.length === 0)
1514
- )
1515
- })
1516
-
1517
- // Also check if it contains arrays with only empty elements
1518
- const hasArraysWithOnlyEmptyElements = Object.entries(value).some(
1519
- ([k, v]) => {
1520
- return (
1521
- Array.isArray(v) &&
1522
- v.length > 0 &&
1523
- v.every(
1524
- item =>
1525
- (Array.isArray(item) && item.length === 0) ||
1526
- (isPojo(item) && Object.keys(item).length === 0) ||
1527
- (typeof item === "string" && item === "")
1528
- )
1529
- )
1530
- }
1531
- )
1532
-
1533
- // Collect type information for arrays in the object BEFORE processing fields
1534
- const arrayTypes = []
1535
- for (const [k, v] of Object.entries(value)) {
1536
- if (Array.isArray(v)) {
1537
- arrayTypes.push(
1538
- `${k.toLowerCase()}="${v.length === 0 ? "empty-list" : "list"}"`
1539
- )
706
+ } else if (typeof v === "number") {
707
+ fieldLines.push(`${k}: ${v}`)
708
+ } else if (typeof v === "boolean") {
709
+ fieldLines.push(`${k}: "${v}"`)
710
+ } else if (v === null) {
711
+ fieldLines.push(`${k}: "null"`)
712
+ } else if (v === undefined) {
713
+ fieldLines.push(`${k}: "undefined"`)
714
+ } else if (typeof v === "symbol") {
715
+ const desc = v.description || "Symbol.for()"
716
+ fieldLines.push(`${k}: "${desc}"`)
717
+ } else if (isBytes(v)) {
718
+ const buffer = toBuffer(v)
719
+ binaryFields.push({ key: k, buffer })
720
+ } else if (Array.isArray(v) && v.length > 0) {
721
+ const childPath = `${bodyKey}/${k}`
722
+ if (!sortedBodyKeys.includes(childPath)) {
723
+ const hasObjects = v.some(item => isPojo(item))
724
+ if (!hasObjects) {
725
+ const encodedItems = v.map(item => encodeArrayItem(item)).join(", ")
726
+ fieldLines.push(`${k}: ${encodedItems}`)
1540
727
  }
1541
728
  }
729
+ }
730
+ }
1542
731
 
1543
- for (const [k, v] of Object.entries(value)) {
1544
- const childPath = `${bodyKey}/${k}`
1545
-
1546
- if (sortedBodyKeys.includes(childPath)) {
1547
- continue
1548
- }
732
+ const allTypes = [...arrayTypes, ...objectTypes]
733
+ return { allTypes, fieldLines, binaryFields }
734
+ }
1549
735
 
1550
- if (Array.isArray(v) && v.some(item => isPojo(item))) {
1551
- // Check if this array will have its own body part
1552
- if (sortedBodyKeys.includes(childPath)) {
1553
- continue
1554
- }
1555
- // Check if array has only empty elements
1556
- const hasOnlyEmpty = v.every(
1557
- item =>
1558
- (Array.isArray(item) && item.length === 0) ||
1559
- (isPojo(item) && Object.keys(item).length === 0) ||
1560
- (typeof item === "string" && item === "")
1561
- )
1562
- if (hasOnlyEmpty) {
1563
- continue
1564
- }
1565
- }
736
+ // Step 13.2.4.5: Create object body part
737
+ function createObjectBodyPart(
738
+ bodyKey,
739
+ value,
740
+ allTypes,
741
+ fieldLines,
742
+ binaryFields,
743
+ headers,
744
+ sortedBodyKeys
745
+ ) {
746
+ const isInline = bodyKey === "body" && headers["inline-body-key"] === "body"
747
+ const lines = []
748
+
749
+ if (isInline) {
750
+ const orderedLines = []
751
+
752
+ // For inline mode: fields first, then headers
753
+ for (const line of fieldLines) {
754
+ orderedLines.push(line)
755
+ }
1566
756
 
1567
- if (Array.isArray(v)) {
1568
- // Type info already added above in arrayTypes
1569
- } else if (
1570
- v === null ||
1571
- v === undefined ||
1572
- typeof v === "symbol" ||
1573
- typeof v === "boolean"
1574
- ) {
1575
- objectTypes.push(`${k.toLowerCase()}="atom"`)
1576
- } else if (typeof v === "number") {
1577
- objectTypes.push(
1578
- `${k.toLowerCase()}="${Number.isInteger(v) ? "integer" : "float"}"`
1579
- )
1580
- } else if (typeof v === "string" && v.length === 0) {
1581
- objectTypes.push(`${k.toLowerCase()}="empty-binary"`)
1582
- } else if (isBytes(v) && (v.length === 0 || v.byteLength === 0)) {
1583
- objectTypes.push(`${k.toLowerCase()}="empty-binary"`)
1584
- } else if (isPojo(v) && Object.keys(v).length === 0) {
1585
- objectTypes.push(`${k.toLowerCase()}="empty-message"`)
1586
- }
757
+ if (allTypes.length > 0) {
758
+ orderedLines.push(`ao-types: ${allTypes.sort().join(", ")}`)
759
+ }
760
+ orderedLines.push("content-disposition: inline")
1587
761
 
1588
- if (typeof v === "string") {
1589
- if (v.length === 0) {
1590
- // Empty strings are shown as "empty: " (with trailing space)
1591
- fieldLines.push(`${k}: `)
1592
- } else {
1593
- fieldLines.push(`${k}: ${v}`)
1594
- }
1595
- } else if (typeof v === "number") {
1596
- fieldLines.push(`${k}: ${v}`)
1597
- } else if (typeof v === "boolean") {
1598
- fieldLines.push(`${k}: "${v}"`)
1599
- } else if (v === null) {
1600
- fieldLines.push(`${k}: "null"`)
1601
- } else if (v === undefined) {
1602
- fieldLines.push(`${k}: "undefined"`)
1603
- } else if (typeof v === "symbol") {
1604
- const desc = v.description || "Symbol.for()"
1605
- fieldLines.push(`${k}: "${desc}"`)
1606
- } else if (isBytes(v)) {
1607
- const buffer = toBuffer(v)
1608
- binaryFields.push({ key: k, buffer })
1609
- continue
1610
- } else if (Array.isArray(v)) {
1611
- if (v.length === 0) {
1612
- // Don't add field line for empty array
1613
- } else {
1614
- // Check if this array will have its own body part
1615
- const childPath = `${bodyKey}/${k}`
1616
- if (!sortedBodyKeys.includes(childPath)) {
1617
- // Check if this array contains objects - if so, don't add field line
1618
- const hasObjects = v.some(item => isPojo(item))
1619
- if (!hasObjects) {
1620
- const encodedItems = v
1621
- .map(item => encodeArrayItem(item))
1622
- .join(", ")
1623
- fieldLines.push(`${k}: ${encodedItems}`)
1624
- }
1625
- }
1626
- }
1627
- } else if (isPojo(v) && Object.keys(v).length === 0) {
1628
- // Empty object - don't add field line
1629
- }
762
+ const binaryFieldsForInline = Object.entries(value)
763
+ .filter(
764
+ ([k, v]) => isBytes(v) && !sortedBodyKeys.includes(`${bodyKey}/${k}`)
765
+ )
766
+ .map(([k, v]) => ({
767
+ key: k,
768
+ buffer: toBuffer(v),
769
+ }))
770
+
771
+ if (binaryFieldsForInline.length > 0) {
772
+ const parts = []
773
+ // Join all text lines first
774
+ parts.push(Buffer.from(orderedLines.join("\r\n")))
775
+ // Then add binary fields
776
+ for (const { key, buffer } of binaryFieldsForInline) {
777
+ parts.push(Buffer.from(`\r\n${key}: `))
778
+ parts.push(buffer)
1630
779
  }
780
+ parts.push(Buffer.from("\r\n"))
781
+ const fullBody = Buffer.concat(parts)
782
+ return new Blob([fullBody])
783
+ } else {
784
+ return new Blob([orderedLines.join("\r\n") + "\r\n"])
785
+ }
786
+ } else {
787
+ // Non-inline mode remains the same
788
+ const orderedLines = []
789
+ if (allTypes.length > 0) {
790
+ orderedLines.push(`ao-types: ${allTypes.sort().join(", ")}`)
791
+ }
792
+ orderedLines.push(`content-disposition: form-data;name="${bodyKey}"`)
793
+
794
+ const hasBinaryFields = binaryFields && binaryFields.length > 0
795
+ if (hasBinaryFields || fieldLines.length === 0) {
796
+ orderedLines.push("")
797
+ }
1631
798
 
1632
- // Combine arrayTypes with objectTypes
1633
- const allTypes = [...arrayTypes, ...objectTypes]
1634
-
1635
- // Check if this object should be skipped entirely
1636
- const shouldSkipObject = Object.entries(value).every(([k, v]) => {
1637
- const childPath = `${bodyKey}/${k}`
1638
- if (sortedBodyKeys.includes(childPath)) return true
1639
- if (Array.isArray(v) && v.some(item => isPojo(item))) {
1640
- // Check if array has only empty elements
1641
- const hasOnlyEmpty = v.every(
1642
- item =>
1643
- (Array.isArray(item) && item.length === 0) ||
1644
- (isPojo(item) && Object.keys(item).length === 0) ||
1645
- (typeof item === "string" && item === "")
1646
- )
1647
- return hasOnlyEmpty || sortedBodyKeys.includes(childPath)
799
+ for (const line of fieldLines) {
800
+ orderedLines.push(line)
801
+ }
802
+
803
+ if (binaryFields && binaryFields.length > 0) {
804
+ const parts = []
805
+ const headerText = orderedLines.join("\r\n")
806
+ parts.push(Buffer.from(headerText))
807
+ for (let i = 0; i < binaryFields.length; i++) {
808
+ const { key, buffer } = binaryFields[i]
809
+ if (i > 0) {
810
+ parts.push(Buffer.from("\r\n"))
1648
811
  }
1649
- return false
1650
- })
812
+ parts.push(Buffer.from(`${key}: `))
813
+ parts.push(buffer)
814
+ }
815
+ parts.push(Buffer.from("\r\n"))
816
+ const fullBody = Buffer.concat(parts)
817
+ return new Blob([fullBody])
818
+ } else {
819
+ if (fieldLines.length > 0) {
820
+ return new Blob([orderedLines.join("\r\n") + "\r\n"])
821
+ } else {
822
+ return new Blob([orderedLines.join("\r\n")])
823
+ }
824
+ }
825
+ }
826
+ }
1651
827
 
828
+ // Step 13.2.4: Handle object values
829
+ function handleObjectValue(
830
+ obj,
831
+ bodyKey,
832
+ value,
833
+ headers,
834
+ sortedBodyKeys,
835
+ pathParts,
836
+ hasSpecialDataBody
837
+ ) {
838
+ if (Object.keys(value).length === 0) {
839
+ // Skip empty objects in certain contexts
840
+ const parentPath = pathParts.slice(0, -1).join("/")
841
+ const parentValue = parentPath ? getValueByPath(obj, parentPath) : obj
842
+
843
+ if (Array.isArray(parentValue)) {
844
+ const parentArrayInfo = analyzeArray(parentValue)
1652
845
  if (
1653
- shouldSkipObject &&
1654
- !hasOnlyEmptyCollections &&
1655
- !hasArraysWithOnlyEmptyElements
846
+ parentArrayInfo.hasObjects &&
847
+ (parentArrayInfo.hasEmptyStrings || parentArrayInfo.hasEmptyObjects)
1656
848
  ) {
1657
- continue
849
+ return null
1658
850
  }
851
+ }
852
+ return null
853
+ }
1659
854
 
1660
- const onlyEmptyCollections = Object.entries(value).every(([k, v]) => {
1661
- const childPath = `${bodyKey}/${k}`
1662
- if (sortedBodyKeys.includes(childPath)) return true
1663
- if (Array.isArray(v) && v.some(item => isPojo(item))) return true
1664
-
1665
- return (
1666
- (Array.isArray(v) && v.length === 0) ||
1667
- (isPojo(v) && Object.keys(v).length === 0) ||
1668
- (isBytes(v) && (v.length === 0 || v.byteLength === 0))
1669
- )
1670
- })
1671
-
1672
- if (isInline) {
1673
- const orderedLines = []
855
+ // Skip special data/body case
856
+ if (
857
+ hasSpecialDataBody &&
858
+ bodyKey === "data" &&
859
+ Object.keys(value).length === 1 &&
860
+ value.body &&
861
+ isBytes(value.body)
862
+ ) {
863
+ return null
864
+ }
1674
865
 
1675
- // FIXED: For inline body, put field lines FIRST
1676
- for (const line of fieldLines) {
1677
- orderedLines.push(line)
1678
- }
866
+ const { allTypes, fieldLines, binaryFields } = processObjectFields(
867
+ value,
868
+ bodyKey,
869
+ sortedBodyKeys
870
+ )
1679
871
 
1680
- if (allTypes.length > 0) {
1681
- orderedLines.push(`ao-types: ${allTypes.sort().join(", ")}`)
1682
- }
872
+ // Check if object should be skipped
873
+ const hasOnlyEmptyCollections = Object.entries(value).every(([k, v]) =>
874
+ isEmpty(v)
875
+ )
876
+ const hasArraysWithOnlyEmptyElements = Object.entries(value).some(
877
+ ([k, v]) =>
878
+ Array.isArray(v) && v.length > 0 && v.every(item => isEmpty(item))
879
+ )
1683
880
 
1684
- orderedLines.push("content-disposition: inline")
881
+ const shouldSkipObject = Object.entries(value).every(([k, v]) => {
882
+ const childPath = `${bodyKey}/${k}`
883
+ if (sortedBodyKeys.includes(childPath)) return true
884
+ if (Array.isArray(v) && v.some(item => isPojo(item))) {
885
+ const hasOnlyEmpty = v.every(item => isEmpty(item))
886
+ return hasOnlyEmpty || sortedBodyKeys.includes(childPath)
887
+ }
888
+ return false
889
+ })
1685
890
 
1686
- const binaryFieldsForInline = Object.entries(value)
1687
- .filter(
1688
- ([k, v]) =>
1689
- isBytes(v) && !sortedBodyKeys.includes(`${bodyKey}/${k}`)
1690
- )
1691
- .map(([k, v]) => ({
1692
- key: k,
1693
- buffer: toBuffer(v),
1694
- }))
891
+ if (
892
+ shouldSkipObject &&
893
+ !hasOnlyEmptyCollections &&
894
+ !hasArraysWithOnlyEmptyElements
895
+ ) {
896
+ return null
897
+ }
1695
898
 
1696
- if (binaryFieldsForInline.length > 0) {
1697
- const parts = []
1698
- parts.push(Buffer.from(orderedLines.join("\r\n")))
899
+ return createObjectBodyPart(
900
+ bodyKey,
901
+ value,
902
+ allTypes,
903
+ fieldLines,
904
+ binaryFields,
905
+ headers,
906
+ sortedBodyKeys
907
+ )
908
+ }
1699
909
 
1700
- for (const { key, buffer } of binaryFieldsForInline) {
1701
- parts.push(Buffer.from(`\r\n${key}: `))
1702
- parts.push(buffer)
1703
- }
910
+ // Step 13.2.5: Handle primitive values
911
+ function handlePrimitiveValue(bodyKey, value, headers) {
912
+ const isInline = bodyKey === "body" && headers["inline-body-key"] === "body"
913
+ const lines = []
1704
914
 
1705
- parts.push(Buffer.from("\r\n"))
915
+ if (isInline) {
916
+ lines.push(`content-disposition: inline`)
917
+ } else {
918
+ lines.push(`content-disposition: form-data;name="${bodyKey}"`)
919
+ }
1706
920
 
1707
- const fullBody = Buffer.concat(parts)
1708
- bodyParts.push(new Blob([fullBody]))
1709
- } else {
1710
- // Check if this is the last body part for special handling
1711
- const isLastBodyPart =
1712
- sortedBodyKeys.indexOf(bodyKey) === sortedBodyKeys.length - 1
1713
- const hasOnlyTypes = allTypes.length > 0 && fieldLines.length === 0
1714
-
1715
- if (isLastBodyPart && hasOnlyTypes) {
1716
- // Don't add empty line for last part with only types
1717
- bodyParts.push(new Blob([orderedLines.join("\r\n")]))
1718
- } else if (fieldLines.length === 0) {
1719
- bodyParts.push(new Blob([orderedLines.join("\r\n")]))
1720
- } else {
1721
- bodyParts.push(new Blob([orderedLines.join("\r\n") + "\r\n"]))
1722
- }
1723
- }
1724
- } else {
1725
- // Put ao-types first, then content-disposition, then field lines
1726
- const orderedLines = []
921
+ if (typeof value === "string") {
922
+ lines.push("")
923
+ lines.push(value)
924
+ return new Blob([lines.join("\r\n")])
925
+ } else {
926
+ const content = encodePrimitiveContent(value)
927
+ lines.push("")
928
+ lines.push(content)
929
+ return new Blob([lines.join("\r\n")])
930
+ }
931
+ }
1727
932
 
1728
- if (allTypes.length > 0) {
1729
- orderedLines.push(`ao-types: ${allTypes.sort().join(", ")}`)
1730
- }
933
+ // Step 13.2.6: Handle binary values
934
+ function handleBinaryValue(bodyKey, value, headers) {
935
+ const isInline = bodyKey === "body" && headers["inline-body-key"] === "body"
936
+ const lines = []
1731
937
 
1732
- orderedLines.push(`content-disposition: form-data;name="${bodyKey}"`)
938
+ if (isInline) {
939
+ lines.push(`content-disposition: inline`)
940
+ } else {
941
+ lines.push(`content-disposition: form-data;name="${bodyKey}"`)
942
+ }
1733
943
 
1734
- // Check if we have any binary fields
1735
- const hasBinaryFields = binaryFields && binaryFields.length > 0
944
+ const buffer = toBuffer(value)
945
+ const headerText = lines.join("\r\n") + "\r\n\r\n"
946
+ return new Blob([headerText, buffer])
947
+ }
1736
948
 
1737
- // Add blank line before field lines if we have binary fields or no field lines
1738
- if (hasBinaryFields || fieldLines.length === 0) {
1739
- orderedLines.push("")
1740
- }
949
+ // Step 13.3: Handle special data/body case
950
+ function handleSpecialDataBodyCase(obj, hasSpecialDataBody) {
951
+ if (
952
+ hasSpecialDataBody &&
953
+ obj.data &&
954
+ obj.data.body &&
955
+ isBytes(obj.data.body)
956
+ ) {
957
+ const buffer = toBuffer(obj.data.body)
958
+ const specialPart = [
959
+ `content-disposition: form-data;name="data/body"`,
960
+ "",
961
+ "",
962
+ ].join("\r\n")
963
+ return new Blob([specialPart, buffer])
964
+ }
965
+ return null
966
+ }
1741
967
 
1742
- // Add field lines
1743
- for (const line of fieldLines) {
1744
- orderedLines.push(line)
1745
- }
968
+ // Step 13: Build body parts for each body key
969
+ function buildBodyParts(obj, sortedBodyKeys, headers, hasSpecialDataBody) {
970
+ // Step 13.1: Initialize body parts collection
971
+ const bodyParts = []
1746
972
 
1747
- if (binaryFields && binaryFields.length > 0) {
1748
- const parts = []
1749
- const headerText = orderedLines.join("\r\n")
1750
- parts.push(Buffer.from(headerText))
973
+ // Step 13.2: Process each body key
974
+ for (const bodyKey of sortedBodyKeys) {
975
+ // Step 13.2.1: Get value for current body key
976
+ const value = getValueByPath(obj, bodyKey)
977
+ const pathParts = bodyKey.split("/")
1751
978
 
1752
- for (let i = 0; i < binaryFields.length; i++) {
1753
- const { key, buffer } = binaryFields[i]
1754
- if (i > 0) {
1755
- parts.push(Buffer.from("\r\n"))
1756
- }
1757
- parts.push(Buffer.from(`${key}: `))
1758
- parts.push(buffer)
1759
- }
979
+ // Step 13.2.2: Handle empty string in nested path
980
+ const emptyStringPart = handleEmptyStringInNestedPath(
981
+ bodyKey,
982
+ value,
983
+ pathParts
984
+ )
985
+ if (emptyStringPart) {
986
+ bodyParts.push(emptyStringPart)
987
+ continue
988
+ }
1760
989
 
1761
- parts.push(Buffer.from("\r\n"))
1762
- const fullBody = Buffer.concat(parts)
1763
- bodyParts.push(new Blob([fullBody]))
1764
- } else {
1765
- // Add trailing blank line if we have field lines
1766
- if (fieldLines.length > 0) {
1767
- bodyParts.push(new Blob([orderedLines.join("\r\n") + "\r\n"]))
1768
- } else {
1769
- bodyParts.push(new Blob([orderedLines.join("\r\n")]))
1770
- }
1771
- }
990
+ // Step 13.2.3: Handle array values
991
+ if (Array.isArray(value)) {
992
+ const arrayPart = handleArrayValue(
993
+ bodyKey,
994
+ value,
995
+ headers,
996
+ sortedBodyKeys,
997
+ pathParts
998
+ )
999
+ if (arrayPart) {
1000
+ bodyParts.push(arrayPart)
1772
1001
  }
1773
-
1774
1002
  continue
1775
1003
  }
1776
1004
 
1777
- const isInline = bodyKey === "body" && headers["inline-body-key"] === "body"
1778
- if (isInline) {
1779
- lines.push(`content-disposition: inline`)
1780
- } else {
1781
- lines.push(`content-disposition: form-data;name="${bodyKey}"`)
1005
+ // Step 13.2.4: Handle object values
1006
+ if (isPojo(value)) {
1007
+ const objectPart = handleObjectValue(
1008
+ obj,
1009
+ bodyKey,
1010
+ value,
1011
+ headers,
1012
+ sortedBodyKeys,
1013
+ pathParts,
1014
+ hasSpecialDataBody
1015
+ )
1016
+ if (objectPart) {
1017
+ bodyParts.push(objectPart)
1018
+ }
1019
+ continue
1782
1020
  }
1783
1021
 
1784
- if (isBytes(value)) {
1785
- const buffer = toBuffer(value)
1786
- const headerText = lines.join("\r\n") + "\r\n\r\n"
1787
- bodyParts.push(new Blob([headerText, buffer]))
1788
- } else if (typeof value === "string") {
1789
- lines.push("")
1790
- lines.push(value)
1791
- bodyParts.push(new Blob([lines.join("\r\n")]))
1792
- } else if (
1022
+ // Step 13.2.5: Handle primitive values
1023
+ if (
1024
+ typeof value === "string" ||
1793
1025
  typeof value === "boolean" ||
1794
1026
  typeof value === "number" ||
1795
1027
  value === null ||
1796
1028
  value === undefined ||
1797
1029
  typeof value === "symbol"
1798
1030
  ) {
1799
- let content
1800
- if (typeof value === "boolean") {
1801
- content = `"${value}"`
1802
- } else if (typeof value === "number") {
1803
- content = String(value)
1804
- } else if (value === null) {
1805
- content = '"null"'
1806
- } else if (value === undefined) {
1807
- content = '"undefined"'
1808
- } else if (typeof value === "symbol") {
1809
- content = `"${value.description || "Symbol.for()"}"`
1810
- }
1031
+ const primitivePart = handlePrimitiveValue(bodyKey, value, headers)
1032
+ bodyParts.push(primitivePart)
1033
+ continue
1034
+ }
1811
1035
 
1812
- lines.push("")
1813
- lines.push(content)
1814
- bodyParts.push(new Blob([lines.join("\r\n")]))
1036
+ // Step 13.2.6: Handle binary values
1037
+ if (isBytes(value)) {
1038
+ const binaryPart = handleBinaryValue(bodyKey, value, headers)
1039
+ bodyParts.push(binaryPart)
1040
+ continue
1815
1041
  }
1816
1042
  }
1817
1043
 
1818
- // Add the special data/body part if needed
1819
- if (
1820
- hasSpecialDataBody &&
1821
- obj.data &&
1822
- obj.data.body &&
1823
- isBytes(obj.data.body)
1824
- ) {
1825
- const buffer = toBuffer(obj.data.body)
1826
- const specialPart = [
1827
- `content-disposition: form-data;name="data/body"`,
1828
- "",
1829
- "",
1830
- ].join("\r\n")
1831
- bodyParts.push(new Blob([specialPart, buffer]))
1044
+ // Step 13.3: Handle special data/body case
1045
+ const specialPart = handleSpecialDataBodyCase(obj, hasSpecialDataBody)
1046
+ if (specialPart) {
1047
+ bodyParts.push(specialPart)
1832
1048
  }
1833
1049
 
1050
+ // Step 13.4: Return body parts
1051
+ return bodyParts
1052
+ }
1053
+
1054
+ // Step 14: Generate multipart boundary
1055
+ async function generateBoundary(bodyParts) {
1834
1056
  const partsContent = await Promise.all(bodyParts.map(part => part.text()))
1835
1057
  const allContent = partsContent.join("")
1836
1058
  const boundaryHash = await sha256(new TextEncoder().encode(allContent))
1837
1059
  const boundary = base64url.encode(Buffer.from(boundaryHash))
1060
+ return boundary
1061
+ }
1838
1062
 
1063
+ // Step 15: Assemble final multipart body
1064
+ function assembleMultipartBody(bodyParts, boundary) {
1839
1065
  const finalParts = []
1840
1066
  for (let i = 0; i < bodyParts.length; i++) {
1841
1067
  if (i === 0) {
@@ -1847,20 +1073,144 @@ async function encode(obj = {}) {
1847
1073
  }
1848
1074
  finalParts.push(new Blob([`\r\n--${boundary}--`]))
1849
1075
 
1850
- headers["content-type"] = `multipart/form-data; boundary="${boundary}"`
1851
- const body = new Blob(finalParts)
1076
+ return new Blob(finalParts)
1077
+ }
1852
1078
 
1079
+ // Step 16: Calculate content digest
1080
+ async function calculateContentDigest(body) {
1853
1081
  const finalContent = await body.arrayBuffer()
1854
1082
 
1855
1083
  if (finalContent.byteLength > 0) {
1856
1084
  const contentDigest = await sha256(finalContent)
1857
1085
  const base64 = base64url.toBase64(base64url.encode(contentDigest))
1858
- headers["content-digest"] = `sha-256=:${base64}:`
1086
+ return { digest: base64, byteLength: finalContent.byteLength }
1859
1087
  }
1860
1088
 
1861
- headers["content-length"] = String(finalContent.byteLength)
1089
+ return { digest: null, byteLength: finalContent.byteLength }
1090
+ }
1091
+
1092
+ // Step 17: Set final headers (content-type, content-length)
1093
+ function setFinalHeaders(headers, boundary, contentDigest, byteLength) {
1094
+ headers["content-type"] = `multipart/form-data; boundary="${boundary}"`
1095
+
1096
+ if (contentDigest) {
1097
+ headers["content-digest"] = `sha-256=:${contentDigest}:`
1098
+ }
1099
+
1100
+ headers["content-length"] = String(byteLength)
1101
+ }
1102
+
1103
+ async function encode(obj = {}) {
1104
+ // Step 1: Process and normalize input values
1105
+ const processedObj = processInputValues(obj)
1106
+
1107
+ // Step 2: Handle empty object case
1108
+ const emptyResult = handleEmptyObject(processedObj)
1109
+ if (emptyResult) return emptyResult
1110
+
1111
+ // Step 3: Handle single field with empty binary
1112
+ const emptyBinaryResult = handleSingleEmptyBinaryField(processedObj)
1113
+ if (emptyBinaryResult) return emptyBinaryResult
1114
+
1115
+ // Step 4: Handle single field with binary data
1116
+ const singleBinaryResult = await handleSingleBinaryField(processedObj)
1117
+ if (singleBinaryResult) return singleBinaryResult
1118
+
1119
+ // Step 5: Handle single field with primitive value
1120
+ const primitiveResult = await handleSinglePrimitiveField(processedObj)
1121
+ if (primitiveResult) return primitiveResult
1122
+
1123
+ // Step 6a: Handle single field with non-empty binary
1124
+ const nonEmptyBinaryResult =
1125
+ await handleSingleNonEmptyBinaryField(processedObj)
1126
+ if (nonEmptyBinaryResult) return nonEmptyBinaryResult
1127
+
1128
+ // Step 6: Handle single field with non-ASCII string
1129
+ const nonAsciiResult = await handleSingleNonAsciiStringField(processedObj)
1130
+ if (nonAsciiResult) return nonAsciiResult
1131
+
1132
+ // Step 7: Collect all keys that need to go in body
1133
+ const bodyKeys = collectBodyKeysStep(processedObj)
1134
+
1135
+ const objKeys = Object.keys(obj)
1136
+ const headers = {}
1137
+ const headerTypes = []
1138
+
1139
+ // Step 8: Process fields that can go in headers
1140
+ processHeaderFields(obj, bodyKeys, headers, headerTypes)
1141
+
1142
+ // Step 9: Handle case where all body keys are empty binaries
1143
+ const emptyBinaryBodyResult = handleAllEmptyBinaryBodyKeys(
1144
+ obj,
1145
+ bodyKeys,
1146
+ headers,
1147
+ headerTypes
1148
+ )
1149
+ if (emptyBinaryBodyResult) return emptyBinaryBodyResult
1150
+
1151
+ // Step 10: Handle single body key optimization
1152
+ const singleBodyKeyResult = await handleSingleBodyKeyOptimization(
1153
+ obj,
1154
+ bodyKeys,
1155
+ headers,
1156
+ headerTypes
1157
+ )
1158
+ if (singleBodyKeyResult) return singleBodyKeyResult
1159
+
1160
+ // Step 11: Sort body keys
1161
+ const sortedBodyKeys = sortBodyKeys(bodyKeys)
1162
+
1163
+ // Step 12: Check for special data/body case
1164
+ const hasSpecialDataBody = checkSpecialDataBodyCase(obj, sortedBodyKeys)
1165
+
1166
+ // Only add body-keys header if there are actual body keys
1167
+ if (sortedBodyKeys.length > 0) {
1168
+ headers["body-keys"] = sortedBodyKeys.map(k => `"${k}"`).join(", ")
1169
+ }
1170
+
1171
+ // Special case: single body key named "body" containing an object
1172
+ if (
1173
+ !hasSpecialDataBody &&
1174
+ sortedBodyKeys.length === 1 &&
1175
+ sortedBodyKeys[0] === "body"
1176
+ ) {
1177
+ const bodyValue = obj.body
1178
+ if (isPojo(bodyValue)) {
1179
+ headers["inline-body-key"] = "body"
1180
+ }
1181
+ }
1182
+
1183
+ if (headerTypes.length > 0) {
1184
+ headers["ao-types"] = headerTypes.sort().join(", ")
1185
+ }
1186
+
1187
+ // Step 13: Build body parts for each body key
1188
+ const bodyParts = buildBodyParts(
1189
+ obj,
1190
+ sortedBodyKeys,
1191
+ headers,
1192
+ hasSpecialDataBody
1193
+ )
1194
+
1195
+ // If no body parts were created, return headers only
1196
+ if (bodyParts.length === 0) {
1197
+ return { headers, body: undefined }
1198
+ }
1199
+
1200
+ // Step 14: Generate multipart boundary
1201
+ const boundary = await generateBoundary(bodyParts)
1202
+
1203
+ // Step 15: Assemble final multipart body
1204
+ const body = assembleMultipartBody(bodyParts, boundary)
1205
+
1206
+ // Step 16: Calculate content digest
1207
+ const { digest: contentDigest, byteLength } =
1208
+ await calculateContentDigest(body)
1209
+
1210
+ // Step 17: Set final headers (content-type, content-length)
1211
+ setFinalHeaders(headers, boundary, contentDigest, byteLength)
1862
1212
 
1863
- console.log("=== ENCODE END ===\n")
1213
+ // Step 18: Return result
1864
1214
  return { headers, body }
1865
1215
  }
1866
1216