diff --git a/.changeset/contract-client-version-check.md b/.changeset/contract-client-version-check.md new file mode 100644 index 000000000..96ea3ff7b --- /dev/null +++ b/.changeset/contract-client-version-check.md @@ -0,0 +1,16 @@ +--- +'@kubb/plugin-react-query': patch +'@kubb/plugin-vue-query': patch +'@kubb/plugin-swr': patch +--- + +Setup now throws a clear error when the registered `@kubb/plugin-fetch` or `@kubb/plugin-axios` +predates the `unwrap()` method these hooks call, instead of generating hooks that fail at runtime. + +``` +`@kubb/plugin-fetch` is registered at version 5.1.2, but this plugin needs `@kubb/plugin-fetch@5.2.0` or newer. +Generated hooks call `unwrap()` on the client's result, which `@kubb/plugin-fetch` only added in 5.2.0. +Upgrade `@kubb/plugin-fetch` to 5.2.0 or later. +``` + +Upgrade `@kubb/plugin-fetch` or `@kubb/plugin-axios` to `5.2.0` or later to clear the error. diff --git a/internals/client/src/resolveClient.test.ts b/internals/client/src/resolveClient.test.ts index 989dd3534..53d0c6f4a 100644 --- a/internals/client/src/resolveClient.test.ts +++ b/internals/client/src/resolveClient.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, test } from 'vitest' -import { resolveClient } from './resolveClient.ts' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'vitest' +import { resolveClient, resolveContractClient } from './resolveClient.ts' describe('resolveClient', () => { test("client: 'fetch' selects plugin-fetch when it is registered", () => { @@ -36,3 +39,56 @@ describe('resolveClient', () => { } }) }) + +describe('resolveContractClient', () => { + let root: string + + function installPackage(name: string, version: string) { + const pkgDir = path.join(root, 'node_modules', ...name.split('/')) + fs.mkdirSync(pkgDir, { recursive: true }) + fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name, version })) + } + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'kubb-resolve-contract-client-')) + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'fixture', version: '0.0.0' })) + }) + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }) + }) + + test('a consumer without requireUnwrap ignores an old contract client version', () => { + installPackage('@kubb/plugin-fetch', '5.1.2') + + expect(resolveContractClient({ client: 'fetch', plugins: [{ name: 'plugin-fetch' }], root })).toStrictEqual({ + kind: 'contract', + pluginName: 'plugin-fetch', + }) + }) + + test('requireUnwrap throws when plugin-fetch predates unwrap()', () => { + installPackage('@kubb/plugin-fetch', '5.1.2') + + expect(() => resolveContractClient({ client: 'fetch', plugins: [{ name: 'plugin-fetch' }], root, requireUnwrap: true })).toThrowError( + /@kubb\/plugin-fetch.*5\.1\.2.*@kubb\/plugin-fetch@5\.2\.0/s, + ) + }) + + test('requireUnwrap throws when plugin-axios predates unwrap()', () => { + installPackage('@kubb/plugin-axios', '5.1.0') + + expect(() => resolveContractClient({ client: 'axios', plugins: [{ name: 'plugin-axios' }], root, requireUnwrap: true })).toThrowError( + /@kubb\/plugin-axios.*5\.1\.0.*@kubb\/plugin-axios@5\.2\.0/s, + ) + }) + + test('requireUnwrap passes when the contract client is new enough', () => { + installPackage('@kubb/plugin-fetch', '5.2.0') + + expect(resolveContractClient({ client: 'fetch', plugins: [{ name: 'plugin-fetch' }], root, requireUnwrap: true })).toStrictEqual({ + kind: 'contract', + pluginName: 'plugin-fetch', + }) + }) +}) diff --git a/internals/client/src/resolveClient.ts b/internals/client/src/resolveClient.ts index 822b2e121..a98252dc2 100644 --- a/internals/client/src/resolveClient.ts +++ b/internals/client/src/resolveClient.ts @@ -14,11 +14,20 @@ * contract client plugin is picked up automatically. */ +import { createRequire } from 'node:module' +import path from 'node:path' + // Canonical plugin names. They mirror the `pluginFetchName` / `pluginAxiosName` consts the plugins // export, kept as literals so this internal needs no plugin install deps. const pluginFetchName = 'plugin-fetch' const pluginAxiosName = 'plugin-axios' +// react-query, vue-query, and swr build their generated hooks on `unwrap()`, which plugin-fetch +// and plugin-axios only started attaching to their result at this version. An older client plugin +// would generate a client without `unwrap()`, so the hooks would call a method that does not +// exist at runtime. +const MIN_CLIENT_VERSION_FOR_UNWRAP = '5.2.0' + /** * The client selector accepted by a consumer's `client` option. Both call a registered contract * client plugin. @@ -96,17 +105,61 @@ export function resolveClient(options: { client: ClientSelector | undefined; plu } } +/** + * Compares dot-separated `major.minor.patch` version strings. Missing or non-numeric parts count + * as `0`, and a prerelease suffix (`-beta.1`) is ignored. + */ +function isVersionAtLeast(version: string, minVersion: string): boolean { + const parts = (value: string) => value.split('-')[0]!.split('.').map(Number) + const [major = 0, minor = 0, patch = 0] = parts(version) + const [minMajor = 0, minMinor = 0, minPatch = 0] = parts(minVersion) + + if (major !== minMajor) return major > minMajor + if (minor !== minMinor) return minor > minMinor + return patch >= minPatch +} + /** * Resolves the contract client during a consumer plugin's setup hook. Extracts the plugin names * from the raw `plugins` config, applies {@link resolveClient}, and throws the diagnostic on a * misconfiguration so every consumer fails fast with the same message. + * + * Pass `requireUnwrap: true` for a consumer (react-query, vue-query, swr) whose generated hooks + * call `unwrap()` on the client's result. This reads the resolved client plugin's installed + * version from the user's project and throws when it predates `unwrap()`, so setup fails with a + * clear diagnostic instead of the generated hooks calling a method that does not exist. */ -export function resolveContractClient(options: { client: ClientSelector | undefined; plugins?: ReadonlyArray }): ResolvedContractClient { - const { client, plugins = [] } = options +export function resolveContractClient(options: { + client: ClientSelector | undefined + plugins?: ReadonlyArray + root: string + requireUnwrap?: boolean +}): ResolvedContractClient { + const { client, plugins = [], root, requireUnwrap = false } = options const pluginNames = plugins.map((plugin) => (plugin as { name?: string }).name).filter((name): name is string => Boolean(name)) const resolved = resolveClient({ client, pluginNames }) if (resolved.kind === 'error') { throw new Error(resolved.message) } + if (!requireUnwrap) { + return resolved + } + + const packageName = `@kubb/${resolved.pluginName}` + let installedVersion: string | undefined + try { + installedVersion = (createRequire(path.join(root, 'package.json'))(`${packageName}/package.json`) as { version?: string }).version + } catch { + installedVersion = undefined + } + + if (installedVersion && !isVersionAtLeast(installedVersion, MIN_CLIENT_VERSION_FOR_UNWRAP)) { + throw new Error( + `\`${packageName}\` is registered at version ${installedVersion}, but this plugin needs \`${packageName}@${MIN_CLIENT_VERSION_FOR_UNWRAP}\` or newer. ` + + `Generated hooks call \`unwrap()\` on the client's result, which \`${packageName}\` only added in ${MIN_CLIENT_VERSION_FOR_UNWRAP}. ` + + `Upgrade \`${packageName}\` to ${MIN_CLIENT_VERSION_FOR_UNWRAP} or later.`, + ) + } + return resolved } diff --git a/packages/plugin-mcp/src/plugin.ts b/packages/plugin-mcp/src/plugin.ts index 88526aa13..94789d432 100644 --- a/packages/plugin-mcp/src/plugin.ts +++ b/packages/plugin-mcp/src/plugin.ts @@ -70,7 +70,7 @@ export const pluginMcp = definePlugin((options) => { include, override, group: groupConfig, - client: resolveContractClient({ client, plugins: ctx.config.plugins }), + client: resolveContractClient({ client, plugins: ctx.config.plugins, root: ctx.config.root }), resolver, }) ctx.setResolver(resolver) diff --git a/packages/plugin-react-query/src/plugin.ts b/packages/plugin-react-query/src/plugin.ts index 29198fe5a..091c4ee8a 100644 --- a/packages/plugin-react-query/src/plugin.ts +++ b/packages/plugin-react-query/src/plugin.ts @@ -88,7 +88,7 @@ export const pluginReactQuery = definePlugin((options) => { ctx.setOptions({ output, - client: resolveContractClient({ client, plugins: ctx.config.plugins }), + client: resolveContractClient({ client, plugins: ctx.config.plugins, root: ctx.config.root, requireUnwrap: true }), queryKey, query: resolveQueryConfig(query, { importPath: '@tanstack/react-query' }), mutationKey, diff --git a/packages/plugin-swr/src/plugin.ts b/packages/plugin-swr/src/plugin.ts index a282a3cd5..347c5a40e 100644 --- a/packages/plugin-swr/src/plugin.ts +++ b/packages/plugin-swr/src/plugin.ts @@ -64,7 +64,7 @@ export const pluginSwr = definePlugin((options) => { ctx.setOptions({ output, - client: resolveContractClient({ client, plugins: ctx.config.plugins }), + client: resolveContractClient({ client, plugins: ctx.config.plugins, root: ctx.config.root, requireUnwrap: true }), queryKey, query: resolveQueryConfig(query, { importPath: 'swr' }), mutationKey, diff --git a/packages/plugin-vue-query/src/plugin.ts b/packages/plugin-vue-query/src/plugin.ts index f6d557ccb..fca986f10 100644 --- a/packages/plugin-vue-query/src/plugin.ts +++ b/packages/plugin-vue-query/src/plugin.ts @@ -69,7 +69,7 @@ export const pluginVueQuery = definePlugin((options) => { ctx.setOptions({ output, - client: resolveContractClient({ client, plugins: ctx.config.plugins }), + client: resolveContractClient({ client, plugins: ctx.config.plugins, root: ctx.config.root, requireUnwrap: true }), queryKey, query: resolveQueryConfig(query, { importPath: '@tanstack/vue-query' }), mutationKey,