Skip to content

Commit 7cb9b96

Browse files
refactor(plugin-webmcp): stabilize runtime lifecycle
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent af2a039 commit 7cb9b96

22 files changed

Lines changed: 315 additions & 198 deletions

File tree

e2e/fixtures/plugin-webmcp/index.test.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -346,10 +346,7 @@ test.describe('plugin-webmcp development server', () => {
346346

347347
await expect
348348
.poll(async () => {
349-
const tool = (await listTools(page)).find(
350-
candidate => candidate.name === 'fixture_increment_counter',
351-
);
352-
return tool?.description;
349+
return (await findTool(page, 'fixture_increment_counter'))?.description;
353350
})
354351
.toContain('by one');
355352
await executeTool(page, 'fixture_reset_counter', {});
@@ -382,10 +379,7 @@ test.describe('plugin-webmcp development server', () => {
382379

383380
await expect
384381
.poll(async () => {
385-
const tool = (await listTools(page)).find(
386-
candidate => candidate.name === 'fixture_page_scoped',
387-
);
388-
return tool?.description;
382+
return (await findTool(page, 'fixture_page_scoped'))?.description;
389383
})
390384
.toContain('guide page');
391385

e2e/fixtures/plugin-webmcp/webmcpTestUtils.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,9 @@ export interface TestingTool {
66
inputSchema?: string;
77
}
88

9-
type ModelContextTool = TestingTool;
10-
119
interface ProducerModelContext {
12-
getTools(): Promise<ModelContextTool[]>;
13-
executeTool(tool: ModelContextTool, input: string): Promise<string | null>;
10+
getTools(): Promise<TestingTool[]>;
11+
executeTool(tool: TestingTool, input: string): Promise<string | null>;
1412
}
1513

1614
interface TestingModelContext {

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
"lint": "rslint --type-check && prettier . --check && pnpm run check-spell",
1515
"prepare": "skills-package-manager install && pnpm run build && simple-git-hooks",
1616
"preview:website": "cd website && npm run preview",
17-
"test": "pnpm test:unit && pnpm test:e2e",
17+
"test": "pnpm test:type && pnpm test:unit && pnpm test:e2e",
1818
"test:e2e": "playwright test",
19+
"test:type": "pnpm --recursive --if-present run test:type",
1920
"test:unit": "rstest run",
2021
"update:rsbuild": "npx taze minor --include /rsbuild/ -w -r -l"
2122
},

packages/core/src/node/runtimeModule/pageData/rsbuildPlugin.ts

Lines changed: 13 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { RsbuildPlugin } from '@rsbuild/core';
2-
import type { PageData } from '@rspress/shared';
32
import { logger } from '@rspress/shared/logger';
43
import { pluginVirtualModule } from 'rsbuild-plugin-virtual-module';
54
import { type FactoryContext, RuntimeModuleID } from '../types';
@@ -11,17 +10,7 @@ export const rsbuildPluginDocVM = async ({
1110
routeService,
1211
pluginDriver,
1312
}: Omit<FactoryContext, 'alias'>): Promise<RsbuildPlugin[]> => {
14-
const ref: {
15-
pageData: PageData | null;
16-
searchIndex: Record<string, string> | null;
17-
indexHashByGroup: Record<string, string> | null;
18-
filepaths: string[];
19-
} = {
20-
pageData: null,
21-
searchIndex: null,
22-
indexHashByGroup: null,
23-
filepaths: [],
24-
};
13+
let pageDataResult: Awaited<ReturnType<typeof createPageData>> | undefined;
2514
let webAlias: Record<string, string> | undefined;
2615
let refreshPromise: Promise<void> | undefined;
2716

@@ -31,20 +20,14 @@ export const rsbuildPluginDocVM = async ({
3120
}
3221
refreshPromise ??= (async () => {
3322
const now = performance.now();
34-
const { pageData, indexHashByGroup, searchIndex, filepaths } =
35-
await createPageData({
36-
config,
37-
alias: webAlias,
38-
userDocRoot,
39-
routeService,
40-
pluginDriver,
41-
});
23+
pageDataResult = await createPageData({
24+
config,
25+
alias: webAlias,
26+
userDocRoot,
27+
routeService,
28+
pluginDriver,
29+
});
4230
logger.debug(`createPageData cost: ${performance.now() - now}ms`);
43-
44-
ref.pageData = pageData;
45-
ref.searchIndex = searchIndex;
46-
ref.indexHashByGroup = indexHashByGroup;
47-
ref.filepaths = filepaths;
4831
})();
4932
try {
5033
await refreshPromise;
@@ -65,11 +48,11 @@ export const rsbuildPluginDocVM = async ({
6548
api.processAssets(
6649
{ stage: 'report', environments: ['web'] },
6750
({ compilation, compiler }) => {
68-
if (!ref.searchIndex) {
51+
if (!pageDataResult) {
6952
return;
7053
}
7154
for (const [filename, stringifiedIndex] of Object.entries(
72-
ref.searchIndex,
55+
pageDataResult.searchIndex,
7356
)) {
7457
compilation.emitAsset(
7558
`static/${filename}`,
@@ -89,12 +72,12 @@ export const rsbuildPluginDocVM = async ({
8972
virtualModules: {
9073
[RuntimeModuleID.PageData]: async ({ addDependency }) => {
9174
await refreshPageData();
92-
for (const file of ref.filepaths) {
75+
for (const file of pageDataResult?.filepaths ?? []) {
9376
addDependency(file);
9477
}
9578

96-
return `export const pageData = ${JSON.stringify(ref.pageData, null, 2)};
97-
export const searchIndexHash = ${JSON.stringify(ref.indexHashByGroup, null, 2)};`;
79+
return `export const pageData = ${JSON.stringify(pageDataResult?.pageData ?? null, null, 2)};
80+
export const searchIndexHash = ${JSON.stringify(pageDataResult?.indexHashByGroup ?? null, null, 2)};`;
9881
},
9982
},
10083
}),
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { UserConfig } from '@rspress/shared';
2+
import { describe, expect, test } from '@rstest/core';
3+
import { createSiteData } from './createSiteData';
4+
5+
describe('createSiteData', () => {
6+
test('preserves the default local search configuration', async () => {
7+
const { siteData } = await createSiteData({});
8+
9+
expect(siteData.search).toEqual({
10+
mode: 'local',
11+
searchHooks: undefined,
12+
});
13+
});
14+
15+
test('preserves disabled search in runtime site data', async () => {
16+
const { siteData } = await createSiteData({ search: false });
17+
18+
expect(siteData.search).toBe(false);
19+
});
20+
21+
test('removes search hooks without mutating user config', async () => {
22+
const search = {
23+
mode: 'local',
24+
searchHooks: '/absolute/search-hooks.ts',
25+
} satisfies NonNullable<UserConfig['search']>;
26+
27+
const { siteData } = await createSiteData({ search });
28+
29+
expect(siteData.search).toEqual({
30+
mode: 'local',
31+
searchHooks: undefined,
32+
});
33+
expect(search.searchHooks).toBe('/absolute/search-hooks.ts');
34+
});
35+
});

packages/core/src/node/runtimeModule/siteData/createSiteData.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import { normalizeThemeConfig } from './normalizeThemeConfig';
55
export async function createSiteData(userConfig: UserConfig): Promise<{
66
siteData: Omit<SiteData, 'root' | 'pages'>;
77
}> {
8-
// prevent modify the origin config object
9-
const tempSearchObj = Object.assign({}, userConfig.search);
10-
11-
// searchHooks is a absolute path which may leak information
12-
if (tempSearchObj) {
13-
tempSearchObj.searchHooks = undefined;
14-
}
8+
const search =
9+
userConfig.search === false
10+
? false
11+
: {
12+
mode: 'local' as const,
13+
...userConfig.search,
14+
// searchHooks is an absolute path which may leak information
15+
searchHooks: undefined,
16+
};
1517

1618
const siteData: Omit<SiteData, 'root' | 'pages'> = {
1719
base: userConfig.base ?? '/',
@@ -33,7 +35,7 @@ export async function createSiteData(userConfig: UserConfig): Promise<{
3335
default: userConfig?.multiVersion?.default || '',
3436
versions: userConfig?.multiVersion?.versions || [],
3537
},
36-
search: tempSearchObj ?? { mode: 'local' },
38+
search,
3739
markdown: {
3840
showLineNumbers: userConfig?.markdown?.showLineNumbers ?? false,
3941
defaultWrapCode: userConfig?.markdown?.defaultWrapCode ?? false,
Lines changed: 47 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,59 @@
11
import { usePageData } from '@rspress/core/runtime';
2-
import { useEffect, useRef, useState } from 'react';
2+
import { useEffect, useMemo, useState } from 'react';
33
import { PageSearcher } from '../components/Search/logic/search';
44
import type { MatchResult } from '../components/Search/logic/types';
55

6-
export function useFullTextSearch(): {
7-
initialized: boolean;
8-
search: (keyword: string, limit?: number) => Promise<MatchResult>;
9-
} {
6+
type Search = (keyword: string, limit?: number) => Promise<MatchResult>;
7+
8+
type FullTextSearchState =
9+
| { initialized: false; search: undefined }
10+
| { initialized: true; search: Search };
11+
12+
export function useFullTextSearch(): FullTextSearchState {
1013
const { siteData, page } = usePageData();
11-
const [initialized, setInitialized] = useState(false);
12-
const searchRef = useRef<PageSearcher | null>(null);
14+
const searchOptions = siteData.search;
15+
const versionedSearch =
16+
typeof searchOptions !== 'boolean' && (searchOptions.versioned ?? true);
17+
const currentVersion = versionedSearch ? page.version : '';
18+
const searcher = useMemo(
19+
() =>
20+
searchOptions === false
21+
? null
22+
: new PageSearcher({
23+
...searchOptions,
24+
mode: 'local',
25+
currentLang: page.lang,
26+
currentVersion,
27+
}),
28+
[searchOptions, page.lang, currentVersion],
29+
);
30+
const [initializedSearcher, setInitializedSearcher] =
31+
useState<PageSearcher | null>(null);
1332

1433
useEffect(() => {
15-
async function init() {
16-
if (!initialized) {
17-
const searcher = new PageSearcher({
18-
...siteData.search,
19-
mode: 'local',
20-
currentLang: page.lang,
21-
currentVersion: page.version,
22-
});
23-
searchRef.current = searcher;
24-
await searcher.init();
25-
setInitialized(true);
26-
}
34+
setInitializedSearcher(null);
35+
if (!searcher) {
36+
return;
2737
}
28-
init();
29-
}, []);
38+
39+
let active = true;
40+
void searcher.init().then(() => {
41+
if (active) {
42+
setInitializedSearcher(searcher);
43+
}
44+
});
45+
46+
return () => {
47+
active = false;
48+
};
49+
}, [searcher]);
50+
51+
if (initializedSearcher !== searcher || !searcher) {
52+
return { initialized: false, search: undefined };
53+
}
3054

3155
return {
32-
initialized,
33-
search: searchRef.current?.match.bind(searchRef.current) as (
34-
keyword: string,
35-
limit?: number,
36-
) => Promise<MatchResult>,
56+
initialized: true,
57+
search: searcher.match.bind(searcher),
3758
};
3859
}

packages/plugin-webmcp/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
"scripts": {
3131
"build": "rslib build",
3232
"dev": "rslib build -w",
33-
"reset": "rimraf ./**/node_modules"
33+
"reset": "rimraf ./**/node_modules",
34+
"test:type": "tsc -p tests/tsconfig.json"
3435
},
3536
"devDependencies": {
3637
"@mcp-b/webmcp-types": "4.0.0",

packages/plugin-webmcp/src/index.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,36 +9,31 @@ import {
99
export type { PluginWebMcpOptions, PluginWebMcpToolsOptions } from './options';
1010

1111
export function pluginWebMcp(options?: PluginWebMcpOptions): RspressPlugin {
12-
const normalizedOptions = normalizePluginWebMcpOptions(options);
13-
const runtimeOptions: WebMcpRuntimeOptions = {
14-
...normalizedOptions,
15-
currentPageEnabled: normalizedOptions.tools.currentPage,
16-
searchEnabled: normalizedOptions.tools.search,
17-
};
12+
const runtimeOptions: WebMcpRuntimeOptions =
13+
normalizePluginWebMcpOptions(options);
1814

19-
const syncRuntimeOptions = (config: UserConfig, isProd: boolean) => {
20-
const currentPageEnabled = normalizedOptions.tools.currentPage && isProd;
21-
runtimeOptions.currentPageEnabled = currentPageEnabled;
22-
runtimeOptions.searchEnabled =
23-
normalizedOptions.tools.search && config.search !== false;
24-
if (currentPageEnabled && config.llms === false) {
15+
const requireSsgMd = (config: UserConfig, isProd: boolean) => {
16+
if (!runtimeOptions.tools.currentPage || !isProd) {
17+
return false;
18+
}
19+
if (config.llms === false) {
2520
throw new Error(
2621
'[@rspress/plugin-webmcp] The rspress_get_current_page tool requires SSG-MD. Remove `llms: false`, enable `llms`, or disable the current-page WebMCP tool.',
2722
);
2823
}
29-
return currentPageEnabled;
24+
return true;
3025
};
3126

3227
return {
3328
name: '@rspress/plugin-webmcp',
3429
config(config, _configUtils, isProd) {
35-
if (syncRuntimeOptions(config, isProd)) {
30+
if (requireSsgMd(config, isProd)) {
3631
config.llms ??= true;
3732
}
3833
return config;
3934
},
4035
beforeBuild(config, isProd) {
41-
syncRuntimeOptions(config, isProd);
36+
requireSsgMd(config, isProd);
4237
},
4338
globalUIComponents: [
4439
[path.join(__dirname, 'runtime/WebMcpRuntime.js'), runtimeOptions],

packages/plugin-webmcp/src/options.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,7 @@ export interface NormalizedPluginWebMcpOptions {
1515
tools: Required<PluginWebMcpToolsOptions>;
1616
}
1717

18-
export interface WebMcpRuntimeOptions extends NormalizedPluginWebMcpOptions {
19-
currentPageEnabled: boolean;
20-
searchEnabled: boolean;
21-
}
18+
export type WebMcpRuntimeOptions = NormalizedPluginWebMcpOptions;
2219

2320
export function normalizePluginWebMcpOptions(
2421
options: PluginWebMcpOptions = {},

0 commit comments

Comments
 (0)