hbsig 0.2.8 → 0.3.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/cjs/commit.js +263 -43
- package/cjs/encode-utils.js +10 -4
- package/cjs/encode.js +66 -19
- package/cjs/erl_json.js +12 -3
- package/cjs/erl_str.js +5 -0
- package/cjs/flat.js +70 -13
- package/cjs/httpsig.js +159 -173
- package/cjs/parser.js +30 -6
- package/cjs/send.js +11 -7
- package/cjs/signer-utils.js +14 -12
- package/cjs/signer.js +140 -281
- package/cjs/structured.js +140 -146
- package/cjs/test.js +0 -15
- package/esm/commit.js +174 -19
- package/esm/encode-utils.js +10 -4
- package/esm/encode.js +52 -15
- package/esm/erl_json.js +4 -1
- package/esm/erl_str.js +5 -0
- package/esm/flat.js +61 -7
- package/esm/httpsig.js +118 -113
- package/esm/parser.js +26 -8
- package/esm/send.js +8 -3
- package/esm/signer-utils.js +5 -1
- package/esm/signer.js +66 -174
- package/esm/structured.js +97 -98
- package/esm/test.js +2 -6
- package/package.json +2 -2
package/esm/encode-utils.js
CHANGED
|
@@ -94,6 +94,8 @@ export function getValueByPath(obj, path) {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
// Get the ao-type for a value
|
|
97
|
+
// Note: dev_codec_structured.erl only supports: integer, float, atom, list, map
|
|
98
|
+
// Do NOT use empty-binary, empty-list, empty-message as they cause binary_to_existing_atom errors
|
|
97
99
|
export function getAoType(value) {
|
|
98
100
|
if (
|
|
99
101
|
typeof value === "boolean" ||
|
|
@@ -105,15 +107,19 @@ export function getAoType(value) {
|
|
|
105
107
|
} else if (typeof value === "number") {
|
|
106
108
|
return Number.isInteger(value) ? "integer" : "float"
|
|
107
109
|
} else if (typeof value === "string" && value.length === 0) {
|
|
108
|
-
|
|
110
|
+
// Empty strings become empty binaries naturally - no type annotation needed
|
|
111
|
+
return null
|
|
109
112
|
} else if (isBytes(value) && (value.length === 0 || value.byteLength === 0)) {
|
|
110
|
-
|
|
113
|
+
// Empty buffers become empty binaries naturally - no type annotation needed
|
|
114
|
+
return null
|
|
111
115
|
} else if (Array.isArray(value) && value.length === 0) {
|
|
112
|
-
|
|
116
|
+
// Use "list" for empty arrays (structured codec compatible)
|
|
117
|
+
return "list"
|
|
113
118
|
} else if (Array.isArray(value)) {
|
|
114
119
|
return "list"
|
|
115
120
|
} else if (isPojo(value) && Object.keys(value).length === 0) {
|
|
116
|
-
|
|
121
|
+
// Use "map" for empty objects (structured codec compatible)
|
|
122
|
+
return "map"
|
|
117
123
|
}
|
|
118
124
|
return null
|
|
119
125
|
}
|
package/esm/encode.js
CHANGED
|
@@ -46,7 +46,16 @@ function handleSingleEmptyBinaryField(obj) {
|
|
|
46
46
|
(fieldValue.length === 0 || fieldValue.byteLength === 0)
|
|
47
47
|
) {
|
|
48
48
|
const headers = {}
|
|
49
|
-
|
|
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()] = ""
|
|
50
59
|
return { headers, body: undefined }
|
|
51
60
|
}
|
|
52
61
|
}
|
|
@@ -208,14 +217,17 @@ function processHeaderFields(obj, bodyKeys, headers, headerTypes) {
|
|
|
208
217
|
)
|
|
209
218
|
} else if (typeof value === "string") {
|
|
210
219
|
if (value.length === 0) {
|
|
211
|
-
|
|
220
|
+
// Empty string becomes empty binary in Erlang - no ao-types needed
|
|
221
|
+
headers[key] = ""
|
|
212
222
|
} else if (hasNonAscii(value)) {
|
|
213
223
|
continue
|
|
214
224
|
} else {
|
|
215
225
|
headers[key] = value
|
|
216
226
|
}
|
|
217
227
|
} else if (Array.isArray(value) && value.length === 0) {
|
|
218
|
-
|
|
228
|
+
// Empty array - use list type annotation
|
|
229
|
+
headers[key] = ""
|
|
230
|
+
headerTypes.push(`${key.toLowerCase()}="list"`)
|
|
219
231
|
} else if (Array.isArray(value) && !value.some(item => isPojo(item))) {
|
|
220
232
|
const hasNonAsciiItems = value.some(
|
|
221
233
|
item => typeof item === "string" && hasNonAscii(item)
|
|
@@ -231,9 +243,18 @@ function processHeaderFields(obj, bodyKeys, headers, headerTypes) {
|
|
|
231
243
|
isBytes(value) &&
|
|
232
244
|
(value.length === 0 || value.byteLength === 0)
|
|
233
245
|
) {
|
|
234
|
-
|
|
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
|
+
}
|
|
235
254
|
} else if (isPojo(value) && Object.keys(value).length === 0) {
|
|
236
|
-
|
|
255
|
+
// Empty object - use map type annotation
|
|
256
|
+
headers[key] = ""
|
|
257
|
+
headerTypes.push(`${key.toLowerCase()}="map"`)
|
|
237
258
|
}
|
|
238
259
|
} else {
|
|
239
260
|
// Fields that need body still get type annotations
|
|
@@ -290,6 +311,14 @@ async function handleSingleBodyKeyOptimization(
|
|
|
290
311
|
headers,
|
|
291
312
|
headerTypes
|
|
292
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
|
+
|
|
293
322
|
if (bodyKeys.length === 1) {
|
|
294
323
|
const singleKey = bodyKeys[0]
|
|
295
324
|
const value = getValueByPath(obj, singleKey)
|
|
@@ -489,14 +518,16 @@ function processArrayItems(
|
|
|
489
518
|
}
|
|
490
519
|
|
|
491
520
|
if (typeof item === "string" && item === "") {
|
|
492
|
-
|
|
521
|
+
// Empty strings become empty binaries naturally - no type annotation needed
|
|
493
522
|
} else if (isPojo(item) && Object.keys(item).length === 0) {
|
|
494
|
-
|
|
523
|
+
// Use "map" instead of "empty-message" for structured codec compatibility
|
|
524
|
+
partTypes.push(`${index}="map"`)
|
|
495
525
|
} else if (isPojo(item)) {
|
|
496
526
|
// Non-empty objects are handled elsewhere
|
|
497
527
|
} else if (Array.isArray(item)) {
|
|
498
528
|
if (item.length === 0) {
|
|
499
|
-
|
|
529
|
+
// Use "list" instead of "empty-list" for structured codec compatibility
|
|
530
|
+
partTypes.push(`${index}="list"`)
|
|
500
531
|
} else {
|
|
501
532
|
partTypes.push(`${index}="list"`)
|
|
502
533
|
const encodedItems = item
|
|
@@ -567,7 +598,7 @@ function processArrayItems(
|
|
|
567
598
|
} else if (isBytes(item)) {
|
|
568
599
|
const buffer = toBuffer(item)
|
|
569
600
|
if (buffer.length === 0) {
|
|
570
|
-
|
|
601
|
+
// Empty buffers become empty binaries naturally - no type annotation needed
|
|
571
602
|
} else {
|
|
572
603
|
partTypes.push(`${index}="binary"`)
|
|
573
604
|
}
|
|
@@ -656,11 +687,10 @@ function processObjectFields(value, bodyKey, sortedBodyKeys) {
|
|
|
656
687
|
const arrayTypes = []
|
|
657
688
|
|
|
658
689
|
// First collect array types
|
|
690
|
+
// Note: Use "list" for both empty and non-empty arrays for structured codec compatibility
|
|
659
691
|
for (const [k, v] of Object.entries(value)) {
|
|
660
692
|
if (Array.isArray(v)) {
|
|
661
|
-
arrayTypes.push(
|
|
662
|
-
`${k.toLowerCase()}="${v.length === 0 ? "empty-list" : "list"}"`
|
|
663
|
-
)
|
|
693
|
+
arrayTypes.push(`${k.toLowerCase()}="list"`)
|
|
664
694
|
}
|
|
665
695
|
}
|
|
666
696
|
|
|
@@ -693,11 +723,12 @@ function processObjectFields(value, bodyKey, sortedBodyKeys) {
|
|
|
693
723
|
`${k.toLowerCase()}="${Number.isInteger(v) ? "integer" : "float"}"`
|
|
694
724
|
)
|
|
695
725
|
} else if (typeof v === "string" && v.length === 0) {
|
|
696
|
-
|
|
726
|
+
// Empty strings become empty binaries naturally - no type annotation needed
|
|
697
727
|
} else if (isBytes(v) && (v.length === 0 || v.byteLength === 0)) {
|
|
698
|
-
|
|
728
|
+
// Empty buffers become empty binaries naturally - no type annotation needed
|
|
699
729
|
} else if (isPojo(v) && Object.keys(v).length === 0) {
|
|
700
|
-
|
|
730
|
+
// Use "map" instead of "empty-message" for structured codec compatibility
|
|
731
|
+
objectTypes.push(`${k.toLowerCase()}="map"`)
|
|
701
732
|
}
|
|
702
733
|
|
|
703
734
|
if (typeof v === "string") {
|
|
@@ -720,6 +751,12 @@ function processObjectFields(value, bodyKey, sortedBodyKeys) {
|
|
|
720
751
|
} else if (isBytes(v)) {
|
|
721
752
|
const buffer = toBuffer(v)
|
|
722
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}: `)
|
|
723
760
|
} else if (Array.isArray(v) && v.length > 0) {
|
|
724
761
|
const childPath = `${bodyKey}/${k}`
|
|
725
762
|
if (!sortedBodyKeys.includes(childPath)) {
|
package/esm/erl_json.js
CHANGED
|
@@ -61,7 +61,10 @@ export function normalize(obj, binaryMode = false) {
|
|
|
61
61
|
|
|
62
62
|
if (binaryMode) {
|
|
63
63
|
// In binary mode, convert strings to buffers
|
|
64
|
-
|
|
64
|
+
// Use binary/latin1 encoding only if all chars are <= 255 (preserves raw bytes)
|
|
65
|
+
// Use UTF-8 for strings with chars > 255 (proper multi-byte encoding)
|
|
66
|
+
const needsUtf8 = [...obj].some(ch => ch.codePointAt(0) > 255)
|
|
67
|
+
return Buffer.from(obj, needsUtf8 ? "utf8" : "binary")
|
|
65
68
|
} else {
|
|
66
69
|
// In string mode, strings stay as strings
|
|
67
70
|
return obj
|
package/esm/erl_str.js
CHANGED
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
* @returns {*} JavaScript value
|
|
11
11
|
*/
|
|
12
12
|
export function erl_str_from(str, binaryMode = false) {
|
|
13
|
+
// Handle null/undefined input
|
|
14
|
+
if (str === null || str === undefined) {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
// Handle the new response format
|
|
14
19
|
if (str.startsWith("#erl_response{")) {
|
|
15
20
|
const rawMatch = str.match(/#erl_response\{raw=(.*?),formatted=(.*?)\}$/s)
|
package/esm/flat.js
CHANGED
|
@@ -70,6 +70,57 @@ function pathToParts(path) {
|
|
|
70
70
|
throw new Error("Path must be a string or array")
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Convert a value to string format to match Erlang codec behavior
|
|
75
|
+
* Erlang dev_codec_flat only handles binaries, so all leaf values become strings
|
|
76
|
+
* @param {*} value - Value to convert
|
|
77
|
+
* @returns {string|Object} - String for leaf values, or recursively processed object
|
|
78
|
+
*/
|
|
79
|
+
function valueToString(value) {
|
|
80
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value) && !Buffer.isBuffer(value)) {
|
|
81
|
+
// Recursively process nested objects
|
|
82
|
+
const result = {}
|
|
83
|
+
for (const [k, v] of Object.entries(value)) {
|
|
84
|
+
result[k] = valueToString(v)
|
|
85
|
+
}
|
|
86
|
+
return result
|
|
87
|
+
}
|
|
88
|
+
if (typeof value === "string") {
|
|
89
|
+
return value
|
|
90
|
+
}
|
|
91
|
+
if (typeof value === "number") {
|
|
92
|
+
return String(value)
|
|
93
|
+
}
|
|
94
|
+
if (typeof value === "boolean") {
|
|
95
|
+
return String(value)
|
|
96
|
+
}
|
|
97
|
+
if (value === null) {
|
|
98
|
+
return "null"
|
|
99
|
+
}
|
|
100
|
+
if (Array.isArray(value)) {
|
|
101
|
+
// Convert array elements to strings first, then format as Erlang list
|
|
102
|
+
// Erlang's io_lib:format("~p", [List]) produces binary syntax like [<<"a">>,<<"b">>]
|
|
103
|
+
const elements = value.map(v => {
|
|
104
|
+
if (typeof v === "string") {
|
|
105
|
+
return `<<"${v}">>`
|
|
106
|
+
} else if (typeof v === "number") {
|
|
107
|
+
return String(v)
|
|
108
|
+
} else if (typeof v === "boolean") {
|
|
109
|
+
return v ? "true" : "false"
|
|
110
|
+
} else if (v === null) {
|
|
111
|
+
return "null"
|
|
112
|
+
} else {
|
|
113
|
+
return String(v)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
return `[${elements.join(",")}]`
|
|
117
|
+
}
|
|
118
|
+
if (Buffer.isBuffer(value)) {
|
|
119
|
+
return value.toString()
|
|
120
|
+
}
|
|
121
|
+
return String(value)
|
|
122
|
+
}
|
|
123
|
+
|
|
73
124
|
/**
|
|
74
125
|
* Helper function to inject a value at a specific path in a nested object
|
|
75
126
|
* @param {Array} pathParts - Array of path parts
|
|
@@ -81,6 +132,9 @@ function injectAtPath(pathParts, value, obj) {
|
|
|
81
132
|
throw new Error("Path cannot be empty")
|
|
82
133
|
}
|
|
83
134
|
|
|
135
|
+
// Convert value to match Erlang codec behavior
|
|
136
|
+
const convertedValue = valueToString(value)
|
|
137
|
+
|
|
84
138
|
if (pathParts.length === 1) {
|
|
85
139
|
const key = pathParts[0]
|
|
86
140
|
|
|
@@ -91,20 +145,20 @@ function injectAtPath(pathParts, value, obj) {
|
|
|
91
145
|
if (
|
|
92
146
|
typeof existing === "object" &&
|
|
93
147
|
existing !== null &&
|
|
94
|
-
typeof
|
|
95
|
-
|
|
148
|
+
typeof convertedValue === "object" &&
|
|
149
|
+
convertedValue !== null &&
|
|
96
150
|
!Array.isArray(existing) &&
|
|
97
|
-
!Array.isArray(
|
|
151
|
+
!Array.isArray(convertedValue)
|
|
98
152
|
) {
|
|
99
|
-
obj[key] = { ...existing, ...
|
|
153
|
+
obj[key] = { ...existing, ...convertedValue }
|
|
100
154
|
} else {
|
|
101
155
|
// Path collision
|
|
102
156
|
throw new Error(
|
|
103
|
-
`Path collision at key: ${key}, existing: ${JSON.stringify(existing)}, value: ${JSON.stringify(
|
|
157
|
+
`Path collision at key: ${key}, existing: ${JSON.stringify(existing)}, value: ${JSON.stringify(convertedValue)}`
|
|
104
158
|
)
|
|
105
159
|
}
|
|
106
160
|
} else {
|
|
107
|
-
obj[key] =
|
|
161
|
+
obj[key] = convertedValue
|
|
108
162
|
}
|
|
109
163
|
return
|
|
110
164
|
}
|
|
@@ -117,7 +171,7 @@ function injectAtPath(pathParts, value, obj) {
|
|
|
117
171
|
throw new Error(`Cannot create nested path at non-object key: ${key}`)
|
|
118
172
|
}
|
|
119
173
|
|
|
120
|
-
injectAtPath(rest,
|
|
174
|
+
injectAtPath(rest, convertedValue, obj[key])
|
|
121
175
|
}
|
|
122
176
|
|
|
123
177
|
/**
|
package/esm/httpsig.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { hash } from "fast-sha256"
|
|
4
4
|
import { flat_from, flat_to } from "./flat.js"
|
|
5
|
+
import { structured_from as structuredFrom, structured_to as structuredTo } from "./structured.js"
|
|
5
6
|
|
|
6
7
|
const CRLF = "\r\n"
|
|
7
8
|
const DOUBLE_CRLF = CRLF + CRLF
|
|
@@ -120,17 +121,18 @@ function boundaryFromParts(parts) {
|
|
|
120
121
|
return bytesToBase64url(hashBytes)
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
// Helper to determine inline key
|
|
124
|
+
// Helper to determine inline key - matches Erlang's inline_key/2
|
|
124
125
|
function inlineKey(msg) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
// Check for ao-body-key (Erlang uses ao-body-key, not inline-body-key)
|
|
127
|
+
const aoBodyKey = msg["ao-body-key"]
|
|
128
|
+
if (aoBodyKey) {
|
|
129
|
+
return [{}, aoBodyKey]
|
|
128
130
|
}
|
|
129
131
|
if ("body" in msg) {
|
|
130
132
|
return [{}, "body"]
|
|
131
133
|
}
|
|
132
134
|
if ("data" in msg) {
|
|
133
|
-
return [{ "
|
|
135
|
+
return [{ "ao-body-key": "data" }, "data"]
|
|
134
136
|
}
|
|
135
137
|
return [{}, "body"]
|
|
136
138
|
}
|
|
@@ -182,6 +184,13 @@ function ungroupIds(msg) {
|
|
|
182
184
|
return result
|
|
183
185
|
}
|
|
184
186
|
|
|
187
|
+
// Get the size of a map (matches Erlang's maps:size behavior)
|
|
188
|
+
// This counts ALL keys including ao-types - empty means literally {}
|
|
189
|
+
function mapSize(obj) {
|
|
190
|
+
if (typeof obj !== "object" || obj === null) return 0
|
|
191
|
+
return Object.keys(obj).length
|
|
192
|
+
}
|
|
193
|
+
|
|
185
194
|
// Group maps for body encoding - following Erlang logic exactly
|
|
186
195
|
function groupMaps(map, parent = "", top = {}) {
|
|
187
196
|
if (
|
|
@@ -210,8 +219,18 @@ function groupMaps(map, parent = "", top = {}) {
|
|
|
210
219
|
!Array.isArray(value) &&
|
|
211
220
|
!Buffer.isBuffer(value)
|
|
212
221
|
) {
|
|
213
|
-
//
|
|
214
|
-
|
|
222
|
+
// Check size of the nested object (including metadata keys like ao-types)
|
|
223
|
+
// Empty means literally {} - a map with only ao-types is NOT empty
|
|
224
|
+
const size = mapSize(value)
|
|
225
|
+
|
|
226
|
+
if (size === 0) {
|
|
227
|
+
// Empty map (no data keys) - add empty-message marker
|
|
228
|
+
// This matches Erlang's group_maps behavior for empty maps
|
|
229
|
+
newTop[flatK] = { "ao-types": "empty-message" }
|
|
230
|
+
} else {
|
|
231
|
+
// Recursively process nested objects
|
|
232
|
+
newTop = groupMaps(value, flatK, newTop)
|
|
233
|
+
}
|
|
215
234
|
} else if (typeof value === "string" && value.length > MAX_HEADER_LENGTH) {
|
|
216
235
|
// Value too large for header, lift to top level
|
|
217
236
|
newTop[flatK] = value
|
|
@@ -235,11 +254,27 @@ function groupMaps(map, parent = "", top = {}) {
|
|
|
235
254
|
}
|
|
236
255
|
}
|
|
237
256
|
|
|
257
|
+
// Helper to compute content-digest for a body value
|
|
258
|
+
function computePartDigest(bodyValue) {
|
|
259
|
+
let bodyBytes
|
|
260
|
+
if (Buffer.isBuffer(bodyValue)) {
|
|
261
|
+
bodyBytes = new Uint8Array(bodyValue)
|
|
262
|
+
} else if (typeof bodyValue === "string") {
|
|
263
|
+
bodyBytes = stringToBytes(bodyValue, "binary")
|
|
264
|
+
} else {
|
|
265
|
+
bodyBytes = stringToBytes(String(bodyValue), "binary")
|
|
266
|
+
}
|
|
267
|
+
const hashBytes = hash(bodyBytes)
|
|
268
|
+
return `sha-256=:${bytesToBase64(hashBytes)}:`
|
|
269
|
+
}
|
|
270
|
+
|
|
238
271
|
// Encode multipart body part
|
|
272
|
+
// NOTE: This matches Erlang's encode_body_part/4 which does NOT apply inline_key
|
|
273
|
+
// logic to nested parts. For nested maps, ALL fields become headers (except 'body'
|
|
274
|
+
// which becomes the part body). The inline_key logic is only for top-level messages.
|
|
239
275
|
function encodeBodyPart(partName, bodyPart, inlineKey) {
|
|
240
276
|
const disposition =
|
|
241
277
|
partName === inlineKey ? "inline" : `form-data;name="${partName}"`
|
|
242
|
-
const isInline = partName === inlineKey
|
|
243
278
|
|
|
244
279
|
if (
|
|
245
280
|
typeof bodyPart === "object" &&
|
|
@@ -247,101 +282,45 @@ function encodeBodyPart(partName, bodyPart, inlineKey) {
|
|
|
247
282
|
!Array.isArray(bodyPart) &&
|
|
248
283
|
!Buffer.isBuffer(bodyPart)
|
|
249
284
|
) {
|
|
250
|
-
//
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
if (hasAoTypes) {
|
|
254
|
-
// For parts WITH ao-types: sort all entries alphabetically
|
|
255
|
-
const allEntries = []
|
|
256
|
-
|
|
257
|
-
// Collect all entries except body
|
|
258
|
-
for (const [key, value] of Object.entries(bodyPart)) {
|
|
259
|
-
if (key === "body") continue
|
|
260
|
-
|
|
261
|
-
if (key === "ao-types") {
|
|
262
|
-
// Keep ao-types as-is (Buffer or string)
|
|
263
|
-
let valueStr = value
|
|
264
|
-
if (Buffer.isBuffer(value)) {
|
|
265
|
-
valueStr = value.toString("binary")
|
|
266
|
-
}
|
|
267
|
-
allEntries.push({ key: "ao-types", line: `ao-types: ${valueStr}` })
|
|
268
|
-
} else {
|
|
269
|
-
// Handle Buffer values properly
|
|
270
|
-
let valueStr = value
|
|
271
|
-
if (Buffer.isBuffer(value)) {
|
|
272
|
-
// Use binary/latin1 encoding to preserve all byte values 0-255
|
|
273
|
-
valueStr = value.toString("binary")
|
|
274
|
-
}
|
|
275
|
-
allEntries.push({ key: key, line: `${key}: ${valueStr}` })
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// Add content-disposition
|
|
280
|
-
allEntries.push({
|
|
281
|
-
key: "content-disposition",
|
|
282
|
-
line: `content-disposition: ${disposition}`,
|
|
283
|
-
})
|
|
285
|
+
// Collect all headers (everything except 'body' and 'priv')
|
|
286
|
+
const allEntries = []
|
|
284
287
|
|
|
285
|
-
|
|
286
|
-
|
|
288
|
+
for (const [key, value] of Object.entries(bodyPart)) {
|
|
289
|
+
if (key === "body" || key === "priv") continue
|
|
287
290
|
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (body) {
|
|
294
|
-
lines.push("") // Always add empty line before body
|
|
295
|
-
lines.push(body)
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
return lines.join(CRLF)
|
|
299
|
-
} else {
|
|
300
|
-
// For parts WITHOUT ao-types
|
|
301
|
-
const allEntries = []
|
|
302
|
-
|
|
303
|
-
for (const [key, value] of Object.entries(bodyPart)) {
|
|
304
|
-
if (key === "body") continue
|
|
305
|
-
// Handle Buffer values properly
|
|
306
|
-
let valueStr = value
|
|
307
|
-
if (Buffer.isBuffer(value)) {
|
|
308
|
-
// Use binary/latin1 encoding to preserve all byte values 0-255
|
|
309
|
-
valueStr = value.toString("binary")
|
|
310
|
-
}
|
|
311
|
-
allEntries.push({ key: key, line: `${key}: ${valueStr}` })
|
|
291
|
+
// Handle Buffer values properly
|
|
292
|
+
let valueStr = value
|
|
293
|
+
if (Buffer.isBuffer(value)) {
|
|
294
|
+
// Use binary/latin1 encoding to preserve all byte values 0-255
|
|
295
|
+
valueStr = value.toString("binary")
|
|
312
296
|
}
|
|
297
|
+
allEntries.push({ key: key, line: `${key}: ${valueStr}` })
|
|
298
|
+
}
|
|
313
299
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
key: "content-disposition",
|
|
320
|
-
line: `content-disposition: ${disposition}`,
|
|
321
|
-
})
|
|
300
|
+
// Add content-disposition
|
|
301
|
+
allEntries.push({
|
|
302
|
+
key: "content-disposition",
|
|
303
|
+
line: `content-disposition: ${disposition}`,
|
|
304
|
+
})
|
|
322
305
|
|
|
323
|
-
|
|
324
|
-
|
|
306
|
+
// Sort all entries by key alphabetically - matches Erlang behavior
|
|
307
|
+
allEntries.sort((a, b) => a.key.localeCompare(b.key))
|
|
325
308
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
} else {
|
|
329
|
-
// Regular parts: content-disposition first, then fields
|
|
330
|
-
lines.push(`content-disposition: ${disposition}`)
|
|
331
|
-
lines.push(...allEntries.map(entry => entry.line))
|
|
332
|
-
}
|
|
309
|
+
// Build the lines
|
|
310
|
+
const lines = allEntries.map(entry => entry.line)
|
|
333
311
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
return lines.join(CRLF)
|
|
312
|
+
// Only the 'body' field (if present) becomes the part body
|
|
313
|
+
const body = bodyPart.body
|
|
314
|
+
if (body !== "" && body !== undefined && body !== null) {
|
|
315
|
+
lines.push("") // Always add empty line before body
|
|
316
|
+
lines.push(Buffer.isBuffer(body) ? body.toString("binary") : String(body))
|
|
342
317
|
}
|
|
318
|
+
|
|
319
|
+
return lines.join(CRLF)
|
|
343
320
|
} else if (typeof bodyPart === "string" || Buffer.isBuffer(bodyPart)) {
|
|
344
|
-
|
|
321
|
+
// Use binary/latin1 encoding to preserve byte values 0-255
|
|
322
|
+
const bodyStr = Buffer.isBuffer(bodyPart) ? bodyPart.toString("binary") : bodyPart
|
|
323
|
+
return `content-disposition: ${disposition}${DOUBLE_CRLF}${bodyStr}`
|
|
345
324
|
}
|
|
346
325
|
return ""
|
|
347
326
|
}
|
|
@@ -696,12 +675,20 @@ export function httpsig_from(http) {
|
|
|
696
675
|
|
|
697
676
|
/**
|
|
698
677
|
* Convert TABM to HTTP message
|
|
678
|
+
* Implements bundle mode like Erlang's dev_codec_httpsig_conv:to/3 with bundle=true
|
|
699
679
|
*/
|
|
700
680
|
export function httpsig_to(tabm) {
|
|
701
681
|
if (typeof tabm === "string") return tabm
|
|
702
682
|
|
|
683
|
+
// Bundle logic: TABM → structured → TABM
|
|
684
|
+
// This matches Erlang's behavior when bundle=true:
|
|
685
|
+
// 1. Convert TABM to structured@1.0 (interprets ao-types, decodes to native types)
|
|
686
|
+
// 2. Convert back to TABM (re-encodes with ao-types)
|
|
687
|
+
const structured = structuredTo(tabm)
|
|
688
|
+
const bundledTabm = structuredFrom(structured)
|
|
689
|
+
|
|
703
690
|
// Group IDs
|
|
704
|
-
const withGroupedIds = groupIds(
|
|
691
|
+
const withGroupedIds = groupIds(bundledTabm)
|
|
705
692
|
|
|
706
693
|
// Remove private and signature-related keys
|
|
707
694
|
const stripped = { ...withGroupedIds }
|
|
@@ -713,26 +700,25 @@ export function httpsig_to(tabm) {
|
|
|
713
700
|
const [inlineFieldHdrs, inlineKeyVal] = inlineKey(tabm)
|
|
714
701
|
|
|
715
702
|
// Check if this is a flat structure that should stay as headers
|
|
716
|
-
// A flat structure has no nested objects (maps)
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
703
|
+
// A flat structure has no nested objects (maps), excluding:
|
|
704
|
+
// - Arrays (JS arrays, not numbered maps)
|
|
705
|
+
// - Buffers
|
|
706
|
+
// Note: List-encoded maps (numbered maps with .="list") ARE nested maps
|
|
707
|
+
// and should trigger multipart encoding, matching Erlang's behavior
|
|
708
|
+
const hasNestedMaps = Object.values(stripped).some(value => {
|
|
709
|
+
// Not an object
|
|
710
|
+
if (typeof value !== "object" || value === null) return false
|
|
711
|
+
// Arrays and Buffers are not nested maps
|
|
712
|
+
if (Array.isArray(value) || Buffer.isBuffer(value)) return false
|
|
713
|
+
// Any other object (including list-encoded maps) is a nested map
|
|
714
|
+
return true
|
|
715
|
+
})
|
|
724
716
|
|
|
725
717
|
// If it's just a flat map with strings/primitives, keep as headers
|
|
726
718
|
// This matches Erlang's behavior where flat maps don't become multipart
|
|
727
719
|
if (!hasNestedMaps) {
|
|
728
720
|
// For flat structures, just return with normalized keys
|
|
729
|
-
|
|
730
|
-
const result = { ...inlineFieldHdrs }
|
|
731
|
-
|
|
732
|
-
for (const [key, value] of Object.entries(stripped)) {
|
|
733
|
-
// Keep Buffers as Buffers - don't convert to strings
|
|
734
|
-
result[key] = value
|
|
735
|
-
}
|
|
721
|
+
const result = { ...inlineFieldHdrs, ...stripped }
|
|
736
722
|
|
|
737
723
|
// Handle inline body key - move data from inline key to body
|
|
738
724
|
if (inlineKeyVal && inlineKeyVal !== "body" && result[inlineKeyVal]) {
|
|
@@ -740,9 +726,28 @@ export function httpsig_to(tabm) {
|
|
|
740
726
|
delete result[inlineKeyVal]
|
|
741
727
|
}
|
|
742
728
|
|
|
743
|
-
// If
|
|
729
|
+
// If the only field is ao-types (no actual data), return empty object
|
|
730
|
+
// This matches Erlang's behavior where ao-types-only messages become empty
|
|
731
|
+
const dataKeys = Object.keys(result).filter(k =>
|
|
732
|
+
k !== "ao-types" &&
|
|
733
|
+
k !== "ao-ids" &&
|
|
734
|
+
k !== "inline-body-key" &&
|
|
735
|
+
k !== "ao-body-key"
|
|
736
|
+
)
|
|
737
|
+
if (dataKeys.length === 0) {
|
|
738
|
+
return {}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// If there's a non-empty body, add content-digest
|
|
742
|
+
// Erlang doesn't add content-digest for empty bodies (<<>>)
|
|
744
743
|
if (result.body) {
|
|
745
|
-
|
|
744
|
+
const bodyIsEmpty = Buffer.isBuffer(result.body)
|
|
745
|
+
? result.body.length === 0
|
|
746
|
+
: (typeof result.body === "string" && result.body.length === 0)
|
|
747
|
+
|
|
748
|
+
if (!bodyIsEmpty) {
|
|
749
|
+
return addContentDigest(result)
|
|
750
|
+
}
|
|
746
751
|
}
|
|
747
752
|
|
|
748
753
|
return result
|
|
@@ -837,7 +842,7 @@ export function httpsig_to(tabm) {
|
|
|
837
842
|
|
|
838
843
|
const result = {
|
|
839
844
|
...headers,
|
|
840
|
-
|
|
845
|
+
// Note: body-keys is NOT included in httpsig output - it's only used for parsing
|
|
841
846
|
"content-type": `multipart/form-data; boundary="${boundary}"`,
|
|
842
847
|
body: finalBody,
|
|
843
848
|
}
|