diff --git a/packages/shared/src/errors/apiResponseError.ts b/packages/shared/src/errors/apiResponseError.ts index e3541649735..e60d15b7cc5 100644 --- a/packages/shared/src/errors/apiResponseError.ts +++ b/packages/shared/src/errors/apiResponseError.ts @@ -6,7 +6,7 @@ import type { import { parseErrors } from './parseError'; -interface ClerkAPIResponseOptions { +export interface ClerkAPIResponseOptions { data: ClerkAPIErrorJSON[]; status: number; clerkTraceId?: string; diff --git a/packages/shared/src/errors/future/clerkApiError.ts b/packages/shared/src/errors/future/clerkApiError.ts new file mode 100644 index 00000000000..b2d449a518b --- /dev/null +++ b/packages/shared/src/errors/future/clerkApiError.ts @@ -0,0 +1,66 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import type { ClerkAPIErrorJSON, ClerkApiErrorResponseJSON } from '@clerk/types'; + +import { parseError } from '../parseError'; +import { ClerkError } from './future'; + +/** + * A ClerkError subclass that represents the shell response of a Clerk API error. + * This error contains an array of ClerkApiError instances, each representing a specific error that occurred. + */ +export class ClerkApiResponseError extends ClerkError { + readonly name = 'ClerkApiResponseError'; + readonly retryAfter?: number; + readonly errors: ClerkApiError[]; + + constructor(data: ClerkApiErrorResponseJSON) { + const errorMesages = data.errors.map(e => e.message).join(', '); + const message = `Api errors occurred: ${errorMesages}. Check the \`errors\` property for more details about the specific errors.`; + super({ message, code: 'clerk_api_error', clerkTraceId: data.clerk_trace_id }); + this.errors = data.errors.map(e => new ClerkApiError(e)); + } +} + +/** + * Type guard to check if an error is a ClerkApiResponseError. + * Can be called as a standalone function or as a method on an error object. + * + * @example + * // As a standalone function + * if (isClerkApiResponseError(error)) { ... } + * + * // As a method (when attached to error object) + * if (error.isClerkApiResponseError()) { ... } + */ +export function isClerkApiResponseError(error: Error): error is ClerkApiResponseError; +export function isClerkApiResponseError(this: Error): this is ClerkApiResponseError; +export function isClerkApiResponseError(this: Error | void, error?: Error): error is ClerkApiResponseError { + const target = error ?? this; + if (!target) { + throw new TypeError('isClerkApiResponseError requires an error object'); + } + return target instanceof ClerkApiResponseError; +} + +/** + * This error contains the specific error message, code, and any additional metadata that was returned by the Clerk API. + */ +export class ClerkApiError extends ClerkError { + readonly name = 'ClerkApiError'; + + constructor(json: ClerkAPIErrorJSON) { + const parsedError = parseError(json); + super({ + code: parsedError.code, + message: parsedError.message, + longMessage: parsedError.longMessage, + }); + } +} + +/** + * Type guard to check if a value is a ClerkApiError instance. + */ +export function isClerkApiError(error: Error): error is ClerkApiError { + return error instanceof ClerkApiError; +} diff --git a/packages/shared/src/errors/future/future.ts b/packages/shared/src/errors/future/future.ts new file mode 100644 index 00000000000..4a1a5936360 --- /dev/null +++ b/packages/shared/src/errors/future/future.ts @@ -0,0 +1,72 @@ +export interface ClerkErrorParams { + /** + * A message that describes the error. This is typically intented to be showed to the developers. + * It should not be shown to the user or parsed directly as the message contents are not guaranteed + * to be stable - use the `code` property instead. + */ + message: string; + /** + * A machine-stable code that identifies the error. + */ + code: string; + /** + * A user-friendly message that describes the error and can be displayed to the user. + * This message defaults to English but can be usually translated to the user's language + * by matching the `code` property to a localized message. + */ + longMessage?: string; + /** + * A trace ID that can be used to identify the error in the Clerk API logs. + */ + clerkTraceId?: string; + kind?: string; + /** + * The cause of the error, typically an `Error` instance that was caught and wrapped by the Clerk error handler. + */ + cause?: Error; + /** + * A URL to the documentation for the error. + */ + docsUrl?: string; +} + +/** + * A temporary placeholder, this will eventually be replaced with a + * build-time flag that will actually perform DCE. + */ +const __DEV__ = true; + +export class ClerkError extends Error { + readonly clerkError = true as const; + readonly name: string = 'ClerkError'; + readonly code: string; + readonly longMessage: string | undefined; + readonly clerkTraceId: string | undefined; + readonly kind: string; + readonly docsUrl: string | undefined; + readonly cause: Error | undefined; + + constructor(opts: ClerkErrorParams) { + const formatMessage = (msg: string, code: string, docsUrl: string | undefined) => { + msg = `${this.name}: ${msg.trim()}\n\n(code="${code}")\n\n`; + if (__DEV__) { + msg += `\n\nDocs: ${docsUrl}`; + } + return msg; + }; + + super(formatMessage(opts.message, opts.code, opts.docsUrl), { cause: opts.cause }); + Object.setPrototypeOf(this, ClerkError.prototype); + + this.code = opts.code; + this.kind = opts.kind ?? 'ClerkError'; + this.docsUrl = opts.docsUrl; + } +} + +/** + * Type guard to check if a value is a ClerkError instance. + */ +export function isClerkError(val: unknown): val is ClerkError { + return !!val && typeof val === 'object' && 'clerkError' in val && val.clerkError === true; +} diff --git a/packages/shared/src/errors/future/globalHookError.ts b/packages/shared/src/errors/future/globalHookError.ts new file mode 100644 index 00000000000..e309ac47a52 --- /dev/null +++ b/packages/shared/src/errors/future/globalHookError.ts @@ -0,0 +1,26 @@ +import { isClerkApiResponseError } from './clerkApiError'; +import type { ClerkError } from './future'; + +/** + * Creates a ClerkGlobalHookError object from a ClerkError instance. + * It's a wrapper for all the different instances of Clerk errors that can + * be returned when using Clerk hooks. + */ +export function ClerkGlobalHookError(error: ClerkError) { + const predicates = { + isClerkApiResponseError, + } as const; + + for (const [name, fn] of Object.entries(predicates)) { + Object.assign(error, { [name]: fn }); + } + + return error as ClerkError & typeof predicates; +} + +const ar = ClerkGlobalHookError({} as any); + +console.log(ar.retryAfter); +if (ar.isClerkApiResponseError()) { + console.log(ar.retryAfter); +} diff --git a/packages/shared/src/errors/parseError.ts b/packages/shared/src/errors/parseError.ts index ee343bbbb5a..9191c58683c 100644 --- a/packages/shared/src/errors/parseError.ts +++ b/packages/shared/src/errors/parseError.ts @@ -19,6 +19,7 @@ export function parseError(error: ClerkAPIErrorJSON): ClerkAPIError { code: error.code, message: error.message, longMessage: error.long_message, + clerkTraceId: error.clerk_trace_id, meta: { paramName: error?.meta?.param_name, sessionId: error?.meta?.session_id, diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index bd1c3cfc3c7..42f47175025 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -14,6 +14,10 @@ export interface ClerkAPIError { * A more detailed message that describes the error. */ longMessage?: string; + /** + * A trace ID that can be used to identify the error in the Clerk API logs. + */ + clerkTraceId?: string; /** * Additional information about the error. */ diff --git a/packages/types/src/errors.ts b/packages/types/src/errors.ts new file mode 100644 index 00000000000..c66ace0514b --- /dev/null +++ b/packages/types/src/errors.ts @@ -0,0 +1,34 @@ +import type { ClientJSON } from './json'; + +export interface ClerkApiErrorResponseJSON { + errors: ClerkAPIErrorJSON[]; + clerk_trace_id?: string; + meta?: { client?: ClientJSON }; +} + +export interface ClerkAPIErrorJSON { + code: string; + message: string; + long_message?: string; + clerk_trace_id?: string; + meta?: { + param_name?: string; + session_id?: string; + email_addresses?: string[]; + identifiers?: string[]; + zxcvbn?: { + suggestions: { + code: string; + message: string; + }[]; + }; + plan?: { + amount_formatted: string; + annual_monthly_amount_formatted: string; + currency_symbol: string; + id: string; + name: string; + }; + is_plan_upgrade_possible?: boolean; + }; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b0f51eec6fc..84360a39bac 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -53,6 +53,7 @@ export * from './router'; /** * TODO @revamp-hooks: Drop this in the next major release. */ +export * from './errors'; export * from './runtime-values'; export * from './saml'; export * from './samlAccount'; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 9ab16c1540c..11f5d5334ca 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -15,6 +15,7 @@ import type { import type { CommerceSettingsJSON } from './commerceSettings'; import type { DisplayConfigJSON } from './displayConfig'; import type { EnterpriseProtocol, EnterpriseProvider } from './enterpriseAccount'; +import type { ClerkAPIErrorJSON } from './errors'; import type { EmailAddressIdentifier, UsernameIdentifier } from './identifiers'; import type { ActClaim } from './jwtv2'; import type { OAuthProvider } from './oauth'; @@ -359,32 +360,6 @@ export interface SignUpVerificationJSON extends VerificationJSON { channel?: PhoneCodeChannel; } -export interface ClerkAPIErrorJSON { - code: string; - message: string; - long_message?: string; - meta?: { - param_name?: string; - session_id?: string; - email_addresses?: string[]; - identifiers?: string[]; - zxcvbn?: { - suggestions: { - code: string; - message: string; - }[]; - }; - plan?: { - amount_formatted: string; - annual_monthly_amount_formatted: string; - currency_symbol: string; - id: string; - name: string; - }; - is_plan_upgrade_possible?: boolean; - }; -} - export interface TokenJSON extends ClerkResourceJSON { object: 'token'; jwt: string;