From d94a5d99820dad3aeacb0753878d03a53545c502 Mon Sep 17 00:00:00 2001 From: Omar Shibli Date: Thu, 2 Jul 2026 13:57:14 +0300 Subject: [PATCH 1/3] fix(server-utils): resolve tsconfig path aliases in emitted declaration files --- .../server-utils-declaration-aliases.md | 7 ++ .../utils/src/compilers/typescript/index.ts | 17 ++-- .../typescript/tsconfigPathsPlugin.ts | 89 ++++++++++++++++++- .../fixtures/ts-example/api/declaration.ts | 7 ++ .../tests/fixtures/ts-example/shared/index.ts | 1 + .../ts-example/tsconfig.declaration.json | 15 ++++ packages/server/utils/tests/ts.test.ts | 38 ++++++++ 7 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 .changeset/server-utils-declaration-aliases.md create mode 100644 packages/server/utils/tests/fixtures/ts-example/api/declaration.ts create mode 100644 packages/server/utils/tests/fixtures/ts-example/tsconfig.declaration.json diff --git a/.changeset/server-utils-declaration-aliases.md b/.changeset/server-utils-declaration-aliases.md new file mode 100644 index 000000000000..71e7f522ce0b --- /dev/null +++ b/.changeset/server-utils-declaration-aliases.md @@ -0,0 +1,7 @@ +--- +'@modern-js/server-utils': patch +--- + +fix(server-utils): resolve tsconfig path aliases in emitted declaration files + +The tsconfig-paths transformer only ran on the JS emit, so path aliases (unresolvable outside the project) leaked into `.d.ts` output when `declaration` is enabled. The same rewrite now also runs on declaration emit, including inline `import("...")` types. diff --git a/packages/server/utils/src/compilers/typescript/index.ts b/packages/server/utils/src/compilers/typescript/index.ts index 8d6866e1a0b3..53ed2bbba293 100644 --- a/packages/server/utils/src/compilers/typescript/index.ts +++ b/packages/server/utils/src/compilers/typescript/index.ts @@ -3,7 +3,7 @@ import { fs, getAliasConfig, logger } from '@modern-js/utils'; import type { ParseConfigFileHost, Program } from 'typescript'; import type ts from 'typescript'; import type { CompileFunc } from '../../common'; -import { tsconfigPathsBeforeHookFactory } from './tsconfigPathsPlugin'; +import { tsconfigPathsTransformersFactory } from './tsconfigPathsPlugin'; import { TypescriptLoader } from './typescriptLoader'; const readTsConfigByFile = (tsConfigFile: string, tsInstance: typeof ts) => { @@ -79,16 +79,23 @@ export const compileByTs: CompileFunc = async ( }, }); - const tsconfigPathsPlugin = tsconfigPathsBeforeHookFactory( + const tsconfigPathsTransformers = tsconfigPathsTransformersFactory( ts, absoluteBaseUrl, paths, compileOptions.moduleType, ); - const emitResult = program.emit(undefined, undefined, undefined, undefined, { - before: [tsconfigPathsPlugin!], - }); + const emitResult = program.emit( + undefined, + undefined, + undefined, + undefined, + tsconfigPathsTransformers && { + before: [tsconfigPathsTransformers.before], + afterDeclarations: [tsconfigPathsTransformers.afterDeclarations], + }, + ); const allDiagnostics = ts .getPreEmitDiagnostics(program as unknown as Program) diff --git a/packages/server/utils/src/compilers/typescript/tsconfigPathsPlugin.ts b/packages/server/utils/src/compilers/typescript/tsconfigPathsPlugin.ts index 8078f7e4139a..f7ce89687e58 100644 --- a/packages/server/utils/src/compilers/typescript/tsconfigPathsPlugin.ts +++ b/packages/server/utils/src/compilers/typescript/tsconfigPathsPlugin.ts @@ -82,7 +82,7 @@ const isDynamicImport = ( ); }; -export function tsconfigPathsBeforeHookFactory( +export function tsconfigPathsTransformersFactory( tsBinary: typeof ts, baseUrl: string, paths: Record, @@ -125,7 +125,7 @@ export function tsconfigPathsBeforeHookFactory( return undefined; } - return (ctx: ts.TransformationContext): ts.Transformer => { + const before = (ctx: ts.TransformationContext): ts.Transformer => { return (sf: ts.SourceFile) => { const visitNode = (node: ts.Node): ts.Node => { if (isDynamicImport(tsBinary, node)) { @@ -201,6 +201,91 @@ export function tsconfigPathsBeforeHookFactory( return tsBinary.visitNode(sf, visitNode); }; }; + + // Declaration emit keeps the module specifiers exactly as authored (TypeScript + // never resolves `paths` there, see microsoft/TypeScript#30952), so the same + // alias rewrite must run on the declaration output. Two differences from the + // JS transform: nodes are synthesized without source positions (specifiers + // must be read via `.text`, not `getText()`), and inline `import("...")` + // types appear as ImportTypeNode instead of dynamic-import CallExpression. + // `moduleType` is intentionally not forwarded: declaration specifiers stay + // extensionless and relative specifiers are already correct as emitted. + const afterDeclarations = ( + ctx: ts.TransformationContext, + ): ts.Transformer => { + return sourceFile => { + if (!tsBinary.isSourceFile(sourceFile)) { + return sourceFile; + } + const visitNode = (node: ts.Node): ts.Node => { + if (tsBinary.isImportTypeNode(node)) { + const literal = node.argument; + let importTypeNode: ts.Node = node; + if ( + tsBinary.isLiteralTypeNode(literal) && + tsBinary.isStringLiteral(literal.literal) + ) { + const result = getNotAliasedPath( + sourceFile, + matchPath, + literal.literal.text, + ); + if (result) { + importTypeNode = ctx.factory.updateImportTypeNode( + node, + ctx.factory.createLiteralTypeNode( + ctx.factory.createStringLiteral(result), + ), + // TS >= 5.3 names this `attributes`; earlier 5.x `assertions`. + (node as any).attributes ?? (node as any).assertions, + node.qualifier, + node.typeArguments, + node.isTypeOf, + ); + } + } + return tsBinary.visitEachChild(importTypeNode, visitNode, ctx); + } + if ( + (tsBinary.isImportDeclaration(node) || + tsBinary.isExportDeclaration(node)) && + node.moduleSpecifier && + tsBinary.isStringLiteral(node.moduleSpecifier) + ) { + const result = getNotAliasedPath( + sourceFile, + matchPath, + node.moduleSpecifier.text, + ); + if (!result) { + return node; + } + const moduleSpecifier = ctx.factory.createStringLiteral(result); + if (tsBinary.isImportDeclaration(node)) { + return ctx.factory.updateImportDeclaration( + node, + node.modifiers, + node.importClause, + moduleSpecifier, + node.assertClause, + ); + } + return ctx.factory.updateExportDeclaration( + node, + node.modifiers, + node.isTypeOnly, + node.exportClause, + moduleSpecifier, + node.assertClause, + ); + } + return tsBinary.visitEachChild(node, visitNode, ctx); + }; + return tsBinary.visitEachChild(sourceFile, visitNode, ctx); + }; + }; + + return { before, afterDeclarations }; } function getNotAliasedPath( diff --git a/packages/server/utils/tests/fixtures/ts-example/api/declaration.ts b/packages/server/utils/tests/fixtures/ts-example/api/declaration.ts new file mode 100644 index 000000000000..ad25a6d87bd3 --- /dev/null +++ b/packages/server/utils/tests/fixtures/ts-example/api/declaration.ts @@ -0,0 +1,7 @@ +import type { SharedMessage } from '@shared/index'; +import { shared } from '@shared/index'; + +export const message: SharedMessage = shared; + +export const getMessage: () => Promise = + async () => message; diff --git a/packages/server/utils/tests/fixtures/ts-example/shared/index.ts b/packages/server/utils/tests/fixtures/ts-example/shared/index.ts index cd35843de7a2..a1796a717fc4 100644 --- a/packages/server/utils/tests/fixtures/ts-example/shared/index.ts +++ b/packages/server/utils/tests/fixtures/ts-example/shared/index.ts @@ -1 +1,2 @@ export const shared = 'shared'; +export type SharedMessage = string; diff --git a/packages/server/utils/tests/fixtures/ts-example/tsconfig.declaration.json b/packages/server/utils/tests/fixtures/ts-example/tsconfig.declaration.json new file mode 100644 index 000000000000..75ba02dad052 --- /dev/null +++ b/packages/server/utils/tests/fixtures/ts-example/tsconfig.declaration.json @@ -0,0 +1,15 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": true, + "jsx": "preserve", + "baseUrl": "./", + "paths": { + "@/*": ["./src/*"], + "@shared/*": ["./shared/*"], + "@api/*": ["./api/*"], + "@server/*": ["./server/*"] + } + }, + "include": ["src", "shared", "server", "config", "api", "modern-app-env.d.ts"] +} diff --git a/packages/server/utils/tests/ts.test.ts b/packages/server/utils/tests/ts.test.ts index d78f5e517c1e..8e6323367720 100644 --- a/packages/server/utils/tests/ts.test.ts +++ b/packages/server/utils/tests/ts.test.ts @@ -100,4 +100,42 @@ describe('typescript', () => { await fs.remove(distDir); } }); + + it('rewrites path aliases in emitted declarations', async () => { + const example = path.join(__dirname, './fixtures', './ts-example'); + const tsconfigPath = path.join(example, './tsconfig.declaration.json'); + const distDir = path.join(example, './dist-declaration'); + const sharedDir = path.join(example, './shared'); + const apiDir = path.join(example, './api'); + const serverDir = path.join(example, './server'); + + try { + await compile( + example, + { + alias: { + '@modern-js/runtime/server': path.join( + sharedDir, + './runtime/server', + ), + }, + } as any, + { + sourceDirs: [sharedDir, apiDir, serverDir], + distDir, + tsconfigPath, + }, + ); + + const declaration = ( + await fs.readFile(path.join(distDir, './api/declaration.d.ts')) + ).toString(); + + expect(declaration).toContain(`from "../shared/index"`); + expect(declaration).toContain(`import("../shared/index")`); + expect(declaration).not.toContain('@shared'); + } finally { + await fs.remove(distDir); + } + }); }); From af4e073224e86fda08c08d6b3b4515d90588cf37 Mon Sep 17 00:00:00 2001 From: Omar Shibli Date: Thu, 2 Jul 2026 13:57:32 +0300 Subject: [PATCH 2/3] fix(plugin-bff): re-base relative specifiers when copying cross-project client declarations --- .changeset/bff-portable-client-types.md | 7 +++ .../plugin-bff/src/utils/clientGenerator.ts | 39 ++++++++++++++-- .../plugin-bff/tests/clientGenerator.test.ts | 43 ++++++++++++++++++ .../bff-api-app/api/lambda/index.ts | 3 +- .../bff-api-app/shared/index.ts | 4 ++ .../bff-corss-project/tests/index.test.ts | 44 +++++++++++++++++++ 6 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 .changeset/bff-portable-client-types.md create mode 100644 packages/cli/plugin-bff/tests/clientGenerator.test.ts diff --git a/.changeset/bff-portable-client-types.md b/.changeset/bff-portable-client-types.md new file mode 100644 index 000000000000..65334d2d3a01 --- /dev/null +++ b/.changeset/bff-portable-client-types.md @@ -0,0 +1,7 @@ +--- +'@modern-js/plugin-bff': patch +--- + +fix(plugin-bff): re-base relative specifiers when copying cross-project client declarations + +The cross-project client generator copied each handler's declaration into `dist/client` verbatim, one directory shallower than its origin, which broke the relative specifiers TypeScript emits. Specifiers are now re-based onto the copy's location so the generated `/api/*` client types resolve in consuming projects. diff --git a/packages/cli/plugin-bff/src/utils/clientGenerator.ts b/packages/cli/plugin-bff/src/utils/clientGenerator.ts index cbe724c56880..623a1b2d46ee 100644 --- a/packages/cli/plugin-bff/src/utils/clientGenerator.ts +++ b/packages/cli/plugin-bff/src/utils/clientGenerator.ts @@ -212,9 +212,40 @@ async function setPackage( } } -export async function copyFiles(from: string, to: string) { +const RELATIVE_SPECIFIER = + /(\bfrom\s*|\bimport\(\s*|\brequire\(\s*)(['"])(\.\.?\/[^'"]*)\2/g; + +// The generated client mirrors the handler's declaration but lives one +// directory shallower (`dist/client/*` vs `dist//*`), so the relative +// specifiers TypeScript emitted for the origin file must be re-based to keep +// resolving from the copy's location. +export function rebaseDeclarationSpecifiers( + source: string, + fromDir: string, + toDir: string, +) { + return source.replace( + RELATIVE_SPECIFIER, + (_match, lead, quote, specifier) => { + const target = path.resolve(fromDir, specifier); + const relative = toPosixPath(path.relative(toDir, target)); + const rebased = relative.startsWith('.') ? relative : `./${relative}`; + return `${lead}${quote}${rebased}${quote}`; + }, + ); +} + +async function copyDeclarationFile(from: string, to: string) { if (await fs.pathExists(from)) { - await fs.copy(toPosixPath(from), toPosixPath(to)); + const source = await fs.readFile(from, 'utf8'); + await fs.outputFile( + to, + rebaseDeclarationSpecifiers( + source, + path.dirname(path.resolve(from)), + path.dirname(path.resolve(to)), + ), + ); } } @@ -264,9 +295,9 @@ async function clientGenerator(draftOptions: APILoaderOptions) { const code = await getClitentCode(source.resourcePath, source.source); if (code?.value) { await writeTargetFile(source.absTargetDir, code.value); - await copyFiles( + await copyDeclarationFile( source.relativeTargetDistDir, - source.targetDir.replace(`js`, 'd.ts'), + source.targetDir.replace(/\.js$/, '.d.ts'), ); } } diff --git a/packages/cli/plugin-bff/tests/clientGenerator.test.ts b/packages/cli/plugin-bff/tests/clientGenerator.test.ts new file mode 100644 index 000000000000..e0d5a778e115 --- /dev/null +++ b/packages/cli/plugin-bff/tests/clientGenerator.test.ts @@ -0,0 +1,43 @@ +import path from 'path'; +import { rebaseDeclarationSpecifiers } from '../src/utils/clientGenerator'; + +describe('rebaseDeclarationSpecifiers', () => { + const originDir = path.resolve('/app/dist/api/lambda/user'); + const clientDir = path.resolve('/app/dist/client/user'); + + test('re-bases relative specifiers onto the copy location', () => { + const source = [ + `import { load } from '../../../src/requests/service';`, + `export * from './helper';`, + `import fs = require('../shared');`, + `export declare const get: () => Promise;`, + ].join('\n'); + + const result = rebaseDeclarationSpecifiers(source, originDir, clientDir); + + expect(result).toContain(`from '../../src/requests/service'`); + expect(result).toContain(`from '../../api/lambda/user/helper'`); + expect(result).toContain(`require('../../api/lambda/shared')`); + expect(result).toContain(`import("../../src/types")`); + }); + + test('leaves bare package specifiers untouched', () => { + const source = [ + `import type { Foo } from '@scope/pkg';`, + `import { z } from 'zod';`, + `export declare const f: () => import("hono").Context;`, + ].join('\n'); + + expect(rebaseDeclarationSpecifiers(source, originDir, clientDir)).toBe( + source, + ); + }); + + test('is a no-op when origin and target directories match', () => { + const source = `import { a } from './sibling';\n`; + + expect(rebaseDeclarationSpecifiers(source, originDir, originDir)).toBe( + source, + ); + }); +}); diff --git a/tests/integration/bff-corss-project/bff-api-app/api/lambda/index.ts b/tests/integration/bff-corss-project/bff-api-app/api/lambda/index.ts index 6e0d438857bd..65e7b8dc3771 100644 --- a/tests/integration/bff-corss-project/bff-api-app/api/lambda/index.ts +++ b/tests/integration/bff-corss-project/bff-api-app/api/lambda/index.ts @@ -7,9 +7,10 @@ import { Query, } from '@modern-js/plugin-bff/server'; import { useHonoContext } from '@modern-js/server-runtime'; +import type { ApiMessage } from '@shared/index'; import { z } from 'zod'; -export default async () => { +export default async (): Promise => { return { message: 'Hello get bff-api-app', }; diff --git a/tests/integration/bff-corss-project/bff-api-app/shared/index.ts b/tests/integration/bff-corss-project/bff-api-app/shared/index.ts index da994e44fb10..d836476a9945 100644 --- a/tests/integration/bff-corss-project/bff-api-app/shared/index.ts +++ b/tests/integration/bff-corss-project/bff-api-app/shared/index.ts @@ -1 +1,5 @@ export const COMMON_PREFIX = '/common-api'; + +export type ApiMessage = { + message: string; +}; diff --git a/tests/integration/bff-corss-project/tests/index.test.ts b/tests/integration/bff-corss-project/tests/index.test.ts index dfacf726e1c7..72e923c40650 100644 --- a/tests/integration/bff-corss-project/tests/index.test.ts +++ b/tests/integration/bff-corss-project/tests/index.test.ts @@ -1,4 +1,5 @@ import dns from 'node:dns'; +import fs from 'node:fs'; import path from 'path'; import puppeteer, { type Browser, type Page } from 'puppeteer'; import { @@ -157,6 +158,49 @@ describe('corss project bff', () => { }); }); + test('generated client declarations are portable', () => { + const clientDir = path.join(apiAppDir, 'dist-1', 'client'); + const declarations: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.name.endsWith('.d.ts')) { + declarations.push(full); + } + } + }; + walk(clientDir); + expect(declarations.length).toBeGreaterThan(0); + + const specifierRegex = + /(?:\bfrom\s*|\bimport\(\s*|\brequire\(\s*)(['"])([^'"]+)\1/g; + for (const file of declarations) { + const content = fs.readFileSync(file, 'utf8'); + for (const match of content.matchAll(specifierRegex)) { + const specifier = match[2]; + // tsconfig path aliases must not leak into published declarations + expect(specifier).not.toMatch(/^@(shared|api)\//); + // every relative specifier must resolve inside the dist tree + if (specifier.startsWith('.')) { + const target = path.resolve(path.dirname(file), specifier); + const resolved = [ + `${target}.d.ts`, + path.join(target, 'index.d.ts'), + ].some(candidate => fs.existsSync(candidate)); + expect(`${specifier}:${resolved}`).toBe(`${specifier}:true`); + } + } + } + + const indexClient = fs.readFileSync( + path.join(clientDir, 'index.d.ts'), + 'utf8', + ); + expect(indexClient).toContain('../shared/index'); + }); + test('basic usage', async () => { await page.goto(`${host}:${port}/${BASE_PAGE}`, { timeout: 50000, From cdfa1c302ba0b496a975be79dab3515c3386c217 Mon Sep 17 00:00:00 2001 From: Omar Shibli Date: Fri, 10 Jul 2026 21:00:06 +0300 Subject: [PATCH 3/3] test(bff): type-check generated client declarations from a clean consumer --- .../type-check-consumer/probe.ts | 17 +++++++++++++++++ .../stubs/modern-js-plugin-bff-server.d.ts | 9 +++++++++ .../type-check-consumer/tsconfig.json | 18 ++++++++++++++++++ .../bff-corss-project/tests/index.test.ts | 15 +++++++++++++++ 4 files changed, 59 insertions(+) create mode 100644 tests/integration/bff-corss-project/bff-client-app/type-check-consumer/probe.ts create mode 100644 tests/integration/bff-corss-project/bff-client-app/type-check-consumer/stubs/modern-js-plugin-bff-server.d.ts create mode 100644 tests/integration/bff-corss-project/bff-client-app/type-check-consumer/tsconfig.json diff --git a/tests/integration/bff-corss-project/bff-client-app/type-check-consumer/probe.ts b/tests/integration/bff-corss-project/bff-client-app/type-check-consumer/probe.ts new file mode 100644 index 000000000000..9e87e28434b7 --- /dev/null +++ b/tests/integration/bff-corss-project/bff-client-app/type-check-consumer/probe.ts @@ -0,0 +1,17 @@ +// Consumer-side type check for the generated BFF client. The integration test +// compiles this with `skipLibCheck: false` and none of the producer's path +// aliases, so it fails if the declarations copied to dist/client leak a +// tsconfig alias or contain a relative specifier that does not resolve from +// their published location. +import context from 'bff-api-app/api/context/index'; +import hello, { post, postHello } from 'bff-api-app/api/index'; +import { upload } from 'bff-api-app/api/upload'; +import getUser from 'bff-api-app/api/user/[id]'; + +export const checks = async () => { + const greeting: { message: string } = await hello(); + const posted: { message: string } = await post(); + const fromContext: { message: string } = await context(); + const user: { id: string; message: string } = await getUser('42'); + return { greeting, posted, fromContext, user, upload, postHello }; +}; diff --git a/tests/integration/bff-corss-project/bff-client-app/type-check-consumer/stubs/modern-js-plugin-bff-server.d.ts b/tests/integration/bff-corss-project/bff-client-app/type-check-consumer/stubs/modern-js-plugin-bff-server.d.ts new file mode 100644 index 000000000000..a401d08ac41d --- /dev/null +++ b/tests/integration/bff-corss-project/bff-client-app/type-check-consumer/stubs/modern-js-plugin-bff-server.d.ts @@ -0,0 +1,9 @@ +// Minimal stand-in for the published `@modern-js/plugin-bff/server` types. +// Inside the monorepo that specifier resolves to workspace source whose +// transitive declarations (compiled vendors, etc.) do not pass a strict +// `skipLibCheck: false` program; consumers install the published package +// instead. The stub keeps the generated client declarations fully checked +// while cutting that graph off. +export type ApiRunner> = ( + ...args: any[] +) => Response; diff --git a/tests/integration/bff-corss-project/bff-client-app/type-check-consumer/tsconfig.json b/tests/integration/bff-corss-project/bff-client-app/type-check-consumer/tsconfig.json new file mode 100644 index 000000000000..2f6d7c31dfe4 --- /dev/null +++ b/tests/integration/bff-corss-project/bff-client-app/type-check-consumer/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "noEmit": true, + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "types": [], + "skipLibCheck": false, + "baseUrl": ".", + "paths": { + "@modern-js/plugin-bff/server": [ + "./stubs/modern-js-plugin-bff-server.d.ts" + ] + } + }, + "include": ["probe.ts"] +} diff --git a/tests/integration/bff-corss-project/tests/index.test.ts b/tests/integration/bff-corss-project/tests/index.test.ts index 72e923c40650..c7a6382d2591 100644 --- a/tests/integration/bff-corss-project/tests/index.test.ts +++ b/tests/integration/bff-corss-project/tests/index.test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from 'node:child_process'; import dns from 'node:dns'; import fs from 'node:fs'; import path from 'path'; @@ -201,6 +202,20 @@ describe('corss project bff', () => { expect(indexClient).toContain('../shared/index'); }); + test('client declarations type-check from a consumer without the producer paths', () => { + const result = spawnSync( + process.execPath, + [ + path.join(appDir, 'node_modules/typescript/bin/tsc'), + '-p', + path.join(appDir, 'type-check-consumer/tsconfig.json'), + ], + { encoding: 'utf8' }, + ); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + test('basic usage', async () => { await page.goto(`${host}:${port}/${BASE_PAGE}`, { timeout: 50000,