ipfs storage for images and other nontext items. for use with etica - runs on etica network and currencys
https://collect.etica-stats.org
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
970 lines
31 KiB
970 lines
31 KiB
/** |
|
* Fetching content from the web is environment-specific, so Ethers |
|
* provides an abstraction that each environment can implement to provide |
|
* this service. |
|
* |
|
* On [Node.js](link-node), the ``http`` and ``https`` libs are used to |
|
* create a request object, register event listeners and process data |
|
* and populate the [[FetchResponse]]. |
|
* |
|
* In a browser, the [DOM fetch](link-js-fetch) is used, and the resulting |
|
* ``Promise`` is waited on to retrieve the payload. |
|
* |
|
* The [[FetchRequest]] is responsible for handling many common situations, |
|
* such as redirects, server throttling, authentication, etc. |
|
* |
|
* It also handles common gateways, such as IPFS and data URIs. |
|
* |
|
* @_section api/utils/fetching:Fetching Web Content [about-fetch] |
|
*/ |
|
import { decodeBase64, encodeBase64 } from "./base64.js"; |
|
import { hexlify } from "./data.js"; |
|
import { assert, assertArgument } from "./errors.js"; |
|
import { defineProperties } from "./properties.js"; |
|
import { toUtf8Bytes, toUtf8String } from "./utf8.js"; |
|
|
|
import { createGetUrl } from "./geturl.js"; |
|
|
|
/** |
|
* An environment's implementation of ``getUrl`` must return this type. |
|
*/ |
|
export type GetUrlResponse = { |
|
statusCode: number, |
|
statusMessage: string, |
|
headers: Record<string, string>, |
|
body: null | Uint8Array |
|
}; |
|
|
|
/** |
|
* This can be used to control how throttling is handled in |
|
* [[FetchRequest-setThrottleParams]]. |
|
*/ |
|
export type FetchThrottleParams = { |
|
maxAttempts?: number; |
|
slotInterval?: number; |
|
}; |
|
|
|
/** |
|
* Called before any network request, allowing updated headers (e.g. Bearer tokens), etc. |
|
*/ |
|
export type FetchPreflightFunc = (req: FetchRequest) => Promise<FetchRequest>; |
|
|
|
/** |
|
* Called on the response, allowing client-based throttling logic or post-processing. |
|
*/ |
|
export type FetchProcessFunc = (req: FetchRequest, resp: FetchResponse) => Promise<FetchResponse>; |
|
|
|
/** |
|
* Called prior to each retry; return true to retry, false to abort. |
|
*/ |
|
export type FetchRetryFunc = (req: FetchRequest, resp: FetchResponse, attempt: number) => Promise<boolean>; |
|
|
|
/** |
|
* Called on Gateway URLs. |
|
*/ |
|
export type FetchGatewayFunc = (url: string, signal?: FetchCancelSignal) => Promise<FetchRequest | FetchResponse>; |
|
|
|
/** |
|
* Used to perform a fetch; use this to override the underlying network |
|
* fetch layer. In NodeJS, the default uses the "http" and "https" libraries |
|
* and in the browser ``fetch`` is used. If you wish to use Axios, this is |
|
* how you would register it. |
|
*/ |
|
export type FetchGetUrlFunc = (req: FetchRequest, signal?: FetchCancelSignal) => Promise<GetUrlResponse>; |
|
|
|
|
|
const MAX_ATTEMPTS = 12; |
|
const SLOT_INTERVAL = 250; |
|
|
|
// The global FetchGetUrlFunc implementation. |
|
let defaultGetUrlFunc: FetchGetUrlFunc = createGetUrl(); |
|
|
|
const reData = new RegExp("^data:([^;:]*)?(;base64)?,(.*)$", "i"); |
|
const reIpfs = new RegExp("^ipfs:/\/(ipfs/)?(.*)$", "i"); |
|
|
|
// If locked, new Gateways cannot be added |
|
let locked = false; |
|
|
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs |
|
async function dataGatewayFunc(url: string, signal?: FetchCancelSignal): Promise<FetchResponse> { |
|
try { |
|
const match = url.match(reData); |
|
if (!match) { throw new Error("invalid data"); } |
|
return new FetchResponse(200, "OK", { |
|
"content-type": (match[1] || "text/plain"), |
|
}, (match[2] ? decodeBase64(match[3]): unpercent(match[3]))); |
|
} catch (error) { |
|
return new FetchResponse(599, "BAD REQUEST (invalid data: URI)", { }, null, new FetchRequest(url)); |
|
} |
|
} |
|
|
|
/** |
|
* Returns a [[FetchGatewayFunc]] for fetching content from a standard |
|
* IPFS gateway hosted at %%baseUrl%%. |
|
*/ |
|
function getIpfsGatewayFunc(baseUrl: string): FetchGatewayFunc { |
|
async function gatewayIpfs(url: string, signal?: FetchCancelSignal): Promise<FetchRequest | FetchResponse> { |
|
try { |
|
const match = url.match(reIpfs); |
|
if (!match) { throw new Error("invalid link"); } |
|
return new FetchRequest(`${ baseUrl }${ match[2] }`); |
|
} catch (error) { |
|
return new FetchResponse(599, "BAD REQUEST (invalid IPFS URI)", { }, null, new FetchRequest(url)); |
|
} |
|
} |
|
|
|
return gatewayIpfs; |
|
} |
|
|
|
const Gateways: Record<string, FetchGatewayFunc> = { |
|
"data": dataGatewayFunc, |
|
"ipfs": getIpfsGatewayFunc("https:/\/gateway.ipfs.io/ipfs/") |
|
}; |
|
|
|
const fetchSignals: WeakMap<FetchRequest, () => void> = new WeakMap(); |
|
|
|
/** |
|
* @_ignore |
|
*/ |
|
export class FetchCancelSignal { |
|
#listeners: Array<() => void>; |
|
#cancelled: boolean; |
|
|
|
constructor(request: FetchRequest) { |
|
this.#listeners = [ ]; |
|
this.#cancelled = false; |
|
|
|
fetchSignals.set(request, () => { |
|
if (this.#cancelled) { return; } |
|
this.#cancelled = true; |
|
|
|
for (const listener of this.#listeners) { |
|
setTimeout(() => { listener(); }, 0); |
|
} |
|
this.#listeners = [ ]; |
|
}); |
|
} |
|
|
|
addListener(listener: () => void): void { |
|
assert(!this.#cancelled, "singal already cancelled", "UNSUPPORTED_OPERATION", { |
|
operation: "fetchCancelSignal.addCancelListener" |
|
}); |
|
this.#listeners.push(listener); |
|
} |
|
|
|
get cancelled(): boolean { return this.#cancelled; } |
|
|
|
checkSignal(): void { |
|
assert(!this.cancelled, "cancelled", "CANCELLED", { }); |
|
} |
|
} |
|
|
|
// Check the signal, throwing if it is cancelled |
|
function checkSignal(signal?: FetchCancelSignal): FetchCancelSignal { |
|
if (signal == null) { throw new Error("missing signal; should not happen"); } |
|
signal.checkSignal(); |
|
return signal; |
|
} |
|
|
|
/** |
|
* Represents a request for a resource using a URI. |
|
* |
|
* By default, the supported schemes are ``HTTP``, ``HTTPS``, ``data:``, |
|
* and ``IPFS:``. |
|
* |
|
* Additional schemes can be added globally using [[registerGateway]]. |
|
* |
|
* @example: |
|
* req = new FetchRequest("https://www.ricmoo.com") |
|
* resp = await req.send() |
|
* resp.body.length |
|
* //_result: |
|
*/ |
|
export class FetchRequest implements Iterable<[ key: string, value: string ]> { |
|
#allowInsecure: boolean; |
|
#gzip: boolean; |
|
#headers: Record<string, string>; |
|
#method: string; |
|
#timeout: number; |
|
#url: string; |
|
|
|
#body?: Uint8Array; |
|
#bodyType?: string; |
|
#creds?: string; |
|
|
|
// Hooks |
|
#preflight?: null | FetchPreflightFunc; |
|
#process?: null | FetchProcessFunc; |
|
#retry?: null | FetchRetryFunc; |
|
|
|
#signal?: FetchCancelSignal; |
|
|
|
#throttle: Required<FetchThrottleParams>; |
|
|
|
#getUrlFunc: null | FetchGetUrlFunc; |
|
|
|
/** |
|
* The fetch URL to request. |
|
*/ |
|
get url(): string { return this.#url; } |
|
set url(url: string) { |
|
this.#url = String(url); |
|
} |
|
|
|
/** |
|
* The fetch body, if any, to send as the request body. //(default: null)// |
|
* |
|
* When setting a body, the intrinsic ``Content-Type`` is automatically |
|
* set and will be used if **not overridden** by setting a custom |
|
* header. |
|
* |
|
* If %%body%% is null, the body is cleared (along with the |
|
* intrinsic ``Content-Type``). |
|
* |
|
* If %%body%% is a string, the intrinsic ``Content-Type`` is set to |
|
* ``text/plain``. |
|
* |
|
* If %%body%% is a Uint8Array, the intrinsic ``Content-Type`` is set to |
|
* ``application/octet-stream``. |
|
* |
|
* If %%body%% is any other object, the intrinsic ``Content-Type`` is |
|
* set to ``application/json``. |
|
*/ |
|
get body(): null | Uint8Array { |
|
if (this.#body == null) { return null; } |
|
return new Uint8Array(this.#body); |
|
} |
|
set body(body: null | string | Readonly<object> | Readonly<Uint8Array>) { |
|
if (body == null) { |
|
this.#body = undefined; |
|
this.#bodyType = undefined; |
|
} else if (typeof(body) === "string") { |
|
this.#body = toUtf8Bytes(body); |
|
this.#bodyType = "text/plain"; |
|
} else if (body instanceof Uint8Array) { |
|
this.#body = body; |
|
this.#bodyType = "application/octet-stream"; |
|
} else if (typeof(body) === "object") { |
|
this.#body = toUtf8Bytes(JSON.stringify(body)); |
|
this.#bodyType = "application/json"; |
|
} else { |
|
throw new Error("invalid body"); |
|
} |
|
} |
|
|
|
/** |
|
* Returns true if the request has a body. |
|
*/ |
|
hasBody(): this is (FetchRequest & { body: Uint8Array }) { |
|
return (this.#body != null); |
|
} |
|
|
|
/** |
|
* The HTTP method to use when requesting the URI. If no method |
|
* has been explicitly set, then ``GET`` is used if the body is |
|
* null and ``POST`` otherwise. |
|
*/ |
|
get method(): string { |
|
if (this.#method) { return this.#method; } |
|
if (this.hasBody()) { return "POST"; } |
|
return "GET"; |
|
} |
|
set method(method: null | string) { |
|
if (method == null) { method = ""; } |
|
this.#method = String(method).toUpperCase(); |
|
} |
|
|
|
/** |
|
* The headers that will be used when requesting the URI. All |
|
* keys are lower-case. |
|
* |
|
* This object is a copy, so any changes will **NOT** be reflected |
|
* in the ``FetchRequest``. |
|
* |
|
* To set a header entry, use the ``setHeader`` method. |
|
*/ |
|
get headers(): Record<string, string> { |
|
const headers = Object.assign({ }, this.#headers); |
|
|
|
if (this.#creds) { |
|
headers["authorization"] = `Basic ${ encodeBase64(toUtf8Bytes(this.#creds)) }`; |
|
}; |
|
|
|
if (this.allowGzip) { |
|
headers["accept-encoding"] = "gzip"; |
|
} |
|
|
|
if (headers["content-type"] == null && this.#bodyType) { |
|
headers["content-type"] = this.#bodyType; |
|
} |
|
if (this.body) { headers["content-length"] = String(this.body.length); } |
|
|
|
return headers; |
|
} |
|
|
|
/** |
|
* Get the header for %%key%%, ignoring case. |
|
*/ |
|
getHeader(key: string): string { |
|
return this.headers[key.toLowerCase()]; |
|
} |
|
|
|
/** |
|
* Set the header for %%key%% to %%value%%. All values are coerced |
|
* to a string. |
|
*/ |
|
setHeader(key: string, value: string | number): void { |
|
this.#headers[String(key).toLowerCase()] = String(value); |
|
} |
|
|
|
/** |
|
* Clear all headers, resetting all intrinsic headers. |
|
*/ |
|
clearHeaders(): void { |
|
this.#headers = { }; |
|
} |
|
|
|
[Symbol.iterator](): Iterator<[ key: string, value: string ]> { |
|
const headers = this.headers; |
|
const keys = Object.keys(headers); |
|
let index = 0; |
|
return { |
|
next: () => { |
|
if (index < keys.length) { |
|
const key = keys[index++]; |
|
return { |
|
value: [ key, headers[key] ], done: false |
|
} |
|
} |
|
return { value: undefined, done: true }; |
|
} |
|
}; |
|
} |
|
|
|
/** |
|
* The value that will be sent for the ``Authorization`` header. |
|
* |
|
* To set the credentials, use the ``setCredentials`` method. |
|
*/ |
|
get credentials(): null | string { |
|
return this.#creds || null; |
|
} |
|
|
|
/** |
|
* Sets an ``Authorization`` for %%username%% with %%password%%. |
|
*/ |
|
setCredentials(username: string, password: string): void { |
|
assertArgument(!username.match(/:/), "invalid basic authentication username", "username", "[REDACTED]"); |
|
this.#creds = `${ username }:${ password }`; |
|
} |
|
|
|
/** |
|
* Enable and request gzip-encoded responses. The response will |
|
* automatically be decompressed. //(default: true)// |
|
*/ |
|
get allowGzip(): boolean { |
|
return this.#gzip; |
|
} |
|
set allowGzip(value: boolean) { |
|
this.#gzip = !!value; |
|
} |
|
|
|
/** |
|
* Allow ``Authentication`` credentials to be sent over insecure |
|
* channels. //(default: false)// |
|
*/ |
|
get allowInsecureAuthentication(): boolean { |
|
return !!this.#allowInsecure; |
|
} |
|
set allowInsecureAuthentication(value: boolean) { |
|
this.#allowInsecure = !!value; |
|
} |
|
|
|
/** |
|
* The timeout (in milliseconds) to wait for a complete response. |
|
* //(default: 5 minutes)// |
|
*/ |
|
get timeout(): number { return this.#timeout; } |
|
set timeout(timeout: number) { |
|
assertArgument(timeout >= 0, "timeout must be non-zero", "timeout", timeout); |
|
this.#timeout = timeout; |
|
} |
|
|
|
/** |
|
* This function is called prior to each request, for example |
|
* during a redirection or retry in case of server throttling. |
|
* |
|
* This offers an opportunity to populate headers or update |
|
* content before sending a request. |
|
*/ |
|
get preflightFunc(): null | FetchPreflightFunc { |
|
return this.#preflight || null; |
|
} |
|
set preflightFunc(preflight: null | FetchPreflightFunc) { |
|
this.#preflight = preflight; |
|
} |
|
|
|
/** |
|
* This function is called after each response, offering an |
|
* opportunity to provide client-level throttling or updating |
|
* response data. |
|
* |
|
* Any error thrown in this causes the ``send()`` to throw. |
|
* |
|
* To schedule a retry attempt (assuming the maximum retry limit |
|
* has not been reached), use [[response.throwThrottleError]]. |
|
*/ |
|
get processFunc(): null | FetchProcessFunc { |
|
return this.#process || null; |
|
} |
|
set processFunc(process: null | FetchProcessFunc) { |
|
this.#process = process; |
|
} |
|
|
|
/** |
|
* This function is called on each retry attempt. |
|
*/ |
|
get retryFunc(): null | FetchRetryFunc { |
|
return this.#retry || null; |
|
} |
|
set retryFunc(retry: null | FetchRetryFunc) { |
|
this.#retry = retry; |
|
} |
|
|
|
/** |
|
* This function is called to fetch content from HTTP and |
|
* HTTPS URLs and is platform specific (e.g. nodejs vs |
|
* browsers). |
|
* |
|
* This is by default the currently registered global getUrl |
|
* function, which can be changed using [[registerGetUrl]]. |
|
* If this has been set, setting is to ``null`` will cause |
|
* this FetchRequest (and any future clones) to revert back to |
|
* using the currently registered global getUrl function. |
|
* |
|
* Setting this is generally not necessary, but may be useful |
|
* for developers that wish to intercept requests or to |
|
* configurege a proxy or other agent. |
|
*/ |
|
get getUrlFunc(): FetchGetUrlFunc { |
|
return this.#getUrlFunc || defaultGetUrlFunc; |
|
} |
|
set getUrlFunc(value: null | FetchGetUrlFunc) { |
|
this.#getUrlFunc = value; |
|
} |
|
|
|
/** |
|
* Create a new FetchRequest instance with default values. |
|
* |
|
* Once created, each property may be set before issuing a |
|
* ``.send()`` to make the request. |
|
*/ |
|
constructor(url: string) { |
|
this.#url = String(url); |
|
|
|
this.#allowInsecure = false; |
|
this.#gzip = true; |
|
this.#headers = { }; |
|
this.#method = ""; |
|
this.#timeout = 300000; |
|
|
|
this.#throttle = { |
|
slotInterval: SLOT_INTERVAL, |
|
maxAttempts: MAX_ATTEMPTS |
|
}; |
|
|
|
this.#getUrlFunc = null; |
|
} |
|
|
|
toString(): string { |
|
return `<FetchRequest method=${ JSON.stringify(this.method) } url=${ JSON.stringify(this.url) } headers=${ JSON.stringify(this.headers) } body=${ this.#body ? hexlify(this.#body): "null" }>`; |
|
} |
|
|
|
/** |
|
* Update the throttle parameters used to determine maximum |
|
* attempts and exponential-backoff properties. |
|
*/ |
|
setThrottleParams(params: FetchThrottleParams): void { |
|
if (params.slotInterval != null) { |
|
this.#throttle.slotInterval = params.slotInterval; |
|
} |
|
if (params.maxAttempts != null) { |
|
this.#throttle.maxAttempts = params.maxAttempts; |
|
} |
|
} |
|
|
|
async #send(attempt: number, expires: number, delay: number, _request: FetchRequest, _response: FetchResponse): Promise<FetchResponse> { |
|
if (attempt >= this.#throttle.maxAttempts) { |
|
return _response.makeServerError("exceeded maximum retry limit"); |
|
} |
|
|
|
assert(getTime() <= expires, "timeout", "TIMEOUT", { |
|
operation: "request.send", reason: "timeout", request: _request |
|
}); |
|
|
|
if (delay > 0) { await wait(delay); } |
|
|
|
let req = this.clone(); |
|
const scheme = (req.url.split(":")[0] || "").toLowerCase(); |
|
|
|
// Process any Gateways |
|
if (scheme in Gateways) { |
|
const result = await Gateways[scheme](req.url, checkSignal(_request.#signal)); |
|
if (result instanceof FetchResponse) { |
|
let response = result; |
|
|
|
if (this.processFunc) { |
|
checkSignal(_request.#signal); |
|
try { |
|
response = await this.processFunc(req, response); |
|
} catch (error: any) { |
|
|
|
// Something went wrong during processing; throw a 5xx server error |
|
if (error.throttle == null || typeof(error.stall) !== "number") { |
|
response.makeServerError("error in post-processing function", error).assertOk(); |
|
} |
|
|
|
// Ignore throttling |
|
} |
|
} |
|
|
|
return response; |
|
} |
|
req = result; |
|
} |
|
|
|
// We have a preflight function; update the request |
|
if (this.preflightFunc) { req = await this.preflightFunc(req); } |
|
|
|
const resp = await this.getUrlFunc(req, checkSignal(_request.#signal)); |
|
let response = new FetchResponse(resp.statusCode, resp.statusMessage, resp.headers, resp.body, _request); |
|
|
|
if (response.statusCode === 301 || response.statusCode === 302) { |
|
|
|
// Redirect |
|
try { |
|
const location = response.headers.location || ""; |
|
return req.redirect(location).#send(attempt + 1, expires, 0, _request, response); |
|
} catch (error) { } |
|
|
|
// Things won't get any better on another attempt; abort |
|
return response; |
|
|
|
} else if (response.statusCode === 429) { |
|
|
|
// Throttle |
|
if (this.retryFunc == null || (await this.retryFunc(req, response, attempt))) { |
|
const retryAfter = response.headers["retry-after"]; |
|
let delay = this.#throttle.slotInterval * Math.trunc(Math.random() * Math.pow(2, attempt)); |
|
if (typeof(retryAfter) === "string" && retryAfter.match(/^[1-9][0-9]*$/)) { |
|
delay = parseInt(retryAfter); |
|
} |
|
return req.clone().#send(attempt + 1, expires, delay, _request, response); |
|
} |
|
} |
|
|
|
if (this.processFunc) { |
|
checkSignal(_request.#signal); |
|
try { |
|
response = await this.processFunc(req, response); |
|
} catch (error: any) { |
|
|
|
// Something went wrong during processing; throw a 5xx server error |
|
if (error.throttle == null || typeof(error.stall) !== "number") { |
|
response.makeServerError("error in post-processing function", error).assertOk(); |
|
} |
|
|
|
// Throttle |
|
let delay = this.#throttle.slotInterval * Math.trunc(Math.random() * Math.pow(2, attempt));; |
|
if (error.stall >= 0) { delay = error.stall; } |
|
|
|
return req.clone().#send(attempt + 1, expires, delay, _request, response); |
|
} |
|
} |
|
|
|
return response; |
|
} |
|
|
|
/** |
|
* Resolves to the response by sending the request. |
|
*/ |
|
send(): Promise<FetchResponse> { |
|
assert(this.#signal == null, "request already sent", "UNSUPPORTED_OPERATION", { operation: "fetchRequest.send" }); |
|
this.#signal = new FetchCancelSignal(this); |
|
return this.#send(0, getTime() + this.timeout, 0, this, new FetchResponse(0, "", { }, null, this)); |
|
} |
|
|
|
/** |
|
* Cancels the inflight response, causing a ``CANCELLED`` |
|
* error to be rejected from the [[send]]. |
|
*/ |
|
cancel(): void { |
|
assert(this.#signal != null, "request has not been sent", "UNSUPPORTED_OPERATION", { operation: "fetchRequest.cancel" }); |
|
const signal = fetchSignals.get(this); |
|
if (!signal) { throw new Error("missing signal; should not happen"); } |
|
signal(); |
|
} |
|
|
|
/** |
|
* Returns a new [[FetchRequest]] that represents the redirection |
|
* to %%location%%. |
|
*/ |
|
redirect(location: string): FetchRequest { |
|
// Redirection; for now we only support absolute locations |
|
const current = this.url.split(":")[0].toLowerCase(); |
|
const target = location.split(":")[0].toLowerCase(); |
|
|
|
// Don't allow redirecting: |
|
// - non-GET requests |
|
// - downgrading the security (e.g. https => http) |
|
// - to non-HTTP (or non-HTTPS) protocols [this could be relaxed?] |
|
assert(this.method === "GET" && (current !== "https" || target !== "http") && location.match(/^https?:/), `unsupported redirect`, "UNSUPPORTED_OPERATION", { |
|
operation: `redirect(${ this.method } ${ JSON.stringify(this.url) } => ${ JSON.stringify(location) })` |
|
}); |
|
|
|
// Create a copy of this request, with a new URL |
|
const req = new FetchRequest(location); |
|
req.method = "GET"; |
|
req.allowGzip = this.allowGzip; |
|
req.timeout = this.timeout; |
|
req.#headers = Object.assign({ }, this.#headers); |
|
if (this.#body) { req.#body = new Uint8Array(this.#body); } |
|
req.#bodyType = this.#bodyType; |
|
|
|
// Do not forward credentials unless on the same domain; only absolute |
|
//req.allowInsecure = false; |
|
// paths are currently supported; may want a way to specify to forward? |
|
//setStore(req.#props, "creds", getStore(this.#pros, "creds")); |
|
|
|
return req; |
|
} |
|
|
|
/** |
|
* Create a new copy of this request. |
|
*/ |
|
clone(): FetchRequest { |
|
const clone = new FetchRequest(this.url); |
|
|
|
// Preserve "default method" (i.e. null) |
|
clone.#method = this.#method; |
|
|
|
// Preserve "default body" with type, copying the Uint8Array is present |
|
if (this.#body) { clone.#body = this.#body; } |
|
clone.#bodyType = this.#bodyType; |
|
|
|
// Preserve "default headers" |
|
clone.#headers = Object.assign({ }, this.#headers); |
|
|
|
// Credentials is readonly, so we copy internally |
|
clone.#creds = this.#creds; |
|
|
|
if (this.allowGzip) { clone.allowGzip = true; } |
|
|
|
clone.timeout = this.timeout; |
|
if (this.allowInsecureAuthentication) { clone.allowInsecureAuthentication = true; } |
|
|
|
clone.#preflight = this.#preflight; |
|
clone.#process = this.#process; |
|
clone.#retry = this.#retry; |
|
|
|
clone.#throttle = Object.assign({ }, this.#throttle); |
|
|
|
clone.#getUrlFunc = this.#getUrlFunc; |
|
|
|
return clone; |
|
} |
|
|
|
/** |
|
* Locks all static configuration for gateways and FetchGetUrlFunc |
|
* registration. |
|
*/ |
|
static lockConfig(): void { |
|
locked = true; |
|
} |
|
|
|
/** |
|
* Get the current Gateway function for %%scheme%%. |
|
*/ |
|
static getGateway(scheme: string): null | FetchGatewayFunc { |
|
return Gateways[scheme.toLowerCase()] || null; |
|
} |
|
|
|
/** |
|
* Use the %%func%% when fetching URIs using %%scheme%%. |
|
* |
|
* This method affects all requests globally. |
|
* |
|
* If [[lockConfig]] has been called, no change is made and this |
|
* throws. |
|
*/ |
|
static registerGateway(scheme: string, func: FetchGatewayFunc): void { |
|
scheme = scheme.toLowerCase(); |
|
if (scheme === "http" || scheme === "https") { |
|
throw new Error(`cannot intercept ${ scheme }; use registerGetUrl`); |
|
} |
|
if (locked) { throw new Error("gateways locked"); } |
|
Gateways[scheme] = func; |
|
} |
|
|
|
/** |
|
* Use %%getUrl%% when fetching URIs over HTTP and HTTPS requests. |
|
* |
|
* This method affects all requests globally. |
|
* |
|
* If [[lockConfig]] has been called, no change is made and this |
|
* throws. |
|
*/ |
|
static registerGetUrl(getUrl: FetchGetUrlFunc): void { |
|
if (locked) { throw new Error("gateways locked"); } |
|
defaultGetUrlFunc = getUrl; |
|
} |
|
|
|
/** |
|
* Creates a getUrl function that fetches content from HTTP and |
|
* HTTPS URLs. |
|
* |
|
* The available %%options%% are dependent on the platform |
|
* implementation of the default getUrl function. |
|
* |
|
* This is not generally something that is needed, but is useful |
|
* when trying to customize simple behaviour when fetching HTTP |
|
* content. |
|
*/ |
|
static createGetUrlFunc(options?: Record<string, any>): FetchGetUrlFunc { |
|
return createGetUrl(options); |
|
} |
|
|
|
/** |
|
* Creates a function that can "fetch" data URIs. |
|
* |
|
* Note that this is automatically done internally to support |
|
* data URIs, so it is not necessary to register it. |
|
* |
|
* This is not generally something that is needed, but may |
|
* be useful in a wrapper to perfom custom data URI functionality. |
|
*/ |
|
static createDataGateway(): FetchGatewayFunc { |
|
return dataGatewayFunc; |
|
} |
|
|
|
/** |
|
* Creates a function that will fetch IPFS (unvalidated) from |
|
* a custom gateway baseUrl. |
|
* |
|
* The default IPFS gateway used internally is |
|
* ``"https:/\/gateway.ipfs.io/ipfs/"``. |
|
*/ |
|
static createIpfsGatewayFunc(baseUrl: string): FetchGatewayFunc { |
|
return getIpfsGatewayFunc(baseUrl); |
|
} |
|
} |
|
|
|
|
|
interface ThrottleError extends Error { |
|
stall: number; |
|
throttle: true; |
|
}; |
|
|
|
/** |
|
* The response for a FetchRequest. |
|
*/ |
|
export class FetchResponse implements Iterable<[ key: string, value: string ]> { |
|
#statusCode: number; |
|
#statusMessage: string; |
|
#headers: Record<string, string>; |
|
#body: null | Readonly<Uint8Array>; |
|
#request: null | FetchRequest; |
|
|
|
#error: { error?: Error, message: string }; |
|
|
|
toString(): string { |
|
return `<FetchResponse status=${ this.statusCode } body=${ this.#body ? hexlify(this.#body): "null" }>`; |
|
} |
|
|
|
/** |
|
* The response status code. |
|
*/ |
|
get statusCode(): number { return this.#statusCode; } |
|
|
|
/** |
|
* The response status message. |
|
*/ |
|
get statusMessage(): string { return this.#statusMessage; } |
|
|
|
/** |
|
* The response headers. All keys are lower-case. |
|
*/ |
|
get headers(): Record<string, string> { return Object.assign({ }, this.#headers); } |
|
|
|
/** |
|
* The response body, or ``null`` if there was no body. |
|
*/ |
|
get body(): null | Readonly<Uint8Array> { |
|
return (this.#body == null) ? null: new Uint8Array(this.#body); |
|
} |
|
|
|
/** |
|
* The response body as a UTF-8 encoded string, or the empty |
|
* string (i.e. ``""``) if there was no body. |
|
* |
|
* An error is thrown if the body is invalid UTF-8 data. |
|
*/ |
|
get bodyText(): string { |
|
try { |
|
return (this.#body == null) ? "": toUtf8String(this.#body); |
|
} catch (error) { |
|
assert(false, "response body is not valid UTF-8 data", "UNSUPPORTED_OPERATION", { |
|
operation: "bodyText", info: { response: this } |
|
}); |
|
} |
|
} |
|
|
|
/** |
|
* The response body, decoded as JSON. |
|
* |
|
* An error is thrown if the body is invalid JSON-encoded data |
|
* or if there was no body. |
|
*/ |
|
get bodyJson(): any { |
|
try { |
|
return JSON.parse(this.bodyText); |
|
} catch (error) { |
|
assert(false, "response body is not valid JSON", "UNSUPPORTED_OPERATION", { |
|
operation: "bodyJson", info: { response: this } |
|
}); |
|
} |
|
} |
|
|
|
[Symbol.iterator](): Iterator<[ key: string, value: string ]> { |
|
const headers = this.headers; |
|
const keys = Object.keys(headers); |
|
let index = 0; |
|
return { |
|
next: () => { |
|
if (index < keys.length) { |
|
const key = keys[index++]; |
|
return { |
|
value: [ key, headers[key] ], done: false |
|
} |
|
} |
|
return { value: undefined, done: true }; |
|
} |
|
}; |
|
} |
|
|
|
constructor(statusCode: number, statusMessage: string, headers: Readonly<Record<string, string>>, body: null | Uint8Array, request?: FetchRequest) { |
|
this.#statusCode = statusCode; |
|
this.#statusMessage = statusMessage; |
|
this.#headers = Object.keys(headers).reduce((accum, k) => { |
|
accum[k.toLowerCase()] = String(headers[k]); |
|
return accum; |
|
}, <Record<string, string>>{ }); |
|
this.#body = ((body == null) ? null: new Uint8Array(body)); |
|
this.#request = (request || null); |
|
|
|
this.#error = { message: "" }; |
|
} |
|
|
|
/** |
|
* Return a Response with matching headers and body, but with |
|
* an error status code (i.e. 599) and %%message%% with an |
|
* optional %%error%%. |
|
*/ |
|
makeServerError(message?: string, error?: Error): FetchResponse { |
|
let statusMessage: string; |
|
if (!message) { |
|
message = `${ this.statusCode } ${ this.statusMessage }`; |
|
statusMessage = `CLIENT ESCALATED SERVER ERROR (${ message })`; |
|
} else { |
|
statusMessage = `CLIENT ESCALATED SERVER ERROR (${ this.statusCode } ${ this.statusMessage }; ${ message })`; |
|
} |
|
const response = new FetchResponse(599, statusMessage, this.headers, |
|
this.body, this.#request || undefined); |
|
response.#error = { message, error }; |
|
return response; |
|
} |
|
|
|
/** |
|
* If called within a [request.processFunc](FetchRequest-processFunc) |
|
* call, causes the request to retry as if throttled for %%stall%% |
|
* milliseconds. |
|
*/ |
|
throwThrottleError(message?: string, stall?: number): never { |
|
if (stall == null) { |
|
stall = -1; |
|
} else { |
|
assertArgument(Number.isInteger(stall) && stall >= 0, "invalid stall timeout", "stall", stall); |
|
} |
|
|
|
const error = new Error(message || "throttling requests"); |
|
|
|
defineProperties(<ThrottleError>error, { stall, throttle: true }); |
|
|
|
throw error; |
|
} |
|
|
|
/** |
|
* Get the header value for %%key%%, ignoring case. |
|
*/ |
|
getHeader(key: string): string { |
|
return this.headers[key.toLowerCase()]; |
|
} |
|
|
|
/** |
|
* Returns true if the response has a body. |
|
*/ |
|
hasBody(): this is (FetchResponse & { body: Uint8Array }) { |
|
return (this.#body != null); |
|
} |
|
|
|
/** |
|
* The request made for this response. |
|
*/ |
|
get request(): null | FetchRequest { return this.#request; } |
|
|
|
/** |
|
* Returns true if this response was a success statusCode. |
|
*/ |
|
ok(): boolean { |
|
return (this.#error.message === "" && this.statusCode >= 200 && this.statusCode < 300); |
|
} |
|
|
|
/** |
|
* Throws a ``SERVER_ERROR`` if this response is not ok. |
|
*/ |
|
assertOk(): void { |
|
if (this.ok()) { return; } |
|
let { message, error } = this.#error; |
|
if (message === "") { |
|
message = `server response ${ this.statusCode } ${ this.statusMessage }`; |
|
} |
|
|
|
let requestUrl: null | string = null; |
|
if (this.request) { requestUrl = this.request.url; } |
|
|
|
let responseBody: null | string = null; |
|
try { |
|
if (this.#body) { responseBody = toUtf8String(this.#body); } |
|
} catch (e) { } |
|
|
|
assert(false, message, "SERVER_ERROR", { |
|
request: (this.request || "unknown request"), response: this, error, |
|
info: { |
|
requestUrl, responseBody, |
|
responseStatus: `${ this.statusCode } ${ this.statusMessage }` } |
|
}); |
|
} |
|
} |
|
|
|
|
|
function getTime(): number { return (new Date()).getTime(); } |
|
|
|
function unpercent(value: string): Uint8Array { |
|
return toUtf8Bytes(value.replace(/%([0-9a-f][0-9a-f])/gi, (all, code) => { |
|
return String.fromCharCode(parseInt(code, 16)); |
|
})); |
|
} |
|
|
|
function wait(delay: number): Promise<void> { |
|
return new Promise((resolve) => setTimeout(resolve, delay)); |
|
}
|
|
|