@bsv/sdk
Version:
BSV Blockchain Software Development Kit
424 lines • 20 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.SimplifiedFetchTransport = void 0;
const Utils = __importStar(require("../../primitives/utils.js"));
const defaultFetch = typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function'
? globalThis.fetch.bind(globalThis)
: 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.
*/
class SimplifiedFetchTransport {
/**
* 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) {
if (typeof fetchClient !== 'function') {
throw new Error('SimplifiedFetchTransport requires a fetch implementation. ' +
'In environments without fetch, provide a polyfill or custom implementation.');
}
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 authUrl = `${this.baseUrl}/.well-known/auth`;
const responsePromise = (async () => {
try {
return await this.fetchClient(authUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(message)
});
}
catch (error) {
throw this.createNetworkError(authUrl, error);
}
})();
// For initialRequest message, mark connection as established and start pool.
if (message.messageType !== 'initialRequest') {
resolve();
}
const response = await responsePromise;
if (!response.ok) {
const responseBodyArray = Array.from(new Uint8Array(await response.arrayBuffer()));
throw this.createUnauthenticatedResponseError(authUrl, response, responseBodyArray);
}
if (this.onDataCallback != null) {
const responseMessage = await response.json();
this.onDataCallback(responseMessage);
}
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
let response;
try {
response = await this.fetchClient(url, {
method: httpRequestWithAuthHeaders.method,
headers: httpRequestWithAuthHeaders.headers,
body: httpRequestWithAuthHeaders.body
});
}
catch (error) {
throw this.createNetworkError(url, error);
}
const responseBodyBuffer = await response.arrayBuffer();
const responseBodyArray = Array.from(new Uint8Array(responseBodyBuffer));
const missingAuthHeaders = ['x-bsv-auth-version', 'x-bsv-auth-identity-key', 'x-bsv-auth-signature']
.filter(headerName => {
const headerValue = response.headers.get(headerName);
return headerValue == null || headerValue.trim().length === 0;
});
if (missingAuthHeaders.length > 0) {
throw this.createUnauthenticatedResponseError(url, response, responseBodyArray, missingAuthHeaders);
}
const requestedCertificatesHeader = response.headers.get('x-bsv-auth-requested-certificates');
let requestedCertificates;
if (requestedCertificatesHeader != null) {
try {
requestedCertificates = JSON.parse(requestedCertificatesHeader);
}
catch (error) {
throw this.createMalformedHeaderError(url, 'x-bsv-auth-requested-certificates', requestedCertificatesHeader, error);
}
}
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
payloadWriter.writeVarIntNum(responseBodyArray.length);
if (responseBodyArray.length > 0) {
payloadWriter.write(responseBodyArray);
}
// 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') ?? undefined,
yourNonce: response.headers.get('x-bsv-auth-your-nonce') ?? undefined,
requestedCertificates,
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 this.createUnauthenticatedResponseError(url, response, responseBodyArray);
}
// 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);
};
}
createNetworkError(url, originalError) {
const baseMessage = `Network error while sending authenticated request to ${url}`;
if (originalError instanceof Error) {
const error = new Error(`${baseMessage}: ${originalError.message}`);
error.stack = originalError.stack;
error.cause = originalError;
return error;
}
return new Error(`${baseMessage}: ${String(originalError)}`);
}
createUnauthenticatedResponseError(url, response, bodyBytes, missingHeaders = []) {
const statusText = (response.statusText ?? '').trim();
const statusDescription = statusText.length > 0
? `${response.status} ${statusText}`
: `${response.status}`;
const headerMessage = missingHeaders.length > 0
? `missing headers: ${missingHeaders.join(', ')}`
: 'response lacked required BSV auth headers';
const bodyPreview = this.getBodyPreview(bodyBytes, response.headers.get('content-type'));
const parts = [`Received HTTP ${statusDescription} from ${url} without valid BSV authentication (${headerMessage})`];
if (bodyPreview != null) {
parts.push(`body preview: ${bodyPreview}`);
}
const error = new Error(parts.join(' - '));
error.details = {
url,
status: response.status,
statusText: response.statusText,
missingHeaders,
bodyPreview
};
return error;
}
createMalformedHeaderError(url, headerName, headerValue, cause) {
const errorMessage = `Failed to parse ${headerName} returned by ${url}: ${headerValue}`;
if (cause instanceof Error) {
const error = new Error(`${errorMessage}. ${cause.message}`);
error.stack = cause.stack;
error.cause = cause;
return error;
}
return new Error(`${errorMessage}. ${String(cause)}`);
}
getBodyPreview(bodyBytes, contentType) {
if (bodyBytes.length === 0) {
return undefined;
}
const maxBytesForPreview = 1024;
const truncated = bodyBytes.length > maxBytesForPreview;
const slice = truncated ? bodyBytes.slice(0, maxBytesForPreview) : bodyBytes;
const isText = this.isTextualContent(contentType, slice);
let preview;
if (isText) {
try {
preview = Utils.toUTF8(slice);
}
catch {
preview = this.formatBinaryPreview(slice, truncated);
}
}
else {
preview = this.formatBinaryPreview(slice, truncated);
}
if (preview.length > 512) {
preview = `${preview.slice(0, 512)}…`;
}
if (truncated) {
preview = `${preview} (truncated)`;
}
return preview;
}
isTextualContent(contentType, sample) {
if (sample.length === 0) {
return false;
}
if (contentType != null) {
const lowered = contentType.toLowerCase();
const textualTokens = [
'application/json',
'application/problem+json',
'application/xml',
'application/xhtml+xml',
'application/javascript',
'application/ecmascript',
'application/x-www-form-urlencoded',
'text/'
];
if (textualTokens.some(token => lowered.includes(token)) || lowered.includes('charset=')) {
return true;
}
}
const printableCount = sample.reduce((count, byte) => {
if (byte === 9 || byte === 10 || byte === 13) {
return count + 1;
}
if (byte >= 32 && byte <= 126) {
return count + 1;
}
return count;
}, 0);
return (printableCount / sample.length) > 0.8;
}
formatBinaryPreview(bytes, truncated) {
const hex = bytes.map(byte => byte.toString(16).padStart(2, '0')).join('');
return `0x${hex}${truncated ? '…' : ''}`;
}
/**
* 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
};
}
}
exports.SimplifiedFetchTransport = SimplifiedFetchTransport;
//# sourceMappingURL=SimplifiedFetchTransport.js.map