Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/bff-portable-client-types.md
Original file line number Diff line number Diff line change
@@ -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 `<package>/api/*` client types resolve in consuming projects.
7 changes: 7 additions & 0 deletions .changeset/server-utils-declaration-aliases.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 35 additions & 4 deletions packages/cli/plugin-bff/src/utils/clientGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<lambda>/*`), 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)),
),
);
}
}

Expand Down Expand Up @@ -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'),
);
}
}
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/plugin-bff/tests/clientGenerator.test.ts
Original file line number Diff line number Diff line change
@@ -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<import("../../../src/types").Data>;`,
].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,
);
});
});
17 changes: 12 additions & 5 deletions packages/server/utils/src/compilers/typescript/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ const isDynamicImport = (
);
};

export function tsconfigPathsBeforeHookFactory(
export function tsconfigPathsTransformersFactory(
tsBinary: typeof ts,
baseUrl: string,
paths: Record<string, string[] | string>,
Expand Down Expand Up @@ -125,7 +125,7 @@ export function tsconfigPathsBeforeHookFactory(
return undefined;
}

return (ctx: ts.TransformationContext): ts.Transformer<any> => {
const before = (ctx: ts.TransformationContext): ts.Transformer<any> => {
return (sf: ts.SourceFile) => {
const visitNode = (node: ts.Node): ts.Node => {
if (isDynamicImport(tsBinary, node)) {
Expand Down Expand Up @@ -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<ts.SourceFile | ts.Bundle> => {
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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { SharedMessage } from '@shared/index';
import { shared } from '@shared/index';

export const message: SharedMessage = shared;

export const getMessage: () => Promise<import('@shared/index').SharedMessage> =
async () => message;
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const shared = 'shared';
export type SharedMessage = string;
Original file line number Diff line number Diff line change
@@ -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"]
}
38 changes: 38 additions & 0 deletions packages/server/utils/tests/ts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiMessage> => {
return {
message: 'Hello get bff-api-app',
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
export const COMMON_PREFIX = '/common-api';

export type ApiMessage = {
message: string;
};
Original file line number Diff line number Diff line change
@@ -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 };
};
Original file line number Diff line number Diff line change
@@ -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<Inputs, Response extends Promise<unknown>> = (
...args: any[]
) => Response;
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading