From ea8c57f16987603d480e4942c9849f2788495dd9 Mon Sep 17 00:00:00 2001 From: Tyler Hall Date: Wed, 12 Feb 2025 01:19:19 +0000 Subject: [PATCH 01/17] feat(connect): lift all maps to top level, then encode to multipart --- connect/src/client/hb-encode.js | 143 ++++++++++++++++++-------------- connect/src/client/hb.js | 2 +- 2 files changed, 84 insertions(+), 61 deletions(-) diff --git a/connect/src/client/hb-encode.js b/connect/src/client/hb-encode.js index b89c41fab..a52f50bb0 100644 --- a/connect/src/client/hb-encode.js +++ b/connect/src/client/hb-encode.js @@ -27,22 +27,25 @@ async function sha256 (data) { return crypto.subtle.digest('SHA-256', data) } -function partition (pred, arr) { - return arr.reduce((acc, cur) => { - acc[pred(cur) ? 0 : 1].push(cur) - return acc - }, - [[], []]) -} - function isBytes (value) { return value instanceof ArrayBuffer || ArrayBuffer.isView(value) } +function isPojo (value) { + return !isBytes(value) && + typeof value === 'object' && + value !== null +} + function hbEncodeValue (key, value) { const typeK = `converge-type-${key}` + if (isBytes(value)) { + if (value.byteLength === 0) return hbEncodeValue(key, '') + return { [key]: value } + } + if (typeof value === 'string') { if (value.length === 0) return { [typeK]: 'empty-binary' } return { [key]: value } @@ -64,42 +67,26 @@ function hbEncodeValue (key, value) { throw new Error(`Cannot encode value: ${value.toString()}`) } -function hbEncode (obj, parent = '') { - return Object.entries(obj).reduce((acc, [key, value]) => { - const flatK = (parent ? `${parent}/${key}` : key) - .toLowerCase() +function hbEncodeLift (obj, prefix = '', result = {}) { + const cur = {} + for (const [key, value] of Object.entries(obj)) { + const flatK = prefix ? `${prefix}/${key}` : key - // skip nullish values - if (value == null) return acc - - // binary data - if (isBytes(value)) { - if (value.byteLength === 0) { - return Object.assign(acc, hbEncodeValue(flatK, '')) - } - return Object.assign(acc, { [flatK]: value }) + if (isPojo(value)) { + result[flatK] = value + hbEncodeLift(value, flatK, result) + continue } - // first/{idx}/name flatten array - if (Array.isArray(value)) { - if (value.length === 0) { - return Object.assign(acc, hbEncodeValue(flatK, value)) - } - value.forEach((v, i) => - Object.assign(acc, hbEncode(v, `${flatK}/${i}`)) - ) - return acc - } + Object.assign(cur, hbEncodeValue(key, value)) + } - // first/second flatten object - if (typeof value === 'object' && value !== null) { - return Object.assign(acc, hbEncode(value, flatK)) - } + if (Object.keys(cur).length === 0) return result - // leaf encode value - Object.assign(acc, hbEncodeValue(flatK, value)) - return acc - }, {}) + if (prefix) result[prefix] = cur + // Merge non-pojo values at top level + else Object.assign(result, cur) + return result } async function boundaryFrom (bodyParts = []) { @@ -112,6 +99,22 @@ async function boundaryFrom (bodyParts = []) { return base64url.encode(Buffer.from(hash)) } +function encodePart (name, { headers, body }) { + headers.append('content-disposition', `form-data;name="${name}"`) + const parts = Object + .entries(headers) + .reduce((acc, [name, value]) => { + acc.push(`${name}: `, value, '\r\n') + return acc + }, []) + + // CRLF if always required, even with empty body + parts.push('\r\n') + if (body) parts.push(body) + + return new Blob(parts) +} + /** * Encode the object as HyperBEAM HTTP multipart * message. Nested objects are flattened to a single @@ -120,41 +123,61 @@ async function boundaryFrom (bodyParts = []) { export async function encode (obj = {}) { if (Object.keys(obj) === 0) return - const flattened = hbEncode(obj) + const flattened = hbEncodeLift(obj) + /** * Some values may be encoded into headers, * while others may be encoded into the body */ - const [bodyKeys, headerKeys] = partition( - (key) => { - if (key.includes('/')) return true - const bytes = Buffer.from(flattened[key]) + const bodyKeys = [] + const headerKeys = [] + await Promise.all( + Object.keys(flattened).map(async (key) => { + const value = flattened[key] + + // if (key === 'data') { + // bodyKeys.push(key) + // flattened[key] = new Blob([ + // `content-disposition: form-data;name="${key}"\r\n\r\n`, + // value + // ]) + // return + // } /** - * Anything larger than 4k goes into - * the body + * Sub maps are always encoded as subparts + * in the body */ - return bytes.byteLength > 4096 - }, - Object.keys(flattened).sort() + if (isPojo(value)) { + // Empty object or nil + const subPart = await encode(value) + if (!subPart) return + + bodyKeys.push(key) + flattened[key] = encodePart(key, subPart) + return + } + + if (key.includes('/') || Buffer.from(value).byteLength > 4096) { + bodyKeys.push(key) + flattened[key] = new Blob([ + `content-disposition: form-data;name="${key}"\r\n\r\n`, + value + ]) + return + } + + headerKeys.push(key) + flattened[key] = value + }) ) const h = new Headers() headerKeys.forEach((key) => h.append(key, flattened[key])) - /** - * Add headers that indicates and orders body-keys - * for the purpose of determinstically reconstructing - * content-digest on the server - */ - // const bk = hbEncodeValue('body-keys', bodyKeys) - // Object.keys(bk).forEach((key) => h.append(key, bk[key])) let body if (bodyKeys.length) { const bodyParts = await Promise.all( - bodyKeys.map((name) => new Blob([ - `content-disposition: form-data;name="${name}"\r\n\r\n`, - flattened[name] - ]).arrayBuffer()) + bodyKeys.map((name) => flattened[name].arrayBuffer()) ) const boundary = await boundaryFrom(bodyParts) diff --git a/connect/src/client/hb.js b/connect/src/client/hb.js index 9eb27f2b4..921e96d7a 100644 --- a/connect/src/client/hb.js +++ b/connect/src/client/hb.js @@ -13,7 +13,7 @@ export async function encodeDataItem ({ processId, data, tags, anchor }) { if (processId) obj.target = processId if (anchor) obj.anchor = anchor - if (tags) tags.forEach(t => { obj[t.name] = t.value }) + if (tags) tags.forEach(t => { obj[t.name.toLowerCase()] = t.value }) /** * Always ensure the variant is mainnet for hyperbeam * TODO: change default variant to be this eventually From 8dc5691e0ce20d985a6d917002298a0a0f35becb Mon Sep 17 00:00:00 2001 From: Tyler Hall Date: Wed, 12 Feb 2025 23:18:36 +0000 Subject: [PATCH 02/17] feat(connect): add ao-types structured field dict in lieu of converge-type-* fields in hb encode --- connect/src/client/hb-encode.js | 102 +++++++++++++++++++------------- 1 file changed, 62 insertions(+), 40 deletions(-) diff --git a/connect/src/client/hb-encode.js b/connect/src/client/hb-encode.js index b89c41fab..1a057f9df 100644 --- a/connect/src/client/hb-encode.js +++ b/connect/src/client/hb-encode.js @@ -40,66 +40,88 @@ function isBytes (value) { ArrayBuffer.isView(value) } -function hbEncodeValue (key, value) { - const typeK = `converge-type-${key}` +function isPojo (value) { + return !isBytes(value) && + typeof value === 'object' && + value !== null +} + +function hbEncodeValue (value) { + if (isBytes(value)) { + if (value.byteLength === 0) return hbEncodeValue('') + return [undefined, value] + } if (typeof value === 'string') { - if (value.length === 0) return { [typeK]: 'empty-binary' } - return { [key]: value } + if (value.length === 0) return [undefined, 'empty-binary'] + return [undefined, value] } if (Array.isArray(value) && value.length === 0) { - return { [typeK]: 'empty-list' } + return ['empty-list', undefined] } if (typeof value === 'number') { - if (!Number.isInteger(value)) return { [typeK]: 'float', [key]: `${value}` } - return { [typeK]: 'integer', [key]: `${value}` } + if (!Number.isInteger(value)) return ['float', `${value}`] + return ['integer', `${value}`] } if (typeof value === 'symbol') { - return { [typeK]: 'atom', [key]: value.description } + return ['atom', value.description] } throw new Error(`Cannot encode value: ${value.toString()}`) } -function hbEncode (obj, parent = '') { - return Object.entries(obj).reduce((acc, [key, value]) => { - const flatK = (parent ? `${parent}/${key}` : key) - .toLowerCase() - - // skip nullish values - if (value == null) return acc +function store (fullK, curK, dest, [type, value]) { + const [encoded, types] = dest + if (type) types[curK] = type + if (value) encoded[fullK] = value + return dest +} - // binary data - if (isBytes(value)) { - if (value.byteLength === 0) { - return Object.assign(acc, hbEncodeValue(flatK, '')) +function hbEncode (obj, parent = '') { + const [flattened, types] = Object.entries(obj) + .reduce((acc, [key, value]) => { + const flatK = (parent ? `${parent}/${key}` : key) + .toLowerCase() + + // skip nullish values + if (value == null) return acc + + // first/{idx}/name flatten array + if (Array.isArray(value)) { + if (value.length === 0) { + return store(flatK, key, acc, hbEncodeValue(value)) + } + value.forEach((v, i) => + Object.assign(acc[0], hbEncode(v, `${flatK}/${i}`)) + ) + return acc } - return Object.assign(acc, { [flatK]: value }) - } - // first/{idx}/name flatten array - if (Array.isArray(value)) { - if (value.length === 0) { - return Object.assign(acc, hbEncodeValue(flatK, value)) + // first/second flatten object + if (isPojo(value)) { + Object.assign(acc[0], hbEncode(value, flatK)) + return acc } - value.forEach((v, i) => - Object.assign(acc, hbEncode(v, `${flatK}/${i}`)) - ) - return acc - } - - // first/second flatten object - if (typeof value === 'object' && value !== null) { - return Object.assign(acc, hbEncode(value, flatK)) - } - - // leaf encode value - Object.assign(acc, hbEncodeValue(flatK, value)) - return acc - }, {}) + + // leaf encode value + return store(flatK, key, acc, hbEncodeValue(value)) + }, [{}, {}]) + + /** + * Add the ao-types key for the specific layer, + * as a structured dictionary + */ + if (Object.keys(types).length) { + const typesKey = (parent ? `${parent}/ao-types` : 'ao-types') + flattened[typesKey] = Object.entries(types) + .map(([key, value]) => `${key.toLowerCase()}=${value}`) + .join(',') + } + + return flattened } async function boundaryFrom (bodyParts = []) { From 70fb3147f5c26964466f71fbdb002b693cb65a8a Mon Sep 17 00:00:00 2001 From: Tyler Hall Date: Tue, 18 Feb 2025 15:54:56 +0000 Subject: [PATCH 03/17] refactor(connect): consolidate details of boundary calc in hb encode --- connect/src/client/hb-encode.js | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/connect/src/client/hb-encode.js b/connect/src/client/hb-encode.js index 1a057f9df..d646db39a 100644 --- a/connect/src/client/hb-encode.js +++ b/connect/src/client/hb-encode.js @@ -1,10 +1,10 @@ import base64url from 'base64url' -import { Buffer } from 'buffer/index.js' +import { Buffer as BufferShim } from 'buffer/index.js' /** * polyfill in Browser */ -if (!globalThis.Buffer) globalThis.Buffer = Buffer +if (!globalThis.Buffer) globalThis.Buffer = BufferShim /** * ****** @@ -124,16 +124,6 @@ function hbEncode (obj, parent = '') { return flattened } -async function boundaryFrom (bodyParts = []) { - const base = new Blob( - bodyParts.flatMap((p, i, arr) => - i < arr.length - 1 ? [p, '\r\n'] : [p]) - ) - - const hash = await sha256(await base.arrayBuffer()) - return base64url.encode(Buffer.from(hash)) -} - /** * Encode the object as HyperBEAM HTTP multipart * message. Nested objects are flattened to a single @@ -166,6 +156,10 @@ export async function encode (obj = {}) { * Add headers that indicates and orders body-keys * for the purpose of determinstically reconstructing * content-digest on the server + * + * TODO: remove dead code. Apparently, this is only needed + * on the HB side, but keeping the commented code here + * just in case we need it client side. */ // const bk = hbEncodeValue('body-keys', bodyKeys) // Object.keys(bk).forEach((key) => h.append(key, bk[key])) @@ -179,7 +173,16 @@ export async function encode (obj = {}) { ]).arrayBuffer()) ) - const boundary = await boundaryFrom(bodyParts) + /** + * Generate a deterministic boundary, from the parts + * to use for the multipart body boundary + */ + const base = new Blob( + bodyParts.flatMap((p, i, arr) => + i < arr.length - 1 ? [p, '\r\n'] : [p]) + ) + const hash = await sha256(await base.arrayBuffer()) + const boundary = base64url.encode(Buffer.from(hash)) /** * Segment each part with the multipart boundary From e0377d9ea233278d221d4339e1299f1256cb312d Mon Sep 17 00:00:00 2001 From: Tyler Hall Date: Tue, 18 Feb 2025 22:19:00 +0000 Subject: [PATCH 04/17] feat(connect): combine ao-types, lifting objects, and max header length, encodings --- connect/src/client/hb-encode.js | 134 +++++++++++++++----------------- connect/src/client/hb.js | 29 +++---- 2 files changed, 74 insertions(+), 89 deletions(-) diff --git a/connect/src/client/hb-encode.js b/connect/src/client/hb-encode.js index 350868e16..98dec5178 100644 --- a/connect/src/client/hb-encode.js +++ b/connect/src/client/hb-encode.js @@ -10,16 +10,12 @@ if (!globalThis.Buffer) globalThis.Buffer = BufferShim * ****** * HyperBEAM Http Encoding * - * TODO: bundle into a package with - * - * - export encode() - * - export encodeDataItem() to convert object - * or ans104 to http message - * - exported signers for both node and browser environments - * (currently located in wallet.js modules) + * TODO: bundle into a separate package * ****** */ +const MAX_HEADER_LENGTH = 4096 + /** * @param {ArrayBuffer} data */ @@ -65,37 +61,7 @@ function hbEncodeValue (value) { throw new Error(`Cannot encode value: ${value.toString()}`) } -function store (fullK, curK, dest, [type, value]) { - const [encoded, types] = dest - if (type) types[curK] = type - if (value) encoded[fullK] = value - return dest -} - -// eslint-disable-next-line -function hbEncodeLift (obj, prefix = '', result = {}) { - const cur = {} - for (const [key, value] of Object.entries(obj)) { - const flatK = prefix ? `${prefix}/${key}` : key - - if (isPojo(value)) { - result[flatK] = value - hbEncodeLift(value, flatK, result) - continue - } - - Object.assign(cur, hbEncodeValue(key, value)) - } - - if (Object.keys(cur).length === 0) return result - - if (prefix) result[prefix] = cur - // Merge non-pojo values at top level - else Object.assign(result, cur) - return result -} - -function hbEncode (obj, parent = '', result = {}) { +export function hbEncodeLift (obj, parent = '', top = {}) { const [flattened, types] = Object.entries(obj) .reduce((acc, [key, value]) => { const flatK = (parent ? `${parent}/${key}` : key) @@ -115,59 +81,85 @@ function hbEncode (obj, parent = '', result = {}) { // return acc // } - // first/second flatten object + // first/second lift object if (isPojo(value)) { - result[flatK] = value - hbEncode(value, flatK, result) + /** + * Encode the pojo on top, but then continuing iterating + * through the current object level + */ + hbEncodeLift(value, flatK, top) return acc } // leaf encode value - return store(flatK, key, acc, hbEncodeValue(value)) + const [type, encoded] = hbEncodeValue(value) + if (encoded) { + /** + * This value is too large to be potentially encoded + * in a multipart header, so we instead need to "lift" it + * as a top level field on result, to be encoded as its own part + * + * So use flatK to preserve the nesting hierarchy + * While ensure it will be encoded as it's own part + */ + if (Buffer.from(encoded).byteLength > MAX_HEADER_LENGTH) { + top[flatK] = encoded + /** + * Encode at the current level as a normal field + */ + } else acc[0][key] = encoded + } + if (type) acc[1][key] = type + return acc }, [{}, {}]) - if (Object.keys(flattened).length === 0) return result + if (Object.keys(flattened).length === 0) return top /** * Add the ao-types key for this specific object, * as a structured dictionary */ - const typesKey = (parent ? `${parent}/ao-types` : 'ao-types') - flattened[typesKey] = Object.entries(types) - .map(([key, value]) => `${key.toLowerCase()}=${value}`) - .join(',') + if (Object.keys(types).length > 0) { + flattened['ao-types'] = Object.entries(types) + .map(([key, value]) => `${key.toLowerCase()}=${value}`) + .join(',') + } - if (parent) result[parent] = flattened + if (parent) top[parent] = flattened // Merge non-pojo values at top level - else Object.assign(result, flattened) - return result + else Object.assign(top, flattened) + return top } function encodePart (name, { headers, body }) { - headers.append('content-disposition', `form-data;name="${name}"`) const parts = Object - .entries(headers) + .entries(Object.fromEntries(headers)) .reduce((acc, [name, value]) => { acc.push(`${name}: `, value, '\r\n') return acc - }, []) + }, [`content-disposition: form-data;name="${name}"\r\n`]) - // CRLF if always required, even with empty body - parts.push('\r\n') - if (body) parts.push(body) + if (body) parts.push('\r\n', body) return new Blob(parts) } /** - * Encode the object as HyperBEAM HTTP multipart - * message. Nested objects are flattened to a single - * depth multipart + * Encoded the object as a HyperBEAM HTTP Multipart Message + * - Nested object are "lifted" to the top level, while preserving + * the hierarchy using "/", to be encoded as a part in the multipart body + * + * - Adds "ao-types" field on each nested object, that defines types + * for each nested field, encoded as a structured dictionary header on the part. + * + * - Conditionally "lifts" fields that too large to be encoded as headers, + * to the top level, to be encoded as a separate part, while preserving + * the hierarchy using "/" */ export async function encode (obj = {}) { if (Object.keys(obj) === 0) return - const flattened = hbEncode(obj) + const flattened = hbEncodeLift(obj) /** * Some values may be encoded into headers, @@ -178,18 +170,13 @@ export async function encode (obj = {}) { await Promise.all( Object.keys(flattened).map(async (key) => { const value = flattened[key] - - // if (key === 'data') { - // bodyKeys.push(key) - // flattened[key] = new Blob([ - // `content-disposition: form-data;name="${key}"\r\n\r\n`, - // value - // ]) - // return - // } /** * Sub maps are always encoded as subparts - * in the body + * in the body. + * + * Since hbEncodeLift already lifts + * objects to the top level, there should only ever + * be 1 recursive call here. */ if (isPojo(value)) { // Empty object or nil @@ -201,7 +188,12 @@ export async function encode (obj = {}) { return } - if (key.includes('/') || Buffer.from(value).byteLength > 4096) { + /** + * This value is too large to be encoded into a header + * on the message, so it must instead be encoded as the body + * in it's own part + */ + if (key.includes('/') || Buffer.from(value).byteLength > MAX_HEADER_LENGTH) { bodyKeys.push(key) flattened[key] = new Blob([ `content-disposition: form-data;name="${key}"\r\n\r\n`, diff --git a/connect/src/client/hb.js b/connect/src/client/hb.js index fd8dfa862..c0ab0989a 100644 --- a/connect/src/client/hb.js +++ b/connect/src/client/hb.js @@ -82,26 +82,19 @@ export function requestWith ({ fetch, logger: _logger, HB_URL, signer }) { .chain(fromPromise(({ url, method, headers, body }) => { return fetch(url, { method, headers, body, redirect: 'follow' }) .then(async res => { - if (res.status < 300) { - const contentType = res.headers.get('content-type') + if (res.status >= 300) return res - if (contentType && contentType.includes('multipart/form-data')) { - return res - } else if (contentType && contentType.includes('application/json')) { - const body = await res.json() - return { - headers: res.headers, - body - } - } else { - const body = await res.text() - return { - headers: res.headers, - body - } - } + const contentType = res.headers.get('content-type') + if (contentType && contentType.includes('multipart/form-data')) { + // TODO: maybe add hbDecode here to decode multipart into maps of { headers, body } + return res + } else if (contentType && contentType.includes('application/json')) { + const body = await res.json() + return { headers: res.headers, body } + } else { + const body = await res.text() + return { headers: res.headers, body } } - return res }) } )) From 971ce48bd0725968d13f4334805fc9f937505a04 Mon Sep 17 00:00:00 2001 From: Tyler Hall Date: Tue, 18 Feb 2025 22:45:29 +0000 Subject: [PATCH 05/17] feat(connect): take into account ao-types size and conditionally lift as separate part --- connect/src/client/hb-encode.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/connect/src/client/hb-encode.js b/connect/src/client/hb-encode.js index 98dec5178..bd4eea9d8 100644 --- a/connect/src/client/hb-encode.js +++ b/connect/src/client/hb-encode.js @@ -100,7 +100,7 @@ export function hbEncodeLift (obj, parent = '', top = {}) { * as a top level field on result, to be encoded as its own part * * So use flatK to preserve the nesting hierarchy - * While ensure it will be encoded as it's own part + * While ensure it will be encoded as its own part */ if (Buffer.from(encoded).byteLength > MAX_HEADER_LENGTH) { top[flatK] = encoded @@ -120,9 +120,18 @@ export function hbEncodeLift (obj, parent = '', top = {}) { * as a structured dictionary */ if (Object.keys(types).length > 0) { - flattened['ao-types'] = Object.entries(types) + const aoTypes = Object.entries(types) .map(([key, value]) => `${key.toLowerCase()}=${value}`) .join(',') + + /** + * The ao-types header was too large to encoded as a header + * so lift to the top, to be encoded as its own part + */ + if (Buffer.from(aoTypes).byteLength > MAX_HEADER_LENGTH) { + const flatK = (parent ? `${parent}/ao-types` : 'ao-types') + top[flatK] = aoTypes + } else flattened['ao-types'] = aoTypes } if (parent) top[parent] = flattened From 5110f6ff47796ff32e6e70fbc3f81ec48d5a142f Mon Sep 17 00:00:00 2001 From: Tyler Hall Date: Mon, 24 Feb 2025 11:53:05 -0500 Subject: [PATCH 06/17] feat(connect): handle lists of primitives and objects in hb encodings --- connect/src/client/hb-encode.js | 39 ++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/connect/src/client/hb-encode.js b/connect/src/client/hb-encode.js index bd4eea9d8..436253a71 100644 --- a/connect/src/client/hb-encode.js +++ b/connect/src/client/hb-encode.js @@ -30,6 +30,7 @@ function isBytes (value) { function isPojo (value) { return !isBytes(value) && + !Array.isArray(value) && typeof value === 'object' && value !== null } @@ -45,8 +46,18 @@ function hbEncodeValue (value) { return [undefined, value] } - if (Array.isArray(value) && value.length === 0) { - return ['empty-list', undefined] + if (Array.isArray(value)) { + if (value.length === 0) return ['empty-list', undefined] + const encoded = value.reduce( + (acc, cur) => { + let [type, curEncoded] = hbEncodeValue(cur) + if (!type) type = 'binary' + acc.push(`(ao-type-${type}) ${curEncoded}`) + return acc + }, + [] + ) + return ['list', encoded.join(',')] } if (typeof value === 'number') { @@ -62,7 +73,7 @@ function hbEncodeValue (value) { } export function hbEncodeLift (obj, parent = '', top = {}) { - const [flattened, types] = Object.entries(obj) + const [flattened, types] = Object.entries({ ...obj }) .reduce((acc, [key, value]) => { const flatK = (parent ? `${parent}/${key}` : key) .toLowerCase() @@ -70,16 +81,18 @@ export function hbEncodeLift (obj, parent = '', top = {}) { // skip nullish values if (value == null) return acc - // // first/{idx}/name flatten array - // if (Array.isArray(value)) { - // if (value.length === 0) { - // return store(flatK, key, acc, hbEncodeValue(value)) - // } - // value.forEach((v, i) => - // Object.assign(acc[0], hbEncode(v, `${flatK}/${i}`)) - // ) - // return acc - // } + // list of objects + if (Array.isArray(value) && value.some(isPojo)) { + /** + * Convert the list of maps into an object + * where keys are indices and values are the maps + * + * This will match the isPojo check below, + * which will handle the recursive lifting that we want. + */ + value = value.reduce((indexedObj, v, idx) => + Object.assign(indexedObj, { [idx]: v }), {}) + } // first/second lift object if (isPojo(value)) { From 5364350e9eead06fe4f75bb4a0a0ec17151978a5 Mon Sep 17 00:00:00 2001 From: Tyler Hall Date: Mon, 24 Feb 2025 12:27:20 -0500 Subject: [PATCH 07/17] feat(connect): ensure values with newlines are properly encoded, including blob values --- connect/src/client/hb-encode.js | 43 +++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/connect/src/client/hb-encode.js b/connect/src/client/hb-encode.js index 436253a71..8280ab340 100644 --- a/connect/src/client/hb-encode.js +++ b/connect/src/client/hb-encode.js @@ -16,6 +16,17 @@ if (!globalThis.Buffer) globalThis.Buffer = BufferShim const MAX_HEADER_LENGTH = 4096 +async function hasNewline (value) { + if (typeof value === 'string') return value.includes('\n') + if (value instanceof Blob) { + value = await value.text() + return value.includes('\n') + } + if (isBytes(value)) return Buffer.from(value).includes('\n') + + return false +} + /** * @param {ArrayBuffer} data */ @@ -31,11 +42,14 @@ function isBytes (value) { function isPojo (value) { return !isBytes(value) && !Array.isArray(value) && + !(value instanceof Blob) && typeof value === 'object' && value !== null } function hbEncodeValue (value) { + if (value instanceof Blob) return [undefined, value] + if (isBytes(value)) { if (value.byteLength === 0) return hbEncodeValue('') return [undefined, value] @@ -115,7 +129,10 @@ export function hbEncodeLift (obj, parent = '', top = {}) { * So use flatK to preserve the nesting hierarchy * While ensure it will be encoded as its own part */ - if (Buffer.from(encoded).byteLength > MAX_HEADER_LENGTH) { + if (encoded instanceof Blob) { + if (encoded.size > MAX_HEADER_LENGTH) top[flatK] = encoded + else acc[0][key] = encoded + } else if (Buffer.from(encoded).byteLength > MAX_HEADER_LENGTH) { top[flatK] = encoded /** * Encode at the current level as a normal field @@ -211,11 +228,27 @@ export async function encode (obj = {}) { } /** - * This value is too large to be encoded into a header - * on the message, so it must instead be encoded as the body - * in it's own part + * There are special cases that will force a field to be + * encoded into the body: + * + * - The field includes any whitespace + * - The field includes '/' + * - The fields size exceeds the max header length + * + * In all cases, the field is forced to be encoded into the body + * as a sub-part, where the part has a single Content-Disposition + * header denoting the field, and the body of the sub-part + * being the field value itself. + * + * (These special cases happen to cover a multitude of issues that + * could cause a Data Item's 'data' to not be encodable as a header, + * but extends that coverage to any sort of field) */ - if (key.includes('/') || Buffer.from(value).byteLength > MAX_HEADER_LENGTH) { + if ( + await hasNewline(value) || + key.includes('/') || + Buffer.from(value).byteLength > MAX_HEADER_LENGTH + ) { bodyKeys.push(key) flattened[key] = new Blob([ `content-disposition: form-data;name="${key}"\r\n\r\n`, From 02cc73c8115b965a43efd3891f86d085e7dc4e84 Mon Sep 17 00:00:00 2001 From: Tyler Hall Date: Mon, 24 Feb 2025 12:39:09 -0500 Subject: [PATCH 08/17] feat(connect): revert support for direct blob value due to ambiguity in intent --- connect/src/client/hb-encode.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/connect/src/client/hb-encode.js b/connect/src/client/hb-encode.js index 8280ab340..867fac6f0 100644 --- a/connect/src/client/hb-encode.js +++ b/connect/src/client/hb-encode.js @@ -48,8 +48,6 @@ function isPojo (value) { } function hbEncodeValue (value) { - if (value instanceof Blob) return [undefined, value] - if (isBytes(value)) { if (value.byteLength === 0) return hbEncodeValue('') return [undefined, value] @@ -129,10 +127,7 @@ export function hbEncodeLift (obj, parent = '', top = {}) { * So use flatK to preserve the nesting hierarchy * While ensure it will be encoded as its own part */ - if (encoded instanceof Blob) { - if (encoded.size > MAX_HEADER_LENGTH) top[flatK] = encoded - else acc[0][key] = encoded - } else if (Buffer.from(encoded).byteLength > MAX_HEADER_LENGTH) { + if (Buffer.from(encoded).byteLength > MAX_HEADER_LENGTH) { top[flatK] = encoded /** * Encode at the current level as a normal field From 82ef5c8b069c67db1889d38ab67ffba480dac8df Mon Sep 17 00:00:00 2001 From: Sam Williams Date: Fri, 7 Mar 2025 22:05:40 -0500 Subject: [PATCH 09/17] feat(connect): support `httpsig@1.0`'s `inline-body-key` encoding form --- connect/src/client/hb-encode.js | 78 ++++++++++++++++++++++----------- connect/src/client/hb.js | 7 +-- 2 files changed, 56 insertions(+), 29 deletions(-) diff --git a/connect/src/client/hb-encode.js b/connect/src/client/hb-encode.js index 867fac6f0..119fd7498 100644 --- a/connect/src/client/hb-encode.js +++ b/connect/src/client/hb-encode.js @@ -259,6 +259,14 @@ export async function encode (obj = {}) { const h = new Headers() headerKeys.forEach((key) => h.append(key, flattened[key])) + + // If there is a data field that didn't otherwise get encoded into a multipart body, + // and there are no other body parts, then we need to encode the data as an + // `inline-body-key`. While doing so, we remove the `data` header that would + // otherwise be duplicated. + if (h.has('data')) { + bodyKeys.push('data') + } /** * Add headers that indicates and orders body-keys * for the purpose of determinstically reconstructing @@ -271,44 +279,62 @@ export async function encode (obj = {}) { // const bk = hbEncodeValue('body-keys', bodyKeys) // Object.keys(bk).forEach((key) => h.append(key, bk[key])) - let body + let body, finalContent if (bodyKeys.length) { - const bodyParts = await Promise.all( - bodyKeys.map((name) => flattened[name].arrayBuffer()) - ) + if (bodyKeys.length === 1) { + // If there is only one element, promote it to be the full body and set the + // `inline-body-key` such that the server knows its name. + body = new Blob([obj.data]) + h.append('inline-body-key', bodyKeys[0]) + h.delete(bodyKeys[0]) + } else { + // This is a multipart body, so we generate and insert the boundary + // appropriately. + const bodyParts = await Promise.all( + bodyKeys.map((name) => { + return flattened[name].arrayBuffer() + }) + ) - /** - * Generate a deterministic boundary, from the parts - * to use for the multipart body boundary - */ - const base = new Blob( - bodyParts.flatMap((p, i, arr) => - i < arr.length - 1 ? [p, '\r\n'] : [p]) - ) - const hash = await sha256(await base.arrayBuffer()) - const boundary = base64url.encode(Buffer.from(hash)) + /** + * Generate a deterministic boundary, from the parts + * to use for the multipart body boundary + */ + const base = new Blob( + bodyParts.flatMap((p, i, arr) => + i < arr.length - 1 ? [p, '\r\n'] : [p]) + ) + const hash = await sha256(await base.arrayBuffer()) + const boundary = base64url.encode(Buffer.from(hash)) - /** - * Segment each part with the multipart boundary - */ - const blobParts = bodyParts - .flatMap((p) => [`--${boundary}\r\n`, p, '\r\n']) + /** + * Segment each part with the multipart boundary + */ + const blobParts = bodyParts + .flatMap((p) => [`--${boundary}\r\n`, p, '\r\n']) - /** - * Add the terminating boundary - */ - blobParts.push(`--${boundary}--`) + /** + * Add the terminating boundary + */ + blobParts.push(`--${boundary}--`) - body = new Blob(blobParts) + h.set('Content-Type', `multipart/form-data; boundary="${boundary}"`) + body = new Blob(blobParts) + } /** * calculate the content-digest */ - const contentDigest = await sha256(await body.arrayBuffer()) + finalContent = await body.arrayBuffer() + const contentDigest = await sha256(finalContent) const base64 = base64url.toBase64(base64url.encode(contentDigest)) - h.set('Content-Type', `multipart/form-data; boundary="${boundary}"`) h.append('Content-Digest', `sha-256=:${base64}:`) } + console.log('Encoded headers:') + console.log(h) + console.log('Encoded body:') + console.log(finalContent) + return { headers: h, body } } diff --git a/connect/src/client/hb.js b/connect/src/client/hb.js index c0ab0989a..f90ef09a8 100644 --- a/connect/src/client/hb.js +++ b/connect/src/client/hb.js @@ -85,15 +85,16 @@ export function requestWith ({ fetch, logger: _logger, HB_URL, signer }) { if (res.status >= 300) return res const contentType = res.headers.get('content-type') + console.log('RECEIVED FROM HB:') + console.log(res) if (contentType && contentType.includes('multipart/form-data')) { // TODO: maybe add hbDecode here to decode multipart into maps of { headers, body } - return res + return { headers: res.headers, body: await res.text() } } else if (contentType && contentType.includes('application/json')) { const body = await res.json() return { headers: res.headers, body } } else { - const body = await res.text() - return { headers: res.headers, body } + return { headers: res.headers, body: await res.text() } } }) } From 201a14d86233b2887069e00b413a90cfee4e26c9 Mon Sep 17 00:00:00 2001 From: Sam Williams Date: Sat, 8 Mar 2025 02:24:08 -0500 Subject: [PATCH 10/17] chore(connect): towards HB 0.8.1 compatibility --- connect/src/client/hb.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/connect/src/client/hb.js b/connect/src/client/hb.js index f90ef09a8..722daa06b 100644 --- a/connect/src/client/hb.js +++ b/connect/src/client/hb.js @@ -85,14 +85,12 @@ export function requestWith ({ fetch, logger: _logger, HB_URL, signer }) { if (res.status >= 300) return res const contentType = res.headers.get('content-type') - console.log('RECEIVED FROM HB:') - console.log(res) if (contentType && contentType.includes('multipart/form-data')) { // TODO: maybe add hbDecode here to decode multipart into maps of { headers, body } return { headers: res.headers, body: await res.text() } } else if (contentType && contentType.includes('application/json')) { - const body = await res.json() - return { headers: res.headers, body } + const msgJSON = await res.json() + return { headers: res.headers, ...msgJSON, body } } else { return { headers: res.headers, body: await res.text() } } From b5ad4bc8229a8a881a0e1246759c22bd47f71e91 Mon Sep 17 00:00:00 2001 From: Sam Williams Date: Sat, 8 Mar 2025 19:18:58 -0500 Subject: [PATCH 11/17] feat(connect): impl HB 0.8.1 compat --- connect/src/client/hb-encode.js | 8 ++++---- connect/src/client/hb.js | 11 +--------- connect/src/lib/request/index.js | 35 ++++++-------------------------- 3 files changed, 11 insertions(+), 43 deletions(-) diff --git a/connect/src/client/hb-encode.js b/connect/src/client/hb-encode.js index 119fd7498..36d3a7111 100644 --- a/connect/src/client/hb-encode.js +++ b/connect/src/client/hb-encode.js @@ -331,10 +331,10 @@ export async function encode (obj = {}) { h.append('Content-Digest', `sha-256=:${base64}:`) } - console.log('Encoded headers:') - console.log(h) - console.log('Encoded body:') - console.log(finalContent) + // console.log('Encoded headers:') + // console.log(h) + // console.log('Encoded body:') + // console.log(finalContent) return { headers: h, body } } diff --git a/connect/src/client/hb.js b/connect/src/client/hb.js index 722daa06b..d278eddf8 100644 --- a/connect/src/client/hb.js +++ b/connect/src/client/hb.js @@ -84,16 +84,7 @@ export function requestWith ({ fetch, logger: _logger, HB_URL, signer }) { .then(async res => { if (res.status >= 300) return res - const contentType = res.headers.get('content-type') - if (contentType && contentType.includes('multipart/form-data')) { - // TODO: maybe add hbDecode here to decode multipart into maps of { headers, body } - return { headers: res.headers, body: await res.text() } - } else if (contentType && contentType.includes('application/json')) { - const msgJSON = await res.json() - return { headers: res.headers, ...msgJSON, body } - } else { - return { headers: res.headers, body: await res.text() } - } + return { headers: res.headers, body: await res.text() } }) } )) diff --git a/connect/src/lib/request/index.js b/connect/src/lib/request/index.js index bc6c942ae..a9c4db11c 100644 --- a/connect/src/lib/request/index.js +++ b/connect/src/lib/request/index.js @@ -184,6 +184,7 @@ if mode == 'process' then request should create a pure httpsig from fields const transformToMap = (mode) => (result) => { const map = {} if (mode === 'relay@1.0') { + // console.log('Mainnet (M1) result', result) if (typeof result === 'string') { return result } @@ -217,6 +218,7 @@ if mode == 'process' then request should create a pure httpsig from fields } return map } else { + // console.log('Mainnet (M2) result', result) const res = result let body = '' res.headers.forEach((v, k) => { @@ -228,38 +230,13 @@ if mode == 'process' then request should create a pure httpsig from fields if (typeof res.body === 'string') { try { body = JSON.parse(res.body) - - if (body.Output && body.Output.data) { - map.Output = { - text: () => Promise.resolve(body.Output.data) - } - } - if (body.Messages) { - map.Messages = body.Messages.map((m) => { - const miniMap = {} - m.Tags.forEach((t) => { - miniMap[t.name] = { - text: () => Promise.resolve(t.value) - } - }) - miniMap.Data = { - text: () => Promise.resolve(m.Data), - json: () => Promise.resolve(JSON.parse(m.Data)), - binary: () => Promise.resolve(Buffer.from(m.Data)) - } - miniMap.Target = { - text: () => Promise.resolve(m.Target) - } - miniMap.Anchor = { - text: () => Promise.resolve(m.Anchor) - } - return miniMap - }) - } + return { ...map, ...body } } catch (e) { - map.body = { text: () => Promise.resolve(body) } + // console.log('Mainnet (M2) error', e) + map.body = body } } + // console.log('Mainnet (M2) default reply', map) return map } } From 18641f6b093702d049708206bb7bd1dd78ea3450 Mon Sep 17 00:00:00 2001 From: Sam Williams Date: Thu, 13 Mar 2025 18:44:20 -0400 Subject: [PATCH 12/17] chore(connect): add format to mainnet request type --- connect/src/client/hb.js | 10 +++++++--- connect/src/lib/request/index.js | 5 +++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/connect/src/client/hb.js b/connect/src/client/hb.js index d278eddf8..d35f749da 100644 --- a/connect/src/client/hb.js +++ b/connect/src/client/hb.js @@ -53,9 +53,9 @@ export function httpSigName (address) { return `http-sig-${hexString}` } -export function requestWith ({ fetch, logger: _logger, HB_URL, signer }) { +export function requestWith (args) { + const { fetch, logger: _logger, HB_URL, signer, signingFormat = 'HTTP' } = args const logger = _logger.child('request') - return (fields) => { const { path, method, ...restFields } = fields @@ -82,8 +82,12 @@ export function requestWith ({ fetch, logger: _logger, HB_URL, signer }) { .chain(fromPromise(({ url, method, headers, body }) => { return fetch(url, { method, headers, body, redirect: 'follow' }) .then(async res => { + console.log('PUSH FORMAT: ', signingFormat, '. RESPONSE:', res) + if (res.status === 422 && signingFormat === 'HTTP') { + return await requestWith({ ...args, signingFormat: 'ANS-104' })(fields) + } + if (res.status >= 400) throw new Error(`${res.status}: ${await res.text()}`) if (res.status >= 300) return res - return { headers: res.headers, body: await res.text() } }) } diff --git a/connect/src/lib/request/index.js b/connect/src/lib/request/index.js index a9c4db11c..115fc6425 100644 --- a/connect/src/lib/request/index.js +++ b/connect/src/lib/request/index.js @@ -256,8 +256,9 @@ if mode == 'process' then request should create a pure httpsig from fields .map((res) => { logger( - 'Received response from message sent to path "%s"', - fields?.path ?? '/' + 'Received response from message sent to path "%s" with res %O', + fields?.path ?? '/', + res ) return res }) From 90dc4dfa2a48dd4f5fe93ee2d5fa4f8197fcdb78 Mon Sep 17 00:00:00 2001 From: Sam Williams Date: Fri, 14 Mar 2025 05:50:01 -0400 Subject: [PATCH 13/17] wip(connect): HTTPSig -> ANS104 422 processing --- connect/src/client/hb.js | 136 ++++++++++++++++++++++++++++----------- 1 file changed, 100 insertions(+), 36 deletions(-) diff --git a/connect/src/client/hb.js b/connect/src/client/hb.js index d35f749da..641141dc3 100644 --- a/connect/src/client/hb.js +++ b/connect/src/client/hb.js @@ -3,7 +3,7 @@ import base64url from 'base64url' import { joinUrl } from '../lib/utils.js' import { encode } from './hb-encode.js' -import { toHttpSigner } from './signer.js' +import { toHttpSigner, toDataItemSigner } from './signer.js' /** * Map data item members to corresponding HB HTTP message @@ -53,46 +53,77 @@ export function httpSigName (address) { return `http-sig-${hexString}` } -export function requestWith (args) { +export function requestWith(args) { const { fetch, logger: _logger, HB_URL, signer, signingFormat = 'HTTP' } = args const logger = _logger.child('request') - return (fields) => { + + return async function(fields) { const { path, method, ...restFields } = fields + + try { + let req = { } + // Step 1: Encode the fields to get headers and body + if (signingFormat === 'ANS-104') { + req = toANS104Request(restFields) + } else { + req = await encode(restFields) + } - return of({ path, method, fields: restFields }) - .chain(fromPromise(({ path, method, fields }) => - encode(fields).then(({ headers, body }) => ({ - path, - method, - headers, - body - })) - )) - .chain(fromPromise(async ({ path, method, headers, body }) => - toHttpSigner(signer)(toSigBaseArgs({ + let fetch_req = { } + console.log('SIGNING FORMAT: ', signingFormat, '. REQUEST: ', req) + if (signingFormat === 'ANS-104') { + const signedRequest = await toANS104Request(restFields) + fetch_req = { ...signedRequest, body: req.body } + } + else { + // Step 2: Create and execute the signing request + const signingArgs = toSigBaseArgs({ url: joinUrl({ url: HB_URL, path }), - method, - headers - // this does not work with hyperbeam - // includePath: true - })).then((req) => ({ ...req, body })) - )) - .map(logger.tap('Sending HTTP signed message to HB: %o')) - .chain((request) => of(request) - .chain(fromPromise(({ url, method, headers, body }) => { - return fetch(url, { method, headers, body, redirect: 'follow' }) - .then(async res => { - console.log('PUSH FORMAT: ', signingFormat, '. RESPONSE:', res) - if (res.status === 422 && signingFormat === 'HTTP') { - return await requestWith({ ...args, signingFormat: 'ANS-104' })(fields) - } - if (res.status >= 400) throw new Error(`${res.status}: ${await res.text()}`) - if (res.status >= 300) return res - return { headers: res.headers, body: await res.text() } - }) - } - )) - ).toPromise() + method: method, + headers: req.headers + }) + + const signedRequest = await toHttpSigner(signer)(signingArgs) + fetch_req = { ...signedRequest, body: restFields.body, path, method } + } + + // Log the request + logger.tap('Sending HTTP signed message to HB: %o')(fetch_req) + + // Step 4: Send the request + const res = await fetch(fetch_req.url, { + method: fetch_req.method, + headers: fetch_req.headers, + body: fetch_req.body, + redirect: 'follow' + }) + + console.log('PUSH FORMAT: ', signingFormat, '. RESPONSE:', res) + + // Step 5: Handle specific status codes + if (res.status === 422 && signingFormat === 'HTTP') { + // Try again with different signing format + return requestWith({ ...args, signingFormat: 'ANS-104' })(fields) + } + + if (res.status >= 400) { + throw new Error(`${res.status}: ${await res.text()}`) + } + + if (res.status >= 300) { + return res + } + + // Step 6: Return the response + return { + headers: res.headers, + body: await res.text() + } + } catch (error) { + // Handle errors appropriately + console.error("Request failed:", error) + throw error + } } } @@ -233,6 +264,39 @@ export function loadResultWith ({ fetch, logger: _logger, HB_URL, signer }) { } } +export function toANS104Request(fields) { + const dataItem = { + target: fields.Target, + anchor: fields.Anchor ?? '', + tags: keys( + omit( + [ + 'Target', + 'Anchor', + 'Data', + 'dryrun', + 'Type', + 'Variant', + 'path', + 'method' + ], + fields + ) + ) + .map(function (key) { + return { name: key, value: fields[key] } + }, fields) + .concat([ + { name: 'Data-Protocol', value: 'ao' }, + { name: 'Type', value: fields.Type ?? 'Message' }, + { name: 'Variant', value: fields.Variant ?? 'ao.N.1' } + ]), + data: fields?.data || '' + } + console.log('ANS104 REQUEST: ', dataItem) + return { headers: { 'Content-Type': 'application/ans104' }, body: dataItem } +} + export class InsufficientFunds extends Error { name = 'InsufficientFunds' } From 129e34ea29997b3635559f28847ec56f7cea1bd2 Mon Sep 17 00:00:00 2001 From: Sam Williams Date: Fri, 14 Mar 2025 20:51:18 -0400 Subject: [PATCH 14/17] wip(connect): httpsig -> ans104 downgrade path --- connect/src/client/hb.js | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/connect/src/client/hb.js b/connect/src/client/hb.js index 641141dc3..89d2d0a31 100644 --- a/connect/src/client/hb.js +++ b/connect/src/client/hb.js @@ -1,4 +1,5 @@ import { Rejected, fromPromise, of } from 'hyper-async' +import { omit, keys } from 'ramda' import base64url from 'base64url' import { joinUrl } from '../lib/utils.js' @@ -61,22 +62,25 @@ export function requestWith(args) { const { path, method, ...restFields } = fields try { - let req = { } - // Step 1: Encode the fields to get headers and body - if (signingFormat === 'ANS-104') { - req = toANS104Request(restFields) - } else { - req = await encode(restFields) - } let fetch_req = { } - console.log('SIGNING FORMAT: ', signingFormat, '. REQUEST: ', req) + console.log('SIGNING FORMAT: ', signingFormat, '. REQUEST: ', fields) if (signingFormat === 'ANS-104') { - const signedRequest = await toANS104Request(restFields) - fetch_req = { ...signedRequest, body: req.body } + const ans104Request = toANS104Request(restFields) + console.log('ANS-104 REQUEST PRE-SIGNING: ', ans104Request) + const signedRequest = await toDataItemSigner(signer)(ans104Request.item) + console.log('SIGNED ANS-104 ITEM: ', signedRequest) + fetch_req = { + body: signedRequest.raw, + url: joinUrl({ url: HB_URL, path }), + path, + method, + headers: ans104Request.headers + } } else { // Step 2: Create and execute the signing request + const req = await encode(restFields) const signingArgs = toSigBaseArgs({ url: joinUrl({ url: HB_URL, path }), method: method, @@ -84,11 +88,11 @@ export function requestWith(args) { }) const signedRequest = await toHttpSigner(signer)(signingArgs) - fetch_req = { ...signedRequest, body: restFields.body, path, method } + fetch_req = { ...signedRequest, body: req.body, path, method } } // Log the request - logger.tap('Sending HTTP signed message to HB: %o')(fetch_req) + logger.tap('Sending signed message to HB: %o')(fetch_req) // Step 4: Send the request const res = await fetch(fetch_req.url, { @@ -265,9 +269,10 @@ export function loadResultWith ({ fetch, logger: _logger, HB_URL, signer }) { } export function toANS104Request(fields) { + console.log('TO ANS 104 REQUEST: ', fields) const dataItem = { - target: fields.Target, - anchor: fields.Anchor ?? '', + target: fields.target, + anchor: fields.anchor ?? '', tags: keys( omit( [ @@ -294,7 +299,7 @@ export function toANS104Request(fields) { data: fields?.data || '' } console.log('ANS104 REQUEST: ', dataItem) - return { headers: { 'Content-Type': 'application/ans104' }, body: dataItem } + return { headers: { 'Content-Type': 'application/ans104', 'codec-device': 'ans104@1.0' }, item: dataItem } } export class InsufficientFunds extends Error { From 08d01d64e6a4ff06ddda796d4926f32ca55285d0 Mon Sep 17 00:00:00 2001 From: Sam Williams Date: Mon, 17 Mar 2025 13:53:01 -0400 Subject: [PATCH 15/17] feat(connect): in-memory cache of preferred endpoint POST format --- connect/src/client/hb.js | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/connect/src/client/hb.js b/connect/src/client/hb.js index 89d2d0a31..5fdf7352e 100644 --- a/connect/src/client/hb.js +++ b/connect/src/client/hb.js @@ -6,6 +6,8 @@ import { joinUrl } from '../lib/utils.js' import { encode } from './hb-encode.js' import { toHttpSigner, toDataItemSigner } from './signer.js' +let reqFormatCache = {} + /** * Map data item members to corresponding HB HTTP message * shape @@ -55,11 +57,16 @@ export function httpSigName (address) { } export function requestWith(args) { - const { fetch, logger: _logger, HB_URL, signer, signingFormat = 'HTTP' } = args + const { fetch, logger: _logger, HB_URL, signer } = args + let signingFormat = args.signingFormat const logger = _logger.child('request') - + return async function(fields) { const { path, method, ...restFields } = fields + + if (!signingFormat) { + signingFormat = reqFormatCache[fields.path] ?? 'HTTP' + } try { @@ -107,10 +114,13 @@ export function requestWith(args) { // Step 5: Handle specific status codes if (res.status === 422 && signingFormat === 'HTTP') { // Try again with different signing format + reqFormatCache[fields.path] = 'ANS-104' return requestWith({ ...args, signingFormat: 'ANS-104' })(fields) } if (res.status >= 400) { + console.log('ERROR RESPONSE: ', res) + process.exit(1) throw new Error(`${res.status}: ${await res.text()}`) } @@ -239,10 +249,10 @@ export function loadResultWith ({ fetch, logger: _logger, HB_URL, signer }) { return of(args) .chain(fromPromise(async ({ id, processId }) => { const { headers, body } = await encodeDataItem({ processId }) - headers.append('slot+integer', id) + headers.append('slot', id) headers.append('accept', 'application/json') return toHttpSigner(signer)(toSigBaseArgs({ - url: `${HB_URL}/${processId}/compute&slot+integer=${id}/results/json`, + url: `${HB_URL}/${processId}~process@1.0/compute&slot=${id}/results/json`, method: 'POST', headers })).then((req) => ({ ...req, body })) @@ -277,11 +287,19 @@ export function toANS104Request(fields) { omit( [ 'Target', + 'target', 'Anchor', + 'anchor', 'Data', + 'data', + 'data-protocol', + 'Data-Protocol', + 'variant', + 'Variant', 'dryrun', + 'Dryrun', 'Type', - 'Variant', + 'type', 'path', 'method' ], From 1f730464a01a4b625f7b8d6c5b9d79af13098c82 Mon Sep 17 00:00:00 2001 From: Sam Williams Date: Thu, 27 Mar 2025 19:19:52 -0400 Subject: [PATCH 16/17] fix: gather signingFormat from fields --- connect/src/client/hb.js | 1 + 1 file changed, 1 insertion(+) diff --git a/connect/src/client/hb.js b/connect/src/client/hb.js index 5fdf7352e..6954f35a9 100644 --- a/connect/src/client/hb.js +++ b/connect/src/client/hb.js @@ -64,6 +64,7 @@ export function requestWith(args) { return async function(fields) { const { path, method, ...restFields } = fields + signingFormat = fields.signingFormat if (!signingFormat) { signingFormat = reqFormatCache[fields.path] ?? 'HTTP' } From e48958871be6c0fcf17d673e4202e66a1a95034a Mon Sep 17 00:00:00 2001 From: Jack Frain Date: Thu, 10 Apr 2025 11:08:21 -0400 Subject: [PATCH 17/17] fix(connect): if body is not an object, do not spread --- connect/src/client/hb.js | 70 ++++++++++++++++---------------- connect/src/lib/request/index.js | 5 ++- 2 files changed, 39 insertions(+), 36 deletions(-) diff --git a/connect/src/client/hb.js b/connect/src/client/hb.js index 6954f35a9..c73d3bffb 100644 --- a/connect/src/client/hb.js +++ b/connect/src/client/hb.js @@ -6,7 +6,7 @@ import { joinUrl } from '../lib/utils.js' import { encode } from './hb-encode.js' import { toHttpSigner, toDataItemSigner } from './signer.js' -let reqFormatCache = {} +const reqFormatCache = {} /** * Map data item members to corresponding HB HTTP message @@ -56,87 +56,87 @@ export function httpSigName (address) { return `http-sig-${hexString}` } -export function requestWith(args) { +export function requestWith (args) { const { fetch, logger: _logger, HB_URL, signer } = args let signingFormat = args.signingFormat const logger = _logger.child('request') - return async function(fields) { + return async function (fields) { const { path, method, ...restFields } = fields - + signingFormat = fields.signingFormat if (!signingFormat) { signingFormat = reqFormatCache[fields.path] ?? 'HTTP' } - - try { - let fetch_req = { } - console.log('SIGNING FORMAT: ', signingFormat, '. REQUEST: ', fields) + try { + let fetchRequest = { } + // console.log('SIGNING FORMAT: ', signingFormat, '. REQUEST: ', fields) if (signingFormat === 'ANS-104') { const ans104Request = toANS104Request(restFields) - console.log('ANS-104 REQUEST PRE-SIGNING: ', ans104Request) + // console.log('ANS-104 REQUEST PRE-SIGNING: ', ans104Request) const signedRequest = await toDataItemSigner(signer)(ans104Request.item) - console.log('SIGNED ANS-104 ITEM: ', signedRequest) - fetch_req = { + // console.log('SIGNED ANS-104 ITEM: ', signedRequest) + fetchRequest = { body: signedRequest.raw, url: joinUrl({ url: HB_URL, path }), path, method, headers: ans104Request.headers } - } - else { + } else { // Step 2: Create and execute the signing request const req = await encode(restFields) const signingArgs = toSigBaseArgs({ url: joinUrl({ url: HB_URL, path }), - method: method, + method, headers: req.headers }) - + const signedRequest = await toHttpSigner(signer)(signingArgs) - fetch_req = { ...signedRequest, body: req.body, path, method } + fetchRequest = { ...signedRequest, body: req.body, path, method } } - + // Log the request - logger.tap('Sending signed message to HB: %o')(fetch_req) - + logger.tap('Sending signed message to HB: %o')(fetchRequest) + // Step 4: Send the request - const res = await fetch(fetch_req.url, { - method: fetch_req.method, - headers: fetch_req.headers, - body: fetch_req.body, - redirect: 'follow' + // console.log('SENDING REQUEST: ', fetchRequest) + + const res = await fetch(fetchRequest.url, { + method: fetchRequest.method, + headers: fetchRequest.headers, + body: fetchRequest.body, + redirect: 'follow' }) - - console.log('PUSH FORMAT: ', signingFormat, '. RESPONSE:', res) - + + // console.log('PUSH FORMAT: ', signingFormat, '. RESPONSE:', res) + const body = await res.text() // Step 5: Handle specific status codes if (res.status === 422 && signingFormat === 'HTTP') { // Try again with different signing format reqFormatCache[fields.path] = 'ANS-104' return requestWith({ ...args, signingFormat: 'ANS-104' })(fields) } - + if (res.status >= 400) { console.log('ERROR RESPONSE: ', res) process.exit(1) throw new Error(`${res.status}: ${await res.text()}`) } - + if (res.status >= 300) { return res } - + // Step 6: Return the response return { headers: res.headers, - body: await res.text() + body } } catch (error) { // Handle errors appropriately - console.error("Request failed:", error) + console.error('Request failed:', error) throw error } } @@ -279,8 +279,8 @@ export function loadResultWith ({ fetch, logger: _logger, HB_URL, signer }) { } } -export function toANS104Request(fields) { - console.log('TO ANS 104 REQUEST: ', fields) +export function toANS104Request (fields) { + // console.log('TO ANS 104 REQUEST: ', fields) const dataItem = { target: fields.target, anchor: fields.anchor ?? '', @@ -317,7 +317,7 @@ export function toANS104Request(fields) { ]), data: fields?.data || '' } - console.log('ANS104 REQUEST: ', dataItem) + // console.log('ANS104 REQUEST: ', dataItem) return { headers: { 'Content-Type': 'application/ans104', 'codec-device': 'ans104@1.0' }, item: dataItem } } diff --git a/connect/src/lib/request/index.js b/connect/src/lib/request/index.js index 115fc6425..7fba6d48e 100644 --- a/connect/src/lib/request/index.js +++ b/connect/src/lib/request/index.js @@ -230,7 +230,10 @@ if mode == 'process' then request should create a pure httpsig from fields if (typeof res.body === 'string') { try { body = JSON.parse(res.body) - return { ...map, ...body } + if (typeof body === 'object') { + return { ...map, ...body } + } + return { ...map, body } } catch (e) { // console.log('Mainnet (M2) error', e) map.body = body