diff --git a/.changeset/dark-turtles-try.md b/.changeset/dark-turtles-try.md new file mode 100644 index 000000000..96ed241f8 --- /dev/null +++ b/.changeset/dark-turtles-try.md @@ -0,0 +1,7 @@ +--- +'@solana/errors': patch +--- + +Refactor `SolanaError` to be produced by `createCodedErrorClass`, eliminating the dual implementation of the same pattern in this package. Behavior (dev/prod message format, instruction-error-index suffix, `cause` / deprecated-cause typing, guard narrowing, frozen context) is preserved exactly. As a side benefit, `isSolanaError`'s guard is now defensively hardened against same-name foreign errors via the factory's shared guard. + +Two additive optional fields on `CodedErrorDefinition` support SolanaError's specific behaviors and are available to any downstream consumer: `prodMessagePrefix` (override the leading token of prod-mode messages) and `messagePostProcessor` (hook for appending a suffix like `" (instruction #N)"` after template interpolation). diff --git a/.changeset/fine-snakes-grow.md b/.changeset/fine-snakes-grow.md new file mode 100644 index 000000000..33c8a4c31 --- /dev/null +++ b/.changeset/fine-snakes-grow.md @@ -0,0 +1,8 @@ +--- +'@solana/errors-core': minor +'@solana/errors': patch +--- + +Add `@solana/errors-core`, a new package containing the primitives — `createCodedErrorClass`, `formatMessageTemplate`, `encodeContextObject` / `decodeEncodedContext` — for building strongly-typed, numerically-coded JavaScript error classes. `@solana/errors` now consumes this package internally and re-exports `createCodedErrorClass` (and its associated types) so existing imports continue to work unchanged. + +Downstream tooling (paymasters, wallets, codegen tools, etc.) that wants its own coded error system should depend on `@solana/errors-core` directly rather than pulling in the full Kit error catalog. diff --git a/.changeset/slow-queens-argue.md b/.changeset/slow-queens-argue.md new file mode 100644 index 000000000..3b32f7376 --- /dev/null +++ b/.changeset/slow-queens-argue.md @@ -0,0 +1,5 @@ +--- +'@solana/errors': minor +--- + +Add `createCodedErrorClass` — a factory that lets downstream tooling (Kora, Codama, Keychain, Solana Pay, etc.) build its own strongly-typed, numerically-coded error class backed by the same machinery as `SolanaError`, without having to register its error codes in `@solana/errors`. The returned bundle includes the new error class, a code-narrowing `isError` type guard, and a `getHumanReadableMessage` helper. diff --git a/packages/errors-core/.gitignore b/packages/errors-core/.gitignore new file mode 100644 index 000000000..aff17b6df --- /dev/null +++ b/packages/errors-core/.gitignore @@ -0,0 +1,2 @@ +.docs/ +dist/ diff --git a/packages/errors-core/.npmrc b/packages/errors-core/.npmrc new file mode 100644 index 000000000..b6f27f135 --- /dev/null +++ b/packages/errors-core/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/packages/errors-core/.prettierignore b/packages/errors-core/.prettierignore new file mode 100644 index 000000000..2bd5f0063 --- /dev/null +++ b/packages/errors-core/.prettierignore @@ -0,0 +1,4 @@ +# Changelogs are autogenerated, so leave them alone +CHANGELOG.md + +dist/ diff --git a/packages/errors-core/CHANGELOG.md b/packages/errors-core/CHANGELOG.md new file mode 100644 index 000000000..bb5aa1318 --- /dev/null +++ b/packages/errors-core/CHANGELOG.md @@ -0,0 +1 @@ +# @solana/errors-core diff --git a/packages/errors-core/LICENSE b/packages/errors-core/LICENSE new file mode 100644 index 000000000..ec09953d3 --- /dev/null +++ b/packages/errors-core/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2023 Solana Labs, Inc + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/errors-core/README.md b/packages/errors-core/README.md new file mode 100644 index 000000000..7263a21e6 --- /dev/null +++ b/packages/errors-core/README.md @@ -0,0 +1,132 @@ +[![npm][npm-image]][npm-url] +[![npm-downloads][npm-downloads-image]][npm-url] +
+[![code-style-prettier][code-style-prettier-image]][code-style-prettier-url] + +[code-style-prettier-image]: https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square +[code-style-prettier-url]: https://github.com/prettier/prettier +[npm-downloads-image]: https://img.shields.io/npm/dm/@solana/errors-core?style=flat +[npm-image]: https://img.shields.io/npm/v/@solana/errors-core?style=flat +[npm-url]: https://www.npmjs.com/package/@solana/errors-core + +# @solana/errors-core + +Primitives for building strongly-typed, numerically-coded JavaScript error classes — the factory machinery that powers [`SolanaError`](https://www.solanakit.com/api/classes/_solana_errors.SolanaError.html), exposed as a standalone package so that downstream tools (paymasters, wallets, program clients, etc.) can build their own coded error systems without growing the [`@solana/errors`](../errors/) catalog. + +If you are throwing or catching errors from Kit itself, you want [`@solana/errors`](../errors/), not this package. + +## Installation + +```package-install +@solana/errors-core +``` + +## Usage + +Define your codes, the context shape associated with each code, and the human-readable templates. `createCodedErrorClass` returns a constructor, a code-narrowing type guard, and a standalone message formatter. + +```ts +import { createCodedErrorClass } from '@solana/errors-core'; + +export const KORA_ERROR__ACCOUNT_NOT_FOUND = -32050 as const; +export const KORA_ERROR__RATE_LIMIT_EXCEEDED = -32030 as const; + +export type KoraErrorCode = typeof KORA_ERROR__ACCOUNT_NOT_FOUND | typeof KORA_ERROR__RATE_LIMIT_EXCEEDED; + +export type KoraErrorContext = { + [KORA_ERROR__ACCOUNT_NOT_FOUND]: { address: string }; + [KORA_ERROR__RATE_LIMIT_EXCEEDED]: undefined; +}; + +export const { ErrorClass: KoraError, isError: isKoraError } = createCodedErrorClass({ + messages: { + [KORA_ERROR__ACCOUNT_NOT_FOUND]: 'Account $address not found', + [KORA_ERROR__RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded', + }, + name: 'KoraError', + prodDecodeCommand: 'npx @kora/errors decode --', +}); +``` + +Throw and narrow: + +```ts +try { + /* ... */ +} catch (e) { + if (isKoraError(e, KORA_ERROR__ACCOUNT_NOT_FOUND)) { + // `e.context.address` is now typed as `string`. + displayError(`Missing account ${e.context.address}`); + } +} +``` + +### Message templates + +Templates use `$variable` tokens to interpolate values from an error's context. Tokens with no matching context key are rendered literally; escape a literal `$` with `\\$`. + +You can supply `messages` as either: + +- A `Readonly>` keyed by code (gives exhaustiveness checking against your code union), or +- A `(code: TCode) => string` function. Prefer this form to let bundlers tree-shake your templates out of production builds — write the body as `(code) => (__DEV__ ? MyMessages[code] : '')` so the reference to `MyMessages` lives inside a `__DEV__`-gated branch and can be eliminated under a static `__DEV__ === false` replacement. + +The factory only consults `messages` when `__DEV__ === true`. In production it emits the short-form decode hint described below. + +### Production mode + +When your bundler sets `__DEV__` to `false`, error messages are stripped from the bundle. The factory emits a short-form message of the form: + +``` +KoraError #-32050; Decode this error by running `npx @kora/errors decode -- -32050 'BASE64_CONTEXT'` +``` + +…where `BASE64_CONTEXT` is produced by [`encodeContextObject`](./src/context.ts). The pair `encodeContextObject` / `decodeEncodedContext` round-trip the context through `URLSearchParams`, so it is lossy for non-string types: the decoder receives string values back. + +If you don't configure a `prodDecodeCommand`, the production message is just `KoraError #-32050`. Either way, **you should pair this package with a small `decode` CLI in your error package** so consumers can recover the human-readable message from a code/context pair shipped by a production build. + +A minimal decoder is just a few lines: + +```ts +#!/usr/bin/env node +// bin/decode.ts +import { decodeEncodedContext } from '@solana/errors-core'; +import { getKoraErrorMessage } from '../src'; + +const [, , rawCode, encodedContext] = process.argv; +const code = Number.parseInt(rawCode, 10); +const context = encodedContext ? decodeEncodedContext(encodedContext) : undefined; +console.log(getKoraErrorMessage(code, context)); +``` + +…where `getKoraErrorMessage` is the `getHumanReadableMessage` returned by your `createCodedErrorClass` call. Wire it up in your package's `package.json`: + +```json +{ + "bin": { + "@kora/errors": "./bin/decode.js" + } +} +``` + +…and the `prodDecodeCommand: 'npx @kora/errors decode --'` you configured will invoke it. See [`@solana/errors`'s `cli.ts`](../errors/src/cli.ts) for a fuller reference (argument validation, colored output, `commander`-based parsing). + +## Rules for publishing coded errors + +Coded errors are part of your public, wire-format-stable surface area. Once you've published a code, treat it like a database migration: + +- **Don't remove an error.** Old clients still throw it. +- **Don't change the meaning of an error message.** It will become misleading. +- **Don't change or reorder error codes.** Decoders look codes up by number. +- **Don't change or remove members of an error's context.** Old throws will still ship the old shape. + +When an older client throws an error, you want to make sure it can always be decoded. If you make any of the changes above, old clients won't have received your changes — and the errors they throw will become impossible to decode going forward. + +The accepted way to evolve the system is to **add** new codes / context fields, never to mutate existing ones. + +## API + +- `createCodedErrorClass(definition)` — returns `{ ErrorClass, isError, getHumanReadableMessage }`. +- `formatMessageTemplate(template, context)` — render a single `$variable` template; the lower-level primitive used by the factory. +- `encodeContextObject(context)` / `decodeEncodedContext(encoded)` — the round-trip used to embed context in production messages. Use `decodeEncodedContext` in your `decode` CLI. + +See the [generated API reference](https://www.solanakit.com/api) for the full type signatures. diff --git a/packages/errors-core/package.json b/packages/errors-core/package.json new file mode 100644 index 000000000..910b841e0 --- /dev/null +++ b/packages/errors-core/package.json @@ -0,0 +1,87 @@ +{ + "name": "@solana/errors-core", + "version": "6.8.0", + "description": "Primitives for building strongly-typed, numerically-coded JavaScript error classes", + "homepage": "https://www.solanakit.com/api#solanaerrorscore", + "exports": { + "edge-light": { + "import": "./dist/index.node.mjs", + "require": "./dist/index.node.cjs" + }, + "workerd": { + "import": "./dist/index.node.mjs", + "require": "./dist/index.node.cjs" + }, + "browser": { + "import": "./dist/index.browser.mjs", + "require": "./dist/index.browser.cjs" + }, + "node": { + "import": "./dist/index.node.mjs", + "require": "./dist/index.node.cjs" + }, + "react-native": "./dist/index.native.mjs", + "types": "./dist/types/index.d.ts" + }, + "browser": { + "./dist/index.node.cjs": "./dist/index.browser.cjs", + "./dist/index.node.mjs": "./dist/index.browser.mjs" + }, + "main": "./dist/index.node.cjs", + "module": "./dist/index.node.mjs", + "react-native": "./dist/index.native.mjs", + "types": "./dist/types/index.d.ts", + "type": "commonjs", + "files": [ + "./dist/", + "./src/" + ], + "sideEffects": false, + "keywords": [ + "blockchain", + "solana", + "web3" + ], + "scripts": { + "compile:docs": "typedoc", + "compile:js": "tsup --config build-scripts/tsup.config.package.ts", + "compile:typedefs": "tsc -p ./tsconfig.declarations.json", + "dev": "NODE_OPTIONS=\"--no-experimental-webstorage\" jest -c ../../node_modules/@solana/test-config/jest-dev.config.js --rootDir . --watch", + "prepublishOnly": "pnpm pkg delete devDependencies", + "publish-impl": "npm view $npm_package_name@$npm_package_version > /dev/null 2>&1 || (pnpm publish --tag ${PUBLISH_TAG:-canary} --access public --no-git-checks && (([ -n \"${GITHUB_OUTPUT:-}\" ] && echo 'published=true' >> \"$GITHUB_OUTPUT\") || true) && (([ \"$PUBLISH_TAG\" != \"canary\" ] && ../build-scripts/maybe-tag-latest.ts --token \"$GITHUB_TOKEN\" $npm_package_name@$npm_package_version) || true))", + "publish-packages": "pnpm prepublishOnly && pnpm publish-impl", + "style:fix": "pnpm eslint --fix src && pnpm prettier --log-level warn --ignore-unknown --write ./*", + "test:lint": "TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-lint.config.js --rootDir . --silent", + "test:prettier": "TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-prettier.config.js --rootDir . --silent", + "test:treeshakability:browser": "agadoo dist/index.browser.mjs", + "test:treeshakability:native": "agadoo dist/index.native.mjs", + "test:treeshakability:node": "agadoo dist/index.node.mjs", + "test:typecheck": "tsc --noEmit", + "test:unit:browser": "NODE_OPTIONS=\"--no-experimental-webstorage\" TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-unit.config.browser.js --rootDir . --silent", + "test:unit:node": "NODE_OPTIONS=\"--no-experimental-webstorage\" TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-unit.config.node.js --rootDir . --silent" + }, + "author": "Solana Labs Maintainers ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anza-xyz/kit" + }, + "bugs": { + "url": "https://github.com/anza-xyz/kit/issues" + }, + "browserslist": [ + "supports bigint and not dead", + "maintained node versions" + ], + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "engines": { + "node": ">=20.18.0" + } +} diff --git a/packages/errors-core/src/.npmignore b/packages/errors-core/src/.npmignore new file mode 100644 index 000000000..b75823c19 --- /dev/null +++ b/packages/errors-core/src/.npmignore @@ -0,0 +1,4 @@ +__benchmarks__/ +__mocks__/ +__tests__/ +__typetests__/ diff --git a/packages/errors-core/src/__tests__/coded-error-test.ts b/packages/errors-core/src/__tests__/coded-error-test.ts new file mode 100644 index 000000000..5cb74baaf --- /dev/null +++ b/packages/errors-core/src/__tests__/coded-error-test.ts @@ -0,0 +1,345 @@ +import '@solana/test-matchers/toBeFrozenObject'; + +import { createCodedErrorClass } from '../coded-error'; + +const CODE_WITH_CONTEXT = 1001; +const CODE_WITHOUT_CONTEXT = 1002; +const CODE_WITH_ESCAPED_DOLLAR = 1003; + +type TestCode = typeof CODE_WITH_CONTEXT | typeof CODE_WITH_ESCAPED_DOLLAR | typeof CODE_WITHOUT_CONTEXT; +type TestContext = { + [CODE_WITHOUT_CONTEXT]: undefined; + [CODE_WITH_CONTEXT]: { count: number; name: string }; + [CODE_WITH_ESCAPED_DOLLAR]: { amount: number }; +}; + +function makeBundle() { + return createCodedErrorClass({ + messages: { + [CODE_WITHOUT_CONTEXT]: 'Something went wrong', + [CODE_WITH_CONTEXT]: 'Hello $name, you have $count items', + [CODE_WITH_ESCAPED_DOLLAR]: 'Price: \\$$amount', + }, + name: 'TestError', + }); +} + +describe('createCodedErrorClass', () => { + let originalDev: boolean | undefined; + beforeEach(() => { + originalDev = (globalThis as { __DEV__?: boolean }).__DEV__; + (globalThis as { __DEV__?: boolean }).__DEV__ = true; + }); + afterEach(() => { + (globalThis as { __DEV__?: boolean }).__DEV__ = originalDev; + }); + + describe('errorClass', () => { + it('constructs an Error subclass with the configured name', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITHOUT_CONTEXT); + expect(err).toBeInstanceOf(Error); + expect(ErrorClass.name).toBe('TestError'); + expect(err.name).toBe('TestError'); + expect(err.constructor.name).toBe('TestError'); + }); + it('sets __code on the context', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITH_CONTEXT, { count: 3, name: 'world' }); + expect(err.context).toHaveProperty('__code', CODE_WITH_CONTEXT); + }); + it('preserves all context properties', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITH_CONTEXT, { count: 3, name: 'world' }); + expect(err.context).toHaveProperty('name', 'world'); + expect(err.context).toHaveProperty('count', 3); + }); + it('freezes the context object', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITH_CONTEXT, { count: 3, name: 'world' }); + expect(err.context).toBeFrozenObject(); + }); + it('exposes a context with only __code for codes without context', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITHOUT_CONTEXT); + expect(err.context).toEqual({ __code: CODE_WITHOUT_CONTEXT }); + }); + it('extracts `cause` from context into ErrorOptions', () => { + const { ErrorClass } = makeBundle(); + const rootCause = new Error('boom'); + const err = new ErrorClass(CODE_WITH_CONTEXT, { + cause: rootCause, + count: 1, + name: 'x', + } as unknown as TestContext[typeof CODE_WITH_CONTEXT]); + expect(err.cause).toBe(rootCause); + expect(err.context).not.toHaveProperty('cause'); + }); + it('interpolates $variable tokens in the message', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITH_CONTEXT, { count: 5, name: 'alice' }); + expect(err.message).toBe('Hello alice, you have 5 items'); + }); + it('leaves unmatched variables literal', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITH_CONTEXT, { + name: 'alice', + } as unknown as TestContext[typeof CODE_WITH_CONTEXT]); + expect(err.message).toBe('Hello alice, you have $count items'); + }); + it('honors backslash escapes for literal $ in templates', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITH_ESCAPED_DOLLAR, { amount: 42 }); + expect(err.message).toBe('Price: $42'); + }); + }); + + describe('isError guard', () => { + it('returns true for any error produced by this bundle', () => { + const { ErrorClass, isError } = makeBundle(); + expect(isError(new ErrorClass(CODE_WITHOUT_CONTEXT))).toBe(true); + }); + it('returns false for unrelated Error instances', () => { + const { isError } = makeBundle(); + expect(isError(new Error('nope'))).toBe(false); + expect(isError({ name: 'TestError' })).toBe(false); + expect(isError(null)).toBe(false); + }); + it('narrows by code when one is supplied', () => { + const { ErrorClass, isError } = makeBundle(); + const err: unknown = new ErrorClass(CODE_WITH_CONTEXT, { count: 1, name: 'x' }); + expect(isError(err, CODE_WITH_CONTEXT)).toBe(true); + expect(isError(err, CODE_WITHOUT_CONTEXT)).toBe(false); + }); + it('rejects a foreign Error whose name was manually reassigned to match', () => { + const { isError } = makeBundle(); + const foreign = new Error('not one of ours'); + foreign.name = 'TestError'; + expect(isError(foreign)).toBe(false); + expect(isError(foreign, CODE_WITH_CONTEXT)).toBe(false); + }); + it('rejects an error whose context is missing __code even if the name matches', () => { + const { isError } = makeBundle(); + const foreign = new Error('bogus'); + foreign.name = 'TestError'; + (foreign as unknown as { context: object }).context = { foo: 'bar' }; + expect(isError(foreign)).toBe(false); + expect(isError(foreign, CODE_WITH_CONTEXT)).toBe(false); + }); + it('does not throw when narrowing on a same-name error without context', () => { + const { isError } = makeBundle(); + const foreign = new Error('bogus'); + foreign.name = 'TestError'; + expect(() => isError(foreign, CODE_WITH_CONTEXT)).not.toThrow(); + }); + it('rejects a same-name error whose context.__code is not a number', () => { + const { isError } = makeBundle(); + const foreign = new Error('bogus'); + foreign.name = 'TestError'; + (foreign as unknown as { context: object }).context = { __code: 'not-a-number' }; + expect(isError(foreign)).toBe(false); + }); + it('does not cross-match between two bundles with different names', () => { + const a = makeBundle(); + const b = createCodedErrorClass({ + messages: { + [CODE_WITHOUT_CONTEXT]: 'other', + [CODE_WITH_CONTEXT]: 'other', + [CODE_WITH_ESCAPED_DOLLAR]: 'other', + }, + name: 'OtherError', + }); + const err = new a.ErrorClass(CODE_WITHOUT_CONTEXT); + expect(a.isError(err)).toBe(true); + expect(b.isError(err)).toBe(false); + }); + }); + + describe('getHumanReadableMessage', () => { + it('renders a message without constructing an error', () => { + const { getHumanReadableMessage } = makeBundle(); + expect(getHumanReadableMessage(CODE_WITH_CONTEXT, { count: 2, name: 'bob' })).toBe( + 'Hello bob, you have 2 items', + ); + }); + it('returns the short-form message in production mode', () => { + (globalThis as { __DEV__?: boolean }).__DEV__ = false; + const { getHumanReadableMessage } = makeBundle(); + expect(getHumanReadableMessage(CODE_WITH_CONTEXT, { count: 2, name: 'bob' })).toBe( + `TestError #${CODE_WITH_CONTEXT}`, + ); + }); + it('does not read the messages map in production mode', () => { + (globalThis as { __DEV__?: boolean }).__DEV__ = false; + const messagesFn = jest.fn(); + const { getHumanReadableMessage, ErrorClass } = createCodedErrorClass({ + messages: messagesFn as unknown as (code: TestCode) => string, + name: 'TestError', + }); + new ErrorClass(CODE_WITHOUT_CONTEXT); + getHumanReadableMessage(CODE_WITHOUT_CONTEXT); + expect(messagesFn).not.toHaveBeenCalled(); + }); + }); + + describe('context preservation', () => { + it('preserves non-enumerable context properties', () => { + const { ErrorClass } = makeBundle(); + const ctx = {} as TestContext[typeof CODE_WITH_CONTEXT]; + Object.defineProperty(ctx, 'name', { enumerable: false, value: 'alice' }); + Object.defineProperty(ctx, 'count', { enumerable: false, value: 7 }); + const err = new ErrorClass(CODE_WITH_CONTEXT, ctx); + expect((err.context as { name: string }).name).toBe('alice'); + expect((err.context as { count: number }).count).toBe(7); + }); + it('preserves accessor (getter) context properties', () => { + const { ErrorClass } = makeBundle(); + const nameGetter = jest.fn().mockReturnValue('alice'); + const ctx = { + count: 1, + get name() { + return nameGetter() as string; + }, + } as unknown as TestContext[typeof CODE_WITH_CONTEXT]; + const err = new ErrorClass(CODE_WITH_CONTEXT, ctx); + expect((err.context as { name: string }).name).toBe('alice'); + expect(nameGetter).toHaveBeenCalled(); + }); + it('does not leak inherited (prototype) properties into context', () => { + const { ErrorClass } = makeBundle(); + const proto = { inherited: 'nope' }; + const ctx = Object.create(proto) as TestContext[typeof CODE_WITH_CONTEXT]; + Object.assign(ctx, { count: 1, name: 'alice' }); + const err = new ErrorClass(CODE_WITH_CONTEXT, ctx); + expect(Object.prototype.hasOwnProperty.call(err.context, 'inherited')).toBe(false); + }); + it('strips `cause: undefined` from context but forwards it to ErrorOptions', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITH_CONTEXT, { + cause: undefined, + count: 1, + name: 'x', + } as unknown as TestContext[typeof CODE_WITH_CONTEXT]); + expect(err.context).not.toHaveProperty('cause'); + expect('cause' in err).toBe(true); + expect(err.cause).toBeUndefined(); + }); + }); + + describe('messagePostProcessor', () => { + it('is invoked with the code, context, and interpolated message in dev mode', () => { + const postProcessor = jest.fn((_code, _ctx, message) => `${message}!!`); + const { ErrorClass } = createCodedErrorClass({ + messagePostProcessor: postProcessor, + messages: { + [CODE_WITHOUT_CONTEXT]: 'Something went wrong', + [CODE_WITH_CONTEXT]: 'Hello $name', + [CODE_WITH_ESCAPED_DOLLAR]: 'ignored', + }, + name: 'TestError', + }); + const err = new ErrorClass(CODE_WITH_CONTEXT, { count: 1, name: 'alice' }); + expect(postProcessor).toHaveBeenCalledWith( + CODE_WITH_CONTEXT, + expect.objectContaining({ name: 'alice' }), + 'Hello alice', + ); + expect(err.message).toBe('Hello alice!!'); + }); + it('is applied to the output of `getHumanReadableMessage`', () => { + const { getHumanReadableMessage } = createCodedErrorClass({ + messagePostProcessor: (_code, _ctx, message) => `${message} (suffix)`, + messages: { + [CODE_WITHOUT_CONTEXT]: 'Something went wrong', + [CODE_WITH_CONTEXT]: 'Hello $name', + [CODE_WITH_ESCAPED_DOLLAR]: 'ignored', + }, + name: 'TestError', + }); + expect(getHumanReadableMessage(CODE_WITH_CONTEXT, { count: 1, name: 'bob' })).toBe('Hello bob (suffix)'); + }); + it('is not invoked in production mode', () => { + (globalThis as { __DEV__?: boolean }).__DEV__ = false; + const postProcessor = jest.fn((_code, _ctx, message) => `${message}!!`); + const { ErrorClass } = createCodedErrorClass({ + messagePostProcessor: postProcessor, + messages: { + [CODE_WITHOUT_CONTEXT]: 'ignored', + [CODE_WITH_CONTEXT]: 'ignored', + [CODE_WITH_ESCAPED_DOLLAR]: 'ignored', + }, + name: 'TestError', + }); + const err = new ErrorClass(CODE_WITHOUT_CONTEXT); + expect(postProcessor).not.toHaveBeenCalled(); + expect(err.message).toBe(`TestError #${CODE_WITHOUT_CONTEXT}`); + }); + }); + + describe('production mode messaging', () => { + beforeEach(() => { + (globalThis as { __DEV__?: boolean }).__DEV__ = false; + }); + // Top-level afterEach restores __DEV__ to its pre-test value. + + it('emits a bare name + code string when no decode command is configured', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITHOUT_CONTEXT); + expect(err.message).toBe(`TestError #${CODE_WITHOUT_CONTEXT}`); + }); + it('emits a decode hint when a decode command is configured', () => { + const { ErrorClass } = createCodedErrorClass({ + messages: { + [CODE_WITHOUT_CONTEXT]: 'ignored', + [CODE_WITH_CONTEXT]: 'ignored', + [CODE_WITH_ESCAPED_DOLLAR]: 'ignored', + }, + name: 'TestError', + prodDecodeCommand: 'npx @test/errors decode --', + }); + const err = new ErrorClass(CODE_WITHOUT_CONTEXT); + expect(err.message).toBe( + `TestError #${CODE_WITHOUT_CONTEXT}; Decode this error by running \`npx @test/errors decode -- ${CODE_WITHOUT_CONTEXT}\``, + ); + }); + it('uses `prodMessagePrefix` in place of `name` when provided', () => { + const { ErrorClass } = createCodedErrorClass({ + messages: { + [CODE_WITHOUT_CONTEXT]: 'ignored', + [CODE_WITH_CONTEXT]: 'ignored', + [CODE_WITH_ESCAPED_DOLLAR]: 'ignored', + }, + name: 'TestError', + prodDecodeCommand: 'npx @test/errors decode --', + prodMessagePrefix: 'Test error', + }); + const err = new ErrorClass(CODE_WITHOUT_CONTEXT); + expect(err.name).toBe('TestError'); + expect(err.message).toBe( + `Test error #${CODE_WITHOUT_CONTEXT}; Decode this error by running \`npx @test/errors decode -- ${CODE_WITHOUT_CONTEXT}\``, + ); + }); + it('does not append an encoded context when no prodDecodeCommand is configured', () => { + const { ErrorClass } = makeBundle(); + const err = new ErrorClass(CODE_WITH_CONTEXT, { count: 1, name: 'x' }); + expect(err.message).toBe(`TestError #${CODE_WITH_CONTEXT}`); + }); + it('appends an encoded context when present', () => { + const { ErrorClass } = createCodedErrorClass({ + messages: { + [CODE_WITHOUT_CONTEXT]: 'ignored', + [CODE_WITH_CONTEXT]: 'ignored', + [CODE_WITH_ESCAPED_DOLLAR]: 'ignored', + }, + name: 'TestError', + prodDecodeCommand: 'npx @test/errors decode --', + }); + const err = new ErrorClass(CODE_WITH_CONTEXT, { count: 1, name: 'x' }); + expect(err.message).toMatch( + new RegExp( + `^TestError #${CODE_WITH_CONTEXT}; Decode this error by running \`npx @test/errors decode -- ${CODE_WITH_CONTEXT} '[^']+'\`$`, + ), + ); + }); + }); +}); diff --git a/packages/errors/src/__tests__/context-test.ts b/packages/errors-core/src/__tests__/context-test.ts similarity index 100% rename from packages/errors/src/__tests__/context-test.ts rename to packages/errors-core/src/__tests__/context-test.ts diff --git a/packages/errors-core/src/__typetests__/coded-error-typetest.ts b/packages/errors-core/src/__typetests__/coded-error-typetest.ts new file mode 100644 index 000000000..4ac179b3b --- /dev/null +++ b/packages/errors-core/src/__typetests__/coded-error-typetest.ts @@ -0,0 +1,91 @@ +import { CodedError, createCodedErrorClass } from '../coded-error'; + +const CODE_WITH_CONTEXT = 1001 as const; +const CODE_WITHOUT_CONTEXT = 1002 as const; + +type TestCode = typeof CODE_WITH_CONTEXT | typeof CODE_WITHOUT_CONTEXT; +type TestContext = { + [CODE_WITHOUT_CONTEXT]: undefined; + [CODE_WITH_CONTEXT]: { count: number; name: string }; +}; + +const { + ErrorClass: TestError, + isError: isTestError, + getHumanReadableMessage, +} = createCodedErrorClass({ + messages: { + [CODE_WITHOUT_CONTEXT]: 'no context', + [CODE_WITH_CONTEXT]: 'hello $name ($count)', + }, + name: 'TestError', +}); + +// Constructor arity: codes without context accept zero or one (ErrorOptions) arg. +new TestError(CODE_WITHOUT_CONTEXT); +new TestError(CODE_WITHOUT_CONTEXT, { cause: new Error('x') }); + +// Constructor arity: codes WITH context require the context arg. +new TestError(CODE_WITH_CONTEXT, { count: 1, name: 'a' }); +// @ts-expect-error Missing required context. +new TestError(CODE_WITH_CONTEXT); +// @ts-expect-error Wrong context shape. +new TestError(CODE_WITH_CONTEXT, { wrong: true }); +// @ts-expect-error Missing required `name`. +new TestError(CODE_WITH_CONTEXT, { count: 1 }); + +// `context.__code` narrows to the specific code on an instance. +{ + const err = new TestError(CODE_WITH_CONTEXT, { count: 1, name: 'a' }); + err.context.__code satisfies typeof CODE_WITH_CONTEXT; + // @ts-expect-error Wrong code. + err.context.__code satisfies typeof CODE_WITHOUT_CONTEXT; + err.context.name satisfies string; + err.context.count satisfies number; + // @ts-expect-error Context is frozen at runtime, so exposed properties are readonly. + err.context.name = 'b'; + // @ts-expect-error The error code tag is readonly. + err.context.__code = CODE_WITHOUT_CONTEXT; + // @ts-expect-error No such property on this code's context. + void err.context.nope; +} + +// `isTestError` code-less overload narrows to the union form. +{ + const e: unknown = null; + if (isTestError(e)) { + e.context.__code satisfies TestCode; + } +} + +// `isTestError` code-specific overload narrows context to that code's shape. +{ + const e: unknown = null; + if (isTestError(e, CODE_WITH_CONTEXT)) { + e.context.name satisfies string; + e.context.count satisfies number; + // @ts-expect-error Context is frozen at runtime, so narrowed properties are readonly. + e.context.name = 'b'; + // @ts-expect-error Not a property of CODE_WITH_CONTEXT's context. + void e.context.other; + } + if (isTestError(e, CODE_WITHOUT_CONTEXT)) { + e.context.__code satisfies typeof CODE_WITHOUT_CONTEXT; + } +} + +// `getHumanReadableMessage` arity: mirrors constructor requirements. +getHumanReadableMessage(CODE_WITHOUT_CONTEXT); +getHumanReadableMessage(CODE_WITH_CONTEXT, { count: 1, name: 'a' }); +// @ts-expect-error Missing required context. +getHumanReadableMessage(CODE_WITH_CONTEXT); +// @ts-expect-error Wrong context shape. +getHumanReadableMessage(CODE_WITH_CONTEXT, { wrong: true }); + +// The exported `CodedError` type can be used to write Kit-style class-generic aliases. +type TestErrorAlias = CodedError; +{ + const e = new TestError(CODE_WITH_CONTEXT, { count: 1, name: 'a' }); + e satisfies TestErrorAlias; + e satisfies TestErrorAlias; +} diff --git a/packages/errors-core/src/coded-error.ts b/packages/errors-core/src/coded-error.ts new file mode 100644 index 000000000..f0532158c --- /dev/null +++ b/packages/errors-core/src/coded-error.ts @@ -0,0 +1,311 @@ +import { encodeContextObject } from './context'; +import { formatMessageTemplate } from './message-formatter'; + +/** + * A map of error code → context object shape. Use `undefined` for codes that carry no context. + * + * @example + * ```ts + * type KoraErrorContext = { + * [KORA_ERROR__ACCOUNT_NOT_FOUND]: { address: string }; + * [KORA_ERROR__RATE_LIMIT_EXCEEDED]: undefined; + * }; + * ``` + */ +export type CodedErrorContextMap = { + [P in TCode]: object | undefined; +}; + +/** + * The shape of an instance produced by a class created with {@link createCodedErrorClass}. + * + * @typeParam TCode Union of all numeric error codes the class can throw. + * @typeParam TContextMap Mapping from error code to its context shape. + * @typeParam C The specific error code this instance carries (narrowed by the guard). + */ +type CodedErrorCodedContext> = { + [P in TCode]: Readonly<{ __code: P }> & + (TContextMap[P] extends undefined ? object : Readonly>); +}; + +export interface CodedError< + TCode extends number, + TContextMap extends CodedErrorContextMap, + C extends TCode = TCode, +> extends Error { + readonly cause?: unknown; + readonly context: CodedErrorCodedContext[C]; + readonly name: string; +} + +/** + * Definition accepted by {@link createCodedErrorClass}. + */ +export interface CodedErrorDefinition { + /** + * Optional hook called on the fully-interpolated message before it is used. Use this to + * append a context-derived suffix (for example, {@link SolanaError} appends + * `" (instruction #N)"` to messages in the instruction-error range when the context carries + * an `index` key). Only invoked when `__DEV__ === true`. + */ + messagePostProcessor?: (code: C, context: object, message: string) => string; + /** + * Human-readable message templates. Use `$variable` tokens to interpolate values from an + * error's context; escape a literal `$` with `\\$`. May be supplied as either: + * + * - A `Readonly>` keyed by code (gives exhaustiveness checking + * against the {@link TCode} union). + * - A `(code: TCode) => string` function. Prefer this form to let bundlers tree-shake your + * templates out of production builds — write the body as + * `(code) => (__DEV__ ? MyMessages[code] : '')` so the reference to `MyMessages` lives + * inside a `__DEV__`-gated branch and can be eliminated under a static + * `__DEV__ === false` replacement. + * + * Either form is only consulted when `__DEV__ === true`. In production the factory emits + * the short-form message described by {@link CodedErrorDefinition.prodDecodeCommand} and + * never invokes this lookup. + */ + messages: Readonly> | ((code: TCode) => string); + /** + * The class name (and the value written to `error.name`). Downstream guards identify + * instances by matching this exact string, so pick something unique (e.g. `'KoraError'`, + * `'CodamaError'`). + * + * Note: `__code` is reserved on the context object — the factory writes it itself and + * frees you from having to declare it in your context shapes. Don't include `__code` in + * a context value passed to the constructor; it will be overwritten. + */ + name: string; + /** + * Optional shell command that recovers a human-readable message from an error code in + * production builds. If supplied, production error messages take the form: + * + * "{prefix} #{code}; Decode this error by running `{prodDecodeCommand} {code}`" + * + * and, when the error carries a non-empty context, an encoded-context segment is appended + * before the closing backtick: + * + * "{prefix} #{code}; Decode this error by running `{prodDecodeCommand} {code} '{encodedContext}'`" + * + * where `{prefix}` is {@link CodedErrorDefinition.prodMessagePrefix} (falling back to + * {@link CodedErrorDefinition.name}). If this field is omitted, production messages are + * simply `"{prefix} #{code}"`. + * + * @example + * ```ts + * prodDecodeCommand: 'npx @kora/errors decode --' + * ``` + */ + prodDecodeCommand?: string; + /** + * Optional override for the leading token of production-mode error messages. Useful when a + * downstream consumer wants a different prefix from the class `name` — e.g. {@link SolanaError} + * uses `name: 'SolanaError'` (for `error.name` / `instanceof` identification) but emits + * `"Solana error #N; ..."` as the prod message for wire-format compatibility with older + * decoders. Defaults to {@link CodedErrorDefinition.name}. + */ + prodMessagePrefix?: string; +} + +type ConstructorArgsFor< + TCode extends number, + TContextMap extends CodedErrorContextMap, + C extends TCode, +> = TContextMap[C] extends undefined + ? [code: C, errorOptions?: ErrorOptions | undefined] + : [code: C, contextAndErrorOptions: TContextMap[C] & (ErrorOptions | undefined)]; + +type FormatterArgsFor< + TCode extends number, + TContextMap extends CodedErrorContextMap, + C extends TCode, +> = TContextMap[C] extends undefined ? [code: C] : [code: C, context: TContextMap[C]]; + +/** + * The constructor produced by {@link createCodedErrorClass}. Instantiate with a code and, if the + * code's context shape is non-`undefined`, the matching context object. A `cause` property on + * that object is extracted and forwarded as {@link ErrorOptions.cause}. + */ +export interface CodedErrorConstructor> { + /** + * The configured constructor name. + */ + readonly name: string; + new (...args: ConstructorArgsFor): CodedError; +} + +/** + * A code-narrowing type guard returned by {@link createCodedErrorClass}. When the `code` argument + * is supplied and the input is an error produced by the factory, TypeScript refines the error's + * `context` property to the shape associated with that code. + */ +export interface CodedErrorGuard> { + (e: unknown, code: C): e is CodedError; + (e: unknown): e is CodedError; +} + +/** + * The value returned by {@link createCodedErrorClass} — a tuple-style object containing the new + * error class, a matching type guard, and a standalone message formatter. + */ +export interface CodedErrorClassBundle> { + /** + * The constructor for the new error class. See {@link CodedErrorConstructor}. + */ + ErrorClass: CodedErrorConstructor; + /** + * Formats the human-readable message for a code/context pair without constructing an error. + * Handy for logging, or for custom `cause` chains where you want the message string alone. + */ + getHumanReadableMessage: (...args: FormatterArgsFor) => string; + /** + * A code-narrowing type guard. See {@link CodedErrorGuard}. + */ + isError: CodedErrorGuard; +} + +/** + * Creates a coded error system — an error class, matching type guard, and message formatter — + * modeled after {@link SolanaError}. + * + * Use this when building tooling around Kit (paymasters, wallets, program clients, etc.) that + * needs its own strongly-typed, numerically-coded errors, but where introducing every domain's + * codes into `@solana/errors` would balloon its scope. Codes, messages, and context shapes stay + * in the downstream package; this helper owns only the mechanical plumbing. + * + * @typeParam TCode Union of all numeric error codes the class can throw. + * @typeParam TContextMap Mapping from error code to its context shape (`undefined` for codes + * that carry no context). + * + * @example + * ```ts + * import { createCodedErrorClass } from '@solana/errors-core'; + * + * export const KORA_ERROR__ACCOUNT_NOT_FOUND = -32050 as const; + * export const KORA_ERROR__RATE_LIMIT_EXCEEDED = -32030 as const; + * + * type KoraErrorCode = typeof KORA_ERROR__ACCOUNT_NOT_FOUND | typeof KORA_ERROR__RATE_LIMIT_EXCEEDED; + * type KoraErrorContext = { + * [KORA_ERROR__ACCOUNT_NOT_FOUND]: { address: string }; + * [KORA_ERROR__RATE_LIMIT_EXCEEDED]: undefined; + * }; + * + * export const { ErrorClass: KoraError, isError: isKoraError } = createCodedErrorClass< + * KoraErrorCode, + * KoraErrorContext + * >({ + * messages: { + * [KORA_ERROR__ACCOUNT_NOT_FOUND]: 'Account $address not found', + * [KORA_ERROR__RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded', + * }, + * name: 'KoraError', + * }); + * + * try { + * // ... + * } catch (e) { + * if (isKoraError(e, KORA_ERROR__ACCOUNT_NOT_FOUND)) { + * // `e.context.address` is now typed as `string`. + * console.error(`Missing account ${e.context.address}`); + * } + * } + * ``` + * + * @see {@link SolanaError} — the reference implementation this factory mirrors. + */ +export function createCodedErrorClass>( + definition: CodedErrorDefinition, +): CodedErrorClassBundle { + const { messagePostProcessor, messages, name, prodDecodeCommand, prodMessagePrefix } = definition; + const prefix = prodMessagePrefix ?? name; + const lookupTemplate = typeof messages === 'function' ? messages : (code: TCode) => messages[code]; + + function getHumanReadableMessage(...args: FormatterArgsFor): string { + const [code, context] = args; + const ctx = context ?? {}; + if (__DEV__) { + const rendered = formatMessageTemplate(lookupTemplate(code), ctx); + return messagePostProcessor ? messagePostProcessor(code, ctx, rendered) : rendered; + } + return `${prefix} #${code}`; + } + + function getMessage(code: C, context: Record): string { + if (__DEV__) { + const rendered = formatMessageTemplate(lookupTemplate(code), context); + return messagePostProcessor ? messagePostProcessor(code, context, rendered) : rendered; + } + let message = `${prefix} #${code}`; + if (prodDecodeCommand !== undefined) { + message += `; Decode this error by running \`${prodDecodeCommand} ${code}`; + if (Object.keys(context).length) { + /** + * DANGER: Be sure that the shell command is escaped in such a way that makes it + * impossible for someone to craft malicious context values that would + * result in an exploit against anyone who blindly copy/pastes it into + * their terminal. + */ + message += ` '${encodeContextObject(context)}'`; + } + message += '`'; + } + return message; + } + + class CodedErrorImpl extends Error { + readonly context: Readonly & { __code: TCode }>; + constructor(code: TCode, contextAndErrorOptions?: ErrorOptions & Record) { + let context: Record | undefined; + let errorOptions: ErrorOptions | undefined; + if (contextAndErrorOptions) { + Object.entries(Object.getOwnPropertyDescriptors(contextAndErrorOptions)).forEach( + ([propName, descriptor]) => { + if (propName === 'cause') { + errorOptions = { cause: descriptor.value }; + } else { + if (context === undefined) { + context = { __code: code }; + } + Object.defineProperty(context, propName, descriptor); + } + }, + ); + } + const message = getMessage(code, context ?? {}); + super(message, errorOptions); + this.context = Object.freeze(context === undefined ? { __code: code } : context) as Readonly< + Record & { __code: TCode } + >; + // This is necessary so that the guard can identify instances without having to import + // the class for use in an `instanceof` check. + this.name = name; + } + } + Object.defineProperty(CodedErrorImpl, 'name', { value: name }); + + const isError = ((e: unknown, code?: TCode) => { + if (!(e instanceof Error) || e.name !== name) { + return false; + } + // A foreign error could share `name` (duplicate install, manual reassignment). Require a + // numeric `context.__code` so we don't narrow onto an unrelated object. + const { context } = e as { context?: unknown }; + if (typeof context !== 'object' || context === null || !('__code' in context)) { + return false; + } + const candidateCode = (context as { __code: unknown }).__code; + if (typeof candidateCode !== 'number') { + return false; + } + if (code === undefined) { + return true; + } + return candidateCode === code; + }) as CodedErrorGuard; + + return { + ErrorClass: CodedErrorImpl as unknown as CodedErrorConstructor, + getHumanReadableMessage, + isError, + }; +} diff --git a/packages/errors-core/src/context.ts b/packages/errors-core/src/context.ts new file mode 100644 index 000000000..21c509640 --- /dev/null +++ b/packages/errors-core/src/context.ts @@ -0,0 +1,45 @@ +/** + * Decodes the base64-encoded context segment emitted by {@link encodeContextObject}. + * + * Use this when implementing a `decode` CLI for a downstream coded-error system: the production + * message produced by a {@link createCodedErrorClass}-generated error embeds an encoded context + * blob that round-trips through these two helpers. + */ +export function decodeEncodedContext(encodedContext: string): object { + const decodedUrlString = __NODEJS__ ? Buffer.from(encodedContext, 'base64').toString('utf8') : atob(encodedContext); + return Object.fromEntries(new URLSearchParams(decodedUrlString).entries()); +} + +function encodeValue(value: unknown): string { + if (Array.isArray(value)) { + const commaSeparatedValues = value.map(encodeValue).join('%2C%20' /* ", " */); + return '%5B' /* "[" */ + commaSeparatedValues + /* "]" */ '%5D'; + } else if (typeof value === 'bigint') { + return `${value}n`; + } else { + return encodeURIComponent( + String( + value != null && Object.getPrototypeOf(value) === null + ? // Plain objects with no prototype don't have a `toString` method. + // Convert them before stringifying them. + { ...(value as object) } + : value, + ), + ); + } +} + +function encodeObjectContextEntry([key, value]: [string, unknown]): `${typeof key}=${string}` { + return `${key}=${encodeValue(value)}`; +} + +/** + * Serializes an error's context into a compact, base64url-friendly string for inclusion in a + * production error message. The pair {@link encodeContextObject} / {@link decodeEncodedContext} + * round-trip values through `URLSearchParams`, so this is lossy for non-string types — the + * decoder reconstructs everything as strings. + */ +export function encodeContextObject(context: object): string { + const searchParamsString = Object.entries(context).map(encodeObjectContextEntry).join('&'); + return __NODEJS__ ? Buffer.from(searchParamsString, 'utf8').toString('base64') : btoa(searchParamsString); +} diff --git a/packages/errors-core/src/index.ts b/packages/errors-core/src/index.ts new file mode 100644 index 000000000..7d6c95f7e --- /dev/null +++ b/packages/errors-core/src/index.ts @@ -0,0 +1,37 @@ +/** + * Primitives for building strongly-typed, numerically-coded JavaScript error classes — the + * factory machinery that powers {@link SolanaError}, exposed as a standalone package so that + * downstream tools (paymasters, wallets, program clients, etc.) can build their own coded error + * systems without growing the {@link SolanaError} catalog. + * + * # Quickstart + * + * ```ts + * import { createCodedErrorClass } from '@solana/errors-core'; + * + * export const KORA_ERROR__ACCOUNT_NOT_FOUND = -32050 as const; + * export const KORA_ERROR__RATE_LIMIT_EXCEEDED = -32030 as const; + * + * type KoraErrorCode = typeof KORA_ERROR__ACCOUNT_NOT_FOUND | typeof KORA_ERROR__RATE_LIMIT_EXCEEDED; + * type KoraErrorContext = { + * [KORA_ERROR__ACCOUNT_NOT_FOUND]: { address: string }; + * [KORA_ERROR__RATE_LIMIT_EXCEEDED]: undefined; + * }; + * + * export const { ErrorClass: KoraError, isError: isKoraError } = createCodedErrorClass< + * KoraErrorCode, + * KoraErrorContext + * >({ + * messages: { + * [KORA_ERROR__ACCOUNT_NOT_FOUND]: 'Account $address not found', + * [KORA_ERROR__RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded', + * }, + * name: 'KoraError', + * }); + * ``` + * + * @packageDocumentation + */ +export * from './coded-error'; +export * from './context'; +export * from './message-formatter'; diff --git a/packages/errors-core/src/message-formatter.ts b/packages/errors-core/src/message-formatter.ts new file mode 100644 index 000000000..280523212 --- /dev/null +++ b/packages/errors-core/src/message-formatter.ts @@ -0,0 +1,85 @@ +const enum StateType { + EscapeSequence, + Text, + Variable, +} +type State = Readonly<{ + [START_INDEX]: number; + [TYPE]: StateType; +}>; +const START_INDEX = 'i'; +const TYPE = 't'; + +/** + * Interpolates `$variable` tokens in a message template with values from a context object. + * Tokens with no matching context key are rendered literally; escape a literal `$` with `\\$`. + * + * Shared by {@link createCodedErrorClass} and any downstream tooling that wants to render the + * same template syntax outside of a thrown error. + */ +export function formatMessageTemplate(messageFormatString: string | undefined, context: object = {}): string { + if (!messageFormatString) { + return ''; + } + const template: string = messageFormatString; + let state: State; + function commitStateUpTo(endIndex?: number) { + if (state[TYPE] === StateType.Variable) { + const variableName = template.slice(state[START_INDEX] + 1, endIndex); + + fragments.push( + variableName in context + ? // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${context[variableName as keyof typeof context]}` + : `$${variableName}`, + ); + } else if (state[TYPE] === StateType.Text) { + fragments.push(template.slice(state[START_INDEX], endIndex)); + } + } + const fragments: string[] = []; + template.split('').forEach((char, ii) => { + if (ii === 0) { + state = { + [START_INDEX]: 0, + [TYPE]: + template[0] === '\\' + ? StateType.EscapeSequence + : template[0] === '$' + ? StateType.Variable + : StateType.Text, + }; + return; + } + let nextState; + switch (state[TYPE]) { + case StateType.EscapeSequence: + nextState = { [START_INDEX]: ii, [TYPE]: StateType.Text }; + break; + case StateType.Text: + if (char === '\\') { + nextState = { [START_INDEX]: ii, [TYPE]: StateType.EscapeSequence }; + } else if (char === '$') { + nextState = { [START_INDEX]: ii, [TYPE]: StateType.Variable }; + } + break; + case StateType.Variable: + if (char === '\\') { + nextState = { [START_INDEX]: ii, [TYPE]: StateType.EscapeSequence }; + } else if (char === '$') { + nextState = { [START_INDEX]: ii, [TYPE]: StateType.Variable }; + } else if (!char.match(/\w/)) { + nextState = { [START_INDEX]: ii, [TYPE]: StateType.Text }; + } + break; + } + if (nextState) { + if (state !== nextState) { + commitStateUpTo(ii); + } + state = nextState; + } + }); + commitStateUpTo(); + return fragments.join(''); +} diff --git a/packages/errors-core/tsconfig.declarations.json b/packages/errors-core/tsconfig.declarations.json new file mode 100644 index 000000000..67ad58e02 --- /dev/null +++ b/packages/errors-core/tsconfig.declarations.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "./dist/types" + }, + "extends": "./tsconfig.json", + "include": ["../build-scripts/build-time-constants.d.ts", "src/index.ts"] +} diff --git a/packages/errors-core/tsconfig.json b/packages/errors-core/tsconfig.json new file mode 100644 index 000000000..accaa4e99 --- /dev/null +++ b/packages/errors-core/tsconfig.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "lib": ["DOM", "ES2015", "ES2022.Error"], + "resolveJsonModule": true + }, + "display": "@solana/errors-core", + "extends": "../tsconfig/base.json", + "include": ["../build-scripts/build-time-constants.d.ts", "src"] +} diff --git a/packages/errors-core/typedoc.json b/packages/errors-core/typedoc.json new file mode 100644 index 000000000..e08261d87 --- /dev/null +++ b/packages/errors-core/typedoc.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "extends": ["../../typedoc.json"], + "entryPoints": ["src/index.ts"], + "out": "./.docs", + "readme": "none" +} diff --git a/packages/errors/README.md b/packages/errors/README.md index 92785b07c..dc81704e0 100644 --- a/packages/errors/README.md +++ b/packages/errors/README.md @@ -85,3 +85,101 @@ try { throw e; } ``` + +## Building your own coded error class + +Downstream tools built on Kit (e.g. paymasters, wallets, programs) often want the same ergonomics as `SolanaError` (strongly-typed class, code-narrowing guard, dev/prod message branching) without adding their own error codes to this package. Use `createCodedErrorClass` to mint a coded error system owned by your package. + +```ts +import { createCodedErrorClass } from '@solana/errors'; + +export const KORA_ERROR__ACCOUNT_NOT_FOUND = -32050 as const; +export const KORA_ERROR__RATE_LIMIT_EXCEEDED = -32030 as const; + +type KoraErrorCode = typeof KORA_ERROR__ACCOUNT_NOT_FOUND | typeof KORA_ERROR__RATE_LIMIT_EXCEEDED; +type KoraErrorContext = { + [KORA_ERROR__ACCOUNT_NOT_FOUND]: { address: string }; + [KORA_ERROR__RATE_LIMIT_EXCEEDED]: undefined; +}; + +export const { ErrorClass: KoraError, isError: isKoraError } = createCodedErrorClass({ + messages: { + [KORA_ERROR__ACCOUNT_NOT_FOUND]: 'Account $address not found', + [KORA_ERROR__RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded', + }, + name: 'KoraError', +}); + +try { + /* ... */ +} catch (e) { + if (isKoraError(e, KORA_ERROR__ACCOUNT_NOT_FOUND)) { + // `e.context.address` is typed as `string`. + displayError(`Missing account ${e.context.address}`); + } +} +``` + +The factory handles context freezing, `cause` extraction, `$variable` interpolation, and dev/prod message branching. Your codes, messages, and context shapes stay in your own package. Pass `prodDecodeCommand` (e.g. `'npx @your-pkg/errors decode --'`) if you ship a CLI for decoding error codes in production bundles. + +The factory only reads `messages` when `__DEV__` is `true`. To let your bundler tree-shake the templates out of production builds, supply `messages` as a function and gate the lookup with `__DEV__`: + +```ts +messages: code => (__DEV__ ? MyMessages[code] : ''), +``` + +Under a static `__DEV__ === false` replacement, the `MyMessages` reference lives only inside a dead branch, so the bundler can drop the module that holds your templates. + +If you want consumers to be able to write `MyError` as a type the same way they can with `SolanaError`, re-export a generic alias alongside the class: + +```ts +import type { CodedError } from '@solana/errors'; + +export type KoraError = CodedError; +``` + +The alias narrows `KoraError['context']` to the context shape for `C`, matching the narrowing you get from `isKoraError(e, code)`. + +### Narrowing `cause`, including a deprecated-cause category + +`CodedError`'s `cause` is typed as `unknown`. To narrow it for specific codes — for example, "transaction errors always have a `cause` that is itself a `KoraError`" — re-shape the alias and wrap the guard with overloads. This is exactly what `SolanaError` and `isSolanaError` do on top of the same factory: + +```ts +import type { CodedError } from '@solana/errors'; +import { createCodedErrorClass } from '@solana/errors'; + +// Codes whose `cause` is meaningful, and the type it should be narrowed to. +type KoraErrorCodeWithCause = typeof KORA_ERROR__TRANSACTION_FAILED; +// Codes for which `cause` is deprecated and should warn in IDEs. +type KoraErrorCodeWithDeprecatedCause = typeof KORA_ERROR__LEGACY_FOO; + +export type KoraError = Omit< + CodedError, + 'cause' +> & { + readonly cause?: C extends KoraErrorCodeWithCause ? KoraError : unknown; +}; + +export interface KoraErrorWithDeprecatedCause extends Omit< + KoraError, + 'cause' +> { + /** @deprecated `cause` is no longer populated for this error code; read `context` instead. */ + readonly cause?: unknown; +} + +const { isError } = createCodedErrorClass({ + /* ... */ +}); + +export function isKoraError( + e: unknown, + code: C, +): e is KoraErrorWithDeprecatedCause; +export function isKoraError(e: unknown, code?: C): e is KoraError; +export function isKoraError(e: unknown, code?: KoraErrorCode): boolean { + return code === undefined ? isError(e) : isError(e, code); +} +``` + +The factory's `isError` runs the actual identity check; the overloads only refine the return type. Add or remove categories independently as your error system evolves. diff --git a/packages/errors/package.json b/packages/errors/package.json index c497d3d46..55b1f1609 100644 --- a/packages/errors/package.json +++ b/packages/errors/package.json @@ -75,6 +75,7 @@ "maintained node versions" ], "dependencies": { + "@solana/errors-core": "workspace:*", "chalk": "5.6.2", "commander": "14.0.3" }, diff --git a/packages/errors/src/__tests__/error-test.ts b/packages/errors/src/__tests__/error-test.ts index 2528b3d92..b666a0e6c 100644 --- a/packages/errors/src/__tests__/error-test.ts +++ b/packages/errors/src/__tests__/error-test.ts @@ -1,11 +1,41 @@ import '@solana/test-matchers/toBeFrozenObject'; +import { SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN } from '../codes'; import { isSolanaError, SolanaError } from '../error'; -import { getErrorMessage } from '../message-formatter'; +import * as MessagesModule from '../messages'; -jest.mock('../message-formatter'); +jest.mock('../messages', () => ({ + get SolanaErrorMessages() { + return {}; + }, + __esModule: true, +})); + +function mockMessage(code: number, message: string) { + jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get').mockReturnValue({ + [code]: message, + } as unknown as typeof MessagesModule.SolanaErrorMessages); +} describe('SolanaError', () => { + let originalDev: boolean | undefined; + beforeEach(() => { + originalDev = (globalThis as { __DEV__?: boolean }).__DEV__; + (globalThis as { __DEV__?: boolean }).__DEV__ = true; + mockMessage(123, 'mock message'); + }); + afterEach(() => { + (globalThis as { __DEV__?: boolean }).__DEV__ = originalDev; + }); + it('exposes the public constructor name on the class and instances', () => { + const err = new SolanaError( + // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` + 123, + undefined, + ); + expect(SolanaError.name).toBe('SolanaError'); + expect(err.constructor.name).toBe('SolanaError'); + }); describe('given an error with context', () => { let errorWithContext: SolanaError; beforeEach(() => { @@ -24,23 +54,21 @@ describe('SolanaError', () => { it('exposes no cause', () => { expect(errorWithContext.cause).toBeUndefined(); }); - it('calls the message formatter with the code and context', () => { - expect(getErrorMessage).toHaveBeenCalledWith(123, expect.objectContaining({ foo: 'bar' })); - }); it('freezes the context object', () => { expect(errorWithContext.context).toBeFrozenObject(); }); }); describe('given an error with no context', () => { + let errorWithoutContext: SolanaError; beforeEach(() => { - new SolanaError( + errorWithoutContext = new SolanaError( // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` 123, undefined, ); }); - it('calls the message formatter with undefined context', () => { - expect(getErrorMessage).toHaveBeenCalledWith(123, undefined); + it('exposes only the `__code` on context', () => { + expect(errorWithoutContext.context).toEqual({ __code: 123 }); }); }); describe('given an error with a cause', () => { @@ -72,21 +100,77 @@ describe('SolanaError', () => { it('omits the error option from its context', () => { expect(errorWithOption.context).not.toHaveProperty(propName); }); - it('calls the message formatter with the error option omitted', () => { - expect(getErrorMessage).toHaveBeenCalledWith(123, undefined); - }); }); - it('sets its message to the output of the message formatter', async () => { - expect.assertions(1); - jest.mocked(getErrorMessage).mockReturnValue('o no'); - await jest.isolateModulesAsync(async () => { - const SolanaErrorModule = - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - await import('../error'); + it('sets its message to the formatted template for the code', () => { + mockMessage(456, 'o no'); + const error456 = new SolanaError( // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` - const error456 = new SolanaErrorModule.SolanaError(456); - expect(error456).toHaveProperty('message', 'o no'); + 456, + undefined, + ); + expect(error456).toHaveProperty('message', 'o no'); + }); + describe('instruction-index suffix (dev mode)', () => { + beforeEach(() => { + mockMessage(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, 'Some instruction error'); + }); + it('appends `(instruction #N)` for codes in the instruction-error range when context carries `index`', () => { + const err = new SolanaError(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { errorName: 'X', index: 0 }); + expect(err.message).toBe('Some instruction error (instruction #1)'); + }); + it('uses one-based instruction numbering', () => { + const err = new SolanaError(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { errorName: 'X', index: 5 }); + expect(err.message).toBe('Some instruction error (instruction #6)'); + }); + it('does not append the suffix when the context has no `index`', () => { + const err = new SolanaError(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { + errorName: 'X', + } as ConstructorParameters>[1]); + expect(err.message).toBe('Some instruction error'); + }); + it('does not append the suffix to non-instruction error codes', () => { + mockMessage(123, 'Some instruction error'); + const err = new SolanaError( + // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` + 123, + { index: 0 }, + ); + expect(err.message).toBe('Some instruction error'); + }); + }); + describe('in production mode', () => { + let originalNodeEnv: string | undefined; + beforeEach(() => { + (globalThis as { __DEV__?: boolean }).__DEV__ = false; + // The compiled `@solana/errors-core` (loaded from `dist`) replaces `__DEV__` with + // `process.env.NODE_ENV !== "production"`, so toggling `globalThis.__DEV__` alone is + // not enough to put it into prod mode. + originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + }); + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + // Outer `afterEach` restores `__DEV__` to its pre-test value. + it('renders the prod message prefix and decode command without context', () => { + const err = new SolanaError( + // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` + 123, + undefined, + ); + expect(err.message).toBe( + 'Solana error #123; Decode this error by running `npx @solana/errors decode -- 123`', + ); + }); + it('renders the prod message prefix, decode command, and encoded context when context is present', () => { + const err = new SolanaError( + // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` + 123, + { foo: 'bar' }, + ); + expect(err.message).toMatch( + /^Solana error #123; Decode this error by running `npx @solana\/errors decode -- 123 '[^']+'`$/, + ); }); }); }); @@ -94,6 +178,10 @@ describe('SolanaError', () => { describe('isSolanaError()', () => { let error123: SolanaError; beforeEach(() => { + jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get').mockReturnValue({ + // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode`. + 123: 'mock message', + }); // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` error123 = new SolanaError(123); }); diff --git a/packages/errors/src/__tests__/message-formatter-test.ts b/packages/errors/src/__tests__/message-formatter-test.ts index df96fed3c..ffbb1fced 100644 --- a/packages/errors/src/__tests__/message-formatter-test.ts +++ b/packages/errors/src/__tests__/message-formatter-test.ts @@ -1,9 +1,7 @@ -import { SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, SolanaErrorCode } from '../codes'; -import { encodeContextObject } from '../context'; -import { getErrorMessage } from '../message-formatter'; +import { SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN } from '../codes'; +import { getHumanReadableErrorMessage } from '../message-formatter'; import * as MessagesModule from '../messages'; -jest.mock('../context'); jest.mock('../messages', () => ({ get SolanaErrorMessages() { return {}; @@ -11,71 +9,15 @@ jest.mock('../messages', () => ({ __esModule: true, })); -describe('getErrorMessage', () => { - describe('in production mode', () => { - beforeEach(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).__DEV__ = false; - }); - it('renders advice on where to decode a context-less error', () => { - const message = getErrorMessage( - // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` - 123, - ); - expect(message).toBe('Solana error #123; Decode this error by running `npx @solana/errors decode -- 123`'); - }); - it('does not call the context encoder when the error has no context', () => { - getErrorMessage( - // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` - 123, - ); - expect(encodeContextObject).not.toHaveBeenCalled(); - }); - it('does not call the context encoder when the error context has no keys', () => { - const context = {}; - getErrorMessage( - // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` - 123, - context, - ); - expect(encodeContextObject).not.toHaveBeenCalled(); - }); - it('calls the context encoder with the context', () => { - const context = { foo: 'bar' }; - getErrorMessage( - // @ts-expect-error Mock error codes don't conform to `SolanaErrorCode` - 123, - context, - ); - expect(encodeContextObject).toHaveBeenCalledWith(context); - }); - it('renders advice on where to decode an error with encoded context', () => { - jest.mocked(encodeContextObject).mockReturnValue('ENCODED_CONTEXT'); - const context = { foo: 'bar' }; - const message = getErrorMessage(123 as SolanaErrorCode, context); - expect(message).toBe( - "Solana error #123; Decode this error by running `npx @solana/errors decode -- 123 'ENCODED_CONTEXT'`", - ); - }); - it('renders no encoded context in the decoding advice when the context has no keys', () => { - jest.mocked(encodeContextObject).mockReturnValue('ENCODED_CONTEXT'); - const context = {}; - const message = getErrorMessage(123 as SolanaErrorCode, context); - expect(message).toBe('Solana error #123; Decode this error by running `npx @solana/errors decode -- 123`'); - }); - }); +describe('getHumanReadableErrorMessage', () => { describe('in dev mode', () => { - beforeEach(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).__DEV__ = true; - }); it('renders static error messages', () => { const messagesSpy = jest.spyOn(MessagesModule, 'SolanaErrorMessages', 'get'); messagesSpy.mockReturnValue({ // @ts-expect-error Mock error config doesn't conform to exported config. 123: 'static error message', }); - const message = getErrorMessage( + const message = getHumanReadableErrorMessage( // @ts-expect-error Mock error config doesn't conform to exported config. 123, ); @@ -139,7 +81,7 @@ describe('getErrorMessage', () => { // @ts-expect-error Mock error config doesn't conform to exported config. 123: input, }); - const message = getErrorMessage( + const message = getHumanReadableErrorMessage( // @ts-expect-error Mock error context doesn't conform to exported context. 123, { foo: 'bar', severity: 'awful' }, @@ -152,7 +94,7 @@ describe('getErrorMessage', () => { // @ts-expect-error Mock error config doesn't conform to exported config. 123: 'Here is some data: $data', }); - const message = getErrorMessage( + const message = getHumanReadableErrorMessage( // @ts-expect-error Mock error context doesn't conform to exported context. 123, { data: new Uint8Array([1, 2, 3, 4]) }, @@ -165,7 +107,7 @@ describe('getErrorMessage', () => { // @ts-expect-error Mock error config doesn't conform to exported config. 123: 'Here is a variable: $variable', }); - const message = getErrorMessage( + const message = getHumanReadableErrorMessage( // @ts-expect-error Mock error context doesn't conform to exported context. 123, { variable: undefined }, @@ -178,7 +120,7 @@ describe('getErrorMessage', () => { messagesSpy.mockReturnValue({ [SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN]: 'Some instruction error', }); - const message = getErrorMessage(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { index: 0 }); + const message = getHumanReadableErrorMessage(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { index: 0 }); expect(message).toBe('Some instruction error (instruction #1)'); }); it('uses one-based instruction numbering', () => { @@ -187,7 +129,7 @@ describe('getErrorMessage', () => { messagesSpy.mockReturnValue({ [SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN]: 'Some instruction error', }); - const message = getErrorMessage(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { index: 5 }); + const message = getHumanReadableErrorMessage(SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, { index: 5 }); expect(message).toBe('Some instruction error (instruction #6)'); }); it('appends the instruction number to error codes at the end of the instruction error range', () => { @@ -197,7 +139,7 @@ describe('getErrorMessage', () => { messagesSpy.mockReturnValue({ [lastInstructionErrorCode]: 'Some instruction error', }); - const message = getErrorMessage( + const message = getHumanReadableErrorMessage( // @ts-expect-error Mock error code doesn't conform to exported config. lastInstructionErrorCode, { index: 2 }, @@ -210,7 +152,7 @@ describe('getErrorMessage', () => { // @ts-expect-error Mock error config doesn't conform to exported config. 123: 'some other error', }); - const message = getErrorMessage( + const message = getHumanReadableErrorMessage( // @ts-expect-error Mock error context doesn't conform to exported context. 123, { index: 0 }, diff --git a/packages/errors/src/context.ts b/packages/errors/src/context.ts index b81b4d875..2a79be624 100644 --- a/packages/errors/src/context.ts +++ b/packages/errors/src/context.ts @@ -882,35 +882,4 @@ export type SolanaErrorContext = ReadonlyContextValue< > >; -export function decodeEncodedContext(encodedContext: string): object { - const decodedUrlString = __NODEJS__ ? Buffer.from(encodedContext, 'base64').toString('utf8') : atob(encodedContext); - return Object.fromEntries(new URLSearchParams(decodedUrlString).entries()); -} - -function encodeValue(value: unknown): string { - if (Array.isArray(value)) { - const commaSeparatedValues = value.map(encodeValue).join('%2C%20' /* ", " */); - return '%5B' /* "[" */ + commaSeparatedValues + /* "]" */ '%5D'; - } else if (typeof value === 'bigint') { - return `${value}n`; - } else { - return encodeURIComponent( - String( - value != null && Object.getPrototypeOf(value) === null - ? // Plain objects with no prototype don't have a `toString` method. - // Convert them before stringifying them. - { ...(value as object) } - : value, - ), - ); - } -} - -function encodeObjectContextEntry([key, value]: [string, unknown]): `${typeof key}=${string}` { - return `${key}=${encodeValue(value)}`; -} - -export function encodeContextObject(context: object): string { - const searchParamsString = Object.entries(context).map(encodeObjectContextEntry).join('&'); - return __NODEJS__ ? Buffer.from(searchParamsString, 'utf8').toString('base64') : btoa(searchParamsString); -} +export { decodeEncodedContext, encodeContextObject } from '@solana/errors-core'; diff --git a/packages/errors/src/error.ts b/packages/errors/src/error.ts index f169cc610..d13d817cb 100644 --- a/packages/errors/src/error.ts +++ b/packages/errors/src/error.ts @@ -1,6 +1,73 @@ -import { SolanaErrorCode, SolanaErrorCodeWithCause, SolanaErrorCodeWithDeprecatedCause } from './codes'; +import { type CodedError, createCodedErrorClass } from '@solana/errors-core'; + +import { + SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, + SolanaErrorCode, + SolanaErrorCodeWithCause, + SolanaErrorCodeWithDeprecatedCause, +} from './codes'; import { SolanaErrorContext } from './context'; -import { getErrorMessage } from './message-formatter'; +import { SolanaErrorMessages } from './messages'; + +const INSTRUCTION_ERROR_RANGE_SIZE = 1000; + +type SolanaErrorContextMap = { [P in SolanaErrorCode]: SolanaErrorContext[P] }; + +const SolanaErrorBundle = /*#__PURE__*/ createCodedErrorClass({ + messagePostProcessor: (code, context, message) => { + if ( + code >= SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN && + code < SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN + INSTRUCTION_ERROR_RANGE_SIZE && + 'index' in context + ) { + return message + ` (instruction #${(context as { index: number }).index + 1})`; + } + return message; + }, + // Use the function form so `SolanaErrorMessages` lives inside a `__DEV__`-gated branch + // and can be tree-shaken out of bundles where `__DEV__` is statically replaced with `false`. + messages: code => (__DEV__ ? SolanaErrorMessages[code] : ''), + name: 'SolanaError', + prodDecodeCommand: 'npx @solana/errors decode --', + prodMessagePrefix: 'Solana error', +}); + +type SolanaErrorConstructor = { + readonly name: string; + new ( + ...args: SolanaErrorContext[TErrorCode] extends undefined + ? [code: TErrorCode, errorOptions?: ErrorOptions | undefined] + : [code: TErrorCode, contextAndErrorOptions: SolanaErrorContext[TErrorCode] & (ErrorOptions | undefined)] + ): SolanaError; +}; + +function getSolanaErrorConstructor(): SolanaErrorConstructor { + return SolanaErrorBundle.ErrorClass as unknown as SolanaErrorConstructor; +} + +/** + * Encapsulates an error's stacktrace, a Solana-specific numeric code that indicates what went + * wrong, and optional context if the type of error indicated by the code supports it. + */ +export const SolanaError = /*#__PURE__*/ getSolanaErrorConstructor(); + +/** + * The type of an instance of {@link SolanaError}. Narrows `context` to the shape associated with + * the supplied error code, and narrows `cause` to {@link SolanaError} for error codes in + * {@link SolanaErrorCodeWithCause} and to `unknown` otherwise. + */ +export type SolanaError = Omit< + CodedError, + 'cause' +> & { + /** + * Indicates the root cause of this {@link SolanaError}, if any. + * + * For example, a transaction error might have an instruction error as its root cause. In this + * case, you will be able to access the instruction error on the transaction error as `cause`. + */ + readonly cause?: TErrorCode extends SolanaErrorCodeWithCause ? SolanaError : unknown; +}; /** * A variant of {@link SolanaError} where the `cause` property is deprecated. @@ -69,80 +136,6 @@ export function isSolanaError( e: unknown, code?: TErrorCode, ): e is SolanaError; -export function isSolanaError( - e: unknown, - /** - * When supplied, this function will require that the input is a {@link SolanaError} _and_ that - * its error code is exactly this value. - */ - code?: TErrorCode, -): e is SolanaError { - const isSolanaError = e instanceof Error && e.name === 'SolanaError'; - if (isSolanaError) { - if (code !== undefined) { - return (e as SolanaError).context.__code === code; - } - return true; - } - return false; -} - -type SolanaErrorCodedContext = { - [P in SolanaErrorCode]: Readonly<{ - __code: P; - }> & - (SolanaErrorContext[P] extends undefined ? object : SolanaErrorContext[P]); -}; - -/** - * Encapsulates an error's stacktrace, a Solana-specific numeric code that indicates what went - * wrong, and optional context if the type of error indicated by the code supports it. - */ -export class SolanaError extends Error { - /** - * Indicates the root cause of this {@link SolanaError}, if any. - * - * For example, a transaction error might have an instruction error as its root cause. In this - * case, you will be able to access the instruction error on the transaction error as `cause`. - */ - readonly cause?: TErrorCode extends SolanaErrorCodeWithCause ? SolanaError : unknown = this.cause; - /** - * Contains context that can assist in understanding or recovering from a {@link SolanaError}. - */ - readonly context: SolanaErrorCodedContext[TErrorCode]; - constructor( - ...[code, contextAndErrorOptions]: SolanaErrorContext[TErrorCode] extends undefined - ? [code: TErrorCode, errorOptions?: ErrorOptions | undefined] - : [code: TErrorCode, contextAndErrorOptions: SolanaErrorContext[TErrorCode] & (ErrorOptions | undefined)] - ) { - let context: SolanaErrorContext[TErrorCode] | undefined; - let errorOptions: ErrorOptions | undefined; - if (contextAndErrorOptions) { - Object.entries(Object.getOwnPropertyDescriptors(contextAndErrorOptions)).forEach(([name, descriptor]) => { - // If the `ErrorOptions` type ever changes, update this code. - if (name === 'cause') { - errorOptions = { cause: descriptor.value }; - } else { - if (context === undefined) { - context = { - __code: code, - } as unknown as SolanaErrorContext[TErrorCode]; - } - Object.defineProperty(context, name, descriptor); - } - }); - } - const message = getErrorMessage(code, context); - super(message, errorOptions); - this.context = Object.freeze( - context === undefined - ? { - __code: code, - } - : context, - ) as SolanaErrorCodedContext[TErrorCode]; - // This is necessary so that `isSolanaError()` can identify a `SolanaError` without having - // to import the class for use in an `instanceof` check. - this.name = 'SolanaError'; - } +export function isSolanaError(e: unknown, code?: SolanaErrorCode): boolean { + return code === undefined ? SolanaErrorBundle.isError(e) : SolanaErrorBundle.isError(e, code); } diff --git a/packages/errors/src/index.ts b/packages/errors/src/index.ts index f0be368b4..1d02c27e3 100644 --- a/packages/errors/src/index.ts +++ b/packages/errors/src/index.ts @@ -63,6 +63,15 @@ * * @packageDocumentation */ +export { + type CodedError, + type CodedErrorClassBundle, + type CodedErrorConstructor, + type CodedErrorContextMap, + type CodedErrorDefinition, + type CodedErrorGuard, + createCodedErrorClass, +} from '@solana/errors-core'; export * from './codes'; export * from './error'; export * from './instruction-error'; diff --git a/packages/errors/src/message-formatter.ts b/packages/errors/src/message-formatter.ts index f9aa02405..6efb1aed1 100644 --- a/packages/errors/src/message-formatter.ts +++ b/packages/errors/src/message-formatter.ts @@ -1,89 +1,15 @@ +import { formatMessageTemplate } from '@solana/errors-core'; + import { SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN, SolanaErrorCode } from './codes'; -import { encodeContextObject } from './context'; import { SolanaErrorMessages } from './messages'; const INSTRUCTION_ERROR_RANGE_SIZE = 1000; -const enum StateType { - EscapeSequence, - Text, - Variable, -} -type State = Readonly<{ - [START_INDEX]: number; - [TYPE]: StateType; -}>; -const START_INDEX = 'i'; -const TYPE = 't'; - export function getHumanReadableErrorMessage( code: TErrorCode, context: object = {}, ): string { - const messageFormatString = SolanaErrorMessages[code]; - if (messageFormatString.length === 0) { - return ''; - } - let state: State; - function commitStateUpTo(endIndex?: number) { - if (state[TYPE] === StateType.Variable) { - const variableName = messageFormatString.slice(state[START_INDEX] + 1, endIndex); - - fragments.push( - variableName in context - ? // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `${context[variableName as keyof typeof context]}` - : `$${variableName}`, - ); - } else if (state[TYPE] === StateType.Text) { - fragments.push(messageFormatString.slice(state[START_INDEX], endIndex)); - } - } - const fragments: string[] = []; - messageFormatString.split('').forEach((char, ii) => { - if (ii === 0) { - state = { - [START_INDEX]: 0, - [TYPE]: - messageFormatString[0] === '\\' - ? StateType.EscapeSequence - : messageFormatString[0] === '$' - ? StateType.Variable - : StateType.Text, - }; - return; - } - let nextState; - switch (state[TYPE]) { - case StateType.EscapeSequence: - nextState = { [START_INDEX]: ii, [TYPE]: StateType.Text }; - break; - case StateType.Text: - if (char === '\\') { - nextState = { [START_INDEX]: ii, [TYPE]: StateType.EscapeSequence }; - } else if (char === '$') { - nextState = { [START_INDEX]: ii, [TYPE]: StateType.Variable }; - } - break; - case StateType.Variable: - if (char === '\\') { - nextState = { [START_INDEX]: ii, [TYPE]: StateType.EscapeSequence }; - } else if (char === '$') { - nextState = { [START_INDEX]: ii, [TYPE]: StateType.Variable }; - } else if (!char.match(/\w/)) { - nextState = { [START_INDEX]: ii, [TYPE]: StateType.Text }; - } - break; - } - if (nextState) { - if (state !== nextState) { - commitStateUpTo(ii); - } - state = nextState; - } - }); - commitStateUpTo(); - let message = fragments.join(''); + let message = formatMessageTemplate(SolanaErrorMessages[code], context); if ( code >= SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN && code < SOLANA_ERROR__INSTRUCTION_ERROR__UNKNOWN + INSTRUCTION_ERROR_RANGE_SIZE && @@ -93,23 +19,3 @@ export function getHumanReadableErrorMessage } return message; } - -export function getErrorMessage( - code: TErrorCode, - context: Record = {}, -): string { - if (__DEV__) { - return getHumanReadableErrorMessage(code, context); - } else { - let decodingAdviceMessage = `Solana error #${code}; Decode this error by running \`npx @solana/errors decode -- ${code}`; - if (Object.keys(context).length) { - /** - * DANGER: Be sure that the shell command is escaped in such a way that makes it - * impossible for someone to craft malicious context values that would result in - * an exploit against anyone who bindly copy/pastes it into their terminal. - */ - decodingAdviceMessage += ` '${encodeContextObject(context)}'`; - } - return `${decodingAdviceMessage}\``; - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 967e25118..636cf36df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -527,6 +527,9 @@ importers: packages/errors: dependencies: + '@solana/errors-core': + specifier: workspace:* + version: link:../errors-core chalk: specifier: 5.6.2 version: 5.6.2 @@ -537,6 +540,12 @@ importers: specifier: '>=5.0.0' version: 5.9.3 + packages/errors-core: + dependencies: + typescript: + specifier: '>=5.0.0' + version: 5.9.3 + packages/eslint-config: dependencies: eslint: