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