UNPKG

replicate

Version:
477 lines (431 loc) 15.7 kB
const ApiError = require("./error"); const { create: createFile } = require("./files"); /** * @see {@link validateWebhook} * @overload * @param {object} requestData - The request data * @param {string} requestData.id - The webhook ID header from the incoming request. * @param {string} requestData.timestamp - The webhook timestamp header from the incoming request. * @param {string} requestData.body - The raw body of the incoming webhook request. * @param {string} requestData.secret - The webhook secret, obtained from `replicate.webhooks.defaul.secret` method. * @param {string} requestData.signature - The webhook signature header from the incoming request, comprising one or more space-delimited signatures. * @param {Crypto} [crypto] - An optional `Crypto` implementation that conforms to the [browser Crypto interface](https://developer.mozilla.org/en-US/docs/Web/API/Window/crypto) */ /** * @see {@link validateWebhook} * @overload * @param {object} requestData - The request object * @param {object} requestData.headers - The request headers * @param {string} requestData.headers["webhook-id"] - The webhook ID header from the incoming request * @param {string} requestData.headers["webhook-timestamp"] - The webhook timestamp header from the incoming request * @param {string} requestData.headers["webhook-signature"] - The webhook signature header from the incoming request, comprising one or more space-delimited signatures * @param {string} requestData.body - The raw body of the incoming webhook request * @param {string} secret - The webhook secret, obtained from `replicate.webhooks.defaul.secret` method * @param {Crypto} [crypto] - An optional `Crypto` implementation that conforms to the [browser Crypto interface](https://developer.mozilla.org/en-US/docs/Web/API/Window/crypto) */ /** * Validate a webhook signature * * @returns {Promise<boolean>} - True if the signature is valid * @throws {Error} - If the request is missing required headers, body, or secret */ async function validateWebhook(requestData, secretOrCrypto, customCrypto) { let id; let body; let timestamp; let signature; let secret; let crypto = globalThis.crypto; if (requestData && requestData.headers && requestData.body) { if (typeof requestData.headers.get === "function") { // Headers object (e.g. Fetch API Headers) id = requestData.headers.get("webhook-id"); timestamp = requestData.headers.get("webhook-timestamp"); signature = requestData.headers.get("webhook-signature"); } else { // Plain object with header key-value pairs id = requestData.headers["webhook-id"]; timestamp = requestData.headers["webhook-timestamp"]; signature = requestData.headers["webhook-signature"]; } body = requestData.body; if (typeof secretOrCrypto !== "string") { throw new Error( "Unexpected value for secret passed to validateWebhook, expected a string" ); } secret = secretOrCrypto; if (customCrypto) { crypto = customCrypto; } } else { id = requestData.id; body = requestData.body; timestamp = requestData.timestamp; signature = requestData.signature; secret = requestData.secret; if (secretOrCrypto) { crypto = secretOrCrypto; } } if (body instanceof ReadableStream || body.readable) { try { body = await new Response(body).text(); } catch (err) { throw new Error(`Error reading body: ${err.message}`); } } else if (isTypedArray(body)) { body = await new Blob([body]).text(); } else if (typeof body === "object") { body = JSON.stringify(body); } else if (typeof body !== "string") { throw new Error("Invalid body type"); } if (!id || !timestamp || !signature) { throw new Error("Missing required webhook headers"); } if (!body) { throw new Error("Missing required body"); } if (!secret) { throw new Error("Missing required secret"); } if (!crypto) { throw new Error( 'Missing `crypto` implementation. If using Node 18 pass in require("node:crypto").webcrypto' ); } const signedContent = `${id}.${timestamp}.${body}`; const computedSignature = await createHMACSHA256( secret.split("_").pop(), signedContent, crypto ); const expectedSignatures = signature .split(" ") .map((sig) => sig.split(",")[1]); return expectedSignatures.some( (expectedSignature) => expectedSignature === computedSignature ); } /** * @param {string} secret - base64 encoded string * @param {string} data - text body of request * @param {Crypto} crypto - an implementation of the web Crypto api */ async function createHMACSHA256(secret, data, crypto) { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( "raw", base64ToBytes(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"] ); const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data)); return bytesToBase64(signature); } /** * Convert a base64 encoded string into bytes. * * @param {string} the base64 encoded string * @return {Uint8Array} * * Two functions for encoding/decoding base64 strings using web standards. Not * intended to be used to encode/decode arbitrary string data. * See: https://developer.mozilla.org/en-US/docs/Glossary/Base64#javascript_support * See: https://stackoverflow.com/a/31621532 * * Performance might take a hit because of the conversion to string and then to binary, * if this is the case we might want to look at an alternative solution. * See: https://jsben.ch/wnaZC */ function base64ToBytes(base64) { return Uint8Array.from(atob(base64), (m) => m.codePointAt(0)); } /** * Convert a base64 encoded string into bytes. * * See {@link base64ToBytes} for caveats. * * @param {Uint8Array | ArrayBuffer} the base64 encoded string * @return {string} */ function bytesToBase64(bytes) { return btoa(String.fromCharCode.apply(null, new Uint8Array(bytes))); } /** * Automatically retry a request if it fails with an appropriate status code. * * A GET request is retried if it fails with a 429 or 5xx status code. * A non-GET request is retried only if it fails with a 429 status code. * * If the response sets a Retry-After header, * the request is retried after the number of seconds specified in the header. * Otherwise, the request is retried after the specified interval, * with exponential backoff and jitter. * * @param {Function} request - A function that returns a Promise that resolves with a Response object * @param {object} options * @param {Function} [options.shouldRetry] - A function that returns true if the request should be retried * @param {number} [options.maxRetries] - Maximum number of retries. Defaults to 5 * @param {number} [options.interval] - Interval between retries in milliseconds. Defaults to 500 * @returns {Promise<Response>} - Resolves with the response object * @throws {ApiError} If the request failed */ async function withAutomaticRetries(request, options = {}) { const shouldRetry = options.shouldRetry || (() => false); const maxRetries = options.maxRetries || 5; const interval = options.interval || 500; const jitter = options.jitter || 100; // eslint-disable-next-line no-promise-executor-return const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); let attempts = 0; do { let delay = interval * 2 ** attempts + Math.random() * jitter; /* eslint-disable no-await-in-loop */ try { const response = await request(); if (response.ok || !shouldRetry(response)) { return response; } } catch (error) { if (error instanceof ApiError) { const retryAfter = error.response.headers.get("Retry-After"); if (retryAfter) { if (!Number.isInteger(retryAfter)) { // Retry-After is a date const date = new Date(retryAfter); if (!Number.isNaN(date.getTime())) { delay = date.getTime() - new Date().getTime(); } } else { // Retry-After is a number of seconds delay = retryAfter * 1000; } } } } if (Number.isInteger(maxRetries) && maxRetries > 0) { if (Number.isInteger(delay) && delay > 0) { await sleep(interval * 2 ** (options.maxRetries - maxRetries)); } attempts += 1; } } while (attempts < maxRetries); return request(); } /** * Walks the inputs and, for any File or Blob, tries to upload it to Replicate * and replaces the input with the URL of the uploaded file. * * @param {Replicate} client - The client used to upload the file * @param {object} inputs - The inputs to transform * @param {"default" | "upload" | "data-uri"} strategy - Whether to upload files to Replicate, encode as dataURIs or try both. * @returns {Promise<object>} - The transformed inputs * @throws {ApiError} If the request to upload the file fails */ async function transformFileInputs(client, inputs, strategy) { switch (strategy) { case "data-uri": return await transformFileInputsToBase64EncodedDataURIs(client, inputs); case "upload": return await transformFileInputsToReplicateFileURLs(client, inputs); case "default": try { return await transformFileInputsToReplicateFileURLs(client, inputs); } catch (error) { if ( error instanceof ApiError && error.response.status >= 400 && error.response.status < 500 ) { throw error; } return await transformFileInputsToBase64EncodedDataURIs(inputs); } default: throw new Error(`Unexpected file upload strategy: ${strategy}`); } } /** * Walks the inputs and, for any File or Blob, tries to upload it to Replicate * and replaces the input with the URL of the uploaded file. * * @param {Replicate} client - The client used to upload the file * @param {object} inputs - The inputs to transform * @returns {Promise<object>} - The transformed inputs * @throws {ApiError} If the request to upload the file fails */ async function transformFileInputsToReplicateFileURLs(client, inputs) { return await transform(inputs, async (value) => { if (value instanceof Blob || value instanceof Buffer) { const file = await createFile.call(client, value); return file.urls.get; } return value; }); } const MAX_DATA_URI_SIZE = 10_000_000; /** * Walks the inputs and transforms any binary data found into a * base64-encoded data URI. * * @param {object} inputs - The inputs to transform * @returns {Promise<object>} - The transformed inputs * @throws {Error} If the size of inputs exceeds a given threshold set by MAX_DATA_URI_SIZE */ async function transformFileInputsToBase64EncodedDataURIs(inputs) { let totalBytes = 0; return await transform(inputs, async (value) => { let buffer; let mime; if (value instanceof Blob) { // Currently, we use a NodeJS only API for base64 encoding, as // we move to support the browser we could support either using // btoa (which does string encoding), the FileReader API or // a JavaScript implementation like base64-js. // See: https://developer.mozilla.org/en-US/docs/Glossary/Base64 // See: https://github.com/beatgammit/base64-js buffer = await value.arrayBuffer(); mime = value.type; } else if (isTypedArray(value)) { buffer = value; } else { return value; } totalBytes += buffer.byteLength; if (totalBytes > MAX_DATA_URI_SIZE) { throw new Error( `Combined filesize of prediction ${totalBytes} bytes exceeds 10mb limit for inline encoding, please provide URLs instead` ); } const data = bytesToBase64(buffer); mime = mime || "application/octet-stream"; return `data:${mime};base64,${data}`; }); } // Walk a JavaScript object and transform the leaf values. async function transform(value, mapper) { if (Array.isArray(value)) { const copy = []; for (const val of value) { const transformed = await transform(val, mapper); copy.push(transformed); } return copy; } if (isPlainObject(value)) { const copy = {}; for (const key of Object.keys(value)) { copy[key] = await transform(value[key], mapper); } return copy; } return await mapper(value); } function isTypedArray(arr) { return ( arr instanceof Int8Array || arr instanceof Int16Array || arr instanceof Int32Array || arr instanceof Uint8Array || arr instanceof Uint8ClampedArray || arr instanceof Uint16Array || arr instanceof Uint32Array || arr instanceof Float32Array || arr instanceof Float64Array ); } // Test for a plain JS object. // Source: lodash.isPlainObject function isPlainObject(value) { const isObjectLike = typeof value === "object" && value !== null; if (!isObjectLike || String(value) !== "[object Object]") { return false; } const proto = Object.getPrototypeOf(value); if (proto === null) { return true; } const Ctor = Object.prototype.hasOwnProperty.call(proto, "constructor") && proto.constructor; return ( typeof Ctor === "function" && Ctor instanceof Ctor && Function.prototype.toString.call(Ctor) === Function.prototype.toString.call(Object) ); } /** * Parse progress from prediction logs. * * This function supports log statements in the following format, * which are generated by https://github.com/tqdm/tqdm and similar libraries: * * ``` * 76%|████████████████████████████ | 7568/10000 [00:33<00:10, 229.00it/s] * ``` * * @example * const progress = parseProgressFromLogs("76%|████████████████████████████ | 7568/10000 [00:33<00:10, 229.00it/s]"); * console.log(progress); * // { * // percentage: 0.76, * // current: 7568, * // total: 10000, * // } * * @param {object|string} input - A prediction object or string. * @returns {(object|null)} - An object with the percentage, current, and total, or null if no progress can be parsed. */ function parseProgressFromLogs(input) { const logs = typeof input === "object" && input.logs ? input.logs : input; if (!logs || typeof logs !== "string") { return null; } const pattern = /^\s*(\d+)%\s*\|.+?\|\s*(\d+)\/(\d+)/; const lines = logs.split("\n").reverse(); for (const line of lines) { const matches = line.match(pattern); if (matches && matches.length === 4) { return { percentage: parseInt(matches[1], 10) / 100, current: parseInt(matches[2], 10), total: parseInt(matches[3], 10), }; } } return null; } /** * Helper to make any `ReadableStream` iterable, this is supported * by most server runtimes but browsers still haven't implemented * it yet. * See: https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream#browser_compatibility * * @template T * @param {ReadableStream<T>} stream an instance of a `ReadableStream` * @yields {T} a chunk/event from the stream */ async function* streamAsyncIterator(stream) { const reader = stream.getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) return; yield value; } } finally { reader.releaseLock(); } } module.exports = { transform, transformFileInputs, validateWebhook, withAutomaticRetries, parseProgressFromLogs, streamAsyncIterator, };