hbsig 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.babelrc-cjs +5 -0
  2. package/.babelrc-esm +5 -0
  3. package/README.md +1 -0
  4. package/dist/package.json +39 -0
  5. package/make.js +36 -0
  6. package/package.json +16 -17
  7. package/src/bin_to_str.js +46 -0
  8. package/src/collect-body-keys.js +436 -0
  9. package/src/commit.js +219 -0
  10. package/src/encode-array-item.js +112 -0
  11. package/src/encode-utils.js +191 -0
  12. package/src/encode.js +1256 -0
  13. package/src/erl_json.js +292 -0
  14. package/src/erl_str.js +1144 -0
  15. package/src/flat.js +250 -0
  16. package/src/http-message-signatures/httpbis.js +438 -0
  17. package/src/http-message-signatures/index.js +4 -0
  18. package/src/http-message-signatures/structured-header.js +105 -0
  19. package/src/httpsig.js +866 -0
  20. package/src/id.js +459 -0
  21. package/src/index.js +13 -0
  22. package/src/nocrypto.js +4 -0
  23. package/src/parser.js +171 -0
  24. package/src/send-utils.js +1132 -0
  25. package/src/send.js +142 -0
  26. package/src/signer-utils.js +375 -0
  27. package/src/signer.js +312 -0
  28. package/src/structured.js +496 -0
  29. package/src/test.js +2 -0
  30. package/src/utils.js +29 -0
  31. package/test/commit.test.js +41 -0
  32. package/test/erl_json.test.js +8 -0
  33. package/test/flat.test.js +27 -0
  34. package/test/httpsig.test.js +31 -0
  35. package/test/id.test.js +114 -0
  36. package/test/lib/all_cases.js +408 -0
  37. package/test/lib/cases.js +408 -0
  38. package/test/lib/erl_json_cases.js +161 -0
  39. package/test/lib/flat_cases.js +189 -0
  40. package/test/lib/gen.js +528 -0
  41. package/test/lib/httpsig_cases.js +313 -0
  42. package/test/lib/structured_cases.js +222 -0
  43. package/test/lib/test-utils.js +399 -0
  44. package/test/signer.test.js +48 -0
  45. package/test/structured.test.js +35 -0
  46. package/bin/install-deps +0 -0
  47. /package/{cjs → dist/cjs}/bin_to_str.js +0 -0
  48. /package/{cjs → dist/cjs}/collect-body-keys.js +0 -0
  49. /package/{cjs → dist/cjs}/commit.js +0 -0
  50. /package/{cjs → dist/cjs}/encode-array-item.js +0 -0
  51. /package/{cjs → dist/cjs}/encode-utils.js +0 -0
  52. /package/{cjs → dist/cjs}/encode.js +0 -0
  53. /package/{cjs → dist/cjs}/erl_json.js +0 -0
  54. /package/{cjs → dist/cjs}/erl_str.js +0 -0
  55. /package/{cjs → dist/cjs}/flat.js +0 -0
  56. /package/{cjs → dist/cjs}/http-message-signatures/httpbis.js +0 -0
  57. /package/{cjs → dist/cjs}/http-message-signatures/index.js +0 -0
  58. /package/{cjs → dist/cjs}/http-message-signatures/structured-header.js +0 -0
  59. /package/{cjs → dist/cjs}/httpsig.js +0 -0
  60. /package/{cjs → dist/cjs}/id.js +0 -0
  61. /package/{cjs → dist/cjs}/index.js +0 -0
  62. /package/{cjs → dist/cjs}/nocrypto.js +0 -0
  63. /package/{cjs → dist/cjs}/parser.js +0 -0
  64. /package/{cjs → dist/cjs}/send-utils.js +0 -0
  65. /package/{cjs → dist/cjs}/send.js +0 -0
  66. /package/{cjs → dist/cjs}/signer-utils.js +0 -0
  67. /package/{cjs → dist/cjs}/signer.js +0 -0
  68. /package/{cjs → dist/cjs}/structured.js +0 -0
  69. /package/{cjs → dist/cjs}/test.js +0 -0
  70. /package/{cjs → dist/cjs}/utils.js +0 -0
  71. /package/{esm → dist/esm}/bin_to_str.js +0 -0
  72. /package/{esm → dist/esm}/collect-body-keys.js +0 -0
  73. /package/{esm → dist/esm}/commit.js +0 -0
  74. /package/{esm → dist/esm}/encode-array-item.js +0 -0
  75. /package/{esm → dist/esm}/encode-utils.js +0 -0
  76. /package/{esm → dist/esm}/encode.js +0 -0
  77. /package/{esm → dist/esm}/erl_json.js +0 -0
  78. /package/{esm → dist/esm}/erl_str.js +0 -0
  79. /package/{esm → dist/esm}/flat.js +0 -0
  80. /package/{esm → dist/esm}/http-message-signatures/httpbis.js +0 -0
  81. /package/{esm → dist/esm}/http-message-signatures/index.js +0 -0
  82. /package/{esm → dist/esm}/http-message-signatures/structured-header.js +0 -0
  83. /package/{esm → dist/esm}/httpsig.js +0 -0
  84. /package/{esm → dist/esm}/id.js +0 -0
  85. /package/{esm → dist/esm}/index.js +0 -0
  86. /package/{esm → dist/esm}/nocrypto.js +0 -0
  87. /package/{esm → dist/esm}/package.json +0 -0
  88. /package/{esm → dist/esm}/parser.js +0 -0
  89. /package/{esm → dist/esm}/send-utils.js +0 -0
  90. /package/{esm → dist/esm}/send.js +0 -0
  91. /package/{esm → dist/esm}/signer-utils.js +0 -0
  92. /package/{esm → dist/esm}/signer.js +0 -0
  93. /package/{esm → dist/esm}/structured.js +0 -0
  94. /package/{esm → dist/esm}/test.js +0 -0
  95. /package/{esm → dist/esm}/utils.js +0 -0
package/src/encode.js ADDED
@@ -0,0 +1,1256 @@
1
+ import base64url from "base64url"
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
+
18
+ import encodeArrayItem from "./encode-array-item.js"
19
+ import collectBodyKeys from "./collect-body-keys.js"
20
+ const MAX_HEADER_LENGTH = 4096
21
+
22
+ // Step 1: Process and normalize input values (handle symbols, nested objects/arrays)
23
+ function processInputValues(obj) {
24
+ // Currently this is a no-op, but will be used for input validation/normalization
25
+ return obj
26
+ }
27
+
28
+ // Step 2: Handle empty object case
29
+ function handleEmptyObject(obj) {
30
+ if (Object.keys(obj).length === 0) {
31
+ return { headers: {}, body: undefined }
32
+ }
33
+ return null
34
+ }
35
+
36
+ // Step 3: Handle single field with empty binary
37
+ function handleSingleEmptyBinaryField(obj) {
38
+ const objKeys = Object.keys(obj)
39
+
40
+ if (objKeys.length === 1) {
41
+ const fieldName = objKeys[0]
42
+ const fieldValue = obj[fieldName]
43
+
44
+ if (
45
+ isBytes(fieldValue) &&
46
+ (fieldValue.length === 0 || fieldValue.byteLength === 0)
47
+ ) {
48
+ const headers = {}
49
+ // For 'body' field, we can't send it as a header (reserved name, gets stripped).
50
+ // Use inline-body-key to signal that body was present but empty.
51
+ // The modOut function will reconstruct body: <<>> when it sees inline-body-key: body
52
+ // with no actual body content.
53
+ if (fieldName.toLowerCase() === "body") {
54
+ headers["inline-body-key"] = "body"
55
+ return { headers, body: undefined }
56
+ }
57
+ // Include the key with empty value - empty string becomes empty binary in Erlang
58
+ headers[fieldName.toLowerCase()] = ""
59
+ return { headers, body: undefined }
60
+ }
61
+ }
62
+
63
+ return null
64
+ }
65
+
66
+ // Step 4: Handle single field with binary data
67
+ async function handleSingleBinaryField(obj) {
68
+ const hasBodyBinary = obj.body && isBytes(obj.body)
69
+ const otherFields = Object.keys(obj).filter(k => k !== "body")
70
+
71
+ if (hasBodyBinary && otherFields.length === 0) {
72
+ const headers = {}
73
+ const bodyBuffer = toBuffer(obj.body)
74
+ const bodyArrayBuffer = bodyBuffer.buffer.slice(
75
+ bodyBuffer.byteOffset,
76
+ bodyBuffer.byteOffset + bodyBuffer.byteLength
77
+ )
78
+
79
+ const contentDigest = await sha256(bodyArrayBuffer)
80
+ const base64 = base64url.toBase64(base64url.encode(contentDigest))
81
+ headers["content-digest"] = `sha-256=:${base64}:`
82
+
83
+ return { headers, body: obj.body }
84
+ }
85
+
86
+ return null
87
+ }
88
+
89
+ // Step 5: Handle single field with primitive value (string/number/boolean/null/undefined/symbol)
90
+ async function handleSinglePrimitiveField(obj) {
91
+ const objKeys = Object.keys(obj)
92
+
93
+ if (objKeys.length === 1) {
94
+ const fieldName = objKeys[0]
95
+ const fieldValue = obj[fieldName]
96
+
97
+ if (
98
+ (fieldName === "data" || fieldName === "body") &&
99
+ (typeof fieldValue === "string" ||
100
+ typeof fieldValue === "boolean" ||
101
+ typeof fieldValue === "number" ||
102
+ fieldValue === null ||
103
+ fieldValue === undefined ||
104
+ typeof fieldValue === "symbol")
105
+ ) {
106
+ const headers = {}
107
+ const bodyContent = encodePrimitiveContent(fieldValue)
108
+
109
+ const encoder = new TextEncoder()
110
+ const encoded = encoder.encode(bodyContent)
111
+ const contentDigest = await sha256(encoded.buffer)
112
+ const base64 = base64url.toBase64(base64url.encode(contentDigest))
113
+ headers["content-digest"] = `sha-256=:${base64}:`
114
+
115
+ const aoType = getAoType(fieldValue)
116
+ if (aoType === "atom" || aoType === "integer" || aoType === "float") {
117
+ headers["ao-types"] = `${fieldName.toLowerCase()}="${aoType}"`
118
+ }
119
+
120
+ if (fieldName !== "body") {
121
+ headers["inline-body-key"] = fieldName
122
+ }
123
+
124
+ return { headers, body: bodyContent }
125
+ }
126
+ }
127
+
128
+ return null
129
+ }
130
+
131
+ // Step 6a: Handle single field with non-empty binary (not body field)
132
+ async function handleSingleNonEmptyBinaryField(obj) {
133
+ const objKeys = Object.keys(obj)
134
+
135
+ if (objKeys.length === 1) {
136
+ const fieldName = objKeys[0]
137
+ const fieldValue = obj[fieldName]
138
+
139
+ if (isBytes(fieldValue) && fieldValue.length > 0) {
140
+ const headers = {}
141
+ const bodyBuffer = toBuffer(fieldValue)
142
+ const bodyArrayBuffer = bodyBuffer.buffer.slice(
143
+ bodyBuffer.byteOffset,
144
+ bodyBuffer.byteOffset + bodyBuffer.byteLength
145
+ )
146
+
147
+ const contentDigest = await sha256(bodyArrayBuffer)
148
+ const base64 = base64url.toBase64(base64url.encode(contentDigest))
149
+ headers["content-digest"] = `sha-256=:${base64}:`
150
+
151
+ if (fieldName !== "body") {
152
+ headers["inline-body-key"] = fieldName
153
+ }
154
+
155
+ return { headers, body: fieldValue }
156
+ }
157
+ }
158
+
159
+ return null
160
+ }
161
+
162
+ // Step 6: Handle single field with non-ASCII string
163
+ async function handleSingleNonAsciiStringField(obj) {
164
+ const objKeys = Object.keys(obj)
165
+
166
+ if (objKeys.length === 1) {
167
+ const fieldName = objKeys[0]
168
+ const fieldValue = obj[fieldName]
169
+
170
+ if (typeof fieldValue === "string" && hasNonAscii(fieldValue)) {
171
+ const headers = {}
172
+ const encoder = new TextEncoder()
173
+ const encoded = encoder.encode(fieldValue)
174
+ const contentDigest = await sha256(encoded.buffer)
175
+ const base64 = base64url.toBase64(base64url.encode(contentDigest))
176
+ headers["content-digest"] = `sha-256=:${base64}:`
177
+
178
+ if (fieldName !== "body") {
179
+ headers["inline-body-key"] = fieldName
180
+ }
181
+
182
+ return { headers, body: fieldValue }
183
+ }
184
+ }
185
+
186
+ return null
187
+ }
188
+
189
+ // Step 7: Collect all keys that need to go in body
190
+ function collectBodyKeysStep(obj) {
191
+ return collectBodyKeys(obj)
192
+ }
193
+
194
+ // Step 8: Process fields that can go in headers
195
+ function processHeaderFields(obj, bodyKeys, headers, headerTypes) {
196
+ for (const [key, value] of Object.entries(obj)) {
197
+ const needsBody =
198
+ bodyKeys.includes(key) || bodyKeys.some(k => k.startsWith(`${key}/`))
199
+
200
+ if (!needsBody) {
201
+ if (value === null) {
202
+ headers[key] = '"null"'
203
+ headerTypes.push(`${key.toLowerCase()}="atom"`)
204
+ } else if (value === undefined) {
205
+ headers[key] = '"undefined"'
206
+ headerTypes.push(`${key.toLowerCase()}="atom"`)
207
+ } else if (typeof value === "boolean") {
208
+ headers[key] = `"${value}"`
209
+ headerTypes.push(`${key.toLowerCase()}="atom"`)
210
+ } else if (typeof value === "symbol") {
211
+ headers[key] = `"${value.description || "Symbol.for()"}"`
212
+ headerTypes.push(`${key.toLowerCase()}="atom"`)
213
+ } else if (typeof value === "number") {
214
+ headers[key] = String(value)
215
+ headerTypes.push(
216
+ `${key.toLowerCase()}="${Number.isInteger(value) ? "integer" : "float"}"`
217
+ )
218
+ } else if (typeof value === "string") {
219
+ if (value.length === 0) {
220
+ // Empty string becomes empty binary in Erlang - no ao-types needed
221
+ headers[key] = ""
222
+ } else if (hasNonAscii(value)) {
223
+ continue
224
+ } else {
225
+ headers[key] = value
226
+ }
227
+ } else if (Array.isArray(value) && value.length === 0) {
228
+ // Empty array - use list type annotation
229
+ headers[key] = ""
230
+ headerTypes.push(`${key.toLowerCase()}="list"`)
231
+ } else if (Array.isArray(value) && !value.some(item => isPojo(item))) {
232
+ const hasNonAsciiItems = value.some(
233
+ item => typeof item === "string" && hasNonAscii(item)
234
+ )
235
+ if (!hasNonAsciiItems) {
236
+ const encodedItems = value
237
+ .map(item => encodeArrayItem(item))
238
+ .join(", ")
239
+ headers[key] = encodedItems
240
+ headerTypes.push(`${key.toLowerCase()}="list"`)
241
+ }
242
+ } else if (
243
+ isBytes(value) &&
244
+ (value.length === 0 || value.byteLength === 0)
245
+ ) {
246
+ // Empty buffer becomes empty binary in Erlang - no ao-types needed
247
+ // For 'body' field, we can't send it as a header (reserved name, gets stripped).
248
+ // Use inline-body-key to signal that body was present but empty.
249
+ if (key.toLowerCase() === "body") {
250
+ headers["inline-body-key"] = "body"
251
+ } else {
252
+ headers[key] = ""
253
+ }
254
+ } else if (isPojo(value) && Object.keys(value).length === 0) {
255
+ // Empty object - use map type annotation
256
+ headers[key] = ""
257
+ headerTypes.push(`${key.toLowerCase()}="map"`)
258
+ }
259
+ } else {
260
+ // Fields that need body still get type annotations
261
+ const aoType = getAoType(value)
262
+ if (aoType) {
263
+ headerTypes.push(`${key.toLowerCase()}="${aoType}"`)
264
+ }
265
+ }
266
+ }
267
+
268
+ // Second pass for array type annotations
269
+ for (const [key, value] of Object.entries(obj)) {
270
+ if (Array.isArray(value)) {
271
+ if (
272
+ bodyKeys.includes(key) ||
273
+ bodyKeys.some(k => k.startsWith(`${key}/`))
274
+ ) {
275
+ if (!headerTypes.some(t => t.startsWith(`${key.toLowerCase()}=`))) {
276
+ headerTypes.push(`${key.toLowerCase()}="list"`)
277
+ }
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ // Step 9: Handle case where all body keys are empty binaries
284
+ function handleAllEmptyBinaryBodyKeys(obj, bodyKeys, headers, headerTypes) {
285
+ if (bodyKeys.length === 0) {
286
+ if (headerTypes.length > 0) {
287
+ headers["ao-types"] = headerTypes.sort().join(", ")
288
+ }
289
+ return { headers, body: undefined }
290
+ }
291
+
292
+ const allBodyKeysAreEmptyBinaries = bodyKeys.every(key => {
293
+ const value = getValueByPath(obj, key)
294
+ return isBytes(value) && (value.length === 0 || value.byteLength === 0)
295
+ })
296
+
297
+ if (allBodyKeysAreEmptyBinaries) {
298
+ if (headerTypes.length > 0) {
299
+ headers["ao-types"] = headerTypes.sort().join(", ")
300
+ }
301
+ return { headers, body: undefined }
302
+ }
303
+
304
+ return null
305
+ }
306
+
307
+ // Step 10: Handle single body key optimization
308
+ async function handleSingleBodyKeyOptimization(
309
+ obj,
310
+ bodyKeys,
311
+ headers,
312
+ headerTypes
313
+ ) {
314
+ // Skip this optimization if there are other header fields - need full multipart encoding
315
+ // to be compatible with HyperBEAM scheduler endpoint
316
+ const otherFields = Object.keys(obj).filter(k => !bodyKeys.includes(k) && !bodyKeys.some(bk => bk.startsWith(`${k}/`)))
317
+ if (otherFields.length > 0 && bodyKeys.length === 1) {
318
+ // Have other header fields + single body key = need multipart, skip optimization
319
+ return null
320
+ }
321
+
322
+ if (bodyKeys.length === 1) {
323
+ const singleKey = bodyKeys[0]
324
+ const value = getValueByPath(obj, singleKey)
325
+
326
+ // Apply optimization for binary data OR strings with newlines
327
+ if (
328
+ (isBytes(value) && value.length > 0) ||
329
+ (typeof value === "string" && value.includes("\n"))
330
+ ) {
331
+ let contentToHash
332
+ let bodyContent = value
333
+
334
+ if (isBytes(value)) {
335
+ const bodyBuffer = toBuffer(value)
336
+ contentToHash = bodyBuffer.buffer.slice(
337
+ bodyBuffer.byteOffset,
338
+ bodyBuffer.byteOffset + bodyBuffer.byteLength
339
+ )
340
+ } else {
341
+ // For strings, encode to UTF-8 for hashing
342
+ const encoder = new TextEncoder()
343
+ const encoded = encoder.encode(value)
344
+ contentToHash = encoded.buffer
345
+ bodyContent = value
346
+ }
347
+
348
+ const contentDigest = await sha256(contentToHash)
349
+ const base64 = base64url.toBase64(base64url.encode(contentDigest))
350
+ headers["content-digest"] = `sha-256=:${base64}:`
351
+
352
+ if (singleKey !== "body") {
353
+ headers["inline-body-key"] = singleKey
354
+ }
355
+
356
+ if (headerTypes.length > 0) {
357
+ headers["ao-types"] = headerTypes.sort().join(", ")
358
+ }
359
+
360
+ return { headers, body: bodyContent }
361
+ }
362
+ }
363
+
364
+ return null
365
+ }
366
+
367
+ // Step 11: Sort body keys
368
+ function sortBodyKeys(bodyKeys) {
369
+ return bodyKeys.sort((a, b) => {
370
+ const aIsArrayElement = /\/\d+$/.test(a)
371
+ const bIsArrayElement = /\/\d+$/.test(b)
372
+ const aBase = a.split("/")[0]
373
+ const bBase = b.split("/")[0]
374
+ if (aBase === bBase) {
375
+ if (!aIsArrayElement && bIsArrayElement) {
376
+ return -1
377
+ }
378
+ if (aIsArrayElement && !bIsArrayElement) {
379
+ return 1
380
+ }
381
+ if (aIsArrayElement && bIsArrayElement) {
382
+ const aIndex = parseInt(a.split("/")[1])
383
+ const bIndex = parseInt(b.split("/")[1])
384
+ return aIndex - bIndex
385
+ }
386
+ return a.localeCompare(b)
387
+ }
388
+ return a.localeCompare(b)
389
+ })
390
+ }
391
+
392
+ // Step 12: Check for special data/body case
393
+ function checkSpecialDataBodyCase(obj, sortedBodyKeys) {
394
+ return (
395
+ sortedBodyKeys.includes("data") &&
396
+ sortedBodyKeys.includes("body") &&
397
+ obj.data &&
398
+ obj.data.body &&
399
+ isBytes(obj.data.body) &&
400
+ obj.body &&
401
+ obj.body.data &&
402
+ isBytes(obj.body.data)
403
+ )
404
+ }
405
+
406
+ // Step 13.2.2: Handle empty string in nested path
407
+ function handleEmptyStringInNestedPath(bodyKey, value, pathParts) {
408
+ if (typeof value === "string" && value === "" && pathParts.length > 1) {
409
+ const lines = []
410
+ lines.push(`content-disposition: form-data;name="${bodyKey}"`)
411
+ lines.push("")
412
+ lines.push("")
413
+ return new Blob([lines.join("\r\n")])
414
+ }
415
+ return null
416
+ }
417
+
418
+ // Step 13.2.3.2: Handle arrays with only empty elements
419
+ function handleArrayWithOnlyEmptyElements(
420
+ bodyKey,
421
+ value,
422
+ headers,
423
+ sortedBodyKeys
424
+ ) {
425
+ const fieldLines = []
426
+ const partTypes = []
427
+
428
+ value.forEach((item, idx) => {
429
+ const index = idx + 1
430
+ const itemType = getAoType(item)
431
+ if (itemType) {
432
+ partTypes.push(`${index}="${itemType}"`)
433
+ }
434
+ })
435
+
436
+ const isInline = bodyKey === "body" && headers["inline-body-key"] === "body"
437
+
438
+ if (isInline) {
439
+ const orderedLines = []
440
+ if (partTypes.length > 0) {
441
+ orderedLines.push(
442
+ `ao-types: ${sortTypeAnnotations(partTypes).join(", ")}`
443
+ )
444
+ }
445
+ orderedLines.push("content-disposition: inline")
446
+ orderedLines.push("")
447
+ return new Blob([orderedLines.join("\r\n")])
448
+ } else {
449
+ const orderedLines = []
450
+ if (partTypes.length > 0) {
451
+ orderedLines.push(
452
+ `ao-types: ${sortTypeAnnotations(partTypes).join(", ")}`
453
+ )
454
+ }
455
+ orderedLines.push(`content-disposition: form-data;name="${bodyKey}"`)
456
+
457
+ const isLastBodyPart =
458
+ sortedBodyKeys.indexOf(bodyKey) === sortedBodyKeys.length - 1
459
+ const hasOnlyTypes = partTypes.length > 0 && fieldLines.length === 0
460
+
461
+ if (isLastBodyPart && hasOnlyTypes) {
462
+ return new Blob([orderedLines.join("\r\n")])
463
+ } else {
464
+ orderedLines.push("")
465
+ return new Blob([orderedLines.join("\r\n")])
466
+ }
467
+ }
468
+ }
469
+
470
+ // Step 13.2.3.3: Build indices with own parts
471
+ function buildIndicesWithOwnParts(bodyKey, sortedBodyKeys) {
472
+ const indicesWithOwnParts = new Set()
473
+ sortedBodyKeys.forEach(key => {
474
+ if (key.startsWith(bodyKey + "/")) {
475
+ const subPath = key.substring(bodyKey.length + 1)
476
+ const match = subPath.match(/^(\d+)/)
477
+ if (match) {
478
+ indicesWithOwnParts.add(parseInt(match[1]))
479
+ }
480
+ }
481
+ })
482
+ return indicesWithOwnParts
483
+ }
484
+
485
+ // Step 13.2.3.4: Process array items
486
+ function processArrayItems(
487
+ value,
488
+ indicesWithOwnParts,
489
+ hasNestedObjectParts,
490
+ pathParts
491
+ ) {
492
+ const fieldLines = []
493
+ const partTypes = []
494
+
495
+ if (hasNestedObjectParts) {
496
+ value.forEach((item, idx) => {
497
+ const index = idx + 1
498
+ if (Array.isArray(item)) {
499
+ partTypes.push(`${index}="list"`)
500
+ }
501
+ })
502
+ }
503
+
504
+ value.forEach((item, idx) => {
505
+ const index = idx + 1
506
+
507
+ if (indicesWithOwnParts.has(index)) {
508
+ // This item has its own part - skip it here
509
+ // Don't add type annotation for items that have their own parts
510
+ return
511
+ }
512
+ if (
513
+ hasNestedObjectParts &&
514
+ Array.isArray(item) &&
515
+ item.some(subItem => isPojo(subItem))
516
+ ) {
517
+ return
518
+ }
519
+
520
+ if (typeof item === "string" && item === "") {
521
+ // Empty strings become empty binaries naturally - no type annotation needed
522
+ } else if (isPojo(item) && Object.keys(item).length === 0) {
523
+ // Use "map" instead of "empty-message" for structured codec compatibility
524
+ partTypes.push(`${index}="map"`)
525
+ } else if (isPojo(item)) {
526
+ // Non-empty objects are handled elsewhere
527
+ } else if (Array.isArray(item)) {
528
+ if (item.length === 0) {
529
+ // Use "list" instead of "empty-list" for structured codec compatibility
530
+ partTypes.push(`${index}="list"`)
531
+ } else {
532
+ partTypes.push(`${index}="list"`)
533
+ const encodedItems = item
534
+ .map(subItem => {
535
+ if (typeof subItem === "number") {
536
+ if (Number.isInteger(subItem)) {
537
+ return `"(ao-type-integer) ${subItem}"`
538
+ } else {
539
+ return `"(ao-type-float) ${formatFloat(subItem)}"`
540
+ }
541
+ } else if (typeof subItem === "string") {
542
+ return `"${subItem}"`
543
+ } else if (subItem === null) {
544
+ return `"(ao-type-atom) \\"null\\""`
545
+ } else if (subItem === undefined) {
546
+ return `"(ao-type-atom) \\"undefined\\""`
547
+ } else if (typeof subItem === "symbol") {
548
+ const desc = subItem.description || "Symbol.for()"
549
+ return `"(ao-type-atom) \\"${desc}\\""`
550
+ } else if (typeof subItem === "boolean") {
551
+ return `"(ao-type-atom) \\"${subItem}\\""`
552
+ } else if (Array.isArray(subItem)) {
553
+ return encodeArrayItem(subItem)
554
+ } else if (isBytes(subItem)) {
555
+ const buffer = toBuffer(subItem)
556
+ if (buffer.length === 0 || buffer.byteLength === 0) {
557
+ return `""`
558
+ }
559
+ return `"(ao-type-binary)"`
560
+ } else if (isPojo(subItem)) {
561
+ const json = JSON.stringify(subItem)
562
+ const escaped = json.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
563
+ return `"(ao-type-map) ${escaped}"`
564
+ } else {
565
+ return `"${String(subItem)}"`
566
+ }
567
+ })
568
+ .join(", ")
569
+ fieldLines.push(`${index}: ${encodedItems}`)
570
+ }
571
+ } else if (typeof item === "number") {
572
+ if (Number.isInteger(item)) {
573
+ partTypes.push(`${index}="integer"`)
574
+ fieldLines.push(`${index}: ${item}`)
575
+ } else {
576
+ partTypes.push(`${index}="float"`)
577
+ fieldLines.push(`${index}: ${formatFloat(item)}`)
578
+ }
579
+ } else if (typeof item === "string") {
580
+ fieldLines.push(`${index}: ${item}`)
581
+ } else if (
582
+ item === null ||
583
+ item === undefined ||
584
+ typeof item === "symbol" ||
585
+ typeof item === "boolean"
586
+ ) {
587
+ partTypes.push(`${index}="atom"`)
588
+ if (item === null) {
589
+ fieldLines.push(`${index}: null`)
590
+ } else if (item === undefined) {
591
+ fieldLines.push(`${index}: undefined`)
592
+ } else if (typeof item === "symbol") {
593
+ const desc = item.description || "Symbol.for()"
594
+ fieldLines.push(`${index}: ${desc}`)
595
+ } else {
596
+ fieldLines.push(`${index}: ${item}`)
597
+ }
598
+ } else if (isBytes(item)) {
599
+ const buffer = toBuffer(item)
600
+ if (buffer.length === 0) {
601
+ // Empty buffers become empty binaries naturally - no type annotation needed
602
+ } else {
603
+ partTypes.push(`${index}="binary"`)
604
+ }
605
+ }
606
+ })
607
+
608
+ return { fieldLines, partTypes }
609
+ }
610
+
611
+ // Step 13.2.3.5: Create array body part
612
+ function createArrayBodyPart(bodyKey, fieldLines, partTypes, headers) {
613
+ const isInline = bodyKey === "body" && headers["inline-body-key"] === "body"
614
+
615
+ if (isInline) {
616
+ const orderedLines = []
617
+ if (partTypes.length > 0) {
618
+ orderedLines.push(
619
+ `ao-types: ${sortTypeAnnotations(partTypes).join(", ")}`
620
+ )
621
+ }
622
+ orderedLines.push("content-disposition: inline")
623
+ if (fieldLines.length > 0) {
624
+ orderedLines.push("")
625
+ for (const line of fieldLines) {
626
+ orderedLines.push(line)
627
+ }
628
+ }
629
+ return new Blob([orderedLines.join("\r\n") + "\r\n"])
630
+ } else {
631
+ const orderedLines = []
632
+ if (partTypes.length > 0) {
633
+ orderedLines.push(
634
+ `ao-types: ${sortTypeAnnotations(partTypes).join(", ")}`
635
+ )
636
+ }
637
+ orderedLines.push(`content-disposition: form-data;name="${bodyKey}"`)
638
+ for (const line of fieldLines) {
639
+ orderedLines.push(line)
640
+ }
641
+ if (fieldLines.length > 0) {
642
+ return new Blob([orderedLines.join("\r\n") + "\r\n"])
643
+ } else {
644
+ return new Blob([orderedLines.join("\r\n")])
645
+ }
646
+ }
647
+ }
648
+
649
+ // Step 13.2.3: Handle array values
650
+ function handleArrayValue(bodyKey, value, headers, sortedBodyKeys, pathParts) {
651
+ const arrayInfo = analyzeArray(value)
652
+
653
+ if (arrayInfo.hasOnlyNonEmptyObjects) {
654
+ return null
655
+ }
656
+
657
+ if (arrayInfo.hasOnlyEmptyElements) {
658
+ return handleArrayWithOnlyEmptyElements(
659
+ bodyKey,
660
+ value,
661
+ headers,
662
+ sortedBodyKeys
663
+ )
664
+ }
665
+
666
+ const indicesWithOwnParts = buildIndicesWithOwnParts(bodyKey, sortedBodyKeys)
667
+ const hasNestedObjectParts = sortedBodyKeys.some(
668
+ key =>
669
+ key.startsWith(bodyKey + "/") &&
670
+ key.split("/").length > pathParts.length + 1
671
+ )
672
+
673
+ const { fieldLines, partTypes } = processArrayItems(
674
+ value,
675
+ indicesWithOwnParts,
676
+ hasNestedObjectParts,
677
+ pathParts
678
+ )
679
+ return createArrayBodyPart(bodyKey, fieldLines, partTypes, headers)
680
+ }
681
+
682
+ // Step 13.2.4.3: Process object fields
683
+ function processObjectFields(value, bodyKey, sortedBodyKeys) {
684
+ const objectTypes = []
685
+ const fieldLines = []
686
+ const binaryFields = []
687
+ const arrayTypes = []
688
+
689
+ // First collect array types
690
+ // Note: Use "list" for both empty and non-empty arrays for structured codec compatibility
691
+ for (const [k, v] of Object.entries(value)) {
692
+ if (Array.isArray(v)) {
693
+ arrayTypes.push(`${k.toLowerCase()}="list"`)
694
+ }
695
+ }
696
+
697
+ // Then process other fields
698
+ for (const [k, v] of Object.entries(value)) {
699
+ const childPath = `${bodyKey}/${k}`
700
+
701
+ if (sortedBodyKeys.includes(childPath)) {
702
+ continue
703
+ }
704
+
705
+ if (Array.isArray(v) && v.some(item => isPojo(item))) {
706
+ const hasOnlyEmpty = v.every(item => isEmpty(item))
707
+ if (hasOnlyEmpty) {
708
+ continue
709
+ }
710
+ }
711
+
712
+ if (Array.isArray(v)) {
713
+ // Type already added in arrayTypes
714
+ } else if (
715
+ v === null ||
716
+ v === undefined ||
717
+ typeof v === "symbol" ||
718
+ typeof v === "boolean"
719
+ ) {
720
+ objectTypes.push(`${k.toLowerCase()}="atom"`)
721
+ } else if (typeof v === "number") {
722
+ objectTypes.push(
723
+ `${k.toLowerCase()}="${Number.isInteger(v) ? "integer" : "float"}"`
724
+ )
725
+ } else if (typeof v === "string" && v.length === 0) {
726
+ // Empty strings become empty binaries naturally - no type annotation needed
727
+ } else if (isBytes(v) && (v.length === 0 || v.byteLength === 0)) {
728
+ // Empty buffers become empty binaries naturally - no type annotation needed
729
+ } else if (isPojo(v) && Object.keys(v).length === 0) {
730
+ // Use "map" instead of "empty-message" for structured codec compatibility
731
+ objectTypes.push(`${k.toLowerCase()}="map"`)
732
+ }
733
+
734
+ if (typeof v === "string") {
735
+ if (v.length === 0) {
736
+ fieldLines.push(`${k}: `)
737
+ } else {
738
+ fieldLines.push(`${k}: ${v}`)
739
+ }
740
+ } else if (typeof v === "number") {
741
+ fieldLines.push(`${k}: ${v}`)
742
+ } else if (typeof v === "boolean") {
743
+ fieldLines.push(`${k}: "${v}"`)
744
+ } else if (v === null) {
745
+ fieldLines.push(`${k}: "null"`)
746
+ } else if (v === undefined) {
747
+ fieldLines.push(`${k}: "undefined"`)
748
+ } else if (typeof v === "symbol") {
749
+ const desc = v.description || "Symbol.for()"
750
+ fieldLines.push(`${k}: "${desc}"`)
751
+ } else if (isBytes(v)) {
752
+ const buffer = toBuffer(v)
753
+ binaryFields.push({ key: k, buffer })
754
+ } else if (Array.isArray(v) && v.length === 0) {
755
+ // Empty array - include the key so Erlang knows it exists
756
+ fieldLines.push(`${k}: `)
757
+ } else if (isPojo(v) && Object.keys(v).length === 0) {
758
+ // Empty object - include the key so Erlang knows it exists
759
+ fieldLines.push(`${k}: `)
760
+ } else if (Array.isArray(v) && v.length > 0) {
761
+ const childPath = `${bodyKey}/${k}`
762
+ if (!sortedBodyKeys.includes(childPath)) {
763
+ const hasObjects = v.some(item => isPojo(item))
764
+ if (!hasObjects) {
765
+ const encodedItems = v.map(item => encodeArrayItem(item)).join(", ")
766
+ fieldLines.push(`${k}: ${encodedItems}`)
767
+ }
768
+ }
769
+ }
770
+ }
771
+
772
+ const allTypes = [...arrayTypes, ...objectTypes]
773
+ return { allTypes, fieldLines, binaryFields }
774
+ }
775
+
776
+ // Step 13.2.4.5: Create object body part
777
+ function createObjectBodyPart(
778
+ bodyKey,
779
+ value,
780
+ allTypes,
781
+ fieldLines,
782
+ binaryFields,
783
+ headers,
784
+ sortedBodyKeys
785
+ ) {
786
+ const isInline = bodyKey === "body" && headers["inline-body-key"] === "body"
787
+ const lines = []
788
+
789
+ if (isInline) {
790
+ const orderedLines = []
791
+
792
+ // For inline mode: fields first, then headers
793
+ for (const line of fieldLines) {
794
+ orderedLines.push(line)
795
+ }
796
+
797
+ if (allTypes.length > 0) {
798
+ orderedLines.push(`ao-types: ${allTypes.sort().join(", ")}`)
799
+ }
800
+ orderedLines.push("content-disposition: inline")
801
+
802
+ const binaryFieldsForInline = Object.entries(value)
803
+ .filter(
804
+ ([k, v]) => isBytes(v) && !sortedBodyKeys.includes(`${bodyKey}/${k}`)
805
+ )
806
+ .map(([k, v]) => ({
807
+ key: k,
808
+ buffer: toBuffer(v),
809
+ }))
810
+
811
+ if (binaryFieldsForInline.length > 0) {
812
+ const parts = []
813
+ // Join all text lines first
814
+ parts.push(Buffer.from(orderedLines.join("\r\n")))
815
+ // Then add binary fields
816
+ for (const { key, buffer } of binaryFieldsForInline) {
817
+ parts.push(Buffer.from(`\r\n${key}: `))
818
+ parts.push(buffer)
819
+ }
820
+ parts.push(Buffer.from("\r\n"))
821
+ const fullBody = Buffer.concat(parts)
822
+ return new Blob([fullBody])
823
+ } else {
824
+ return new Blob([orderedLines.join("\r\n") + "\r\n"])
825
+ }
826
+ } else {
827
+ // Non-inline mode remains the same
828
+ const orderedLines = []
829
+ if (allTypes.length > 0) {
830
+ orderedLines.push(`ao-types: ${allTypes.sort().join(", ")}`)
831
+ }
832
+ orderedLines.push(`content-disposition: form-data;name="${bodyKey}"`)
833
+
834
+ const hasBinaryFields = binaryFields && binaryFields.length > 0
835
+ if (hasBinaryFields || fieldLines.length === 0) {
836
+ orderedLines.push("")
837
+ }
838
+
839
+ for (const line of fieldLines) {
840
+ orderedLines.push(line)
841
+ }
842
+
843
+ if (binaryFields && binaryFields.length > 0) {
844
+ const parts = []
845
+ const headerText = orderedLines.join("\r\n")
846
+ parts.push(Buffer.from(headerText))
847
+ for (let i = 0; i < binaryFields.length; i++) {
848
+ const { key, buffer } = binaryFields[i]
849
+ if (i > 0) {
850
+ parts.push(Buffer.from("\r\n"))
851
+ }
852
+ parts.push(Buffer.from(`${key}: `))
853
+ parts.push(buffer)
854
+ }
855
+ parts.push(Buffer.from("\r\n"))
856
+ const fullBody = Buffer.concat(parts)
857
+ return new Blob([fullBody])
858
+ } else {
859
+ if (fieldLines.length > 0) {
860
+ return new Blob([orderedLines.join("\r\n") + "\r\n"])
861
+ } else {
862
+ return new Blob([orderedLines.join("\r\n")])
863
+ }
864
+ }
865
+ }
866
+ }
867
+
868
+ // Step 13.2.4: Handle object values
869
+ function handleObjectValue(
870
+ obj,
871
+ bodyKey,
872
+ value,
873
+ headers,
874
+ sortedBodyKeys,
875
+ pathParts,
876
+ hasSpecialDataBody
877
+ ) {
878
+ if (Object.keys(value).length === 0) {
879
+ // Skip empty objects in certain contexts
880
+ const parentPath = pathParts.slice(0, -1).join("/")
881
+ const parentValue = parentPath ? getValueByPath(obj, parentPath) : obj
882
+
883
+ if (Array.isArray(parentValue)) {
884
+ const parentArrayInfo = analyzeArray(parentValue)
885
+ if (
886
+ parentArrayInfo.hasObjects &&
887
+ (parentArrayInfo.hasEmptyStrings || parentArrayInfo.hasEmptyObjects)
888
+ ) {
889
+ return null
890
+ }
891
+ }
892
+ return null
893
+ }
894
+
895
+ // Skip special data/body case
896
+ if (
897
+ hasSpecialDataBody &&
898
+ bodyKey === "data" &&
899
+ Object.keys(value).length === 1 &&
900
+ value.body &&
901
+ isBytes(value.body)
902
+ ) {
903
+ return null
904
+ }
905
+
906
+ const { allTypes, fieldLines, binaryFields } = processObjectFields(
907
+ value,
908
+ bodyKey,
909
+ sortedBodyKeys
910
+ )
911
+
912
+ // Check if object should be skipped
913
+ const hasOnlyEmptyCollections = Object.entries(value).every(([k, v]) =>
914
+ isEmpty(v)
915
+ )
916
+ const hasArraysWithOnlyEmptyElements = Object.entries(value).some(
917
+ ([k, v]) =>
918
+ Array.isArray(v) && v.length > 0 && v.every(item => isEmpty(item))
919
+ )
920
+
921
+ const shouldSkipObject = Object.entries(value).every(([k, v]) => {
922
+ const childPath = `${bodyKey}/${k}`
923
+ if (sortedBodyKeys.includes(childPath)) return true
924
+ if (Array.isArray(v) && v.some(item => isPojo(item))) {
925
+ const hasOnlyEmpty = v.every(item => isEmpty(item))
926
+ return hasOnlyEmpty || sortedBodyKeys.includes(childPath)
927
+ }
928
+ return false
929
+ })
930
+
931
+ if (
932
+ shouldSkipObject &&
933
+ !hasOnlyEmptyCollections &&
934
+ !hasArraysWithOnlyEmptyElements
935
+ ) {
936
+ return null
937
+ }
938
+
939
+ return createObjectBodyPart(
940
+ bodyKey,
941
+ value,
942
+ allTypes,
943
+ fieldLines,
944
+ binaryFields,
945
+ headers,
946
+ sortedBodyKeys
947
+ )
948
+ }
949
+
950
+ // Step 13.2.5: Handle primitive values
951
+ function handlePrimitiveValue(bodyKey, value, headers) {
952
+ const isInline = bodyKey === "body" && headers["inline-body-key"] === "body"
953
+ const lines = []
954
+
955
+ if (isInline) {
956
+ lines.push(`content-disposition: inline`)
957
+ } else {
958
+ lines.push(`content-disposition: form-data;name="${bodyKey}"`)
959
+ }
960
+
961
+ if (typeof value === "string") {
962
+ lines.push("")
963
+ lines.push(value)
964
+ return new Blob([lines.join("\r\n")])
965
+ } else {
966
+ const content = encodePrimitiveContent(value)
967
+ lines.push("")
968
+ lines.push(content)
969
+ return new Blob([lines.join("\r\n")])
970
+ }
971
+ }
972
+
973
+ // Step 13.2.6: Handle binary values
974
+ function handleBinaryValue(bodyKey, value, headers) {
975
+ const isInline = bodyKey === "body" && headers["inline-body-key"] === "body"
976
+ const lines = []
977
+
978
+ if (isInline) {
979
+ lines.push(`content-disposition: inline`)
980
+ } else {
981
+ lines.push(`content-disposition: form-data;name="${bodyKey}"`)
982
+ }
983
+
984
+ const buffer = toBuffer(value)
985
+ // Always keep binary data as raw binary, regardless of whether it's in an array
986
+ const headerText = lines.join("\r\n") + "\r\n\r\n"
987
+ return new Blob([headerText, buffer])
988
+ }
989
+
990
+ // Step 13.3: Handle special data/body case
991
+ function handleSpecialDataBodyCase(obj, hasSpecialDataBody) {
992
+ if (
993
+ hasSpecialDataBody &&
994
+ obj.data &&
995
+ obj.data.body &&
996
+ isBytes(obj.data.body)
997
+ ) {
998
+ const buffer = toBuffer(obj.data.body)
999
+ const specialPart = [
1000
+ `content-disposition: form-data;name="data/body"`,
1001
+ "",
1002
+ "",
1003
+ ].join("\r\n")
1004
+ return new Blob([specialPart, buffer])
1005
+ }
1006
+ return null
1007
+ }
1008
+
1009
+ // Step 13: Build body parts for each body key
1010
+ function buildBodyParts(obj, sortedBodyKeys, headers, hasSpecialDataBody) {
1011
+ // Step 13.1: Initialize body parts collection
1012
+ const bodyParts = []
1013
+
1014
+ // Step 13.2: Process each body key
1015
+ for (const bodyKey of sortedBodyKeys) {
1016
+ // Step 13.2.1: Get value for current body key
1017
+ const value = getValueByPath(obj, bodyKey)
1018
+ const pathParts = bodyKey.split("/")
1019
+
1020
+ // Step 13.2.2: Handle empty string in nested path
1021
+ const emptyStringPart = handleEmptyStringInNestedPath(
1022
+ bodyKey,
1023
+ value,
1024
+ pathParts
1025
+ )
1026
+ if (emptyStringPart) {
1027
+ bodyParts.push(emptyStringPart)
1028
+ continue
1029
+ }
1030
+
1031
+ // Step 13.2.3: Handle array values
1032
+ if (Array.isArray(value)) {
1033
+ const arrayPart = handleArrayValue(
1034
+ bodyKey,
1035
+ value,
1036
+ headers,
1037
+ sortedBodyKeys,
1038
+ pathParts
1039
+ )
1040
+ if (arrayPart) {
1041
+ bodyParts.push(arrayPart)
1042
+ }
1043
+ continue
1044
+ }
1045
+
1046
+ // Step 13.2.4: Handle object values
1047
+ if (isPojo(value)) {
1048
+ const objectPart = handleObjectValue(
1049
+ obj,
1050
+ bodyKey,
1051
+ value,
1052
+ headers,
1053
+ sortedBodyKeys,
1054
+ pathParts,
1055
+ hasSpecialDataBody
1056
+ )
1057
+ if (objectPart) {
1058
+ bodyParts.push(objectPart)
1059
+ }
1060
+ continue
1061
+ }
1062
+
1063
+ // Step 13.2.5: Handle primitive values
1064
+ if (
1065
+ typeof value === "string" ||
1066
+ typeof value === "boolean" ||
1067
+ typeof value === "number" ||
1068
+ value === null ||
1069
+ value === undefined ||
1070
+ typeof value === "symbol"
1071
+ ) {
1072
+ const primitivePart = handlePrimitiveValue(bodyKey, value, headers)
1073
+ bodyParts.push(primitivePart)
1074
+ continue
1075
+ }
1076
+
1077
+ // Step 13.2.6: Handle binary values
1078
+ if (isBytes(value)) {
1079
+ const binaryPart = handleBinaryValue(bodyKey, value, headers)
1080
+ bodyParts.push(binaryPart)
1081
+ continue
1082
+ }
1083
+ }
1084
+
1085
+ // Step 13.3: Handle special data/body case
1086
+ const specialPart = handleSpecialDataBodyCase(obj, hasSpecialDataBody)
1087
+ if (specialPart) {
1088
+ bodyParts.push(specialPart)
1089
+ }
1090
+
1091
+ // Step 13.4: Return body parts
1092
+ return bodyParts
1093
+ }
1094
+
1095
+ // Step 14: Generate multipart boundary
1096
+ async function generateBoundary(bodyParts) {
1097
+ const partsContent = await Promise.all(bodyParts.map(part => part.text()))
1098
+ const allContent = partsContent.join("")
1099
+ const boundaryHash = await sha256(new TextEncoder().encode(allContent))
1100
+ const boundary = base64url.encode(Buffer.from(boundaryHash))
1101
+ return boundary
1102
+ }
1103
+
1104
+ // Step 15: Assemble final multipart body
1105
+ function assembleMultipartBody(bodyParts, boundary) {
1106
+ const finalParts = []
1107
+ for (let i = 0; i < bodyParts.length; i++) {
1108
+ if (i === 0) {
1109
+ finalParts.push(new Blob([`--${boundary}\r\n`]))
1110
+ } else {
1111
+ finalParts.push(new Blob([`\r\n--${boundary}\r\n`]))
1112
+ }
1113
+ finalParts.push(bodyParts[i])
1114
+ }
1115
+ finalParts.push(new Blob([`\r\n--${boundary}--`]))
1116
+
1117
+ return new Blob(finalParts)
1118
+ }
1119
+
1120
+ // Step 16: Calculate content digest
1121
+ async function calculateContentDigest(body) {
1122
+ const finalContent = await body.arrayBuffer()
1123
+
1124
+ if (finalContent.byteLength > 0) {
1125
+ const contentDigest = await sha256(finalContent)
1126
+ const base64 = base64url.toBase64(base64url.encode(contentDigest))
1127
+ return { digest: base64, byteLength: finalContent.byteLength }
1128
+ }
1129
+
1130
+ return { digest: null, byteLength: finalContent.byteLength }
1131
+ }
1132
+
1133
+ // Step 17: Set final headers (content-type, content-length)
1134
+ function setFinalHeaders(headers, boundary, contentDigest, byteLength) {
1135
+ headers["content-type"] = `multipart/form-data; boundary="${boundary}"`
1136
+
1137
+ if (contentDigest) {
1138
+ headers["content-digest"] = `sha-256=:${contentDigest}:`
1139
+ }
1140
+
1141
+ headers["content-length"] = String(byteLength)
1142
+ }
1143
+
1144
+ export async function enc(obj = {}) {
1145
+ // Step 1: Process and normalize input values
1146
+ const processedObj = processInputValues(obj)
1147
+
1148
+ // Step 2: Handle empty object case
1149
+ const emptyResult = handleEmptyObject(processedObj)
1150
+ if (emptyResult) return emptyResult
1151
+
1152
+ // Step 3: Handle single field with empty binary
1153
+ const emptyBinaryResult = handleSingleEmptyBinaryField(processedObj)
1154
+ if (emptyBinaryResult) return emptyBinaryResult
1155
+
1156
+ // Step 4: Handle single field with binary data
1157
+ const singleBinaryResult = await handleSingleBinaryField(processedObj)
1158
+ if (singleBinaryResult) return singleBinaryResult
1159
+
1160
+ // Step 5: Handle single field with primitive value
1161
+ const primitiveResult = await handleSinglePrimitiveField(processedObj)
1162
+ if (primitiveResult) return primitiveResult
1163
+
1164
+ // Step 6a: Handle single field with non-empty binary
1165
+ const nonEmptyBinaryResult =
1166
+ await handleSingleNonEmptyBinaryField(processedObj)
1167
+ if (nonEmptyBinaryResult) return nonEmptyBinaryResult
1168
+
1169
+ // Step 6: Handle single field with non-ASCII string
1170
+ const nonAsciiResult = await handleSingleNonAsciiStringField(processedObj)
1171
+ if (nonAsciiResult) return nonAsciiResult
1172
+
1173
+ // Step 7: Collect all keys that need to go in body
1174
+ const bodyKeys = collectBodyKeysStep(processedObj)
1175
+
1176
+ const objKeys = Object.keys(obj)
1177
+ const headers = {}
1178
+ const headerTypes = []
1179
+
1180
+ // Step 8: Process fields that can go in headers
1181
+ processHeaderFields(obj, bodyKeys, headers, headerTypes)
1182
+
1183
+ // Step 9: Handle case where all body keys are empty binaries
1184
+ const emptyBinaryBodyResult = handleAllEmptyBinaryBodyKeys(
1185
+ obj,
1186
+ bodyKeys,
1187
+ headers,
1188
+ headerTypes
1189
+ )
1190
+ if (emptyBinaryBodyResult) return emptyBinaryBodyResult
1191
+
1192
+ // Step 10: Handle single body key optimization
1193
+ const singleBodyKeyResult = await handleSingleBodyKeyOptimization(
1194
+ obj,
1195
+ bodyKeys,
1196
+ headers,
1197
+ headerTypes
1198
+ )
1199
+ if (singleBodyKeyResult) return singleBodyKeyResult
1200
+
1201
+ // Step 11: Sort body keys
1202
+ const sortedBodyKeys = sortBodyKeys(bodyKeys)
1203
+
1204
+ // Step 12: Check for special data/body case
1205
+ const hasSpecialDataBody = checkSpecialDataBodyCase(obj, sortedBodyKeys)
1206
+
1207
+ // Only add body-keys header if there are actual body keys
1208
+ if (sortedBodyKeys.length > 0) {
1209
+ headers["body-keys"] = sortedBodyKeys.map(k => `"${k}"`).join(", ")
1210
+ }
1211
+
1212
+ // Special case: single body key named "body" containing an object
1213
+ if (
1214
+ !hasSpecialDataBody &&
1215
+ sortedBodyKeys.length === 1 &&
1216
+ sortedBodyKeys[0] === "body"
1217
+ ) {
1218
+ const bodyValue = obj.body
1219
+ if (isPojo(bodyValue)) {
1220
+ headers["inline-body-key"] = "body"
1221
+ }
1222
+ }
1223
+
1224
+ if (headerTypes.length > 0) {
1225
+ headers["ao-types"] = headerTypes.sort().join(", ")
1226
+ }
1227
+
1228
+ // Step 13: Build body parts for each body key
1229
+ const bodyParts = buildBodyParts(
1230
+ obj,
1231
+ sortedBodyKeys,
1232
+ headers,
1233
+ hasSpecialDataBody
1234
+ )
1235
+
1236
+ // If no body parts were created, return headers only
1237
+ if (bodyParts.length === 0) {
1238
+ return { headers, body: undefined }
1239
+ }
1240
+
1241
+ // Step 14: Generate multipart boundary
1242
+ const boundary = await generateBoundary(bodyParts)
1243
+
1244
+ // Step 15: Assemble final multipart body
1245
+ const body = assembleMultipartBody(bodyParts, boundary)
1246
+
1247
+ // Step 16: Calculate content digest
1248
+ const { digest: contentDigest, byteLength } =
1249
+ await calculateContentDigest(body)
1250
+
1251
+ // Step 17: Set final headers (content-type, content-length)
1252
+ setFinalHeaders(headers, boundary, contentDigest, byteLength)
1253
+
1254
+ // Step 18: Return result
1255
+ return { headers, body }
1256
+ }