Skip to content

Commit b52276d

Browse files
committed
refactor(rsc-mf): infer callback bootstrap from expose source directives
1 parent f0c0530 commit b52276d

2 files changed

Lines changed: 127 additions & 2 deletions

File tree

tests/integration/rsc-mf/remote/src/runtime/createRscExposeDefinitions.ts

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
14
const CALLBACK_BOOTSTRAP_IMPORT = './src/runtime/initServerCallback.ts';
25
const CALLBACK_BOOTSTRAP_PREFIX = './src/runtime/';
36
const USERLAND_EXPOSE_PREFIX = './';
@@ -7,6 +10,21 @@ const CALLBACK_BOOTSTRAP_EXPOSE_KEY_PATTERN =
710
/^\.\/(?:RemoteClient[\w-]*|actions|nestedActions|defaultAction|actionBundle)$/;
811
const CALLBACK_BOOTSTRAP_IMPORT_PATH_PATTERN =
912
/\/(?:RemoteClient[\w-]*|actions|nestedActions|defaultAction|actionBundle)\.[cm]?[jt]sx?$/;
13+
const LOCAL_MODULE_SPECIFIER_PATTERN = /^\.{1,2}\//;
14+
const SOURCE_DIRECTIVE_PATTERN = /^\s*['"]use (?:client|server)['"]\s*;?/m;
15+
const EXPORT_FROM_SPECIFIER_PATTERN =
16+
/export\s+(?:\*\s+from|\{[^}]*\}\s+from)\s*['"]([^'"]+)['"]/g;
17+
const SOURCE_ENTRY_EXTENSIONS = [
18+
'.ts',
19+
'.tsx',
20+
'.js',
21+
'.jsx',
22+
'.mts',
23+
'.cts',
24+
'.mjs',
25+
'.cjs',
26+
] as const;
27+
const REMOTE_PROJECT_ROOT = path.resolve(__dirname, '../..');
1028

1129
type ExposeImportInput = string | string[];
1230
export type ExposeDefinitionInput =
@@ -121,9 +139,87 @@ const shouldInjectCallbackBootstrap = (
121139
importPaths: string[],
122140
) =>
123141
CALLBACK_BOOTSTRAP_EXPOSE_KEY_PATTERN.test(exposeKey) ||
124-
importPaths.some(importPath =>
125-
CALLBACK_BOOTSTRAP_IMPORT_PATH_PATTERN.test(importPath),
142+
importPaths.some(
143+
importPath =>
144+
CALLBACK_BOOTSTRAP_IMPORT_PATH_PATTERN.test(importPath) ||
145+
referencesCallbackCapableSourceModule(importPath),
146+
);
147+
148+
const readSourceFile = (filePath: string) => {
149+
try {
150+
return fs.readFileSync(filePath, 'utf8');
151+
} catch {
152+
return undefined;
153+
}
154+
};
155+
156+
const resolveUserlandImportPathToFile = (
157+
importPath: string,
158+
fromFilePath?: string,
159+
) => {
160+
const importPathWithoutPrefix = importPath.replace(/^\.\//, '');
161+
const basePath = fromFilePath
162+
? path.resolve(path.dirname(fromFilePath), importPath)
163+
: path.resolve(REMOTE_PROJECT_ROOT, importPathWithoutPrefix);
164+
const hasExplicitExtension = SOURCE_ENTRY_EXTENSIONS.some(extension =>
165+
basePath.endsWith(extension),
126166
);
167+
if (hasExplicitExtension) {
168+
return fs.existsSync(basePath) ? basePath : undefined;
169+
}
170+
const extensionCandidates = SOURCE_ENTRY_EXTENSIONS.map(
171+
extension => `${basePath}${extension}`,
172+
);
173+
const indexCandidates = SOURCE_ENTRY_EXTENSIONS.map(extension =>
174+
path.join(basePath, `index${extension}`),
175+
);
176+
return [...extensionCandidates, ...indexCandidates].find(candidatePath =>
177+
fs.existsSync(candidatePath),
178+
);
179+
};
180+
181+
const referencesCallbackCapableSourceModule = (importPath: string) => {
182+
const rootSourceFile = resolveUserlandImportPathToFile(importPath);
183+
if (!rootSourceFile) {
184+
return false;
185+
}
186+
187+
const visitedFiles = new Set<string>();
188+
const hasCallbackDirective = (filePath: string): boolean => {
189+
if (visitedFiles.has(filePath)) {
190+
return false;
191+
}
192+
visitedFiles.add(filePath);
193+
const sourceText = readSourceFile(filePath);
194+
if (!sourceText) {
195+
return false;
196+
}
197+
if (SOURCE_DIRECTIVE_PATTERN.test(sourceText)) {
198+
return true;
199+
}
200+
201+
const exportFromMatches = sourceText.matchAll(
202+
EXPORT_FROM_SPECIFIER_PATTERN,
203+
);
204+
for (const match of exportFromMatches) {
205+
const moduleSpecifier = match[1];
206+
if (!LOCAL_MODULE_SPECIFIER_PATTERN.test(moduleSpecifier)) {
207+
continue;
208+
}
209+
const childModuleFilePath = resolveUserlandImportPathToFile(
210+
moduleSpecifier,
211+
filePath,
212+
);
213+
if (childModuleFilePath && hasCallbackDirective(childModuleFilePath)) {
214+
return true;
215+
}
216+
}
217+
218+
return false;
219+
};
220+
221+
return hasCallbackDirective(rootSourceFile);
222+
};
127223

128224
const assertValidExposeConfig = (
129225
normalizedExposeImportPaths: NormalizedExposeImportPaths,

tests/integration/rsc-mf/tests/createRscExposeDefinitions.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,4 +347,33 @@ describe('createRscExposeDefinitions', () => {
347347
},
348348
});
349349
});
350+
351+
it('infers callback bootstrap from re-exported use server modules', () => {
352+
const { createRscExposeDefinitions, CALLBACK_BOOTSTRAP_MODULE } =
353+
loadCreateRscExposeDefinitions();
354+
expect(
355+
createRscExposeDefinitions({
356+
'./customBundledActions': './src/components/actionBundle.ts',
357+
}),
358+
).toEqual({
359+
'./customBundledActions': {
360+
import: [CALLBACK_BOOTSTRAP_MODULE, './src/components/actionBundle.ts'],
361+
layer: 'react-server-components',
362+
},
363+
});
364+
});
365+
366+
it('keeps non-callback source modules free of callback bootstrap import', () => {
367+
const { createRscExposeDefinitions } = loadCreateRscExposeDefinitions();
368+
expect(
369+
createRscExposeDefinitions({
370+
'./customInfoBundle': './src/components/infoBundle.ts',
371+
}),
372+
).toEqual({
373+
'./customInfoBundle': {
374+
import: ['./src/components/infoBundle.ts'],
375+
layer: 'react-server-components',
376+
},
377+
});
378+
});
350379
});

0 commit comments

Comments
 (0)