diff --git a/.changeset/plenty-papers-unite.md b/.changeset/plenty-papers-unite.md new file mode 100644 index 000000000..7574cf8f1 --- /dev/null +++ b/.changeset/plenty-papers-unite.md @@ -0,0 +1,6 @@ +--- +'@solana/plugin-core': minor +'@solana/kit': minor +--- + +Add new `@solana/plugin-core` package enabling us to create modular Kit clients that can be extended with plugins. diff --git a/packages/kit/package.json b/packages/kit/package.json index 6b8a19650..9c3415368 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -85,6 +85,7 @@ "@solana/instruction-plans": "workspace:*", "@solana/keys": "workspace:*", "@solana/offchain-messages": "workspace:*", + "@solana/plugin-core": "workspace:*", "@solana/programs": "workspace:*", "@solana/rpc": "workspace:*", "@solana/rpc-parsed-types": "workspace:*", diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts index 0e662a3d7..178468441 100644 --- a/packages/kit/src/index.ts +++ b/packages/kit/src/index.ts @@ -15,6 +15,7 @@ export * from '@solana/instructions'; export * from '@solana/instruction-plans'; export * from '@solana/keys'; export * from '@solana/offchain-messages'; +export * from '@solana/plugin-core'; export * from '@solana/programs'; export * from '@solana/rpc'; export * from '@solana/rpc-parsed-types'; diff --git a/packages/plugin-core/.gitignore b/packages/plugin-core/.gitignore new file mode 100644 index 000000000..aff17b6df --- /dev/null +++ b/packages/plugin-core/.gitignore @@ -0,0 +1,2 @@ +.docs/ +dist/ diff --git a/packages/plugin-core/.npmrc b/packages/plugin-core/.npmrc new file mode 100644 index 000000000..b6f27f135 --- /dev/null +++ b/packages/plugin-core/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/packages/plugin-core/.prettierignore b/packages/plugin-core/.prettierignore new file mode 100644 index 000000000..2bd5f0063 --- /dev/null +++ b/packages/plugin-core/.prettierignore @@ -0,0 +1,4 @@ +# Changelogs are autogenerated, so leave them alone +CHANGELOG.md + +dist/ diff --git a/packages/plugin-core/LICENSE b/packages/plugin-core/LICENSE new file mode 100644 index 000000000..ec09953d3 --- /dev/null +++ b/packages/plugin-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/plugin-core/README.md b/packages/plugin-core/README.md new file mode 100644 index 000000000..13b36199d --- /dev/null +++ b/packages/plugin-core/README.md @@ -0,0 +1,14 @@ +[![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/plugin-core?style=flat +[npm-image]: https://img.shields.io/npm/v/@solana/plugin-core?style=flat +[npm-url]: https://www.npmjs.com/package/@solana/plugin-core + +# @solana/plugin-core + +This package contains utilities for creating modular Kit clients that can be extended with plugins. It can be used standalone, but it is also exported as part of Kit [`@solana/kit`](https://github.com/anza-xyz/kit/tree/main/packages/kit). diff --git a/packages/plugin-core/package.json b/packages/plugin-core/package.json new file mode 100644 index 000000000..429ace03f --- /dev/null +++ b/packages/plugin-core/package.json @@ -0,0 +1,81 @@ +{ + "name": "@solana/plugin-core", + "version": "5.1.0", + "description": "Core helpers for creating and extending Kit clients with plugins", + "homepage": "https://www.solanakit.com/api#solanaplugin-core", + "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/" + ], + "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=\"--localstorage-file=$(mktemp)\" jest -c ../../node_modules/@solana/test-config/jest-dev.config.ts --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.ts --rootDir . --silent", + "test:prettier": "TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-prettier.config.ts --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=\"--localstorage-file=$(mktemp)\" TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-unit.config.browser.ts --rootDir . --silent", + "test:unit:node": "NODE_OPTIONS=\"--localstorage-file=$(mktemp)\" TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-unit.config.node.ts --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.3.3" + }, + "engines": { + "node": ">=20.18.0" + } +} diff --git a/packages/plugin-core/src/__tests__/client-test.ts b/packages/plugin-core/src/__tests__/client-test.ts new file mode 100644 index 000000000..414322e82 --- /dev/null +++ b/packages/plugin-core/src/__tests__/client-test.ts @@ -0,0 +1,154 @@ +import '@solana/test-matchers/toBeFrozenObject'; + +import { createEmptyClient } from '../client'; + +describe('createEmptyClient', () => { + it('creates an empty object with a use function', () => { + const emptyClient = createEmptyClient(); + expect(typeof emptyClient).toBe('object'); + const attributes = Object.getOwnPropertyNames(emptyClient); + expect(attributes).toStrictEqual(['use']); + expect(typeof emptyClient.use).toBe('function'); + }); + + it('evolves when using plugins', () => { + expect( + createEmptyClient() + .use(c => ({ ...c, fruit: 'apple' as const })) + .use(c => ({ ...c, vegetable: 'carrot' as const })), + ).toStrictEqual({ + fruit: 'apple', + use: expect.any(Function), + vegetable: 'carrot', + }); + }); + + it('can be overriden by subsequent plugins', () => { + expect( + createEmptyClient() + .use(() => ({ fruit: 'apple' as const })) + .use(() => ({ vegetable: 'carrot' as const })), + ).toStrictEqual({ + use: expect.any(Function), + vegetable: 'carrot', + }); + }); + + it('allows plugins to enforce input type constraints', () => { + expect( + createEmptyClient() + .use(c => ({ ...c, fruit: 'apple' as const })) + .use((c: T) => ({ ...c, dessert: 'apple cake' as const })), + ).toStrictEqual({ + dessert: 'apple cake', + fruit: 'apple', + use: expect.any(Function), + }); + }); + + it('supports asynchronous plugins', async () => { + expect.assertions(1); + await expect( + createEmptyClient() + .use(c => Promise.resolve({ ...c, fruit: 'apple' as const })) + .use(c => Promise.resolve({ ...c, vegetable: 'carrot' as const })), + ).resolves.toStrictEqual({ + fruit: 'apple', + use: expect.any(Function), + vegetable: 'carrot', + }); + }); + + it('supports a mixture of synchronous and asynchronous plugins', async () => { + expect.assertions(1); + await expect( + createEmptyClient() + .use(c => ({ ...c, fruit: 'apple' as const })) + .use(c => Promise.resolve({ ...c, vegetable: 'carrot' as const })) + .use(c => ({ ...c, grain: 'rice' as const })) + .use(c => Promise.resolve({ ...c, protein: 'beans' as const })), + ).resolves.toStrictEqual({ + fruit: 'apple', + grain: 'rice', + protein: 'beans', + use: expect.any(Function), + vegetable: 'carrot', + }); + }); + + it('can catch synchronous errors', () => { + expect(() => + createEmptyClient().use(() => { + throw new Error('Missing fruit'); + }), + ).toThrow('Missing fruit'); + }); + + it('can catch asynchronous errors', async () => { + expect.assertions(1); + await expect( + createEmptyClient().use(() => { + return Promise.reject(new Error('Missing fruit')); + }), + ).rejects.toThrow('Missing fruit'); + }); + + it('can chain the then function on the async client', async () => { + expect.assertions(1); + const thenFn = jest.fn(); + await createEmptyClient() + .use(() => Promise.resolve({ fruit: 'apple' as const })) + .then(thenFn); + expect(thenFn).toHaveBeenNthCalledWith(1, expect.objectContaining({ fruit: 'apple' })); + }); + + it('can chain the catch function on the async client', async () => { + expect.assertions(1); + const catchFn = jest.fn(); + await createEmptyClient() + .use(() => Promise.reject(new Error('Missing fruit'))) + .catch(catchFn); + expect(catchFn).toHaveBeenNthCalledWith(1, expect.objectContaining({ message: 'Missing fruit' })); + }); + + it('can chain the finally function on the async client when successful', async () => { + expect.assertions(1); + const finallyFn = jest.fn(); + await createEmptyClient() + .use(() => Promise.resolve({ fruit: 'apple' as const })) + .finally(finallyFn); + expect(finallyFn).toHaveBeenCalledTimes(1); + }); + + it('can chain the finally function on the async client when unsuccessful', async () => { + expect.assertions(1); + const finallyFn = jest.fn(); + await createEmptyClient() + .use(() => Promise.reject(new Error('Missing fruit'))) + .finally(finallyFn) + .catch(() => {}); + expect(finallyFn).toHaveBeenCalledTimes(1); + }); + + it('does not resolve subsequent asynchronous plugins after an error', async () => { + expect.assertions(1); + const subsequentPlugin = jest.fn(); + await createEmptyClient() + .use(() => Promise.reject(new Error('Missing fruit'))) + .use(subsequentPlugin) + .catch(() => {}); + expect(subsequentPlugin).not.toHaveBeenCalled(); + }); + + it('returns a frozen object when empty', () => { + expect(createEmptyClient()).toBeFrozenObject(); + }); + + it('returns a frozen object when extended by a plugin', () => { + expect(createEmptyClient().use(() => ({ fruit: 'apple' as const }))).toBeFrozenObject(); + }); + + it('returns a frozen object when extended by an asynchronous plugin', () => { + expect(createEmptyClient().use(() => Promise.resolve({ fruit: 'apple' as const }))).toBeFrozenObject(); + }); +}); diff --git a/packages/plugin-core/src/__typetests__/client-typetest.ts b/packages/plugin-core/src/__typetests__/client-typetest.ts new file mode 100644 index 000000000..09859aeaa --- /dev/null +++ b/packages/plugin-core/src/__typetests__/client-typetest.ts @@ -0,0 +1,206 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import { type AsyncClient, type Client, type ClientPlugin, createEmptyClient } from '../client'; + +const EMPTY_CLIENT = null as unknown as Client; +const EMPTY_ASYNC_CLIENT = null as unknown as AsyncClient; + +// [DESCRIBE] ClientPlugin +{ + // A plugin can be the identity function. + { + const plugin = (c: object) => c; + plugin satisfies ClientPlugin; + } + + // A plugin can extend the input object. + { + const plugin = (c: { fruit: 'apple' }) => ({ ...c, vegetable: 'carrot' as const }); + plugin satisfies ClientPlugin<{ fruit: 'apple' }, { fruit: 'apple'; vegetable: 'carrot' }>; + } + + // A plugin can override the input object. + { + const plugin = (c: { fruit: 'apple' }) => ({ ...c, fruit: 'banana' as const }); + plugin satisfies ClientPlugin<{ fruit: 'apple' }, { fruit: 'banana' }>; + // @ts-expect-error - output fruit is no longer an apple. + plugin satisfies ClientPlugin<{ fruit: 'apple' }, { fruit: 'apple' }>; + } + + // A plugin can have requirements on the input object. + { + const plugin = (c: T) => ({ ...c, dessert: 'apple cake' as const }); + plugin satisfies ClientPlugin<{ fruit: 'apple' }, { dessert: 'apple cake'; fruit: 'apple' }>; + } + + // A plugin may be asynchronous. + { + const plugin = (c: { fruit: 'apple' }) => Promise.resolve({ ...c, vegetable: 'carrot' as const }); + plugin satisfies ClientPlugin<{ fruit: 'apple' }, Promise<{ fruit: 'apple'; vegetable: 'carrot' }>>; + } + + // A plugin must accept an object. + { + const plugin = (c: number) => c; + // @ts-expect-error - input is not an object. + plugin satisfies ClientPlugin; + } + + // A plugin must return an object. + { + const plugin = (c: object): number => Object.getOwnPropertyNames(c).length; + // @ts-expect-error - output is not an object. + plugin satisfies ClientPlugin; + } +} + +// [DESCRIBE] Client +{ + // It returns a modified Client when using a plugin. + { + const client = EMPTY_CLIENT.use(c => ({ ...c, fruit: 'apple' as const })); + client satisfies Client<{ fruit: 'apple' }>; + } + + // It returns a new AsyncClient when using an asynchronous plugin. + { + const client = EMPTY_CLIENT.use(c => Promise.resolve({ ...c, fruit: 'apple' as const })); + client satisfies AsyncClient<{ fruit: 'apple' }>; + } + + // It does not accept plugins with invalid inputs. + { + // @ts-expect-error - input is not an object. + EMPTY_CLIENT.use((value: 42) => ({ value })); + // @ts-expect-error - input is not an object. + EMPTY_CLIENT.use((value: 'hello') => ({ value })); + // @ts-expect-error - input is not an object. + EMPTY_CLIENT.use((value: true) => ({ value })); + // @ts-expect-error - input is not an object. + EMPTY_CLIENT.use((value: null) => ({ value })); + // @ts-expect-error - input is not an object. + EMPTY_CLIENT.use((value: undefined) => ({ value })); + } + + // It does not accept plugins with invalid outputs. + { + // @ts-expect-error - output is not an object. + EMPTY_CLIENT.use(() => 42); + // @ts-expect-error - output is not an object. + EMPTY_CLIENT.use(() => 'hello'); + // @ts-expect-error - output is not an object. + EMPTY_CLIENT.use(() => true); + // @ts-expect-error - output is not an object. + EMPTY_CLIENT.use(() => null); + // @ts-expect-error - output is not an object. + EMPTY_CLIENT.use(() => undefined); + } + + // It evolves through multiple plugins. + { + const client = EMPTY_CLIENT.use(c => ({ ...c, fruit: 'apple' as const })) + .use(c => ({ ...c, vegetable: 'carrot' as const })) + .use(c => ({ ...c, grain: 'rice' as const })); + client satisfies Client<{ fruit: 'apple'; grain: 'rice'; vegetable: 'carrot' }>; + } + + // It accepts plugins when input type constraints are satisfied. + { + const apple = (p: T) => ({ ...p, fruit: 'apple' as const }); + const appleCake = (p: T) => ({ ...p, dessert: 'apple cake' as const }); + const client = EMPTY_CLIENT.use(apple).use(appleCake); + client satisfies Client<{ dessert: 'apple cake'; fruit: 'apple' }>; + } + + // It rejects plugins when input type constraints are not satisfied. + { + const banana = (p: T) => ({ ...p, fruit: 'banana' as const }); + const appleCake = (p: T) => ({ ...p, dessert: 'apple cake' as const }); + const bananaClient = EMPTY_CLIENT.use(banana); + // @ts-expect-error - banana does not satisfy apple cake input type. + bananaClient.use(appleCake); + } +} + +// [DESCRIBE] AsyncClient +{ + // It is a Promise. + { + EMPTY_ASYNC_CLIENT satisfies Promise; + null as unknown as AsyncClient<{ fruit: 'apple' }> satisfies Promise<{ fruit: 'apple' }>; + } + + // It returns a modified AsyncClient when using a synchronous plugin. + { + const client = EMPTY_ASYNC_CLIENT.use(c => ({ ...c, fruit: 'apple' as const })); + client satisfies AsyncClient<{ fruit: 'apple' }>; + } + + // It returns a modified AsyncClient when using an asynchronous plugin. + { + const client = EMPTY_ASYNC_CLIENT.use(c => Promise.resolve({ ...c, fruit: 'apple' as const })); + client satisfies AsyncClient<{ fruit: 'apple' }>; + } + + // It does not accept plugins with invalid inputs. + { + // @ts-expect-error - input is not an object. + EMPTY_ASYNC_CLIENT.use((value: 42) => ({ value })); + // @ts-expect-error - input is not an object. + EMPTY_ASYNC_CLIENT.use((value: 'hello') => ({ value })); + // @ts-expect-error - input is not an object. + EMPTY_ASYNC_CLIENT.use((value: true) => ({ value })); + // @ts-expect-error - input is not an object. + EMPTY_ASYNC_CLIENT.use((value: null) => ({ value })); + // @ts-expect-error - input is not an object. + EMPTY_ASYNC_CLIENT.use((value: undefined) => ({ value })); + } + + // It does not accept plugins with invalid outputs. + { + // @ts-expect-error - output is not an object. + EMPTY_ASYNC_CLIENT.use(() => 42); + // @ts-expect-error - output is not an object. + EMPTY_ASYNC_CLIENT.use(() => 'hello'); + // @ts-expect-error - output is not an object. + EMPTY_ASYNC_CLIENT.use(() => true); + // @ts-expect-error - output is not an object. + EMPTY_ASYNC_CLIENT.use(() => null); + // @ts-expect-error - output is not an object. + EMPTY_ASYNC_CLIENT.use(() => undefined); + } + + // It evolves through multiple plugins. + { + const client = EMPTY_ASYNC_CLIENT.use(c => ({ ...c, fruit: 'apple' as const })) + .use(c => Promise.resolve({ ...c, vegetable: 'carrot' as const })) + .use(c => ({ ...c, grain: 'rice' as const })); + client satisfies AsyncClient<{ fruit: 'apple'; grain: 'rice'; vegetable: 'carrot' }>; + } + + // It accepts plugins when input type constraints are satisfied. + { + const apple = (p: T) => ({ ...p, fruit: 'apple' as const }); + const appleCake = (p: T) => + Promise.resolve({ ...p, dessert: 'apple cake' as const }); + const client = EMPTY_ASYNC_CLIENT.use(apple).use(appleCake); + client satisfies AsyncClient<{ dessert: 'apple cake'; fruit: 'apple' }>; + } + + // It rejects plugins when input type constraints are not satisfied. + { + const banana = (p: T) => ({ ...p, fruit: 'banana' as const }); + const appleCake = (p: T) => + Promise.resolve({ ...p, dessert: 'apple cake' as const }); + const bananaClient = EMPTY_ASYNC_CLIENT.use(banana); + // @ts-expect-error - banana does not satisfy apple cake input type. + bananaClient.use(appleCake); + } +} + +// [DESCRIBE] createEmptyClient +{ + // It returns an empty Client (See typetests above). + { + createEmptyClient() satisfies typeof EMPTY_CLIENT; + } +} diff --git a/packages/plugin-core/src/client.ts b/packages/plugin-core/src/client.ts new file mode 100644 index 000000000..85748eba9 --- /dev/null +++ b/packages/plugin-core/src/client.ts @@ -0,0 +1,201 @@ +/** + * Defines a plugin that transforms or extends a client with additional functionality. + * + * For instance, plugins may add RPC capabilities, wallet integration, transaction building, + * or other features necessary for interacting with the Solana blockchain. + * + * Plugins are functions that take a client object as input and return a new client object + * or a promise that resolves to a new client object. This allows for both synchronous + * and asynchronous transformations and extensions of the client. + * + * Plugins are usually applied using the `use` method on a {@link Client} or {@link AsyncClient} + * instance, which {@link createEmptyClient} provides as a starting point. + * + * @typeParam TInput - The input client object type that this plugin accepts. + * @typeParam TOutput - The output type. Either a new client object or a promise resolving to one. + * + * @example Basic RPC plugin + * Given an RPC endpoint, this plugin adds an `rpc` property to the client. + * + * ```ts + * import { createEmptyClient, createSolanaRpc } from '@solana/kit'; + * + * // Define a simple RPC plugin. + * function rpcPlugin(endpoint: string) { + * return (client: T) => ({...client, rpc: createSolanaRpc(endpoint) }); + * } + * + * // Use the plugin. + * const client = createEmptyClient().use(rpcPlugin('https://api.mainnet-beta.solana.com')); + * await client.rpc.getLatestBlockhash().send(); + * ``` + * + * @example Async plugin that generates a payer wallet + * The following plugin shows how to create an asynchronous plugin that generates a new keypair signer. + * + * ```ts + * import { createEmptyClient, generateKeypairSigner } from '@solana/kit'; + * + * // Define a plugin that generates a new keypair signer. + * function generatedPayerPlugin() { + * return async (client: T) => ({...client, payer: await generateKeypairSigner() }); + * } + * + * // Use the plugin. + * const client = await createEmptyClient().use(generatedPayerPlugin()); + * console.log(client.payer.address); + * ``` + * + * @example Plugins with input requirements + * A plugin can specify required properties on the input client. The example below requires the + * client to already have a `payer` signer attached to the client in order to perform an airdrop. + * + * ```ts + * import { createEmptyClient, TransactionSigner, Lamports, lamports } from '@solana/kit'; + * + * // Define a plugin that airdrops lamports to the payer set on the client. + * function airdropPayerPlugin(lamports: Lamports) { + * return async (client: T) => { + * await myAirdropFunction(client.payer, lamports); + * return client; + * }; + * } + * + * // Use the plugins. + * const client = await createEmptyClient() + * .use(generatedPayerPlugin()) // This is required before using the airdrop plugin. + * .use(airdropPayerPlugin(lamports(1_000_000_000n))); + * ``` + * + * @example Chaining plugins + * Multiple plugins — asynchronous or not — can be chained together to build up complex clients. + * The example below demonstrates how to gradually build a client with multiple plugins. + * Notice how, despite having multiple asynchronous plugins, we only need to `await` the final result. + * This is because the `use` method on `AsyncClient` returns another `AsyncClient`, allowing for seamless chaining. + * + * ```ts + * import { createEmptyClient, createSolanaRpc, createSolanaRpcSubscriptions, generateKeypairSigner } from '@solana/kit'; + * + * // Define multiple plugins. + * function rpcPlugin(endpoint: string) { + * return (client: T) => ({...client, rpc: createSolanaRpc(endpoint) }); + * } + * function rpcSubscriptionsPlugin(endpoint: string) { + * return (client: T) => ({...client, rpc: createSolanaRpcSubscriptions(endpoint) }); + * } + * function generatedPayerPlugin() { + * return async (client: T) => ({...client, payer: await generateKeypairSigner() }); + * } + * function generatedAuthorityPlugin() { + * return async (client: T) => ({...client, authority: await generateKeypairSigner() }); + * } + * + * // Chain plugins together. + * const client = await createEmptyClient() + * .use(rpcPlugin('https://api.mainnet-beta.solana.com')) + * .use(rpcSubscriptionsPlugin('wss://api.mainnet-beta.solana.com')) + * .use(generatedPayerPlugin()) + * .use(generatedAuthorityPlugin()); + * ``` + */ +export type ClientPlugin | object> = (input: TInput) => TOutput; + +/** + * A client that can be extended with plugins. + * + * The `Client` type represents a client object that can be built up through + * the application of one or more plugins. It provides a `use` method to + * apply plugins, either synchronously (returning a new `Client`) or + * asynchronously (returning an {@link AsyncClient}). + * + * @typeParam TSelf - The current shape of the client object including all applied plugins. + */ +export type Client = TSelf & { + /** + * Applies a plugin to extend or transform the client. + * + * @param plugin The plugin function to apply to this client. + * @returns Either a new `Client` (for sync plugins) or {@link AsyncClient} (for async plugins). + */ + readonly use: | object>( + plugin: ClientPlugin, + ) => TOutput extends Promise ? AsyncClient : Client; +}; + +/** + * An asynchronous wrapper that represents a promise of a client. + * + * The `AsyncClient` type is returned when an async plugin is applied to a client. + * It behaves like a `Promise>` but with an additional `use` method + * that allows chaining more plugins before the promise resolves. + * + * This enables fluent chaining of both synchronous and asynchronous plugins + * without having to await intermediate promises. + * + * @typeParam TSelf - The shape of the client object that this async client will resolve to. + */ +export type AsyncClient = Promise> & { + /** + * Applies a plugin to the client once it resolves. + * + * @param plugin The plugin function to apply to the resolved client. + * @returns A new `AsyncClient` representing the result of applying the plugin. + */ + readonly use: | object>( + plugin: ClientPlugin, + ) => AsyncClient ? (U extends object ? U : never) : TOutput>; +}; + +// TODO(loris): Add examples in this docblock using real plugins once they have been published. + +/** + * Creates a new empty client that can be extended with plugins. + * + * This serves as an entry point for building Solana clients. + * Start with an empty client and chain the `.use()` method + * to apply plugins that add various functionalities such as RPC + * connectivity, wallet integration, transaction building, and more. + * + * See {@link ClientPlugin} for detailed examples on creating and using plugins. + * + * @returns An empty client object with only the `use` method available. + * + * @example Basic client setup + * ```ts + * import { createEmptyClient } from '@solana/client'; + * + * const client = createEmptyClient() + * .use(myRpcPlugin('https://api.mainnet-beta.solana.com')) + * .use(myWalletPlugin()); + * ``` + */ +export function createEmptyClient(): Client { + return addUse({}); +} + +function addUse(value: TSelf): Client { + return Object.freeze({ + ...value, + use | object>(plugin: ClientPlugin) { + const result = plugin(value); + return result instanceof Promise ? createAsyncClient(result) : addUse(result); + }, + } as Client); +} + +function createAsyncClient(promise: Promise): AsyncClient { + return Object.freeze({ + catch(onrejected) { + return promise.then(v => addUse(v)).catch(onrejected); + }, + finally(onfinally) { + return promise.then(v => addUse(v)).finally(onfinally); + }, + then(onfulfilled, onrejected) { + return promise.then(v => addUse(v)).then(onfulfilled, onrejected); + }, + use | object>(plugin: ClientPlugin) { + return createAsyncClient(promise.then(plugin)); + }, + } as AsyncClient); +} diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts new file mode 100644 index 000000000..9e1e63902 --- /dev/null +++ b/packages/plugin-core/src/index.ts @@ -0,0 +1,8 @@ +/** + * This package contains utilities for creating modular Kit clients that can be extended + * with plugins. It can be used standalone, but it is also exported as part of Kit + * [`@solana/kit`](https://github.com/anza-xyz/kit/tree/main/packages/kit). + * + * @packageDocumentation + */ +export * from './client'; diff --git a/packages/plugin-core/tsconfig.declarations.json b/packages/plugin-core/tsconfig.declarations.json new file mode 100644 index 000000000..67ad58e02 --- /dev/null +++ b/packages/plugin-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/plugin-core/tsconfig.json b/packages/plugin-core/tsconfig.json new file mode 100644 index 000000000..9e8ecccdf --- /dev/null +++ b/packages/plugin-core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "lib": ["DOM", "ES2015", "ES2019.Array", "ES2020.BigInt", "ES2022.Error"] + }, + "display": "@solana/plugin-core", + "extends": "../tsconfig/base.json", + "include": ["../build-scripts/build-time-constants.d.ts", "src"] +} diff --git a/packages/plugin-core/typedoc.json b/packages/plugin-core/typedoc.json new file mode 100644 index 000000000..2c830355d --- /dev/null +++ b/packages/plugin-core/typedoc.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "extends": ["../../typedoc.json"], + "entryPoints": ["src/index.ts"], + "readme": "none", + "out": "./.docs" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c5ebdb48..6e6b5292e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -711,6 +711,9 @@ importers: '@solana/offchain-messages': specifier: workspace:* version: link:../offchain-messages + '@solana/plugin-core': + specifier: workspace:* + version: link:../plugin-core '@solana/programs': specifier: workspace:* version: link:../programs @@ -805,6 +808,12 @@ importers: specifier: '>=5.9.3' version: 5.9.3 + packages/plugin-core: + dependencies: + typescript: + specifier: '>=5.3.3' + version: 5.9.3 + packages/programs: dependencies: '@solana/addresses':