diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bb06a2d..70792b5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -54,6 +54,9 @@ jobs: - name: Run tsc in nextjs-app run: pnpm --filter nextjs-app test:generated + - name: Run next build in nextjs-app + run: pnpm --filter nextjs-app build + - name: Run tsc in tanstack-router-app run: pnpm --filter tanstack-router-app test:generated diff --git a/examples/nextjs-app/package.json b/examples/nextjs-app/package.json index 3aa3201..97558a0 100644 --- a/examples/nextjs-app/package.json +++ b/examples/nextjs-app/package.json @@ -8,7 +8,6 @@ "dev:mock": "prism mock ../petstore.yaml --dynamic", "build": "next build", "start": "next start", - "lint": "next lint", "generate:api": "rimraf ./openapi && node ../../dist/cli.mjs -i ../petstore.yaml --format=biome --lint=biome", "test:generated": "tsc -p ./tsconfig.json --noEmit" }, @@ -16,7 +15,7 @@ "@hey-api/client-fetch": "^0.6.0", "@tanstack/react-query": "^5.59.13", "@tanstack/react-query-devtools": "^5.32.1", - "next": "^15.5.16", + "next": "^16.2.10", "react": "^18", "react-dom": "^18" }, diff --git a/examples/nextjs-app/tsconfig.json b/examples/nextjs-app/tsconfig.json index 86824be..7830c0c 100644 --- a/examples/nextjs-app/tsconfig.json +++ b/examples/nextjs-app/tsconfig.json @@ -11,7 +11,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -22,6 +22,12 @@ "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], "exclude": ["node_modules"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf94f2b..74fffda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,8 +133,8 @@ importers: specifier: ^5.32.1 version: 5.45.0(@tanstack/react-query@5.59.13(react@18.3.1))(react@18.3.1) next: - specifier: ^15.5.16 - version: 15.5.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^16.2.10 + version: 16.2.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18 version: 18.3.1 @@ -1660,53 +1660,53 @@ packages: '@mjackson/node-fetch-server@0.2.0': resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==} - '@next/env@15.5.20': - resolution: {integrity: sha512-dXh51Wvddf8daEyBXryZZEe1FdVxEWx9lgaTseLZUtC1XP/W8Wri+Z+VPOElHlByk23CyqHdc2oVByX7wsTWsw==} + '@next/env@16.2.10': + resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==} - '@next/swc-darwin-arm64@15.5.20': - resolution: {integrity: sha512-in0yXG7/pRBVjWeEl7f7ZZETpletSMFKXVS4GJgHENTPVrJFNJKPrYewa9rpZcvdjwFece5fZP0CK34G4PxowA==} + '@next/swc-darwin-arm64@16.2.10': + resolution: {integrity: sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.20': - resolution: {integrity: sha512-0hsFshdPnTzGJdDTHeHJ+XPUShOpnyp9pUFDwDhqctsA0Cd8NcIVGRPtptYhgYY9DjkKgCDRkXxmgRc+CgT5Wg==} + '@next/swc-darwin-x64@16.2.10': + resolution: {integrity: sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.20': - resolution: {integrity: sha512-DMvkoBtAABOzE6pMZRW/xNm7sKqql3wzzzZJ1R/d/rp4BCxv6LykouD3tHjGY8WdQqGpZs11t+R9AtjPxvvljw==} + '@next/swc-linux-arm64-gnu@16.2.10': + resolution: {integrity: sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.5.20': - resolution: {integrity: sha512-RQmDfeYBtXV2FSId7dfA1hE6M/T6+g7wdbYnFQ47tw/gUBwV+CccLVejNmCGa9yLDitk83foeg8hl/3DjfYQ5g==} + '@next/swc-linux-arm64-musl@16.2.10': + resolution: {integrity: sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.5.20': - resolution: {integrity: sha512-DkWLEdKajJwdGt27M3i1VEO2kelTvZrK6Pcb7JvW2BY+nofWm7FBsBNDj7g7Pr1NuQ5PLJvqEqYa20GTsBDnKQ==} + '@next/swc-linux-x64-gnu@16.2.10': + resolution: {integrity: sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.5.20': - resolution: {integrity: sha512-rAO5b7pKHvX+ExdmJskusDXTNbiNZfptifIPZItbUx+AOXxxTydVBsPt7Oz84DRd5mY8e0DcE8kvLj3AIfjE6w==} + '@next/swc-linux-x64-musl@16.2.10': + resolution: {integrity: sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.5.20': - resolution: {integrity: sha512-Hp3zFsN8N8Kj9+vY6L4vnZ9EtA9eXyATu0q4EfGbZTiocgPUNSfz8NWhym6xvaOmHpJ8EuoypuU1WejCPsTFtg==} + '@next/swc-win32-arm64-msvc@16.2.10': + resolution: {integrity: sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.20': - resolution: {integrity: sha512-T/L7CXpR1M0wij/xbF3rT1+7KvSkfOLr7C+ToHHWZTG2eKmb52C5WvsyGCBNtkVvDEUESWkRUbbqSH4rSbOCYQ==} + '@next/swc-win32-x64-msvc@16.2.10': + resolution: {integrity: sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2557,6 +2557,11 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.43: + resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} + engines: {node: '>=6.0.0'} + hasBin: true + basic-auth@2.0.1: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} @@ -4291,9 +4296,9 @@ packages: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} - next@15.5.20: - resolution: {integrity: sha512-cvyS3/geydan1xLtE3FA8VCgdoQ/Gg/dlOldFkFCbB5VcVYJV7090hQLBnvTW2PwT76Z/dHdzDZCsVhZpoOlUA==} - engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + next@16.2.10: + resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==} + engines: {node: '>=20.9.0'} hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -7157,30 +7162,30 @@ snapshots: '@mjackson/node-fetch-server@0.2.0': {} - '@next/env@15.5.20': {} + '@next/env@16.2.10': {} - '@next/swc-darwin-arm64@15.5.20': + '@next/swc-darwin-arm64@16.2.10': optional: true - '@next/swc-darwin-x64@15.5.20': + '@next/swc-darwin-x64@16.2.10': optional: true - '@next/swc-linux-arm64-gnu@15.5.20': + '@next/swc-linux-arm64-gnu@16.2.10': optional: true - '@next/swc-linux-arm64-musl@15.5.20': + '@next/swc-linux-arm64-musl@16.2.10': optional: true - '@next/swc-linux-x64-gnu@15.5.20': + '@next/swc-linux-x64-gnu@16.2.10': optional: true - '@next/swc-linux-x64-musl@15.5.20': + '@next/swc-linux-x64-musl@16.2.10': optional: true - '@next/swc-win32-arm64-msvc@15.5.20': + '@next/swc-win32-arm64-msvc@16.2.10': optional: true - '@next/swc-win32-x64-msvc@15.5.20': + '@next/swc-win32-x64-msvc@16.2.10': optional: true '@nodable/entities@2.2.0': {} @@ -7453,7 +7458,7 @@ snapshots: lodash: 4.18.0 openapi3-ts: 2.0.2 postman-collection: 4.4.0 - tslib: 2.6.3 + tslib: 2.8.1 type-is: 1.6.18 transitivePeerDependencies: - encoding @@ -7610,7 +7615,7 @@ snapshots: '@stoplight/ordered-object-literal': 1.0.5 '@stoplight/types': 14.1.1 '@stoplight/yaml-ast-parser': 0.0.50 - tslib: 2.6.3 + tslib: 2.8.1 '@swc/helpers@0.5.15': dependencies: @@ -8237,6 +8242,8 @@ snapshots: base64-js@1.5.1: {} + baseline-browser-mapping@2.10.43: {} + basic-auth@2.0.1: dependencies: safe-buffer: 5.1.2 @@ -10494,24 +10501,25 @@ snapshots: neotraverse@0.6.18: {} - next@15.5.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@16.2.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@next/env': 15.5.20 + '@next/env': 16.2.10 '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.43 caniuse-lite: 1.0.30001667 postcss: 8.5.10 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.6(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.20 - '@next/swc-darwin-x64': 15.5.20 - '@next/swc-linux-arm64-gnu': 15.5.20 - '@next/swc-linux-arm64-musl': 15.5.20 - '@next/swc-linux-x64-gnu': 15.5.20 - '@next/swc-linux-x64-musl': 15.5.20 - '@next/swc-win32-arm64-msvc': 15.5.20 - '@next/swc-win32-x64-msvc': 15.5.20 + '@next/swc-darwin-arm64': 16.2.10 + '@next/swc-darwin-x64': 16.2.10 + '@next/swc-linux-arm64-gnu': 16.2.10 + '@next/swc-linux-arm64-musl': 16.2.10 + '@next/swc-linux-x64-gnu': 16.2.10 + '@next/swc-linux-x64-musl': 16.2.10 + '@next/swc-win32-arm64-msvc': 16.2.10 + '@next/swc-win32-x64-msvc': 16.2.10 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' diff --git a/src/generate.mts b/src/generate.mts index 6a04b62..827ec7a 100644 --- a/src/generate.mts +++ b/src/generate.mts @@ -1,4 +1,5 @@ -import { writeFile } from "node:fs/promises"; +import { readdirSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { type UserConfig, createClient } from "@hey-api/openapi-ts"; import type { LimitedUserConfig } from "./cli.mjs"; @@ -11,6 +12,27 @@ import { createSource } from "./createSource.mjs"; import { formatOutput, processOutput } from "./format.mjs"; import { print } from "./print.mjs"; +// openapi-ts's own tsconfig auto-detection walks up from its *install* +// location, which in this monorepo reaches this repo's own root tsconfig +// (NodeNext) instead of the caller's project config. Anchoring the search +// at cwd instead finds the tsconfig that actually applies to the generated +// output, matching the resolution the caller's own `tsc` will use. +export function findNearestTsConfigPath(startDir: string): string | undefined { + let dir = startDir; + while (true) { + const candidates = readdirSync(dir).filter( + (file) => file.startsWith("tsconfig") && file.endsWith(".json"), + ); + if (candidates.length > 0) { + candidates.sort((a) => (a === "tsconfig.json" ? -1 : 1)); + return path.join(dir, candidates[0]); + } + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + export async function generate(options: LimitedUserConfig, version: string) { const openApiOutputPath = buildRequestsOutputPath(options.output); const formattedOptions = formatOptions(options); @@ -61,14 +83,25 @@ export async function generate(options: LimitedUserConfig, version: string) { const config: UserConfig = { dryRun: false, input: formattedOptions.input, - output: openApiOutputPath, + output: { + path: openApiOutputPath, + tsConfigPath: findNearestTsConfigPath(process.cwd()) ?? null, + }, plugins, }; await createClient(config); // Generate backward-compatible services.gen.ts shim - // client.gen.ts has the `client` instance; sdk.gen.ts has SDK functions - const shimContent = `// This file is auto-generated for backward compatibility\nexport * from './client.gen.js';\nexport * from './sdk.gen.js';\n`; + // client.gen.ts has the `client` instance; sdk.gen.ts has SDK functions. + // Mirror whatever extension convention openapi-ts used for its own + // cross-file imports (e.g. `.js` for NodeNext, none for bundler resolution). + const sdkGenContent = await readFile( + path.join(openApiOutputPath, "sdk.gen.ts"), + "utf-8", + ); + const importExtension = + sdkGenContent.match(/from ['"]\.\/client\.gen(\.\S*)?['"]/)?.[1] ?? ""; + const shimContent = `// This file is auto-generated for backward compatibility\nexport * from './client.gen${importExtension}';\nexport * from './sdk.gen${importExtension}';\n`; await writeFile(path.join(openApiOutputPath, "services.gen.ts"), shimContent); const source = await createSource({ diff --git a/tests/generate.test.ts b/tests/generate.test.ts index 4088028..f5a3b35 100644 --- a/tests/generate.test.ts +++ b/tests/generate.test.ts @@ -1,9 +1,10 @@ import { existsSync, readFileSync } from "node:fs"; -import { rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import type { LimitedUserConfig } from "../src/cli.mts"; -import { generate } from "../src/generate.mjs"; +import { findNearestTsConfigPath, generate } from "../src/generate.mjs"; const readOutput = (fileName: string) => { return readFileSync( @@ -132,3 +133,110 @@ describe("generate - noSchemas option", () => { expect(readNoSchemasOutput("queries.ts")).toMatchSnapshot(); }); }); + +describe("findNearestTsConfigPath", () => { + let fixtureRoot: string; + + beforeAll(async () => { + fixtureRoot = await mkdtemp(path.join(tmpdir(), "openapi-rq-tsconfig-")); + }); + + afterAll(async () => { + await rm(fixtureRoot, { recursive: true, force: true }); + }); + + test("finds tsconfig.json in the start directory", async () => { + const dir = await mkdtemp(path.join(fixtureRoot, "same-dir-")); + await writeFile(path.join(dir, "tsconfig.json"), "{}"); + expect(findNearestTsConfigPath(dir)).toBe(path.join(dir, "tsconfig.json")); + }); + + test("walks up to an ancestor directory", async () => { + const ancestor = await mkdtemp(path.join(fixtureRoot, "ancestor-")); + await writeFile(path.join(ancestor, "tsconfig.json"), "{}"); + const nested = path.join(ancestor, "packages", "app"); + await mkdir(nested, { recursive: true }); + expect(findNearestTsConfigPath(nested)).toBe( + path.join(ancestor, "tsconfig.json"), + ); + }); + + test("prefers exact tsconfig.json over other tsconfig*.json files", async () => { + const dir = await mkdtemp(path.join(fixtureRoot, "prefers-")); + await writeFile(path.join(dir, "tsconfig.build.json"), "{}"); + await writeFile(path.join(dir, "tsconfig.json"), "{}"); + expect(findNearestTsConfigPath(dir)).toBe(path.join(dir, "tsconfig.json")); + }); + + test("returns undefined when no ancestor has a tsconfig", async () => { + const dir = await mkdtemp(path.join(fixtureRoot, "empty-")); + // Real filesystem walk-up, so this only holds if no ancestor of `dir` + // (up to and including `/`) happens to contain a tsconfig*.json — true + // for a fresh os.tmpdir() fixture in CI and local dev. + expect(findNearestTsConfigPath(dir)).toBeUndefined(); + }); +}); + +describe("generate - import extension follows the caller's tsconfig", () => { + const readGenerated = async (outputDir: string, fileName: string) => + readFile(path.join(outputDir, "requests", fileName), "utf-8"); + + const runInFixture = async (moduleResolution: string) => { + const fixtureDir = await mkdtemp( + path.join(tmpdir(), "openapi-rq-generate-"), + ); + await writeFile( + path.join(fixtureDir, "tsconfig.json"), + JSON.stringify({ compilerOptions: { moduleResolution } }), + ); + const originalCwd = process.cwd(); + try { + process.chdir(fixtureDir); + await generate( + { + input: path.join(__dirname, "inputs", "petstore.yaml"), + // `generate()` joins this against `process.cwd()` internally, so + // it must be relative (matching how the CLI is always invoked). + output: "output", + client: "@hey-api/client-fetch", + pageParam: "page", + nextPageParam: "meta.next", + initialPageParam: "initial", + }, + "1.0.0", + ); + } finally { + process.chdir(originalCwd); + } + return { fixtureDir, outputDir: path.join(fixtureDir, "output") }; + }; + + test("bundler resolution: no extension on relative imports", async () => { + const { fixtureDir, outputDir } = await runInFixture("bundler"); + try { + const sdkGen = await readGenerated(outputDir, "sdk.gen.ts"); + const shim = await readGenerated(outputDir, "services.gen.ts"); + expect(sdkGen).toContain("from './client.gen'"); + expect(sdkGen).not.toContain("from './client.gen.js'"); + expect(shim).toBe( + "// This file is auto-generated for backward compatibility\nexport * from './client.gen';\nexport * from './sdk.gen';\n", + ); + } finally { + await rm(fixtureDir, { recursive: true, force: true }); + } + }); + + test("NodeNext resolution: .js extension on relative imports, mirrored by the shim", async () => { + const { fixtureDir, outputDir } = await runInFixture("nodenext"); + try { + const sdkGen = await readGenerated(outputDir, "sdk.gen.ts"); + const shim = await readGenerated(outputDir, "services.gen.ts"); + expect(sdkGen).toContain("from './client.gen.js'"); + expect(shim).toBe( + "// This file is auto-generated for backward compatibility\nexport * from './client.gen.js';\nexport * from './sdk.gen.js';\n", + ); + } finally { + await rm(fixtureDir, { recursive: true, force: true }); + } + }); +});