UNPKG

@apify/utilities

Version:

Tools and constants shared across Apify projects.

1 lines 102 kB
{"version":3,"sources":["../../src/index.ts","../../src/utilities.ts","../../src/utilities.client.ts","../../src/exponential_backoff.ts","../../src/health_checker.ts","../../src/parse_jsonl_stream.ts","../../src/streams_utilities.ts","../../src/webhook_payload_template.ts","../../src/crypto.ts","../../src/url_params_utils.ts","../../src/code_hash_manager.ts","../../src/hmac.ts","../../src/storages.ts"],"sourcesContent":["export * from './utilities';\nexport * from './utilities.client';\nexport * from './exponential_backoff';\nexport * from './health_checker';\nexport * from './parse_jsonl_stream';\nexport * from './streams_utilities';\nexport * from './webhook_payload_template';\nexport * from './crypto';\nexport * from './url_params_utils';\nexport * from './code_hash_manager';\nexport * from './hmac';\nexport * from './storages';\n","/*!\n * This module contains various server utility and helper functions.\n * Note that it automatically exports functions from utilities.client.js\n *\n * Author: Jan Curn (jan@apify.com)\n * Copyright(c) 2015 Apify. All rights reserved.\n *\n */\n\nimport crypto from 'node:crypto';\n\nimport { ANONYMOUS_USERNAME, APIFY_ID_REGEX } from '@apify/consts';\nimport type { Log } from '@apify/log';\nimport log, { LoggerJson, LogLevel } from '@apify/log';\n\n/**\n * Generates a random cryptographically strong string consisting of 17 alphanumeric characters.\n * This string is similar to MongoDB ObjectIds generated by Meteor.\n */\nexport function cryptoRandomObjectId(length = 17): string {\n const chars = 'abcdefghijklmnopqrstuvwxyzABCEDFGHIJKLMNOPQRSTUVWXYZ0123456789';\n const bytes = crypto.randomBytes(length);\n let str = '';\n // eslint-disable-next-line\n for (let i = bytes.length - 1; i >= 0; i--) { str += chars[(bytes[i] | 0) % chars.length]; }\n return str;\n}\n\n/**\n * Generates unique, deterministic record ID from the provided key with given length (defaults to 17).\n */\nexport function deterministicUniqueId(key: string, length = 17): string {\n return crypto\n .createHash('sha256')\n .update(key)\n .digest('base64')\n .replace(/(\\+|\\/|=)/g, 'x')\n .substr(0, length);\n}\n\n/**\n * Returns a random integer between 0 and max (excluded, unless it is also 0).\n * @param {number} maxExcluded\n * @returns {number}\n */\nexport function getRandomInt(maxExcluded: number) {\n maxExcluded = Math.floor(maxExcluded);\n return Math.floor(Math.random() * maxExcluded);\n}\n\n/**\n * If 'date' is a String, this function converts and returns it as a Date object.\n * Otherwise, the function returns the original 'date' argument.\n * This function is useful to convert dates transfered via JSON which doesn't natively support dates.\n */\nexport function parseDateFromJson(date: string | Date) {\n if (typeof date === 'string') { return new Date(Date.parse(date)); }\n return date;\n}\n\n/**\n * Returns a Promise object that will wait a specific number of milliseconds.\n * @param {number} millis Time to wait. If the value is not larger than zero, the promise resolves immediately.\n */\nexport async function delayPromise(millis: number): Promise<void> {\n return new Promise(((resolve) => {\n if (millis > 0) {\n setTimeout(() => resolve(), millis);\n } else {\n resolve();\n }\n }));\n}\n\n/**\n * Removes an element from an array.\n */\nexport function removeFromArray<T>(array: T[], element: T) {\n const index = array.indexOf(element);\n if (index >= 0) {\n array.splice(index, 1);\n return true;\n }\n return false;\n}\n\ninterface RequestLike {\n url: string;\n}\n\ninterface ResponseLike {\n status: (code: number) => void;\n send: (payload: string) => void;\n headersSent?: boolean;\n}\n\n/**\n * A default route for HTTP 404 error page for API endpoints.\n */\nexport function http404Route(req: RequestLike, res: ResponseLike) {\n res.status(404);\n res.send('Page not found');\n}\n\n/**\n * Default error handler of Express API endpoints.\n */\nexport function expressErrorHandler(err: Error, req: RequestLike, res: ResponseLike, next: (...a: unknown[]) => unknown) {\n log.warning('Client HTTP request failed', { url: req.url, errMsg: err.message });\n if (res.headersSent) {\n next(err);\n return;\n }\n res.status(505);\n res.send('Internal server error');\n}\n\nexport type BetterIntervalID = { _betterClearInterval: () => void };\n\n/**\n * Similar to setInterval() but with two important differences:\n * First, it assumes the function is asynchronous and only schedules its next invocation AFTER the asynchronous function finished.\n * Second, it invokes the function immediately.\n * @param func Function to be periodically executed.\n * For backwards compatibility reasons, it is passed a callback as its first argument during invocation, however that callback has no effect.\n * @param delay The number of milliseconds to wait to next invocation of the function after the current invocation finishes.\n * @returns Object that can be passed to betterClearInterval()\n */\nexport function betterSetInterval(func: ((a: (...args: unknown[]) => unknown) => void) | ((...args: unknown[]) => unknown), delay: number): BetterIntervalID {\n let scheduleNextRun: () => void;\n let timeoutId: NodeJS.Timeout;\n let isRunning = true;\n\n const funcWrapper = function () {\n // Historically, the function was passed a callback that it needed to call to signal it was done.\n // We keep passing this callback for backwards compatibility, but it has no effect anymore.\n void new Promise((resolve) => {\n resolve(func(() => undefined));\n }).finally(scheduleNextRun);\n };\n scheduleNextRun = function () {\n if (isRunning) timeoutId = setTimeout(funcWrapper, delay);\n };\n funcWrapper();\n\n return {\n _betterClearInterval() {\n isRunning = false;\n clearTimeout(timeoutId);\n },\n };\n}\n\nexport function betterClearInterval(intervalID: BetterIntervalID) {\n // eslint-disable-next-line no-underscore-dangle\n if (intervalID && intervalID._betterClearInterval) {\n try {\n // eslint-disable-next-line no-underscore-dangle\n intervalID._betterClearInterval();\n } catch (e) {\n log.exception(e as Error, '_betterClearInterval() threw an exception!?');\n }\n }\n}\n\n/**\n * Escapes a string so that it can be used in regular expression (e.g. converts \"myfile.*\" to \"myfile\\\\.\\\\*\").\n */\nexport function escapeRegExp(str: string): string {\n // code taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions\n return String(str).replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n/**\n * String left pad\n */\nexport function leftpad(str: string, len: number, ch: string | number = ' ') {\n // code inspired by https://www.theregister.co.uk/2016/03/23/npm_left_pad_chaos/\n str = String(str);\n let i = -1;\n\n if (!ch && ch !== 0) ch = ' ';\n\n len -= str.length;\n\n while (++i < len) {\n str = ch + str;\n }\n\n return str;\n}\n\n/**\n * Computes weighted average of 2 values.\n */\nexport function weightedAverage(val1: number, weight1: number, val2: number, weight2: number) {\n return (val1 * weight1 + val2 * weight2) / (weight1 + weight2);\n}\n\n/**\n * List of forbidden usernames. Note that usernames can be used as apify.com/username,\n * so we need to prohibit any username that might be part of our website or confusing in anyway.\n */\nconst FORBIDDEN_USERNAMES_REGEXPS = [\n // App routes\n 'page-not-found', 'docs', 'terms-of-use', 'about', 'pricing', 'privacy-policy', 'customers',\n 'request-form', 'request-solution', 'release-notes', 'jobs', 'api-reference', 'video-tutorials',\n 'acts', 'key-value-stores', 'schedules', 'account', 'sign-up', 'sign-in-discourse', 'admin',\n 'documentation', 'change-password', 'enroll-account', 'forgot-password', 'reset-password',\n 'sign-in', 'verify-email', 'live-status', 'browser-info', 'webhooks', 'health-check', 'api',\n 'change-log', 'dashboard', 'community', 'crawlers', 'ext',\n\n // Various strings\n 'admin', 'administration', 'crawler', 'act', 'library', 'lib', 'apifier', 'team',\n 'contact', 'doc', 'documentation', 'for-business', 'for-developers', 'developers', 'business',\n 'integrations', 'job', 'setting', 'settings', 'privacy', 'policy', 'assets', 'help',\n 'config', 'configuration', 'terms', 'hiring', 'hire', 'status', 'status-page', 'solutions',\n 'support', 'market', 'marketplace', 'download', 'downloads', 'username', 'users', 'user',\n 'login', 'logout', 'signin', 'sign', 'signup', 'sign-out', 'signout', 'plugins', 'plug-ins',\n 'reset', 'password', 'passwords', 'square', 'profile-photos', 'profiles', 'true', 'false',\n 'js', 'css', 'img', 'images', 'image', 'partials', 'fonts', 'font', 'dynamic_templates',\n 'app', 'schedules', 'community', 'storage', 'storages', 'account', 'node_modules', 'bower_components',\n 'video', 'knowledgebase', 'forum', 'customers', 'blog', 'health-check', 'health', 'anim',\n 'forum_topics.json', 'forum_categories.json', 'me', 'you', 'him', 'she', 'it', 'external',\n 'actor', 'crawler', 'scheduler', 'api', 'sdk', 'puppeteer', 'webdriver',\n 'selenium', '(selenium.*webdriver)', 'undefined', 'page-analyzer', 'wp-login.php',\n 'welcome.action', 'echo', 'proxy', 'super-proxy', 'gdpr', 'case-studies', 'use-cases', 'how-to',\n 'kb', 'cookies', 'cookie-policy', 'cookies-policy', 'powered-by', 'run', 'runs', 'actor', 'actors',\n 'act', 'acts', 'success-stories', 'roadmap', 'join-marketplace', 'presskit', 'press-kit', 'covid-19',\n 'covid', 'covid19', 'matfyz', 'ideas', 'public-actors', 'resources', 'partners', 'affiliate',\n 'industries', 'web-scraping', 'custom-solutions', 'solution-provider', 'alternatives', 'platform',\n 'freelancers', 'freelancer', 'partner', 'preview', 'templates', 'data-for-generative-ai',\n 'discord', 'praguecrawl', 'prague-crawl', 'bob', 'ai-agents', 'reel', 'video-reel',\n 'mcp', 'model-context-protocol', 'modelcontextprotocol', 'apify.com', 'design-kit', 'press-kit',\n 'scrapers', 'professional-services',\n\n // Special files\n 'index', 'index\\\\.html', '(favicon\\\\.[a-z]+)', 'BingSiteAuth.xml', '(google.+\\\\.html)', 'robots\\\\.txt',\n '(sitemap\\\\.[a-z]+)', '(apple-touch-icon.*)', 'security-whitepaper\\\\.pdf', 'security\\\\.txt',\n\n // All hidden files\n '(\\\\..*)',\n\n // File starting with xxx-\n '(xxx-.*)',\n\n // Strings not starting with letter or number\n '([^0-9a-z].*)',\n\n // Strings not ending with letter or number\n '(.*[^0-9a-z])',\n\n // Strings where there's more than one underscore, comma or dash in row\n '(.*[_.\\\\-]{2}.*)',\n\n // Reserved usernames from https://github.com/shouldbee/reserved-usernames/blob/master/reserved-usernames.json\n '0', 'about', 'access', 'account', 'accounts', 'activate', 'activities', 'activity', 'ad', 'add',\n 'address', 'adm', 'admin', 'administration', 'administrator', 'ads', 'adult', 'advertising',\n 'affiliate', 'affiliates', 'ajax', 'all', 'alpha', 'analysis', 'analytics', 'android', 'anon',\n 'anonymous', 'api', 'app', 'apps', 'archive', 'archives', 'article', 'asct', 'asset', 'atom',\n 'auth', 'authentication', 'avatar', 'backup', 'balancer-manager', 'banner', 'banners', 'beta',\n 'billing', 'bin', 'blog', 'blogs', 'board', 'book', 'bookmark', 'bot', 'bots', 'bug', 'business',\n 'cache', 'cadastro', 'calendar', 'call', 'campaign', 'cancel', 'captcha', 'career', 'careers',\n 'cart', 'categories', 'category', 'cgi', 'cgi-bin', 'changelog', 'chat', 'check', 'checking',\n 'checkout', 'client', 'cliente', 'clients', 'code', 'codereview', 'comercial', 'comment',\n 'comments', 'communities', 'community', 'company', 'compare', 'compras', 'config', 'configuration',\n 'connect', 'contact', 'contact-us', 'contact_us', 'contactus', 'contest', 'contribute', 'corp',\n 'create', 'css', 'dashboard', 'data', 'db', 'default', 'delete', 'demo', 'design', 'designer',\n 'destroy', 'dev', 'devel', 'developer', 'developers', 'diagram', 'diary', 'dict', 'dictionary',\n 'die', 'dir', 'direct_messages', 'directory', 'dist', 'doc', 'docs', 'documentation', 'domain',\n 'download', 'downloads', 'ecommerce', 'edit', 'editor', 'edu', 'education', 'email', 'employment',\n 'empty', 'end', 'enterprise', 'entries', 'entry', 'error', 'errors', 'eval', 'event', 'exit',\n 'explore', 'facebook', 'faq', 'favorite', 'favorites', 'feature', 'features', 'feed', 'feedback',\n 'feeds', 'file', 'files', 'first', 'flash', 'fleet', 'fleets', 'flog', 'follow', 'followers',\n 'following', 'forgot', 'form', 'forum', 'forums', 'founder', 'free', 'friend', 'friends', 'ftp',\n 'gadget', 'gadgets', 'game', 'games', 'get', 'gift', 'gifts', 'gist', 'github', 'graph', 'group',\n 'groups', 'guest', 'guests', 'help', 'home', 'homepage', 'host', 'hosting', 'hostmaster',\n 'hostname', 'howto', 'hpg', 'html', 'http', 'httpd', 'https', 'i', 'iamges', 'icon', 'icons',\n 'id', 'idea', 'ideas', 'image', 'images', 'imap', 'img', 'index', 'indice', 'info', 'information',\n 'inquiry', 'instagram', 'intranet', 'invitations', 'invite', 'ipad', 'iphone', 'irc', 'is',\n 'issue', 'issues', 'it', 'item', 'items', 'java', 'javascript', 'job', 'jobs', 'join', 'js',\n 'json', 'jump', 'knowledgebase', 'language', 'languages', 'last', 'ldap-status', 'legal', 'license',\n 'link', 'links', 'linux', 'list', 'lists', 'log', 'log-in', 'log-out', 'log_in', 'log_out',\n 'login', 'logout', 'logs', 'm', 'mac', 'mail', 'mail1', 'mail2', 'mail3', 'mail4', 'mail5',\n 'mailer', 'mailing', 'maintenance', 'manager', 'manual', 'map', 'maps', 'marketing', 'master',\n 'me', 'media', 'member', 'members', 'message', 'messages', 'messenger', 'microblog', 'microblogs',\n 'mine', 'mis', 'mob', 'mobile', 'movie', 'movies', 'mp3', 'msg', 'msn', 'music', 'musicas', 'mx',\n 'my', 'mysql', 'name', 'named', 'nan', 'navi', 'navigation', 'net', 'network', 'new', 'news',\n 'newsletter', 'nick', 'nickname', 'notes', 'noticias', 'notification', 'notifications', 'notify',\n 'ns', 'ns1', 'ns10', 'ns2', 'ns3', 'ns4', 'ns5', 'ns6', 'ns7', 'ns8', 'ns9', 'null', 'oauth',\n 'oauth_clients', 'offer', 'offers', 'official', 'old', 'online', 'openid', 'operator', 'order',\n 'orders', 'organization', 'organizations', 'overview', 'owner', 'owners', 'page', 'pager',\n 'pages', 'panel', 'password', 'payment', 'perl', 'phone', 'photo', 'photoalbum', 'photos', 'php',\n 'phpmyadmin', 'phppgadmin', 'phpredisadmin', 'pic', 'pics', 'ping', 'plan', 'plans', 'plugin',\n 'plugins', 'policy', 'pop', 'pop3', 'popular', 'portal', 'post', 'postfix', 'postmaster', 'posts',\n 'pr', 'premium', 'press', 'price', 'pricing', 'privacy', 'privacy-policy', 'privacy_policy',\n 'privacypolicy', 'private', 'product', 'products', 'profile', 'project', 'projects', 'promo',\n 'pub', 'public', 'purpose', 'put', 'python', 'query', 'random', 'ranking', 'read', 'readme',\n 'recent', 'recruit', 'recruitment', 'register', 'registration', 'release', 'remove', 'replies',\n 'report', 'reports', 'repositories', 'repository', 'req', 'request', 'requests', 'reset', 'roc',\n 'root', 'rss', 'ruby', 'rule', 'sag', 'sale', 'sales', 'sample', 'samples', 'save', 'school',\n 'script', 'scripts', 'search', 'secure', 'security', 'self', 'send', 'server', 'server-info',\n 'server-status', 'service', 'services', 'session', 'sessions', 'setting', 'settings', 'setup',\n 'share', 'shop', 'show', 'sign-in', 'sign-up', 'sign_in', 'sign_up', 'signin', 'signout', 'signup',\n 'site', 'sitemap', 'sites', 'smartphone', 'smtp', 'soporte', 'source', 'spec', 'special', 'sql',\n 'src', 'ssh', 'ssl', 'ssladmin', 'ssladministrator', 'sslwebmaster', 'staff', 'stage', 'staging',\n 'start', 'stat', 'state', 'static', 'stats', 'status', 'store', 'stores', 'stories', 'style',\n 'styleguide', 'stylesheet', 'stylesheets', 'subdomain', 'subscribe', 'subscription', 'subscriptions', 'suporte',\n 'support', 'svn', 'swf', 'sys', 'sysadmin', 'sysadministrator', 'system', 'tablet', 'tablets',\n 'tag', 'talk', 'task', 'tasks', 'team', 'teams', 'tech', 'telnet', 'term', 'terms',\n 'terms-of-service', 'terms_of_service', 'termsofservice', 'test', 'test1', 'test2', 'test3',\n 'teste', 'testing', 'tests', 'theme', 'themes', 'thread', 'threads', 'tmp', 'todo', 'tool',\n 'tools', 'top', 'topic', 'topics', 'tos', 'tour', 'translations', 'trends', 'tutorial', 'tux',\n 'tv', 'twitter', 'undef', 'unfollow', 'unsubscribe', 'update', 'upload', 'uploads', 'url',\n 'usage', 'user', 'username', 'users', 'usuario', 'vendas', 'ver', 'version', 'video', 'videos',\n 'visitor', 'watch', 'weather', 'web', 'webhook', 'webhooks', 'webmail', 'webmaster', 'website',\n 'websites', 'welcome', 'widget', 'widgets', 'wiki', 'win', 'windows', 'word', 'work', 'works',\n 'workshop', 'ww', 'wws', 'www', 'www1', 'www2', 'www3', 'www4', 'www5', 'www6', 'www7', 'wwws',\n 'wwww', 'xfn', 'xml', 'xmpp', 'xpg', 'xxx', 'yaml', 'year', 'yml', 'you', 'yourdomain', 'yourname',\n 'yoursite', 'yourusername',\n];\n\n// Regex matching forbidden usernames.\nconst FORBIDDEN_REGEXP = new RegExp(`^(${ANONYMOUS_USERNAME}|${FORBIDDEN_USERNAMES_REGEXPS.join('|')})$`, 'i');\n\n/**\n * Checks whether username is listed in FORBIDDEN_USERNAMES\n * or matches any root route path.\n */\nexport function isForbiddenUsername(username: string): boolean {\n return !!username.match(APIFY_ID_REGEX) || !!username.match(FORBIDDEN_REGEXP);\n}\n\n/**\n * Executes array of promises in sequence and then returns array where Nth item is result of Nth promise.\n */\nexport async function sequentializePromises<T>(promises: (Promise<T> | (() => Promise<T>))[]) {\n if (!promises.length) return [];\n const results: T[] = [];\n\n for (const promiseOrFunc of promises) {\n const promise = promiseOrFunc instanceof Function ? promiseOrFunc() : promiseOrFunc;\n results.push(await promise);\n }\n\n return results;\n}\n\n/**\n * Helper function for validation if parameter is an instance of given prototype or multiple prototypes.\n */\nexport function checkParamPrototypeOrThrow(paramVal: any, paramName: string, prototypes: any, prototypeName: string, isOptional = false) {\n if (isOptional && (paramVal === undefined || paramVal === null)) return;\n\n const hasCorrectPrototype = prototypes instanceof Array\n ? prototypes.some((prototype) => paramVal instanceof prototype)\n : paramVal instanceof prototypes;\n\n if (!hasCorrectPrototype) throw new Error(`Parameter \"${paramName}\" must be an instance of ${prototypeName}`);\n}\n\ninterface Server {\n removeListener(event: string, cb: (...params: any[]) => any): unknown;\n on(event: string, cb: (...params: any[]) => any): unknown;\n listen(port: number): unknown;\n}\n\n/**\n * Starts listening at a port specified in the constructor.\n * Unfortunately server.listen() is not a normal function that fails on error, so we need this trickery.\n * Returns a function that calls `server.listen(port)` and resolves once server starts listening.\n *\n * Usage: `promisifyServerListen(server)(1234)`;\n */\nexport function promisifyServerListen<T extends Server>(server: T) {\n return async (port: number) => {\n return new Promise<void>((resolve, reject) => {\n const onError = (err: Error) => {\n removeListeners();\n reject(err);\n };\n const onListening = () => {\n removeListeners();\n resolve();\n };\n const removeListeners = () => {\n server.removeListener('error', onError);\n server.removeListener('listening', onListening);\n };\n\n server.on('error', onError);\n server.on('listening', onListening);\n server.listen(port);\n });\n };\n}\n\nexport function configureLogger(givenLog: Log, isProduction?: boolean) {\n if (isProduction) {\n givenLog.setOptions({\n level: LogLevel.INFO,\n logger: new LoggerJson(),\n });\n } else {\n givenLog.setOptions({ level: LogLevel.DEBUG });\n }\n}\n\n/**\n * Wraps given promise with timeout.\n */\nexport async function timeoutPromise<T>(promise: Promise<T>, timeoutMillis: number, errorMessage = 'Promise has timed-out') {\n return new Promise((resolve, reject) => {\n let timeout: number;\n let hasFulfilled = false;\n\n const callback = (err: Error | null, result?: T) => {\n if (hasFulfilled) return;\n clearTimeout(timeout);\n hasFulfilled = true;\n if (err) {\n reject(err);\n return;\n }\n resolve(result);\n };\n\n promise.then<void, void>((result: T) => callback(null, result), callback);\n timeout = setTimeout(() => callback(new Error(errorMessage)), timeoutMillis) as unknown as number;\n });\n}\n\n/**\n * Removes the leading ^ and trailing $ from regex\n */\nexport function createInjectableRegExp(regex: RegExp) {\n return new RegExp(regex.source.replace(/^\\^|\\$$/g, ''));\n}\n","/*!\n * This module contains various client-side utility and helper functions.\n *\n * Author: Jan Curn (jan@apify.com)\n * Copyright(c) 2016 Apify. All rights reserved.\n *\n */\n\nimport { RELATIVE_URL_REGEX, VERSION_INT_MAJOR_BASE, VERSION_INT_MINOR_BASE } from '@apify/consts';\n\n/**\n * Returns true if object equals null or undefined, otherwise returns false.\n */\nexport function isNullOrUndefined(obj: unknown): boolean {\n return obj == null;\n}\n\nexport function isBuffer(obj: any): boolean {\n return obj != null && obj.constructor != null && typeof obj.constructor.isBuffer === 'function' && obj.constructor.isBuffer(obj);\n}\n\n/**\n * Converts Date object to ISO string.\n */\nexport function dateToString(date: Date, middleT: boolean): string {\n if (!(date instanceof Date)) { return ''; }\n const year = date.getFullYear();\n const month = date.getMonth() + 1; // January is 0, February is 1, and so on.\n const day = date.getDate();\n const hours = date.getHours();\n const minutes = date.getMinutes();\n const seconds = date.getSeconds();\n const millis = date.getMilliseconds();\n\n const pad = (num: number) => (num < 10 ? `0${num}` : num);\n const datePart = `${year}-${pad(month)}-${pad(day)}`;\n // eslint-disable-next-line no-nested-ternary\n const millisPart = millis < 10 ? `00${millis}` : (millis < 100 ? `0${millis}` : millis);\n const timePart = `${pad(hours)}:${pad(minutes)}:${pad(seconds)}.${millisPart}`;\n\n return `${datePart}${middleT ? 'T' : ' '}${timePart}`;\n}\n\n/**\n * Ensures a string is shorter than a specified number of character, and truncates it if not,\n * appending a specific suffix to it.\n * @param str\n * @param maxLength\n * @param [suffix] Suffix to be appended to truncated string. Defaults to \"...[truncated]\".\n */\nexport function truncate(str: string, maxLength: number, suffix = '...[truncated]'): string {\n maxLength = Math.floor(maxLength);\n\n // TODO: we should just ignore rest of the suffix...\n if (suffix.length > maxLength) {\n throw new Error('suffix string cannot be longer than maxLength');\n }\n\n if (typeof str === 'string' && str.length > maxLength) {\n str = str.substr(0, maxLength - suffix.length) + suffix;\n }\n\n return str;\n}\n\n/**\n * Gets ordinal suffix for a number (e.g. \"nd\" for 2).\n */\nexport function getOrdinalSuffix(num: number) {\n // code from https://ecommerce.shopify.com/c/ecommerce-design/t/ordinal-number-in-javascript-1st-2nd-3rd-4th-29259\n const s = ['th', 'st', 'nd', 'rd'];\n const v = num % 100;\n return s[(v - 20) % 10] || s[v] || s[0];\n}\n\ninterface Uri {\n protocol?: string;\n host?: string;\n path?: string;\n query?: string;\n fragment?: string;\n fragmentKey?: Record<string, unknown>;\n}\n\n/**\n * @deprecated use `new URL()` instead\n */\nexport function parseUrl(str: string): Uri {\n if (typeof str !== 'string') return {};\n const o = {\n strictMode: false,\n key: ['source', 'protocol', 'authority', 'userInfo', 'user', 'password', 'host', 'port',\n 'relative', 'path', 'directory', 'file', 'query', 'fragment'],\n q: {\n name: 'queryKey',\n parser: /(?:^|&)([^&=]*)=?([^&]*)/g,\n },\n parser: {\n strict: /^(?:([^:\\/?#]+):)?(?:\\/\\/((?:(([^:@]*)(?::([^:@]*))?)?@)?([^:\\/?#]*)(?::(\\d*))?))?((((?:[^?#\\/]*\\/)*)([^?#]*))(?:\\?([^#]*))?(?:#(.*))?)/, // eslint-disable-line max-len,no-useless-escape\n loose: /^(?:(?![^:@]+:[^:@\\/]*@)([^:\\/?#.]+):)?(?:\\/\\/)?((?:(([^:@]*)(?::([^:@]*))?)?@)?([^:\\/?#]*)(?::(\\d*))?)(((\\/(?:[^?#](?![^?#\\/]*\\.[^?#\\/.]+(?:[?#]|$)))*\\/?)?([^?#\\/]*))(?:\\?([^#]*))?(?:#(.*))?)/, // eslint-disable-line max-len,no-useless-escape\n },\n };\n\n const m = o.parser[o.strictMode ? 'strict' : 'loose'].exec(str);\n const uri: Record<string, any> = {};\n let i = o.key.length;\n\n while (i--) uri[o.key[i]] = m![i] || '';\n\n uri[o.q.name] = {};\n uri[o.key[12]].replace(o.q.parser, ($0: any, $1: any, $2: any) => {\n if ($1) uri[o.q.name][$1] = $2;\n });\n\n // our extension - parse fragment using a query string format (i.e. \"#key1=val1&key2=val2\")\n // this format is used by many websites\n uri.fragmentKey = {};\n if (uri.fragment) {\n // casting as any, as the usage seems invalid, replacer should always return something (but keeping as is to mitigate unwanted BCs)\n uri.fragment.replace(o.q.parser, (($0: any, $1: any, $2: any) => {\n if ($1) uri.fragmentKey![$1] = $2;\n }) as any);\n }\n\n return uri;\n}\n\nexport function normalizeUrl(url: string, keepFragment?: boolean) {\n if (typeof url !== 'string' || !url.length) {\n return null;\n }\n\n let urlObj;\n\n try {\n urlObj = new URL(url.trim());\n } catch {\n return null;\n }\n\n const { searchParams } = urlObj;\n\n for (const key of [...searchParams.keys()]) {\n if (key.startsWith('utm_')) {\n searchParams.delete(key);\n }\n }\n\n searchParams.sort();\n\n const protocol = urlObj.protocol.toLowerCase();\n const host = urlObj.host.toLowerCase();\n const path = urlObj.pathname.replace(/\\/$/, '');\n const search = searchParams.toString() ? `?${searchParams}` : '';\n const hash = keepFragment ? urlObj.hash : '';\n\n return `${protocol}//${host}${path}${search}${hash}`;\n}\n\n// Helper function for markdown rendered marked\n// If passed referrerHostname, it renders links outside that hostname in readme with rel=\"noopener noreferrer\" and target=\"_blank\" attributes\n// And links outside apify.com in readme with rel=\"noopener noreferrer nofollow\" and target=\"_blank\" attributes\nexport function markedSetNofollowLinks(href: string, title: string, text: string, referrerHostname?: string) {\n let urlParsed: URL;\n try {\n urlParsed = new URL(href);\n } catch {\n // Probably invalid url, go on\n }\n const isApifyLink = (urlParsed! && /(\\.|^)apify\\.com$/i.test(urlParsed.hostname));\n const isSameHostname = !referrerHostname || (urlParsed! && urlParsed.hostname === referrerHostname);\n\n if (isApifyLink && isSameHostname) {\n return `<a href=\"${href}\">${title || text}</a>`;\n } if (isApifyLink) {\n return `<a rel=\"noopener noreferrer\" target=\"_blank\" href=\"${href}\">${title || text}</a>`;\n }\n\n return `<a rel=\"noopener noreferrer nofollow\" target=\"_blank\" href=\"${href}\">${title || text}</a>`;\n}\n\n// Helper function for markdown rendered marked\n// Decreases level of all headings by one, h1 -> h2\nexport function markedDecreaseHeadsLevel(text: string, level: number) {\n level += 1;\n return `<h${level}>${text}</h${level}>`;\n}\n\n/**\n * Converts integer version number previously generated by buildNumberToInt() or versionNumberToInt()\n * to string in a form 'MAJOR.MINOR' or 'MAJOR.MINOR.BUILD' in case build number is non-zero.\n */\nexport function buildOrVersionNumberIntToStr(int: number): string | null {\n if (typeof int !== 'number' || !(int >= 0)) return null;\n\n const major = Math.floor(int / VERSION_INT_MAJOR_BASE);\n const remainder = int % VERSION_INT_MAJOR_BASE;\n const minor = Math.floor(remainder / VERSION_INT_MINOR_BASE);\n const build = remainder % VERSION_INT_MINOR_BASE;\n\n let str = `${major}.${minor}`;\n if (build > 0) str += `.${build}`;\n\n return str;\n}\n\n// escaped variants for various strings\nconst ESCAPE_DOT = '\\uFF0E'; // \".\"\nconst ESCAPE_DOLLAR = '\\uFF04'; // \"$\"\nconst ESCAPE_TO_BSON = '\\uFF54\\uFF4F\\uFF22\\uFF33\\uFF2F\\uFF2E'; // \"toBSON\"\nconst ESCAPE_TO_STRING = '\\uFF54\\uFF4F\\uFF33\\uFF54\\uFF52\\uFF49\\uFF4E\\uFF47'; // \"toString\"\nconst ESCAPE_BSON_TYPE = '\\uFF3F\\uFF42\\uFF53\\uFF4F\\uFF4E\\uFF54\\uFF59\\uFF50\\uFF45'; // \"_bsontype\"\nconst ESCAPE_NULL = ''; // \"\\0\" (null chars are removed completely, they won't be recovered)\n\nconst REGEXP_IS_ESCAPED = new RegExp(`(${ESCAPE_DOT}|^${ESCAPE_DOLLAR}|^${ESCAPE_TO_BSON}$|^${ESCAPE_BSON_TYPE}|^${ESCAPE_TO_STRING}$)`);\n\nconst REGEXP_DOT = new RegExp(ESCAPE_DOT, 'g');\nconst REGEXP_DOLLAR = new RegExp(`^${ESCAPE_DOLLAR}`);\nconst REGEXP_TO_BSON = new RegExp(`^${ESCAPE_TO_BSON}$`);\nconst REGEXP_TO_STRING = new RegExp(`^${ESCAPE_TO_STRING}$`);\nconst REGEXP_BSON_TYPE = new RegExp(`^${ESCAPE_BSON_TYPE}$`);\n\n/**\n * If a property name is invalid for MongoDB or BSON, the function transforms\n * it to a valid form, which can be (most of the time) reversed back using unescapePropertyName().\n * For a detailed list of transformations, see escapeForBson().\n * @private\n */\nexport function escapePropertyName(name: string) {\n // From MongoDB docs:\n // \"Field names cannot contain dots (.) or null (\"\\0\") characters, and they must not start with\n // a dollar sign (i.e. $). See faq-dollar-sign-escaping for an alternate approach.\"\n // Moreover, the name cannot be \"toBSON\" and \"_bsontype\" because they have a special meaning in BSON serialization.\n // Other special BSON properties like $id and $db are covered thanks to $ escape.\n // 2021-06-25: The `toString` string was added as a property to escape because\n // it generates issues due to a bug in mongo bson-ext package https://jira.mongodb.org/browse/NODE-3375.\n\n // pre-test to improve performance\n if (/(\\.|^\\$|^toBSON$|^_bsontype$|^toString$|\\0)/.test(name)) {\n name = name.replace(/\\./g, ESCAPE_DOT);\n name = name.replace(/^\\$/, ESCAPE_DOLLAR);\n name = name.replace(/^toBSON$/, ESCAPE_TO_BSON);\n name = name.replace(/^toString$/, ESCAPE_TO_STRING);\n name = name.replace(/^_bsontype$/, ESCAPE_BSON_TYPE);\n name = name.replace(/\\0/g, ESCAPE_NULL);\n }\n\n return name;\n}\n\n/**\n * Reverses a string transformed using escapePropertyName() back to its original form.\n * Note that the reverse transformation might not be 100% correct for certain unlikely-to-occur strings\n * (e.g. string contain null chars).\n * @private\n */\nexport function unescapePropertyName(name: string) {\n // pre-test to improve performance\n if (REGEXP_IS_ESCAPED.test(name)) {\n name = name.replace(REGEXP_DOT, '.');\n name = name.replace(REGEXP_DOLLAR, '$');\n name = name.replace(REGEXP_TO_BSON, 'toBSON');\n name = name.replace(REGEXP_TO_STRING, 'toString');\n name = name.replace(REGEXP_BSON_TYPE, '_bsontype');\n }\n\n return name;\n}\n\n/**\n * Traverses an object, creates a deep clone if requested and transforms object keys and values using a provided function.\n * The `traverseObject` is recursive, hence if the input object has circular references, the function will run into\n * and infinite recursion and crash the Node.js process.\n * @param obj Object to traverse, it must not contain circular references!\n * @param clone If true, object is not modified but cloned.\n * @param transformFunc Function used to transform the property names na value.\n * It has the following signature: `(key, value) => [key, value]`.\n * Beware that the transformed value is only set if it !== old value.\n * @returns {*}\n * @private\n */\nexport function traverseObject(obj: Record<string, any>, clone: boolean, transformFunc: (key: string, value: unknown) => [string, unknown]) {\n // Primitive types don't need to be cloned or further traversed.\n // Buffer needs to be skipped otherwise this will iterate over the whole buffer which kills the event loop.\n if (\n obj === null\n || typeof obj !== 'object'\n || Object.prototype.toString.call(obj) === '[object Date]'\n || isBuffer(obj)\n ) return obj;\n\n let result;\n\n if (Array.isArray(obj)) {\n // obj is an array, keys are numbers and never need to be escaped\n result = clone ? new Array(obj.length) : obj;\n for (let i = 0; i < obj.length; i++) {\n const val = traverseObject(obj[i], clone, transformFunc);\n if (clone) result[i] = val;\n }\n\n return result;\n }\n\n // obj is an object, all keys need to be checked\n result = clone ? {} : obj;\n for (const key in obj) { // eslint-disable-line no-restricted-syntax, guard-for-in\n const val = traverseObject(obj[key], clone, transformFunc);\n const [transformedKey, transformedVal] = transformFunc(key, val);\n if (key === transformedKey) {\n // For better efficiency, skip setting the key-value if not cloning and nothing changed\n if (clone || val !== transformedVal) result[key] = transformedVal;\n } else {\n // Key has been renamed\n result[transformedKey] = transformedVal;\n if (!clone) delete obj[key];\n }\n }\n\n return result;\n}\n\n/**\n * Transforms an object so that it can be stored to MongoDB or serialized to BSON.\n * It does so by transforming prohibited property names (e.g. names starting with \"$\",\n * containing \".\" or null char, equal to \"toBSON\" or \"_bsontype\") to equivalent full-width Unicode chars\n * which are normally allowed. To revert this transformation, use unescapeFromBson().\n * @param obj Object to be transformed. It must not contain circular references or any complex types (e.g. Maps, Promises etc.)!\n * @param clone If true, the function transforms a deep clone of the object rather than the original object.\n * @returns {*} Transformed object\n */\nexport function escapeForBson(obj: Record<string, any>, clone = false) {\n return traverseObject(obj, clone, (key, value) => [escapePropertyName(key), value]);\n}\n\n/**\n * Reverts a transformation of object property names performed by escapeForBson().\n * Note that the reverse transformation might not be 100% equal to the original object\n * for certain unlikely-to-occur property name (e.g. one contain null chars or full-width Unicode chars).\n * @param obj Object to be transformed. It must not contain circular references or any complex types (e.g. Maps, Promises etc.)!\n * @param clone If true, the function transforms a deep clone of the object rather than the original object.\n * @returns {*} Transformed object.\n */\nexport function unescapeFromBson(obj: Record<string, any>, clone = false): Record<string, any> {\n return traverseObject(obj, clone, (key, value) => [unescapePropertyName(key), value]);\n}\n\n/**\n * Determines whether an object contains property names that cannot be stored to MongoDB.\n * See escapeForBson() for more details.\n * Note that this function only works with objects that are serializable to JSON!\n * @param obj Object to be checked. It must not contain circular references or any complex types (e.g. Maps, Promises etc.)!\n * @returns {boolean} Returns true if object is invalid, otherwise it returns false.\n */\nexport function isBadForMongo(obj: Record<string, any>): boolean {\n let isBad = false;\n try {\n traverseObject(obj, false, (key, value) => {\n const escapedKey = escapePropertyName(key);\n if (key !== escapedKey) {\n isBad = true;\n throw new Error();\n }\n return [key, value];\n });\n } catch (e) {\n if (!isBad) throw e;\n }\n return isBad;\n}\n\nexport class JsonVariable {\n constructor(readonly name: string) { }\n\n getToken() {\n return `{{${this.name}}}`;\n }\n}\n\n/**\n * Stringifies provided value to JSON with a difference that supports functions that\n * are stringified using .toString() method.\n *\n * In addition to that supports instances of JsonVariable('my.token') that are replaced\n * with a {{my.token}}.\n */\nexport function jsonStringifyExtended(value: Record<string, any>, replacer?: ((k: string, val: unknown) => unknown) | null, space = 0): string {\n if (replacer && !(replacer instanceof Function)) throw new Error('Parameter \"replacer\" of jsonStringifyExtended() must be a function!');\n\n const replacements: Record<string, string> = {};\n\n const extendedReplacer = (key: string, val: unknown) => {\n val = replacer ? replacer(key, val) : val;\n\n if (val instanceof Function) return val.toString();\n if (val instanceof JsonVariable) {\n const randomToken = `<<<REPLACEMENT_TOKEN::${Math.random()}>>>`;\n replacements[randomToken] = val.getToken();\n return randomToken;\n }\n\n return val;\n };\n\n let stringifiedValue = JSON.stringify(value, extendedReplacer, space);\n Object.entries(replacements).forEach(([replacementToken, replacementValue]) => {\n stringifiedValue = stringifiedValue.replace(`\"${replacementToken}\"`, replacementValue);\n });\n\n return stringifiedValue;\n}\n\n/**\n * Splits a full name into the first name and last name, trimming all internal and external spaces.\n * Returns an array with two elements or null if splitting is not possible.\n */\nexport function splitFullName(fullName: string) {\n if (typeof fullName !== 'string') return [null, null];\n\n const names = (fullName || '').trim().split(' ');\n const nonEmptyNames = names.filter((val) => val);\n\n if (nonEmptyNames.length === 0) {\n return [null, null];\n }\n\n if (nonEmptyNames.length === 1) {\n return [null, nonEmptyNames[0]];\n }\n\n return [names[0], nonEmptyNames.slice(1).join(' ')];\n}\n\n/**\n * Perform a Regex test on a given URL to see if it is relative.\n */\nexport function isUrlRelative(url: string): boolean {\n return RELATIVE_URL_REGEX.test(url);\n}\n","import log from '@apify/log';\n\nimport { delayPromise } from './utilities';\n\nexport class RetryableError extends Error {\n readonly error: Error;\n\n constructor(error: Error, ...args: unknown[]) {\n super(...args as [string]);\n this.error = error;\n }\n}\n\n// extend the error with added properties\nexport interface RetryableError extends Error {}\n\nexport async function retryWithExpBackoff<T>(\n params: { func?: (...args: unknown[]) => T | Promise<T>, expBackoffMillis?: number, expBackoffMaxRepeats?: number } = {},\n): Promise<T> {\n const { func, expBackoffMillis, expBackoffMaxRepeats } = params;\n\n if (typeof func !== 'function') {\n throw new Error('Parameter \"func\" should be a function.');\n }\n\n if (typeof expBackoffMillis !== 'number') {\n throw new Error('Parameter \"expBackoffMillis\" should be a number.');\n }\n\n if (typeof expBackoffMaxRepeats !== 'number') {\n throw new Error('Parameter \"expBackoffMaxRepeats\" should be a number.');\n }\n\n for (let i = 0; ; i++) {\n let error;\n\n try {\n return await func();\n } catch (e) {\n error = e;\n }\n\n if (!(error instanceof RetryableError)) {\n throw error;\n }\n\n if (i >= expBackoffMaxRepeats - 1) {\n throw error.error;\n }\n\n const waitMillis = expBackoffMillis * (2 ** i);\n const rand = (from: number, to: number) => from + Math.floor(Math.random() * (to - from + 1));\n const randomizedWaitMillis = rand(waitMillis, waitMillis * 2);\n\n if (i === Math.round(expBackoffMaxRepeats / 2)) {\n log.warning(`Retry failed ${i} times and will be repeated in ${randomizedWaitMillis}ms`, {\n originalError: error.error.message,\n errorDetails: Reflect.get(error.error, 'details'),\n });\n }\n\n await delayPromise(randomizedWaitMillis);\n }\n}\n","import { cryptoRandomObjectId, timeoutPromise } from './utilities';\n\nexport enum CHECK_TYPES {\n MONGODB_PING = 'MONGODB_PING',\n MONGODB_READ = 'MONGODB_READ',\n MONGODB_WRITE = 'MONGODB_WRITE',\n REDIS = 'REDIS', // Old alias for 'REDIS_WRITE', deprecated\n REDIS_PING = 'REDIS_PING',\n REDIS_WRITE = 'REDIS_WRITE',\n}\n\ntype CheckType<T extends Record<string, any> = Record<string, any>> = { client: T; type: CHECK_TYPES };\n\ninterface HealthCheckerOptions {\n checks: CheckType[];\n redisPrefix?: string;\n redisTtlSecs?: number;\n checkTimeoutMillis?: number;\n mongoDbWriteTestCollection?: string;\n mongoDbWriteTestRemoveOlderThanSecs?: number;\n}\n\n/**\n * Provides health-checking functionality to ensure that connection to Redis and MongoDB is working.\n *\n * Example use:\n *\n * ```javascript\n * const redis = new Redis();\n * const mongo = await MongoClient.connect('mongodb://127.0.0.1:3001/my-db');\n *\n * const checks = [{\n * client: redis,\n * type: HealthChecker.CHECK_TYPES.REDIS,\n * }, {\n * client: mongo.db('my-db'),\n * type: HealthChecker.CHECK_TYPES.MONGODB_READ,\n * }];\n *\n * const checker = new HealthChecker({ checks });\n * setInterval(() => checker.ensureIsHealthy().then(() => console.log('ok'), err => console.log(err)), 5000);\n * ```\n */\nexport class HealthChecker {\n static readonly CHECK_TYPES = CHECK_TYPES;\n\n checks: CheckType[];\n\n redisPrefix: string;\n\n redisTtlSecs: number;\n\n checkTimeoutMillis: number;\n\n mongoDbWriteTestCollection: string;\n\n mongoDbWriteTestRemoveOlderThanSecs: number;\n\n constructor(private readonly options: HealthCheckerOptions) {\n const {\n checks,\n redisPrefix = 'health-check',\n redisTtlSecs = 15,\n checkTimeoutMillis = 15000,\n mongoDbWriteTestCollection = 'healthCheckPlayground',\n mongoDbWriteTestRemoveOlderThanSecs = 15,\n } = options;\n\n if (!Array.isArray(checks)) throw new Error('Parameter \"check\" must be an array');\n checks.map((check) => this._validateCheck(check));\n\n this.checks = checks;\n this.redisPrefix = redisPrefix;\n this.redisTtlSecs = redisTtlSecs;\n this.checkTimeoutMillis = checkTimeoutMillis;\n this.mongoDbWriteTestCollection = mongoDbWriteTestCollection;\n this.mongoDbWriteTestRemoveOlderThanSecs = mongoDbWriteTestRemoveOlderThanSecs;\n }\n\n async ensureIsHealthy() {\n for (const check of this.checks) {\n try {\n const checkPromise = this._performCheck(check);\n await timeoutPromise(checkPromise, this.checkTimeoutMillis, 'Check has timed-out');\n } catch (_err) {\n const err = _err as Error;\n throw new Error(`Health check test \"${check.type}\" failed with an error: ${err.message}\"`);\n }\n }\n }\n\n _validateCheck(check: CheckType): void {\n if (!(check.type in CHECK_TYPES)) throw new Error(`Check type \"${check.type}\" is invalid`);\n if (typeof check.client !== 'object') throw new Error(`Check client must be an object got \"${typeof check.client}\" instead`);\n }\n\n async _performCheck(check: CheckType): Promise<void> {\n switch (check.type) {\n case CHECK_TYPES.MONGODB_PING:\n return this._testMongoDbPing(check);\n case CHECK_TYPES.MONGODB_READ:\n return this._testMongoDbRead(check);\n case CHECK_TYPES.MONGODB_WRITE:\n return this._testMongoDbWrite(check);\n case CHECK_TYPES.REDIS_PING:\n return this._testRedisPing(check);\n case CHECK_TYPES.REDIS:\n case CHECK_TYPES.REDIS_WRITE:\n return this._testRedisWrite(check);\n default:\n throw new Error('Unknown check type');\n }\n }\n\n async _testMongoDbPing({ client }: CheckType) {\n const response = await client.command({ ping: 1 });\n if (response.ok !== 1) throw new Error(`Got ${response.ok} instead of 1!`);\n }\n\n async _testMongoDbRead({ client }: CheckType) {\n const response = await client.listCollections().toArray();\n if (!Array.isArray(response)) throw new Error(`Got ${typeof response} instead of an array!`);\n }\n\n async _testMongoDbWrite({ client }: CheckType) {\n const id = cryptoRandomObjectId();\n const collection = client.collection(this.mongoDbWriteTestCollection);\n\n // Remove old test items.\n await collection.deleteMany({\n createdAt: {\n $lt: new Date(Date.now() - this.mongoDbWriteTestRemoveOlderThanSecs * 1000),\n },\n });\n\n // Insert and read some item.\n await collection.insertOne({\n _id: id,\n createdAt: new Date(),\n });\n const retrieved = await collection.findOne({ _id: id });\n if (!retrieved) throw new Error(`Item with ID \"${id}\" not found!`);\n }\n\n async _testRedisPing({ client }: CheckType) {\n const response = await client.ping();\n if (response !== 'PONG') throw new Error(`Got \"${response}\" instead of \"PONG\"!`);\n }\n\n async _testRedisWrite({ client }: CheckType) {\n const key = `${this.redisPrefix}:${cryptoRandomObjectId()}`;\n const expected = 'OK';\n\n // Set some value in Redis and try to read it.\n await client.set(key, expected, 'EX', this.redisTtlSecs);\n const given = await client.get(key);\n if (given !== expected) throw new Error(`Returned value \"${given}\" is not equal to \"${expected}\"!`);\n }\n}\n","import type { TransformCallback } from 'node:stream';\nimport { Transform } from 'node:stream';\n\n// TODO: Fix the issue with the separate 'data' and 'object' event - see below.\n// For example, we could just have 'data' and it would just pass the object.\n// For that, you can use the 'objectMode' param\n\n/**\n * A transforming stream which accepts string/Buffer data with JSON Lines objects on input\n * and emits 'object' event for every parsed JavaScript objects.\n * The stream passes through the original data.\n * Each JSON object is expected to be on a separate line, some lines might be empty or contain whitespace.\n * After each JSON object there needs to be '\\n' or end of stream.\n * This stream is especially useful for processing stream from Docker engine, such as:\n *\n * <pre>\n * {\"status\":\"Preparing\",\"progressDetail\":{},\"id\":\"e0380bb6c0bb\"}\n * {\"status\":\"Preparing\",\"progressDetail\":{},\"id\":\"9f8566ee5135\"}\n * {\"errorDetail\":{\"message\":\"no basic auth credentials\"},\"error\":\"no basic auth credentials\"}\n * </pre>\n *\n * **WARNING**: You still need to consume the `data` event from the transformed stream,\n * otherwise the internal buffers will get full and the stream might be corked.\n */\nexport class ParseJsonlStream extends Transform {\n private pendingChunk: string | null = null;\n\n parseLineAndEmitObject(line: string): void {\n line = line.trim();\n\n if (!line) {\n return;\n }\n\n try {\n const obj = JSON.parse(line);\n this.emit('object', obj);\n } catch (e) {\n throw new Error(`Cannot parse JSON stream data ('${String(line)}'): ${String(e)}`);\n }\n }\n\n _transform(chunk: any, encoding: BufferEncoding, callback: TransformCallback): void {\n let allData;\n if (this.pendingChunk) {\n allData = this.pendingChunk + chunk;\n this.pendingChunk = null;\n } else {\n allData = chunk;\n }\n\n const lines = allData.toString().split('\\n');\n\n // One line can span multiple chunks, so if the new chunk doesn't end with '\\n',\n // store the last part and later concat it with the new chunk\n if (lines[lines.length - 1] !== '') {\n this.pendingChunk = lines.pop();\n }\n\n try {\n for (let i = 0; i < lines.length; i++) {\n this.parseLineAndEmitObject(lines[i]);\n }\n } catch (err) {\n callback(err as Error, null);\n return;\n }\n\n callback(null, chunk);\n }\n\n // This function is called right after stream.end() is called by the writer.\n // It just tries to process the pending chunk and returns an error if that fails.\n _flush(callback: TransformCallback): void