wao 0.26.2 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/esm/encode.js ADDED
@@ -0,0 +1,1199 @@
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
+
26
+ const MAX_HEADER_LENGTH = 4096
27
+
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
+ // Format float in scientific notation with proper padding
59
+ let exp = num.toExponential(20)
60
+ // Replace "1.23e+0" with "1.23e+00"
61
+ exp = exp.replace(/e\+(\d)$/, "e+0$1")
62
+ exp = exp.replace(/e-(\d)$/, "e-0$1")
63
+ return exp
64
+ }
65
+
66
+ function encodeArrayItem(item) {
67
+ if (typeof item === "number") {
68
+ if (Number.isInteger(item)) {
69
+ return `"(ao-type-integer) ${item}"`
70
+ } else {
71
+ return `"(ao-type-float) ${formatFloat(item)}"`
72
+ }
73
+ } else if (typeof item === "string") {
74
+ return `"${item}"`
75
+ } else if (item === null) {
76
+ return `"(ao-type-atom) \\"null\\""`
77
+ } else if (item === undefined) {
78
+ return `"(ao-type-atom) \\"undefined\\""`
79
+ } else if (typeof item === "symbol") {
80
+ const desc = item.description || "Symbol.for()"
81
+ return `"(ao-type-atom) \\"${desc}\\""`
82
+ } else if (typeof item === "boolean") {
83
+ return `"(ao-type-atom) \\"${item}\\""`
84
+ } else if (Array.isArray(item)) {
85
+ // Nested array
86
+ const nestedItems = item
87
+ .map(nestedItem => {
88
+ if (typeof nestedItem === "number") {
89
+ if (Number.isInteger(nestedItem)) {
90
+ return `\\"(ao-type-integer) ${nestedItem}\\"`
91
+ } else {
92
+ return `\\"(ao-type-float) ${formatFloat(nestedItem)}\\"`
93
+ }
94
+ } else if (typeof nestedItem === "string") {
95
+ return `\\"${nestedItem}\\"`
96
+ } else if (nestedItem === null) {
97
+ return `\\"(ao-type-atom) \\\\\\"null\\\\\\"\\"`
98
+ } else if (typeof nestedItem === "symbol") {
99
+ const desc = nestedItem.description || "Symbol.for()"
100
+ return `\\"(ao-type-atom) \\\\\\"${desc}\\\\\\"\\"`
101
+ } else {
102
+ return `\\"${String(nestedItem)}\\"`
103
+ }
104
+ })
105
+ .join(", ")
106
+ return `"(ao-type-list) ${nestedItems}"`
107
+ } else if (isBytes(item)) {
108
+ // For empty binaries in arrays, return empty string
109
+ const buffer = toBuffer(item)
110
+ if (buffer.length === 0 || buffer.byteLength === 0) {
111
+ return `""`
112
+ }
113
+ // For non-empty binaries, we can't include them in headers
114
+ return `"(ao-type-binary)"`
115
+ } else if (isPojo(item)) {
116
+ const json = JSON.stringify(item)
117
+ const escaped = json.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
118
+ return `"(ao-type-map) ${escaped}"`
119
+ } else {
120
+ return `"${String(item)}"`
121
+ }
122
+ }
123
+
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
+ )
138
+ }
139
+ return false
140
+ }
141
+
142
+ function collectBodyKeys(obj, prefix = "") {
143
+ const keys = []
144
+
145
+ function traverse(current, path) {
146
+ // Track if current level has simple fields or empty objects
147
+ let hasSimpleFields = false
148
+ // Track nested paths that need body parts
149
+ const nestedPaths = []
150
+
151
+ for (const [key, value] of Object.entries(current)) {
152
+ const fullPath = path ? `${path}/${key}` : key
153
+
154
+ if (Array.isArray(value)) {
155
+ const hasObjects = value.some(item => isPojo(item))
156
+ const hasNonObjects = value.some(item => !isPojo(item))
157
+
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
+ }
164
+ })
165
+
166
+ // If array ALSO has non-object items, it needs its own body part
167
+ if (hasNonObjects) {
168
+ hasSimpleFields = true
169
+ }
170
+ } else {
171
+ // Simple array - parent needs body part
172
+ hasSimpleFields = true
173
+ }
174
+ } else if (isPojo(value)) {
175
+ // Check if this is an empty object
176
+ if (Object.keys(value).length === 0) {
177
+ // Empty objects need a body part
178
+ hasSimpleFields = true
179
+ } else {
180
+ // Non-empty objects are processed recursively
181
+ nestedPaths.push(fullPath)
182
+ }
183
+ } else if (isBytes(value)) {
184
+ hasSimpleFields = true
185
+ } else if (
186
+ typeof value === "string" ||
187
+ typeof value === "number" ||
188
+ typeof value === "boolean" ||
189
+ value === null ||
190
+ value === undefined ||
191
+ typeof value === "symbol"
192
+ ) {
193
+ hasSimpleFields = true
194
+ }
195
+ }
196
+
197
+ // Add current path if it has simple fields or empty objects
198
+ if (hasSimpleFields) {
199
+ keys.push(path)
200
+ }
201
+
202
+ // Process nested paths
203
+ for (const nestedPath of nestedPaths) {
204
+ const parts = nestedPath.split("/")
205
+ let nestedObj = obj
206
+
207
+ for (const part of parts) {
208
+ if (/^\d+$/.test(part)) {
209
+ nestedObj = nestedObj[parseInt(part) - 1]
210
+ } else {
211
+ nestedObj = nestedObj[part]
212
+ }
213
+ }
214
+
215
+ if (isPojo(nestedObj)) {
216
+ traverse(nestedObj, nestedPath)
217
+ }
218
+ }
219
+ }
220
+
221
+ // Handle top-level fields
222
+ for (const [key, value] of Object.entries(obj)) {
223
+ if (Array.isArray(value)) {
224
+ const hasObjects = value.some(item => isPojo(item))
225
+ const hasArrays = value.some(item => Array.isArray(item))
226
+ const hasNonObjects = value.some(item => !isPojo(item))
227
+
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}`)
236
+ }
237
+ }
238
+ }
239
+ })
240
+
241
+ // Mixed arrays also need their own body part
242
+ if (hasNonObjects) {
243
+ keys.push(key)
244
+ }
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
+ }
252
+ } else if (isPojo(value)) {
253
+ // Top-level object that may have nested structures
254
+ traverse(value, key)
255
+ } else if (isBytes(value)) {
256
+ // All binary data needs body parts, even empty ones
257
+ keys.push(key)
258
+ } else if (typeof value === "string" && value.includes("\n")) {
259
+ // Multiline string
260
+ keys.push(key)
261
+ }
262
+ }
263
+
264
+ return [...new Set(keys)].filter(k => k !== "")
265
+ }
266
+
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
+ }
282
+ }
283
+
284
+ async function encode(obj = {}) {
285
+ // Convert symbols to strings for logging
286
+ const processValue = value => {
287
+ if (typeof value === "symbol") {
288
+ return value.description || "Symbol.for()"
289
+ } else if (Array.isArray(value)) {
290
+ return value.map(processValue)
291
+ } else if (isPojo(value)) {
292
+ const result = {}
293
+ for (const [k, v] of Object.entries(value)) {
294
+ result[k] = processValue(v)
295
+ }
296
+ return result
297
+ }
298
+ return value
299
+ }
300
+
301
+ const processedObj = {}
302
+ for (const [k, v] of Object.entries(obj)) {
303
+ processedObj[k] = processValue(v)
304
+ }
305
+
306
+ // Remove debug logging for cleaner output
307
+ console.log("[encode] START with obj:", JSON.stringify(processedObj))
308
+
309
+ if (Object.keys(obj).length === 0) {
310
+ return { headers: {}, body: undefined }
311
+ }
312
+
313
+ // Check for special case: body field with binary + other simple fields or empty binaries
314
+ const hasBodyBinary = obj.body && isBytes(obj.body)
315
+ 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
+
327
+ if (hasBodyBinary && allOthersSimpleOrEmptyBinary) {
328
+ console.log("[encode] Special case: body with binary + simple fields")
329
+ // Special case: body with binary + other simple fields
330
+ const headers = {}
331
+ const headerTypes = []
332
+
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)}`
338
+ )
339
+
340
+ if (value === null) {
341
+ headers[key] = '"null"'
342
+ headerTypes.push(`${key}="atom"`)
343
+ } else if (value === undefined) {
344
+ headers[key] = '"undefined"'
345
+ headerTypes.push(`${key}="atom"`)
346
+ } else if (typeof value === "boolean") {
347
+ headers[key] = `"${value}"`
348
+ headerTypes.push(`${key}="atom"`)
349
+ } else if (typeof value === "symbol") {
350
+ headers[key] = `"${value.description || "Symbol.for()"}"`
351
+ headerTypes.push(`${key}="atom"`)
352
+ } else if (typeof value === "number") {
353
+ headers[key] = String(value)
354
+ headerTypes.push(
355
+ `${key}="${Number.isInteger(value) ? "integer" : "float"}"`
356
+ )
357
+ } else if (typeof value === "string") {
358
+ 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"`)
362
+ } else {
363
+ headers[key] = value
364
+ }
365
+ } 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"`)
368
+ } 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"`)
372
+ } else if (
373
+ isBytes(value) &&
374
+ (value.length === 0 || value.byteLength === 0)
375
+ ) {
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"`)
484
+ }
485
+ } else {
486
+ // Field needs body - still need to add type info to ao-types
487
+ if (isBytes(value) && (value.length === 0 || value.byteLength === 0)) {
488
+ headerTypes.push(`${key}="empty-binary"`)
489
+ } else if (typeof value === "string" && value.length === 0) {
490
+ headerTypes.push(`${key}="empty-binary"`)
491
+ } else if (Array.isArray(value) && value.length === 0) {
492
+ headerTypes.push(`${key}="empty-list"`)
493
+ } else if (isPojo(value) && Object.keys(value).length === 0) {
494
+ headerTypes.push(`${key}="empty-message"`)
495
+ }
496
+ }
497
+ }
498
+
499
+ // Add ao-types for arrays that go in body
500
+ for (const [key, value] of Object.entries(obj)) {
501
+ if (Array.isArray(value)) {
502
+ // Check if this array goes in the body
503
+ if (
504
+ bodyKeys.includes(key) ||
505
+ bodyKeys.some(k => k.startsWith(`${key}/`))
506
+ ) {
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"`)
510
+ }
511
+ }
512
+ }
513
+ }
514
+
515
+ // If no body needed
516
+ if (bodyKeys.length === 0) {
517
+ if (headerTypes.length > 0) {
518
+ headers["ao-types"] = headerTypes.sort().join(", ")
519
+ }
520
+ console.log("[encode] FINAL - headers:", headers, "body:", undefined)
521
+ return { headers, body: undefined }
522
+ }
523
+
524
+ // Check if all body keys are for empty binaries - if so, treat as no body needed
525
+ const allBodyKeysAreEmptyBinaries = bodyKeys.every(key => {
526
+ const pathParts = key.split("/")
527
+ let value = obj
528
+ for (const part of pathParts) {
529
+ if (/^\d+$/.test(part)) {
530
+ value = value[parseInt(part) - 1]
531
+ } else {
532
+ value = value[part]
533
+ }
534
+ }
535
+ return isBytes(value) && (value.length === 0 || value.byteLength === 0)
536
+ })
537
+
538
+ if (allBodyKeysAreEmptyBinaries) {
539
+ // Treat as header-only encoding
540
+ if (headerTypes.length > 0) {
541
+ headers["ao-types"] = headerTypes.sort().join(", ")
542
+ }
543
+ console.log(
544
+ "[encode] FINAL (all empty binaries) - headers:",
545
+ headers,
546
+ "body:",
547
+ undefined
548
+ )
549
+ return { headers, body: undefined }
550
+ }
551
+
552
+ // Sort body keys and add to headers
553
+ 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
557
+ return a.localeCompare(b)
558
+ })
559
+
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
562
+ const hasSpecialDataBody =
563
+ sortedBodyKeys.includes("data") &&
564
+ sortedBodyKeys.includes("body") &&
565
+ obj.data &&
566
+ obj.data.body &&
567
+ isBytes(obj.data.body) &&
568
+ obj.body &&
569
+ obj.body.data &&
570
+ isBytes(obj.body.data)
571
+
572
+ headers["body-keys"] = sortedBodyKeys.map(k => `"${k}"`).join(", ")
573
+
574
+ // Check for inline keys - but not in the special data/body case
575
+ 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
+ }
582
+ }
583
+ }
584
+
585
+ // Add ao-types header if needed
586
+ if (headerTypes.length > 0) {
587
+ headers["ao-types"] = headerTypes.sort().join(", ")
588
+ }
589
+
590
+ // Create multipart body parts
591
+ const bodyParts = []
592
+
593
+ for (const bodyKey of sortedBodyKeys) {
594
+ const lines = []
595
+
596
+ // Parse the path to get to the value
597
+ const pathParts = bodyKey.split("/")
598
+ let value = obj
599
+ let parent = null
600
+
601
+ // Get the actual value at this path
602
+ for (let i = 0; i < pathParts.length; i++) {
603
+ parent = value
604
+ const part = pathParts[i]
605
+
606
+ if (/^\d+$/.test(part)) {
607
+ value = value[parseInt(part) - 1]
608
+ } else {
609
+ value = value[part]
610
+ }
611
+ }
612
+
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
+ )
651
+
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 {
657
+ lines.push(`content-disposition: form-data;name="${bodyKey}"`)
658
+ }
659
+
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
+
667
+ if (isBytes(value)) {
668
+ console.log("[encode] Processing binary value for key:", bodyKey)
669
+ // Binary data
670
+ const buffer = toBuffer(value)
671
+
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))
776
+ )
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
871
+ const hasObjects = value.some(item => isPojo(item))
872
+ const hasArrays = value.some(item => Array.isArray(item))
873
+ const nonObjectItems = value
874
+ .map((item, index) => ({ item, index: index + 1 }))
875
+ .filter(({ item }) => !isPojo(item))
876
+
877
+ if (hasObjects && nonObjectItems.length > 0) {
878
+ // Mixed array - only include non-object items
879
+ const fieldLines = []
880
+ const partTypes = []
881
+
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}`)
926
+ }
927
+ }
928
+
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
+ })
936
+
937
+ // Rebuild in correct order: field lines, ao-types, content-disposition
938
+ const orderedLines = []
939
+
940
+ // First: field lines
941
+ for (const line of fieldLines) {
942
+ orderedLines.push(line)
943
+ }
944
+
945
+ // Then: ao-types
946
+ if (partTypes.length > 0) {
947
+ orderedLines.push(
948
+ `ao-types: ${partTypes
949
+ .sort((a, b) => {
950
+ const aNum = parseInt(a.split("=")[0])
951
+ const bNum = parseInt(b.split("=")[0])
952
+ return aNum - bNum
953
+ })
954
+ .join(", ")}`
955
+ )
956
+ }
957
+
958
+ // Finally: content-disposition (from lines[0])
959
+ orderedLines.push(lines[0])
960
+ orderedLines.push("")
961
+
962
+ console.log("[encode] Ordered lines:", orderedLines)
963
+
964
+ bodyParts.push(new Blob([orderedLines.join("\r\n")]))
965
+ } else {
966
+ // Normal order for non-inline parts
967
+ if (partTypes.length > 0) {
968
+ lines.unshift(
969
+ `ao-types: ${partTypes
970
+ .sort((a, b) => {
971
+ const aNum = parseInt(a.split("=")[0])
972
+ const bNum = parseInt(b.split("=")[0])
973
+ return aNum - bNum
974
+ })
975
+ .join(", ")}`
976
+ )
977
+ }
978
+
979
+ for (const line of fieldLines) {
980
+ lines.push(line)
981
+ }
982
+
983
+ lines.push("")
984
+ bodyParts.push(new Blob([lines.join("\r\n")]))
985
+ }
986
+ } else if (hasArrays || (!hasObjects && value.length > 0)) {
987
+ // Array of arrays or simple array - use indexed format
988
+ const fieldLines = []
989
+ const partTypes = []
990
+
991
+ value.forEach((item, idx) => {
992
+ const index = idx + 1
993
+ 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
1043
+ }
1044
+ })
1045
+
1046
+ // For inline arrays, use different order
1047
+ if (isInline) {
1048
+ console.log("[encode] Reordering for inline array - indexed format")
1049
+
1050
+ const orderedLines = []
1051
+
1052
+ // First: field lines
1053
+ for (const line of fieldLines) {
1054
+ orderedLines.push(line)
1055
+ }
1056
+
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
+ }
1069
+
1070
+ // Finally: content-disposition
1071
+ orderedLines.push(lines[0])
1072
+ orderedLines.push("")
1073
+
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
+ )
1089
+ }
1090
+
1091
+ console.log("[encode] Adding field lines:", fieldLines)
1092
+ for (const line of fieldLines) {
1093
+ lines.push(line)
1094
+ }
1095
+
1096
+ console.log("[encode] Final lines before blob:", lines)
1097
+ lines.push("")
1098
+ bodyParts.push(new Blob([lines.join("\r\n")]))
1099
+ }
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
+ }
1108
+ } else if (typeof value === "string") {
1109
+ // String with newlines or too long
1110
+ lines.push("")
1111
+ lines.push(value)
1112
+ lines.push("")
1113
+ bodyParts.push(new Blob([lines.join("\r\n")]))
1114
+ }
1115
+ }
1116
+
1117
+ // Special case: add data/body as a separate form-data part if needed
1118
+ if (
1119
+ hasSpecialDataBody &&
1120
+ obj.data &&
1121
+ obj.data.body &&
1122
+ isBytes(obj.data.body)
1123
+ ) {
1124
+ const buffer = toBuffer(obj.data.body)
1125
+ const specialPart = [
1126
+ `content-disposition: form-data;name="data/body"`,
1127
+ "",
1128
+ "",
1129
+ ].join("\r\n")
1130
+ bodyParts.push(new Blob([specialPart, buffer]))
1131
+ }
1132
+
1133
+ // Calculate boundary from content
1134
+ const partsContent = await Promise.all(bodyParts.map(part => part.text()))
1135
+ const allContent = partsContent.join("")
1136
+ const boundaryHash = await sha256(new TextEncoder().encode(allContent))
1137
+ const boundary = base64url.encode(Buffer.from(boundaryHash))
1138
+
1139
+ // Assemble final multipart body - NO newlines after each part except the last
1140
+ const finalParts = []
1141
+ for (let i = 0; i < bodyParts.length; i++) {
1142
+ if (i === 0) {
1143
+ finalParts.push(new Blob([`--${boundary}\r\n`]))
1144
+ } else {
1145
+ finalParts.push(new Blob([`\r\n--${boundary}\r\n`]))
1146
+ }
1147
+ finalParts.push(bodyParts[i])
1148
+ }
1149
+ finalParts.push(new Blob([`\r\n--${boundary}--`]))
1150
+
1151
+ headers["content-type"] = `multipart/form-data; boundary="${boundary}"`
1152
+ const body = new Blob(finalParts)
1153
+
1154
+ // Calculate content digest
1155
+ 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
+
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
+ }
1192
+ }
1193
+
1194
+ return { headers, body }
1195
+ }
1196
+
1197
+ export async function enc(fields) {
1198
+ return await encode(fields)
1199
+ }