@bsv/sdk
Version:
BSV Blockchain Software Development Kit
259 lines • 13 kB
JavaScript
import * as Utils from '../../primitives/utils.js';
// Only bind window.fetch in the browser
const defaultFetch = typeof window !== 'undefined' ? fetch.bind(window) : fetch;
/**
* Implements an HTTP-specific transport for handling Peer mutual authentication messages.
* This class integrates with fetch to send and receive authenticated messages between peers.
*/
export class SimplifiedFetchTransport {
onDataCallback;
fetchClient;
baseUrl;
/**
* Constructs a new instance of SimplifiedFetchTransport.
* @param baseUrl - The base URL for all HTTP requests made by this transport.
* @param fetchClient - A fetch implementation to use for HTTP requests (default: global fetch).
*/
constructor(baseUrl, fetchClient = defaultFetch) {
this.fetchClient = fetchClient;
this.baseUrl = baseUrl;
}
/**
* Sends a message to an HTTP server using the transport mechanism.
* Handles both general and authenticated message types. For general messages,
* the payload is deserialized and sent as an HTTP request. For other message types,
* the message is sent as a POST request to the `/auth` endpoint.
*
* @param message - The AuthMessage to send.
* @returns A promise that resolves when the message is successfully sent.
*
* @throws Will throw an error if no listener has been registered via `onData`.
*/
async send(message) {
if (this.onDataCallback == null) {
throw new Error('Listen before you start speaking. God gave you two ears and one mouth for a reason.');
}
if (message.messageType !== 'general') {
return await new Promise((resolve, reject) => {
void (async () => {
try {
const responsePromise = this.fetchClient(`${this.baseUrl}/.well-known/auth`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(message)
});
// For initialRequest message, mark connection as established and start pool.
if (message.messageType !== 'initialRequest') {
resolve();
}
const response = await responsePromise;
// Handle the response if data is received and callback is set
if (response.ok && (this.onDataCallback != null)) {
const responseMessage = await response.json();
this.onDataCallback(responseMessage);
}
else {
// Server may be a non authenticated server
throw new Error('HTTP server failed to authenticate');
}
if (message.messageType === 'initialRequest') {
resolve();
}
}
catch (e) {
reject(e);
}
})();
});
}
else {
// Parse message payload
const httpRequest = this.deserializeRequestPayload(message.payload);
// Send the byte array as the HTTP payload
const url = `${this.baseUrl}${httpRequest.urlPostfix}`;
const httpRequestWithAuthHeaders = httpRequest;
if (typeof httpRequest.headers !== 'object') {
httpRequestWithAuthHeaders.headers = {};
}
// Append auth headers in request to server
httpRequestWithAuthHeaders.headers['x-bsv-auth-version'] = message.version;
httpRequestWithAuthHeaders.headers['x-bsv-auth-identity-key'] = message.identityKey;
httpRequestWithAuthHeaders.headers['x-bsv-auth-nonce'] = message.nonce;
httpRequestWithAuthHeaders.headers['x-bsv-auth-your-nonce'] = message.yourNonce;
httpRequestWithAuthHeaders.headers['x-bsv-auth-signature'] = Utils.toHex(message.signature);
httpRequestWithAuthHeaders.headers['x-bsv-auth-request-id'] = httpRequest.requestId;
// Ensure Content-Type is set for requests with a body
if (httpRequestWithAuthHeaders.body != null) {
const headers = httpRequestWithAuthHeaders.headers;
if (headers['content-type'] == null) {
throw new Error('Content-Type header is required for requests with a body.');
}
const contentType = String(headers['content-type'] ?? '');
// Transform body based on Content-Type
if (contentType.includes('application/json')) {
// Convert byte array to JSON string
httpRequestWithAuthHeaders.body = Utils.toUTF8(httpRequestWithAuthHeaders.body);
}
else if (contentType.includes('application/x-www-form-urlencoded')) {
// Convert byte array to URL-encoded string
httpRequestWithAuthHeaders.body = Utils.toUTF8(httpRequestWithAuthHeaders.body);
}
else if (contentType.includes('text/plain')) {
// Convert byte array to plain UTF-8 string
httpRequestWithAuthHeaders.body = Utils.toUTF8(httpRequestWithAuthHeaders.body);
}
else {
// For all other content types, treat as binary data
httpRequestWithAuthHeaders.body = new Uint8Array(httpRequestWithAuthHeaders.body);
}
}
// Send the actual fetch request to the server
const response = await this.fetchClient(url, {
method: httpRequestWithAuthHeaders.method,
headers: httpRequestWithAuthHeaders.headers,
body: httpRequestWithAuthHeaders.body
});
// Check for an acceptable status
if (response.status === 500 && (response.headers.get('x-bsv-auth-request-id') == null &&
response.headers.get('x-bsv-auth-requested-certificates') == null)) {
// Try parsing JSON error
const errorInfo = await response.json();
// Otherwise just throw whatever we got
throw new Error(`HTTP ${response.status} - ${JSON.stringify(errorInfo)}`);
}
const parsedBody = await response.arrayBuffer();
const payloadWriter = new Utils.Writer();
if (response.headers.get('x-bsv-auth-request-id') != null) {
payloadWriter.write(Utils.toArray(response.headers.get('x-bsv-auth-request-id'), 'base64'));
}
payloadWriter.writeVarIntNum(response.status);
// PARSE RESPONSE HEADERS FROM SERVER --------------------------------
// Parse response headers from the server and include only the signed headers:
// - Include custom headers prefixed with x-bsv (excluding those starting with x-bsv-auth)
// - Include the authorization header
const includedHeaders = [];
response.headers.forEach((value, key) => {
const lowerKey = key.toLowerCase();
if ((lowerKey.startsWith('x-bsv-') || lowerKey === 'authorization') && !lowerKey.startsWith('x-bsv-auth')) {
includedHeaders.push([lowerKey, value]);
}
});
// Sort the headers by key to ensure a consistent order for signing and verification.
includedHeaders.sort(([keyA], [keyB]) => keyA.localeCompare(keyB));
// nHeaders
payloadWriter.writeVarIntNum(includedHeaders.length);
for (let i = 0; i < includedHeaders.length; i++) {
// headerKeyLength
const headerKeyAsArray = Utils.toArray(includedHeaders[i][0], 'utf8');
payloadWriter.writeVarIntNum(headerKeyAsArray.length);
// headerKey
payloadWriter.write(headerKeyAsArray);
// headerValueLength
const headerValueAsArray = Utils.toArray(includedHeaders[i][1], 'utf8');
payloadWriter.writeVarIntNum(headerValueAsArray.length);
// headerValue
payloadWriter.write(headerValueAsArray);
}
// Handle body
if (parsedBody != null) {
const bodyAsArray = Array.from(new Uint8Array(parsedBody));
payloadWriter.writeVarIntNum(bodyAsArray.length);
payloadWriter.write(bodyAsArray);
}
else {
payloadWriter.writeVarIntNum(-1);
}
// Build the correct AuthMessage for the response
const responseMessage = {
version: response.headers.get('x-bsv-auth-version'),
messageType: response.headers.get('x-bsv-auth-message-type') === 'certificateRequest' ? 'certificateRequest' : 'general',
identityKey: response.headers.get('x-bsv-auth-identity-key'),
nonce: response.headers.get('x-bsv-auth-nonce'),
yourNonce: response.headers.get('x-bsv-auth-your-nonce'),
requestedCertificates: JSON.parse(response.headers.get('x-bsv-auth-requested-certificates')),
payload: payloadWriter.toArray(),
signature: Utils.toArray(response.headers.get('x-bsv-auth-signature'), 'hex')
};
// If the server didn't provide the correct authentication headers, throw an error
if (responseMessage.version == null) {
throw new Error('HTTP server failed to authenticate');
}
// Handle the response if data is received and callback is set
this.onDataCallback(responseMessage);
}
}
/**
* Registers a callback to handle incoming messages.
* This must be called before sending any messages to ensure responses can be processed.
*
* @param callback - A function to invoke when an incoming AuthMessage is received.
* @returns A promise that resolves once the callback is set.
*/
async onData(callback) {
this.onDataCallback = (m) => {
void callback(m);
};
}
/**
* Deserializes a request payload from a byte array into an HTTP request-like structure.
*
* @param payload - The serialized payload to deserialize.
* @returns An object representing the deserialized request, including the method,
* URL postfix (path and query string), headers, body, and request ID.
*/
deserializeRequestPayload(payload) {
// Create a reader
const requestReader = new Utils.Reader(payload);
// The first 32 bytes is the requestId
const requestId = Utils.toBase64(requestReader.read(32));
// Method
const methodLength = requestReader.readVarIntNum();
let method = 'GET';
if (methodLength > 0) {
method = Utils.toUTF8(requestReader.read(methodLength));
}
// Path
const pathLength = requestReader.readVarIntNum();
let path = '';
if (pathLength > 0) {
path = Utils.toUTF8(requestReader.read(pathLength));
}
// Search
const searchLength = requestReader.readVarIntNum();
let search = '';
if (searchLength > 0) {
search = Utils.toUTF8(requestReader.read(searchLength));
}
// Read headers
const requestHeaders = {};
const nHeaders = requestReader.readVarIntNum();
if (nHeaders > 0) {
for (let i = 0; i < nHeaders; i++) {
const nHeaderKeyBytes = requestReader.readVarIntNum();
const headerKeyBytes = requestReader.read(nHeaderKeyBytes);
const headerKey = Utils.toUTF8(headerKeyBytes);
const nHeaderValueBytes = requestReader.readVarIntNum();
const headerValueBytes = requestReader.read(nHeaderValueBytes);
const headerValue = Utils.toUTF8(headerValueBytes);
requestHeaders[headerKey] = headerValue;
}
}
// Read body
let requestBody;
const requestBodyBytes = requestReader.readVarIntNum();
if (requestBodyBytes > 0) {
requestBody = requestReader.read(requestBodyBytes);
}
// Return the deserialized RequestInit
return {
urlPostfix: path + search,
method,
headers: requestHeaders,
body: requestBody,
requestId
};
}
}
//# sourceMappingURL=SimplifiedFetchTransport.js.map