Skip to content

Commit af4e073

Browse files
committed
fix(plugin-bff): re-base relative specifiers when copying cross-project client declarations
1 parent d94a5d9 commit af4e073

6 files changed

Lines changed: 135 additions & 5 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@modern-js/plugin-bff': patch
3+
---
4+
5+
fix(plugin-bff): re-base relative specifiers when copying cross-project client declarations
6+
7+
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.

packages/cli/plugin-bff/src/utils/clientGenerator.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,9 +212,40 @@ async function setPackage(
212212
}
213213
}
214214

215-
export async function copyFiles(from: string, to: string) {
215+
const RELATIVE_SPECIFIER =
216+
/(\bfrom\s*|\bimport\(\s*|\brequire\(\s*)(['"])(\.\.?\/[^'"]*)\2/g;
217+
218+
// The generated client mirrors the handler's declaration but lives one
219+
// directory shallower (`dist/client/*` vs `dist/<lambda>/*`), so the relative
220+
// specifiers TypeScript emitted for the origin file must be re-based to keep
221+
// resolving from the copy's location.
222+
export function rebaseDeclarationSpecifiers(
223+
source: string,
224+
fromDir: string,
225+
toDir: string,
226+
) {
227+
return source.replace(
228+
RELATIVE_SPECIFIER,
229+
(_match, lead, quote, specifier) => {
230+
const target = path.resolve(fromDir, specifier);
231+
const relative = toPosixPath(path.relative(toDir, target));
232+
const rebased = relative.startsWith('.') ? relative : `./${relative}`;
233+
return `${lead}${quote}${rebased}${quote}`;
234+
},
235+
);
236+
}
237+
238+
async function copyDeclarationFile(from: string, to: string) {
216239
if (await fs.pathExists(from)) {
217-
await fs.copy(toPosixPath(from), toPosixPath(to));
240+
const source = await fs.readFile(from, 'utf8');
241+
await fs.outputFile(
242+
to,
243+
rebaseDeclarationSpecifiers(
244+
source,
245+
path.dirname(path.resolve(from)),
246+
path.dirname(path.resolve(to)),
247+
),
248+
);
218249
}
219250
}
220251

@@ -264,9 +295,9 @@ async function clientGenerator(draftOptions: APILoaderOptions) {
264295
const code = await getClitentCode(source.resourcePath, source.source);
265296
if (code?.value) {
266297
await writeTargetFile(source.absTargetDir, code.value);
267-
await copyFiles(
298+
await copyDeclarationFile(
268299
source.relativeTargetDistDir,
269-
source.targetDir.replace(`js`, 'd.ts'),
300+
source.targetDir.replace(/\.js$/, '.d.ts'),
270301
);
271302
}
272303
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import path from 'path';
2+
import { rebaseDeclarationSpecifiers } from '../src/utils/clientGenerator';
3+
4+
describe('rebaseDeclarationSpecifiers', () => {
5+
const originDir = path.resolve('/app/dist/api/lambda/user');
6+
const clientDir = path.resolve('/app/dist/client/user');
7+
8+
test('re-bases relative specifiers onto the copy location', () => {
9+
const source = [
10+
`import { load } from '../../../src/requests/service';`,
11+
`export * from './helper';`,
12+
`import fs = require('../shared');`,
13+
`export declare const get: () => Promise<import("../../../src/types").Data>;`,
14+
].join('\n');
15+
16+
const result = rebaseDeclarationSpecifiers(source, originDir, clientDir);
17+
18+
expect(result).toContain(`from '../../src/requests/service'`);
19+
expect(result).toContain(`from '../../api/lambda/user/helper'`);
20+
expect(result).toContain(`require('../../api/lambda/shared')`);
21+
expect(result).toContain(`import("../../src/types")`);
22+
});
23+
24+
test('leaves bare package specifiers untouched', () => {
25+
const source = [
26+
`import type { Foo } from '@scope/pkg';`,
27+
`import { z } from 'zod';`,
28+
`export declare const f: () => import("hono").Context;`,
29+
].join('\n');
30+
31+
expect(rebaseDeclarationSpecifiers(source, originDir, clientDir)).toBe(
32+
source,
33+
);
34+
});
35+
36+
test('is a no-op when origin and target directories match', () => {
37+
const source = `import { a } from './sibling';\n`;
38+
39+
expect(rebaseDeclarationSpecifiers(source, originDir, originDir)).toBe(
40+
source,
41+
);
42+
});
43+
});

tests/integration/bff-corss-project/bff-api-app/api/lambda/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import {
77
Query,
88
} from '@modern-js/plugin-bff/server';
99
import { useHonoContext } from '@modern-js/server-runtime';
10+
import type { ApiMessage } from '@shared/index';
1011
import { z } from 'zod';
1112

12-
export default async () => {
13+
export default async (): Promise<ApiMessage> => {
1314
return {
1415
message: 'Hello get bff-api-app',
1516
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
export const COMMON_PREFIX = '/common-api';
2+
3+
export type ApiMessage = {
4+
message: string;
5+
};

tests/integration/bff-corss-project/tests/index.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import dns from 'node:dns';
2+
import fs from 'node:fs';
23
import path from 'path';
34
import puppeteer, { type Browser, type Page } from 'puppeteer';
45
import {
@@ -157,6 +158,49 @@ describe('corss project bff', () => {
157158
});
158159
});
159160

161+
test('generated client declarations are portable', () => {
162+
const clientDir = path.join(apiAppDir, 'dist-1', 'client');
163+
const declarations: string[] = [];
164+
const walk = (dir: string) => {
165+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
166+
const full = path.join(dir, entry.name);
167+
if (entry.isDirectory()) {
168+
walk(full);
169+
} else if (entry.name.endsWith('.d.ts')) {
170+
declarations.push(full);
171+
}
172+
}
173+
};
174+
walk(clientDir);
175+
expect(declarations.length).toBeGreaterThan(0);
176+
177+
const specifierRegex =
178+
/(?:\bfrom\s*|\bimport\(\s*|\brequire\(\s*)(['"])([^'"]+)\1/g;
179+
for (const file of declarations) {
180+
const content = fs.readFileSync(file, 'utf8');
181+
for (const match of content.matchAll(specifierRegex)) {
182+
const specifier = match[2];
183+
// tsconfig path aliases must not leak into published declarations
184+
expect(specifier).not.toMatch(/^@(shared|api)\//);
185+
// every relative specifier must resolve inside the dist tree
186+
if (specifier.startsWith('.')) {
187+
const target = path.resolve(path.dirname(file), specifier);
188+
const resolved = [
189+
`${target}.d.ts`,
190+
path.join(target, 'index.d.ts'),
191+
].some(candidate => fs.existsSync(candidate));
192+
expect(`${specifier}:${resolved}`).toBe(`${specifier}:true`);
193+
}
194+
}
195+
}
196+
197+
const indexClient = fs.readFileSync(
198+
path.join(clientDir, 'index.d.ts'),
199+
'utf8',
200+
);
201+
expect(indexClient).toContain('../shared/index');
202+
});
203+
160204
test('basic usage', async () => {
161205
await page.goto(`${host}:${port}/${BASE_PAGE}`, {
162206
timeout: 50000,

0 commit comments

Comments
 (0)