diff --git a/src/__tests__/__snapshots__/server.getResources.test.ts.snap b/src/__tests__/__snapshots__/server.getResources.test.ts.snap index d17e5910..8e5df059 100644 --- a/src/__tests__/__snapshots__/server.getResources.test.ts.snap +++ b/src/__tests__/__snapshots__/server.getResources.test.ts.snap @@ -12,7 +12,19 @@ exports[`processDocsFunction should handle errors gracefully: errors 1`] = ` "content": "❌ Failed to load bad-file.md: File not found", "isSuccess": false, "path": "bad-file.md", - "resolvedPath": undefined, + "resolvedPath": "/bad-file.md", + }, +] +`; + +exports[`processDocsFunction should process local and remote inputs, de-duplicate with first metadata 1`] = ` +[ + { + "content": "local file content", + "isSuccess": true, + "lorem": "ipsum", + "path": "file.md", + "resolvedPath": "/file.md", }, ] `; @@ -51,7 +63,7 @@ exports[`processDocsFunction should process local and remote inputs, files and U ] `; -exports[`processDocsFunction should process local and remote inputs, filter empty strings 1`] = ` +exports[`processDocsFunction should process local and remote inputs, filter empty strings with varied input 1`] = ` [ { "content": "local file content", @@ -68,23 +80,38 @@ exports[`processDocsFunction should process local and remote inputs, filter empt ] `; +exports[`processDocsFunction should process local and remote inputs, metadata passthrough 1`] = ` +[ + { + "content": "local file content", + "dolor": "sit", + "isSuccess": true, + "lorem": "ispum", + "path": "file.md", + "resolvedPath": "/file.md", + }, +] +`; + exports[`promiseQueue should execute promises in order: allSettled 1`] = ` [ { "status": "fulfilled", "value": { "content": "/dolor-sit.md", + "path": "dolor-sit.md", "resolvedPath": "/dolor-sit.md", }, }, { - "reason": "https://example.com/remote.md", + "reason": [Error: https://example.com/remote.md], "status": "rejected", }, { "status": "fulfilled", "value": { "content": "/lorem-ipsum.md", + "path": "lorem-ipsum.md", "resolvedPath": "/lorem-ipsum.md", }, }, diff --git a/src/__tests__/server.getResources.test.ts b/src/__tests__/server.getResources.test.ts index 45f07b54..645d46ad 100644 --- a/src/__tests__/server.getResources.test.ts +++ b/src/__tests__/server.getResources.test.ts @@ -320,6 +320,7 @@ describe('loadFileFetch', () => { expect(mockReadCall).toHaveBeenCalledTimes(expectedIsFetch ? 0 : 1); expect(result).toEqual({ content: 'content', + path: expect.any(String), resolvedPath: expect.any(String) }); }); @@ -374,8 +375,9 @@ describe('processDocsFunction', () => { fetchMemoHits: 1 }, { - description: 'filter empty strings', + description: 'filter empty strings with varied input', inputs: [ + { doc: 'file.md' }, 'file.md', '', ' ', @@ -383,6 +385,23 @@ describe('processDocsFunction', () => { ], options: {}, fileMemoHits: 2 + }, + { + description: 'de-duplicate with first metadata', + inputs: [ + { doc: 'file.md', lorem: 'ipsum' }, + { doc: 'file.md', dolor: 'sit' } + ], + options: {}, + fileMemoHits: 1 + }, + { + description: 'metadata passthrough', + inputs: [ + { doc: 'file.md', lorem: 'ispum', dolor: 'sit' } + ], + options: {}, + fileMemoHits: 1 } ])('should process local and remote inputs, $description', async ({ inputs, options, fileMemoHits = 0, fetchMemoHits = 0 }) => { const result = await processDocsFunction(inputs, { diff --git a/src/__tests__/server.helpers.test.ts b/src/__tests__/server.helpers.test.ts index 18fe2a00..ca8b6482 100644 --- a/src/__tests__/server.helpers.test.ts +++ b/src/__tests__/server.helpers.test.ts @@ -1,5 +1,6 @@ import { buildSearchString, + createError, freezeObject, generateHash, hashCode, @@ -8,11 +9,13 @@ import { isPromise, isReferenceLike, isUrl, + isUrlObject, isPath, isWhitelistedUrl, listAllCombinations, listIncrementalCombinations, mergeObjects, + parseUrl, portValid, splitUri, stringJoin, @@ -57,12 +60,66 @@ describe('buildSearchString', () => { lorem: 'ipsum dolor' }, expected: 'lorem=ipsum+dolor' + }, + { + description: 'prefix with base that has query', + values: { + lorem: 'ipsum' + }, + options: { + prefix: true, + base: 'patternfly://docs?version=1' + }, + expected: '&lorem=ipsum' } ])('should build a search string, $description', ({ values, options, expected }) => { expect(buildSearchString(values, options || {})).toBe(expected); }); }); +describe('createError', () => { + it.each([ + { + description: 'use an explicit message and metadata', + message: 'Custom error message', + metadata: { code: 404, details: 'Not found' }, + expectedMessage: 'Custom error message', + expectedMetadata: { code: 404, details: 'Not found' } + }, + { + description: 'use an Error object as message', + message: new Error('Original error message'), + metadata: { path: '/tmp' }, + expectedMessage: 'Original error message', + expectedMetadata: { path: '/tmp' } + }, + { + description: 'use cause message when message is undefined', + message: undefined, + options: { cause: new Error('Cause error') }, + metadata: { retry: true }, + expectedMessage: 'Cause error', + expectedMetadata: { retry: true } + }, + { + description: 'use fallback message when nothing else is provided', + message: undefined, + metadata: {}, + expectedMessage: 'An error occurred', + expectedMetadata: {} + } + ])('should create an error, $description', ({ message, options, metadata, expectedMessage, expectedMetadata }: any) => { + const err = createError(message, options || {}, metadata); + + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe(expectedMessage); + Object.entries(expectedMetadata).forEach(([key, value]) => { + expect((err as any)[key]).toBe(value); + }); + expect(err.cause).toBe(options?.cause); + }); +}); + describe('freezeObject', () => { it.each([ { @@ -463,6 +520,55 @@ describe('isPromise', () => { }); }); +describe('isUrlObject', () => { + it.each([ + { + description: 'valid URL object with no protocol restrictions', + obj: new URL('https://patternfly.org'), + options: {}, + expected: true + }, + { + description: 'valid URL object with matching allowed protocol', + obj: new URL('patternfly://docs'), + options: { allowedProtocols: ['patternfly'] }, + expected: true + }, + { + description: 'valid URL object with non-matching allowed protocol', + obj: new URL('http://patternfly.org'), + options: { allowedProtocols: ['https'] }, + expected: false + }, + { + description: 'invalid input (string instead of URL object)', + obj: 'https://patternfly.org', + options: {}, + expected: false + }, + { + description: 'invalid input, null', + obj: null, + options: {}, + expected: false + }, + { + description: 'invalid input, undefined', + obj: undefined, + options: {}, + expected: false + }, + { + description: 'invalid input, empty string', + obj: ' ', + options: {}, + expected: false + } + ])('should return $expected for $description', ({ obj, options, expected }) => { + expect(isUrlObject(obj, options)).toBe(expected); + }); +}); + describe('isPlainObject', () => { it.each([ { @@ -729,6 +835,11 @@ describe('isWhitelistedUrl', () => { description: 'invalid URL string', url: 'not-a-url', expected: false + }, + { + description: 'reject encoded slash in pathname', + url: 'https://github.com/patternfly%2Fother-repo', + expected: false } ])('should confirm if a URL is whitelisted, $description', ({ url, expected }) => { const whitelist = [ @@ -950,6 +1061,84 @@ describe('portValid', () => { }); }); +describe('parseUrl', () => { + it.each([ + { + description: 'absolute URI without prefix', + uri: 'patternfly://docs/button?v=1', + options: { isStrict: false }, + expected: { + protocol: 'patternfly:', + hostname: 'docs', + path: 'button', + params: { v: '1' } + } + }, + { + description: 'relative URI with prefix', + uri: 'docs/button', + options: { prefix: 'patternfly', isStrict: false }, + expected: { + protocol: 'patternfly:', + hostname: 'docs', + path: 'button', + params: {} + } + }, + { + description: 'absolute URI with ignored prefix', + uri: 'https://google.com/search?q=test', + options: { prefix: 'patternfly://' }, + expected: { + protocol: 'https:', + hostname: 'google.com', + path: 'search', + params: { q: 'test' } + } + }, + { + description: 'invalid URI', + uri: 'not a uri', + expected: undefined + }, + { + description: 'relative URI without prefix', + uri: 'docs/button', + expected: undefined + }, + { + description: 'absolute URI without hostname', + uri: 'patternfly:docs/button', + options: { isStrict: false }, + expected: { + protocol: 'patternfly:', + hostname: '', + path: 'docs/button', + params: {} + } + }, + { + description: 'pass through a malformed percent-encoding in absolute URI', + uri: 'https://patternfly.org/docs/bad%zzpath', + options: {}, + expected: { + protocol: 'https:', + hostname: 'patternfly.org', + path: 'docs/bad%zzpath', + params: {} + } + }, + { + description: 'return undefined for malformed percent-encoding in prefixed path', + uri: 'docs/bad%zzpath', + options: { prefix: 'patternfly' }, + expected: undefined + } + ])('should parse a URI, $description', ({ uri, options, expected }) => { + expect(parseUrl(uri, options)).toEqual(expected); + }); +}); + describe('splitUri', () => { it.each([ { diff --git a/src/server.getResources.ts b/src/server.getResources.ts index 7b33151d..718e869e 100644 --- a/src/server.getResources.ts +++ b/src/server.getResources.ts @@ -6,15 +6,39 @@ import { getOptions } from './options.context'; import { DEFAULT_OPTIONS } from './options.defaults'; import { memo } from './server.caching'; import { normalizeString } from './server.search'; -import { isUrl, isPath } from './server.helpers'; +import { isUrl, isPath, createError } from './server.helpers'; import { log, formatUnknownError } from './logger'; -interface ProcessedDoc { +/** + * Represents a successful document processing attempt. + * + * @template T - Metadata that can be returned with the processed document. + */ +type ProcessedDocSuccess> = { + content: string; + path: string; + resolvedPath: string; + isSuccess: true; +} & T; + +/** + * Represents a failed document processing attempt. + * + * @template T - Metadata that can be returned with the processed document. + */ +type ProcessedDocFailure> = { content: string; path: string | undefined; resolvedPath: string | undefined; - isSuccess: boolean; -} + isSuccess: false; +} & T; + +/** + * A processed document, either successful or failed. + * + * @template T - Metadata that can be returned with the processed document. + */ +type ProcessedDoc> = ProcessedDocSuccess | ProcessedDocFailure; /** * Match a dependency version against a list of supported versions. @@ -225,25 +249,33 @@ const mockPathOrUrlFunction = async (pathOrUrl: string, options = getOptions()) * * @param pathOrUrl - Path or URL to load. If it's a URL, it will be fetched with `timeout` and `error` handling. * @param options - Options - * @returns Resolves to an object containing the loaded content and the resolved path. + * @returns Resolves to an object containing the loaded content, path, and the resolved path. + * @throws {Error} If the path cannot be accessed in the current mode. Includes `path` and `resolvedPath` + * properties when available. */ const loadFileFetch = async (pathOrUrl: string, options = getOptions()) => { - if (options.mode === 'test') { - const mockContent = await mockPathOrUrlFunction(pathOrUrl); + let updatedPathOrUrl = pathOrUrl; - return { content: mockContent, resolvedPath: pathOrUrl }; - } + try { + if (options.mode === 'test') { + const mockContent = await mockPathOrUrlFunction(pathOrUrl); + + return { content: mockContent, resolvedPath: updatedPathOrUrl, path: pathOrUrl }; + } - const updatedPathOrUrl = resolveLocalPathFunction(pathOrUrl); - let content; + updatedPathOrUrl = resolveLocalPathFunction(pathOrUrl); + let content; - if (isUrl(updatedPathOrUrl)) { - content = await fetchUrlFunction.memo(updatedPathOrUrl); - } else { - content = await readLocalFileFunction.memo(updatedPathOrUrl); - } + if (isUrl(updatedPathOrUrl)) { + content = await fetchUrlFunction.memo(updatedPathOrUrl); + } else { + content = await readLocalFileFunction.memo(updatedPathOrUrl); + } - return { content, resolvedPath: updatedPathOrUrl }; + return { content, resolvedPath: updatedPathOrUrl, path: pathOrUrl }; + } catch (error) { + throw createError(error, {}, { resolvedPath: updatedPathOrUrl, path: pathOrUrl }); + } }; /** @@ -281,54 +313,83 @@ const promiseQueue = async (queue: string[], limit = 5) => { }; /** - * Normalize inputs, load all in parallel, and return a joined string. + * Normalize inputs, load all in parallel, and return per-doc results. + * + * @note Remember: + * - To limit the number of docs to load to avoid OOM. + * - Deduplication of paths happens using `normalizeString.memo`. Original paths are + * still used to fetch and are returned as part of the result. * - * @note Remember to limit the number of docs to load to avoid OOM. + * @template T - Metadata fields on `{ doc, ...metadata }` inputs, merged into each result. * @param inputs - List of paths or URLs to load * @param options - Optional options - * @returns An array of loaded docs with content, path, resolvedPath, and isSuccess properties: + * @returns An array of {@link ProcessedDoc} entries: * - `content` is the loaded content string. * - `path` is the original input path or URL. - * - `resolvedPath` is the resolved path after normalization. + * - `resolvedPath` is the resolved path after normalization, see {@link loadFileFetch}. * - `isSuccess` is true if the doc was successfully loaded, false otherwise. */ -const processDocsFunction = async ( - inputs: string[], +const processDocsFunction = async = Record>( + inputs: (string | ({ doc: string } & T))[], options = getOptions() -): Promise => { - const uniqueInputs = new Map( - inputs.map(input => [normalizeString.memo(input), input.trim()]) - ); - const list = Array.from(uniqueInputs.values()).slice(0, options.minMax.docsToLoad.max).filter(Boolean); +): Promise>[]> => { + const normalizeInputs = inputs.map(input => + (typeof input === 'string' ? { doc: input } : input) as { doc: string } & T); + + const uniqueInputsMap = new Map(); + + for (const input of normalizeInputs) { + const trimmedDoc = input.doc.trim(); + + if (trimmedDoc) { + const normalizedPath = normalizeString.memo(trimmedDoc); + + if (!uniqueInputsMap.has(normalizedPath)) { + uniqueInputsMap.set(normalizedPath, { ...input, doc: trimmedDoc }); + } + } + } + + const uniqueInputsList = Array.from(uniqueInputsMap.values()).slice(0, options.minMax.docsToLoad.max); + const list = uniqueInputsList.map(input => input.doc); const settled = await promiseQueue(list); - const docs: { content: string, path: string | undefined, resolvedPath: string | undefined, isSuccess: boolean }[] = []; + const docs: ProcessedDoc>[] = []; settled.forEach((res, index) => { - const original = list[index]; - let content; - let resolvedPath; - const path = original; - let isSuccess = false; + const originalInput = uniqueInputsList[index]; - if (res.status === 'fulfilled') { - const { resolvedPath: docResolvedPath, content: docContent } = res.value; + if (!originalInput) { + return; + } - resolvedPath = docResolvedPath; - content = docContent; - isSuccess = true; - } else { - const errorMessage = res.reason instanceof Error ? res.reason.message : String(res.reason); + const { doc: originalPath, ...metadata } = originalInput; - content = `❌ Failed to load ${original}: ${errorMessage}`; + if (res.status === 'fulfilled') { + docs.push({ + ...(metadata as Omit), + ...res.value, + isSuccess: true + }); + + return; } + const reason: Error & { path?: string; resolvedPath?: string } = res.reason; + const error = reason instanceof Error ? reason : undefined; + const errorPath = error?.path || originalPath; + const errorResolvedPath = error?.resolvedPath || undefined; + const errorMessage = error?.message || String(reason); + docs.push({ - content, - path, - resolvedPath, - isSuccess + ...(metadata as Omit), + content: `❌ Failed to load ${errorPath}: ${errorMessage}`, + path: errorPath, + resolvedPath: errorResolvedPath, + isSuccess: false }); + + log.debug(`Failed to load ${errorPath} from processing: ${formatUnknownError(errorMessage)}`); }); return docs; @@ -348,5 +409,7 @@ export { promiseQueue, readLocalFileFunction, resolveLocalPathFunction, - type ProcessedDoc + type ProcessedDoc, + type ProcessedDocSuccess, + type ProcessedDocFailure }; diff --git a/src/server.helpers.ts b/src/server.helpers.ts index 110b7226..fd3cd775 100644 --- a/src/server.helpers.ts +++ b/src/server.helpers.ts @@ -183,19 +183,41 @@ const isAsync = (obj: unknown) => /^\[object (Async|AsyncFunction)]/.test(Object const isPromise = (obj: unknown) => /^\[object (Promise|Async|AsyncFunction)]/.test(Object.prototype.toString.call(obj)); /** - * Check if a value is a valid URL, URL-like. + * Check if a value is a URL object. + * + * Setting allowedProtocols to `undefined` or an empty array will allow all protocols. + * + * @param obj - The unknown value to check + * @param [options] - Options + * @param [options.allowedProtocols] - Array of allowed URL protocols. Default `undefined`. + * @returns `true` if the value is an instance of URL + */ +const isUrlObject = (obj: unknown, { allowedProtocols }: { allowedProtocols?: string[] | undefined } = {}): obj is URL => { + const isUrlObj = obj instanceof URL; + const isProtocolAvailable = Array.isArray(allowedProtocols) && allowedProtocols.length > 0; + const isUrlObjAndProtocolAllowed = isUrlObj && isProtocolAvailable && allowedProtocols?.includes(obj.protocol.slice(0, -1)); + + return (isUrlObj && !isProtocolAvailable) || isUrlObjAndProtocolAllowed; +}; + +/** + * Check if a value is a valid URL object, URL string, URL-like. * * @note URL-like validation can be updated to support more URL schemes (e.g. `blob:`). * Be aware this helper is used to gate-keep tools-as-plugins. Consider additions carefully * since they may fall outside our use cases. * - * @param str - String to check + * @param str - String or object to check * @param [options] - Options * @param [options.allowedProtocols] - List of allowed URL protocols. Default: `['file', 'http', 'https', 'data', 'node']` * @param [options.isStrict] - If `true`, only strict URL validation is performed. Default: `true` * @returns `true` if the string is a valid URL, URL-like. */ const isUrl = (str: unknown, { allowedProtocols = ['file', 'http', 'https', 'data', 'node'], isStrict = true } = {}) => { + if (isUrlObject(str, { allowedProtocols })) { + return true; + } + if (typeof str !== 'string' || !str.trim()) { return false; } @@ -374,9 +396,11 @@ const hashNormalizeValue = (value: unknown): unknown => { * Generate a consistent hash from a value * * @param anyValue - Value to hash + * @param [options] - Hash options + * @param [options.isLowercase] - If `true`, lowercase the stringified value before hashing. Default: `false` * @returns Hash string */ -const generateHash = (anyValue: unknown): string => { +const generateHash = (anyValue: unknown, { isLowercase = false }: { isLowercase?: boolean } = {}): string => { const normalizeValue = (_key: string, value: unknown) => hashNormalizeValue(value); let stringify: string; @@ -386,12 +410,16 @@ const generateHash = (anyValue: unknown): string => { stringify = `$error:${Object.prototype.toString.call(anyValue)}:${error}`; } - return hashCode(stringify); + return hashCode(isLowercase ? stringify.toLowerCase() : stringify); }; /** * Check if a string URL matches a whitelist entry * + * @note Avoid decoding the URL as it can introduce unnecessary security risks. + * The prefix checks must use the raw path, so percent-encoded slashes and + * similar sequences can't attempt to bypass the whitelist using decoding. + * * @param url - string URL to check * @param {WhitelistUrl[]} whitelist - List of whitelist entries * @param options - Options for URL validation @@ -465,6 +493,69 @@ const listIncrementalCombinations = (values: string[]): string[][] => return acc; }, [[]] as string[][]); +/** + * URL and URI parser with prefix/protocol support. + * + * @note Avoid decoding for paths for now. If it becomes necessary to use decoding, + * review adding a strict whitelist check before the decoding. + * + * @param url - URL or URI to parse + * @param [options] - Configuration options + * @param [options.prefix] - Optional filtering URL prefix sans-colon and slashes (e.g. "http" vs. "http://"). + * This will match against the provided URI. If the URI does not start with the prefix, `undefined` is returned. + * @param [options.normalizeSearchParamKeys=true] - If `true`, search param keys are normalized to lowercase. Default: `true` + * @param [options.isStrict] - If `true`, only strict URL and path validation is performed. Default: `true` + * @returns Parsed URI, or `undefined` if parsing fails. + */ +const parseUrl = (url: string, { prefix, normalizeSearchParamKeys = true, isStrict = true }: { prefix?: string, normalizeSearchParamKeys?: boolean, isStrict?: boolean } = {}) => { + const isPrefix = typeof prefix === 'string' && prefix.length > 0 && !prefix.includes(':') && !prefix.includes('/'); + const opts = isPrefix ? { allowedProtocols: [prefix] } : {}; + const isUri = isUrl(url, { ...opts, isStrict }); + + // Normalize search param keys into a plain object with original keys, or a plain object with lowercase keys. + const normalizeParamKeys = (searchParams: URLSearchParams) => { + if (normalizeSearchParamKeys) { + return Object.fromEntries(new URLSearchParams( + Array.from(searchParams, ([key, value]) => [key.toLowerCase(), value]) + )); + } + + return Object.fromEntries(searchParams); + }; + + if (isUri) { + try { + const updatedUrl = new URL(url); + + return { + protocol: updatedUrl.protocol, + hostname: updatedUrl.hostname, + path: updatedUrl.pathname.replace(/^\//, ''), + params: normalizeParamKeys(updatedUrl.searchParams) + }; + } catch { + return undefined; + } + } + + if (isPrefix && isPath(url, { isStrict })) { + try { + const updatedUrl = new URL(`${prefix}://${url}`); + + return { + protocol: updatedUrl.protocol, + hostname: updatedUrl.hostname, + path: updatedUrl.pathname.replace(/^\//, ''), + params: normalizeParamKeys(updatedUrl.searchParams) + }; + } catch { + return undefined; + } + } + + return undefined; +}; + /** * Basic split for URIs to find base and search. * @@ -537,17 +628,21 @@ stringJoin.newlineFiltered = (...args: unknown[]) => stringJoin(args, { sep: '\n /** * Construct a search/query string from an object of key-value pairs, optionally filtering out - * specific values and adding a `?` prefix. + * specific values and adding a context-aware separator. + * + * @note This helper needs to migrate over to a "buildUrl" helper function. Patched to allow base + * detection for prefixes in the short-term. * * @param values - An object containing key-value pairs to be converted into a query string. * @param [options] - Configuration options for constructing the query string. - * @param [options.filter=[undefined, null]] - Array of values to filter out from the key-value pairs. - * @param [options.prefix=false] - Determines whether to prepend a "?" to the query string. - * @returns The constructed query string, optionally prefixed with "?", or `undefined` if no valid key-value pairs remain. + * @param [options.filter=[undefined, null]] - Array of values to filter out. + * @param [options.prefix=false] - Whether to prepend a separator. + * @param [options.base=''] - The base URI to check for existing separators. + * @returns The constructed query string with the correct separator. */ const buildSearchString = ( values: Record, - { filter = [undefined, null], prefix = false }: { filter?: unknown[], prefix?: boolean } = {} + { filter = [undefined, null], prefix = false, base = '' }: { filter?: unknown[], prefix?: boolean, base?: string | undefined } = {} ) => { if (!isPlainObject(values)) { return undefined; @@ -565,8 +660,64 @@ const buildSearchString = ( const entriesToString = entries.sort(([aKey], [bKey]) => aKey.localeCompare(bKey)).map(([key, value]) => [key, `${value}`]); const searchParams = new URLSearchParams(Object.fromEntries(entriesToString)); + const searchString = searchParams.toString(); + + if (prefix) { + const separator = base.includes('?') ? '&' : '?'; + + return `${separator}${searchString}`; + } + + return searchString; +}; + +/** + * Create a customized error instance with a fallback message, options, and additional metadata, if provided. + * + * @note Leveraging {@link formatUnknownError} in the future is desired. We'll mark that as a future enhancement + * since it involves potentially creating a new file to avoid circular dependencies, like `server.error.ts` + * + * @template TMetadata A type extending `Record`, representing additional metadata that can be assigned to the error. + * @param message - An error message, Error instance, or other value. If left `undefined`, the error message will be derived from the + * `options.cause` or default to 'An error occurred'. + * @param options - An object containing options for the Error instance. Used for specifying a cause using `options.cause`. + * @param {TMetadata} metadata - An object containing additional metadata to attach to the error. Metadata object must be a plain object. + * @param [settings] - Additional function settings. + * @param [settings.fallbackMessage] - Fallback error message. + * @returns {Error & TMetadata} An error instance enhanced with the provided metadata and a processed error message. + */ +const createError = >( + message: unknown | string | Error, + options: ErrorOptions, + metadata: TMetadata, + { fallbackMessage = 'An error occurred' }: { fallbackMessage?: string } = {} +) => { + let updatedMessage: string = fallbackMessage; + let updatedOptions = options; + + if (typeof message === 'string' && message.length > 0) { + updatedMessage = message; + } else if (message instanceof Error && message.message) { + updatedMessage = message.message; + + if (!options?.cause && message.cause) { + updatedOptions = { ...options, cause: message.cause }; + } + } else if (options?.cause instanceof Error && options.cause.message) { + updatedMessage = options.cause.message; + + if (options.cause.cause) { + updatedOptions = { ...options, cause: options.cause.cause }; + } + } + + const err = new Error(updatedMessage, updatedOptions) as Error & TMetadata; + + if (isPlainObject(metadata)) { + Object.assign(err, metadata); + } - return prefix ? `?${searchParams.toString()}` : searchParams.toString(); + return err; }; /** @@ -602,6 +753,7 @@ const timeoutFunction = async ( export { buildSearchString, + createError, freezeObject, generateHash, hashCode, @@ -613,10 +765,12 @@ export { isPromise, isReferenceLike, isUrl, + isUrlObject, isWhitelistedUrl, listAllCombinations, listIncrementalCombinations, mergeObjects, + parseUrl, portValid, splitUri, stringJoin,