UNPKG

arangojs

Version:

The official ArangoDB JavaScript driver.

730 lines 25.5 kB
import * as configuration from "./configuration.js"; import * as errors from "./errors.js"; import { ERROR_ARANGO_CONFLICT } from "./lib/codes.js"; import * as util from "./lib/util.js"; import { LinkedList } from "./lib/x3-linkedlist.js"; const MIME_JSON = /\/(json|javascript)(\W|$)/; const LEADER_ENDPOINT_HEADER = "x-arango-endpoint"; const REASON_TIMEOUT = "timeout"; /** * @internal * * Create a function for performing fetch requests against a given host. * * @param arangojsHostUrl - Base URL of the host, i.e. protocol, port and domain name. * @param options - Options to use for all fetch requests. */ function createHost(arangojsHostUrl, agentOptions) { const baseUrl = new URL(arangojsHostUrl); let fetch = globalThis.fetch; let createDispatcher; let dispatcher; let socketPath; if (arangojsHostUrl.match(/^\w+:\/\/unix:\//)) { socketPath = baseUrl.pathname; baseUrl.hostname = "localhost"; baseUrl.pathname = "/"; agentOptions = { ...agentOptions, connect: { ...agentOptions?.connect, socketPath, }, }; } if (agentOptions) { createDispatcher = async () => { let undici; try { // Prevent overzealous bundlers from attempting to bundle undici const undiciName = "undici"; undici = await import(undiciName); } catch (cause) { if (socketPath) { throw new Error("Undici is required for Unix domain sockets", { cause, }); } throw new Error("Undici is required when using config.agentOptions", { cause, }); } fetch = undici.fetch; return new undici.Agent(agentOptions); }; } const pending = new Map(); return { async fetch({ method, pathname, search, headers: requestHeaders, body, timeout, fetchOptions, beforeRequest, afterResponse, }) { const url = new URL(pathname + baseUrl.search, baseUrl); if (search) { const searchParams = search instanceof URLSearchParams ? search : new URLSearchParams(search); for (const [key, value] of searchParams) { url.searchParams.append(key, value); } } const headers = new Headers(requestHeaders); if (!headers.has("authorization")) { headers.set("authorization", `Basic ${btoa(`${baseUrl.username || "root"}:${baseUrl.password || ""}`)}`); } const abortController = new AbortController(); const signal = abortController.signal; if (createDispatcher) { dispatcher = await createDispatcher(); createDispatcher = undefined; } const request = new Request(url, { ...fetchOptions, dispatcher, method, headers, body, signal, }); if (beforeRequest) { const p = beforeRequest(request); if (p instanceof Promise) await p; } const requestId = util.generateRequestId(); pending.set(requestId, abortController); let clearTimer; if (timeout) { clearTimer = util.createTimer(timeout, () => { clearTimer = undefined; abortController.abort(REASON_TIMEOUT); }); } let response; try { response = Object.assign(await fetch(request), { request, arangojsHostUrl, }); if (fetchOptions?.redirect === "manual" && isRedirect(response)) { throw new errors.HttpError(response); } } catch (e) { const cause = e instanceof Error ? e : new Error(String(e)); let error; if (cause instanceof errors.NetworkError) { error = cause; } else if (signal.aborted) { const reason = typeof signal.reason == "string" ? signal.reason : undefined; if (reason === REASON_TIMEOUT) { error = new errors.ResponseTimeoutError(undefined, request, { cause, }); } else { error = new errors.RequestAbortedError(reason, request, { cause }); } } else if (cause instanceof TypeError) { error = new errors.FetchFailedError(undefined, request, { cause }); } else { error = new errors.NetworkError(cause.message, request, { cause }); } if (afterResponse) { const p = afterResponse(error); if (p instanceof Promise) await p; } throw error; } finally { clearTimer?.(); pending.delete(requestId); } if (afterResponse) { const p = afterResponse(null, response); if (p instanceof Promise) await p; } return response; }, close() { if (!pending.size) return; const controllers = [...pending.values()]; pending.clear(); for (const controller of controllers) { try { controller.abort(); } catch (e) { // noop } } }, }; } //#endregion //#region Response types const STATUS_CODE_DEFAULT_MESSAGES = { 0: "Network Error", 300: "Multiple Choices", 301: "Moved Permanently", 302: "Found", 303: "See Other", 304: "Not Modified", 307: "Temporary Redirect", 308: "Permanent Redirect", 400: "Bad Request", 401: "Unauthorized", 402: "Payment Required", 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed", 406: "Not Acceptable", 407: "Proxy Authentication Required", 408: "Request Timeout", 409: "Conflict", 410: "Gone", 411: "Length Required", 412: "Precondition Failed", 413: "Payload Too Large", 414: "Request-URI Too Long", 415: "Unsupported Media Type", 416: "Requested Range Not Satisfiable", 417: "Expectation Failed", 418: "I'm a teapot", 421: "Misdirected Request", 422: "Unprocessable Entity", 423: "Locked", 424: "Failed Dependency", 426: "Upgrade Required", 428: "Precondition Required", 429: "Too Many Requests", 431: "Request Header Fields Too Large", 444: "Connection Closed Without Response", 451: "Unavailable For Legal Reasons", 499: "Client Closed Request", 500: "Internal Server Error", 501: "Not Implemented", 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout", 505: "HTTP Version Not Supported", 506: "Variant Also Negotiates", 507: "Insufficient Storage", 508: "Loop Detected", 510: "Not Extended", 511: "Network Authentication Required", 599: "Network Connect Timeout Error", }; const KNOWN_STATUS_CODES = Object.keys(STATUS_CODE_DEFAULT_MESSAGES).map((k) => Number(k)); const REDIRECT_CODES = [301, 302, 303, 307, 308]; /** * @internal * * Indicates whether the given status code can be translated to a known status * message. */ function isKnownStatusCode(code) { return KNOWN_STATUS_CODES.includes(code); } /** * @internal * * Indicates whether the given status code represents a redirect. */ function isRedirect(response) { return REDIRECT_CODES.includes(response.status); } /** * Returns the status message for the given response's status code or the * status text of the response. */ export function getStatusMessage(response) { if (isKnownStatusCode(response.status)) { return STATUS_CODE_DEFAULT_MESSAGES[response.status]; } if (response.statusText) return response.statusText; return "Unknown response status"; } /** * Indicates whether the given value represents an ArangoDB error response. */ export function isArangoErrorResponse(body) { if (!body || typeof body !== "object") return false; const obj = body; return (obj.error === true && typeof obj.errorMessage === "string" && typeof obj.errorNum === "number" && (obj.code === undefined || typeof obj.code === "number")); } /** * Indicates whether the given value represents a {@link Connection}. * * @param connection - A value that might be a connection. * * @internal */ export function isArangoConnection(connection) { return Boolean(connection && connection.isArangoConnection); } /** * Represents a connection pool shared by one or more databases. * * @internal */ export class Connection { _activeTasks = 0; _arangoVersion; _loadBalancingStrategy; _taskPoolSize; _commonRequestOptions; _commonFetchOptions; _queue = new LinkedList(); _databases = new Map(); _hosts = []; _hostUrls = []; _activeHostUrl; _activeDirtyHostUrl; _transactionId = null; _onError; _precaptureStackTraces; _queueTimes = new LinkedList(); _responseQueueTimeSamples; /** * @internal * * Creates a new `Connection` instance. * * @param config - An object with configuration options. * */ constructor(config = {}) { const { url = "http://127.0.0.1:8529", auth, arangoVersion = 31100, loadBalancingStrategy = "NONE", maxRetries = 0, poolSize = 3 * (loadBalancingStrategy === "ROUND_ROBIN" && Array.isArray(url) ? url.length : 1), fetchOptions: { headers, ...commonFetchOptions } = {}, onError, precaptureStackTraces = false, responseQueueTimeSamples = 10, ...commonRequestOptions } = config; const URLS = Array.isArray(url) ? url : [url]; this._loadBalancingStrategy = loadBalancingStrategy; this._precaptureStackTraces = precaptureStackTraces; this._responseQueueTimeSamples = responseQueueTimeSamples < 0 ? Infinity : responseQueueTimeSamples; this._arangoVersion = arangoVersion; this._taskPoolSize = poolSize; this._onError = onError; this._commonRequestOptions = commonRequestOptions; this._commonFetchOptions = { headers: new Headers(headers), ...commonFetchOptions, }; this._commonFetchOptions.headers.set("x-arango-version", String(arangoVersion)); this._commonFetchOptions.headers.set("x-arango-driver", `arangojs/10.1.1 (cloud)`); this.addToHostList(URLS); if (auth) { if (configuration.isBearerAuth(auth)) { this.setBearerAuth(auth); } else { this.setBasicAuth(auth); } } if (this._loadBalancingStrategy === "ONE_RANDOM") { this._activeHostUrl = this._hostUrls[Math.floor(Math.random() * this._hostUrls.length)]; this._activeDirtyHostUrl = this._hostUrls[Math.floor(Math.random() * this._hostUrls.length)]; } else { this._activeHostUrl = this._hostUrls[0]; this._activeDirtyHostUrl = this._hostUrls[0]; } } /** * @internal * * Indicates that this object represents an ArangoDB connection. */ get isArangoConnection() { return true; } get queueTime() { return { getLatest: () => this._queueTimes.last?.value[1], getValues: () => Array.from(this._queueTimes.values()), getAvg: () => { let avg = 0; for (const [, [, value]] of this._queueTimes) { avg += value / this._queueTimes.length; } return avg; }, }; } async _runQueue() { if (this._activeTasks >= this._taskPoolSize) return; const task = this._queue.shift(); if (!task) return; let hostUrl = this._activeHostUrl; try { this._activeTasks += 1; if (task.options.hostUrl !== undefined) { hostUrl = task.options.hostUrl; } else if (task.options.allowDirtyRead) { hostUrl = this._activeDirtyHostUrl; const i = this._hostUrls.indexOf(this._activeDirtyHostUrl) + 1; this._activeDirtyHostUrl = this._hostUrls[i % this._hostUrls.length]; } else if (this._loadBalancingStrategy === "ROUND_ROBIN") { const i = this._hostUrls.indexOf(this._activeHostUrl) + 1; this._activeHostUrl = this._hostUrls[i % this._hostUrls.length]; } const host = this._hosts[this._hostUrls.indexOf(hostUrl)]; const res = Object.assign(await host.fetch(task.options), { arangojsHostUrl: hostUrl, }); const leaderEndpoint = res.headers.get(LEADER_ENDPOINT_HEADER); if (res.status === 503 && leaderEndpoint) { const [cleanUrl] = this.addToHostList(leaderEndpoint); task.options.hostUrl = cleanUrl; if (this._activeHostUrl === hostUrl) { this._activeHostUrl = cleanUrl; } this._queue.push(task); return; } const queueTime = res.headers.get("x-arango-queue-time-seconds"); if (queueTime) { this._queueTimes.push([Date.now(), Number(queueTime)]); while (this._responseQueueTimeSamples < this._queueTimes.length) { this._queueTimes.shift(); } } const contentType = res.headers.get("content-type"); if (res.status >= 400) { if (contentType?.match(MIME_JSON)) { const errorResponse = res.clone(); let errorBody; try { errorBody = await errorResponse.json(); } catch { // noop } if (isArangoErrorResponse(errorBody)) { res.parsedBody = errorBody; throw errors.ArangoError.from(res); } } throw new errors.HttpError(res); } if (res.body) { if (task.options.expectBinary) { res.parsedBody = await res.blob(); } else if (contentType?.match(MIME_JSON)) { res.parsedBody = await res.json(); } else { res.parsedBody = await res.text(); } } let result = res; if (task.transform) result = task.transform(res); task.resolve(result); } catch (e) { const err = e; if (!task.options.allowDirtyRead && this._hosts.length > 1 && this._activeHostUrl === hostUrl && this._loadBalancingStrategy !== "ROUND_ROBIN") { const i = this._hostUrls.indexOf(this._activeHostUrl) + 1; this._activeHostUrl = this._hostUrls[i % this._hostUrls.length]; } if (errors.isArangoError(err) && err.errorNum === ERROR_ARANGO_CONFLICT && task.options.retryOnConflict && task.conflicts < task.options.retryOnConflict) { task.conflicts += 1; this._queue.push(task); return; } if ((errors.isNetworkError(err) || errors.isArangoError(err)) && err.isSafeToRetry && task.options.hostUrl === undefined && this._commonRequestOptions.maxRetries !== false && task.retries < (this._commonRequestOptions.maxRetries || this._hosts.length - 1)) { task.retries += 1; this._queue.push(task); return; } if (task.stack) { err.stack += task.stack(); } if (this._onError) { try { const p = this._onError(err); if (p instanceof Promise) await p; } catch (e) { e.cause = err; task.reject(e); return; } } task.reject(err); } finally { this._activeTasks -= 1; setTimeout(() => this._runQueue(), 0); } } setBearerAuth(auth) { this.setHeader("authorization", `Bearer ${auth.token}`); } setBasicAuth(auth) { this.setHeader("authorization", `Basic ${btoa(`${auth.username}:${auth.password}`)}`); } setResponseQueueTimeSamples(responseQueueTimeSamples) { if (responseQueueTimeSamples < 0) { responseQueueTimeSamples = Infinity; } this._responseQueueTimeSamples = responseQueueTimeSamples; while (this._responseQueueTimeSamples < this._queueTimes.length) { this._queueTimes.shift(); } } database(databaseName, database) { if (database === null) { this._databases.delete(databaseName); return undefined; } if (!database) { return this._databases.get(databaseName); } this._databases.set(databaseName, database); return database; } /** * @internal * * Replaces the host list with the given URLs. * * See {@link Connection#acquireHostList}. * * @param urls - URLs to use as host list. */ setHostList(urls) { const cleanUrls = urls.map((url) => util.normalizeUrl(url)); this._hosts.splice(0, this._hosts.length, ...cleanUrls.map((url) => { const i = this._hostUrls.indexOf(url); if (i !== -1) return this._hosts[i]; return createHost(url); })); this._hostUrls.splice(0, this._hostUrls.length, ...cleanUrls); } /** * @internal * * Adds the given URL or URLs to the host list. * * See {@link Connection#acquireHostList}. * * @param urls - URL or URLs to add. */ addToHostList(urls) { const cleanUrls = (Array.isArray(urls) ? urls : [urls]).map((url) => util.normalizeUrl(url)); const newUrls = cleanUrls.filter((url) => this._hostUrls.indexOf(url) === -1); this._hostUrls.push(...newUrls); this._hosts.push(...newUrls.map((url) => createHost(url))); return cleanUrls; } /** * @internal * * Sets the connection's active `transactionId`. * * While set, all requests will use this ID, ensuring the requests are executed * within the transaction if possible. Setting the ID manually may cause * unexpected behavior. * * See also {@link Connection#clearTransactionId}. * * @param transactionId - ID of the active transaction. */ setTransactionId(transactionId) { this._transactionId = transactionId; } /** * @internal * * Clears the connection's active `transactionId`. */ clearTransactionId() { this._transactionId = null; } /** * @internal * * Sets the header `headerName` with the given `value` or clears the header if * `value` is `null`. * * @param headerName - Name of the header to set. * @param value - Value of the header. */ setHeader(headerName, value) { if (value === null) { this._commonFetchOptions.headers.delete(headerName); } else { this._commonFetchOptions.headers.set(headerName, value); } } /** * @internal * * Closes all open connections. * * See {@link databases.Database#close}. */ close() { for (const host of this._hosts) { if (host.close) host.close(); } } /** * @internal * * Waits for propagation. * * See {@link databases.Database#waitForPropagation}. * * @param request - Request to perform against each coordinator. * @param timeout - Maximum number of milliseconds to wait for propagation. */ async waitForPropagation(request, timeout = Infinity) { const numHosts = this._hosts.length; const propagated = []; const started = Date.now(); const endOfTime = started + timeout; let index = 0; while (true) { if (propagated.length === numHosts) { return; } while (propagated.includes(this._hostUrls[index])) { index = (index + 1) % numHosts; } const hostUrl = this._hostUrls[index]; try { await this.request({ ...request, hostUrl, timeout: endOfTime - Date.now(), }); } catch (e) { if (endOfTime < Date.now()) { throw new errors.PropagationTimeoutError(undefined, { cause: e, }); } await new Promise((resolve) => setTimeout(resolve, 1000)); continue; } if (!propagated.includes(hostUrl)) { propagated.push(hostUrl); } } } /** * @internal * * Performs a request using the arangojs connection pool. */ async request(requestOptions, transform) { const { hostUrl, allowDirtyRead = false, isBinary = false, maxRetries = 0, method = "GET", retryOnConflict = 0, timeout = 0, headers: requestHeaders, body: requestBody, fetchOptions, ...taskOptions } = { ...this._commonRequestOptions, ...requestOptions }; const headers = util.mergeHeaders(this._commonFetchOptions.headers, requestHeaders); let body = requestBody; if (body instanceof FormData) { const res = new Response(body); const blob = await res.blob(); // Workaround for ArangoDB 3.12.0-rc1 and earlier: // Omitting the final CRLF results in "bad request body" fatal error body = new Blob([blob, "\r\n"], { type: blob.type }); } else if (body) { let contentType; if (isBinary) { contentType = "application/octet-stream"; } else if (typeof body === "object") { body = JSON.stringify(body); contentType = "application/json"; } else { body = String(body); contentType = "text/plain"; } if (!headers.has("content-type")) { headers.set("content-type", contentType); } } if (this._transactionId) { headers.set("x-arango-trx-id", this._transactionId); } if (allowDirtyRead) { headers.set("x-arango-allow-dirty-read", "true"); } return new Promise((resolve, reject) => { const task = { resolve, reject, transform, retries: 0, conflicts: 0, options: { ...taskOptions, hostUrl, method, headers, body, allowDirtyRead, retryOnConflict, maxRetries, fetchOptions, timeout, }, }; if (this._precaptureStackTraces) { if (typeof Error.captureStackTrace === "function") { const capture = {}; Error.captureStackTrace(capture); task.stack = () => `\n${capture.stack.split("\n").slice(3).join("\n")}`; } else { const capture = util.generateStackTrace(); if (Object.prototype.hasOwnProperty.call(capture, "stack")) { task.stack = () => `\n${capture.stack.split("\n").slice(4).join("\n")}`; } } } this._queue.push(task); this._runQueue(); }); } } //#endregion //# sourceMappingURL=connection.js.map