Skip to content

Commit 18a8193

Browse files
committed
test(rsc-mf): harden manifest fallback URL resolution
1 parent 4321302 commit 18a8193

2 files changed

Lines changed: 167 additions & 3 deletions

File tree

tests/integration/rsc-mf/host/server/modern.server.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ const toCanonicalChunkName = (filePath: string) =>
3636
?.replace(/\.(js|css)$/i, '')
3737
.replace(EXPOSE_CHUNK_HASH_SUFFIX_PATTERN, '');
3838

39+
const toNormalizedManifestAssetPath = (assetPath: string) => {
40+
try {
41+
return new URL(assetPath).pathname.replace(/^\/+/, '');
42+
} catch {
43+
return assetPath.replace(/^[./]+/, '').split(/[?#]/, 1)[0];
44+
}
45+
};
46+
3947
const collectManifestAssetPaths = (manifest: RemoteManifestShape) => {
4048
const entries = [...(manifest.shared || []), ...(manifest.exposes || [])];
4149
const assetPaths = new Set<string>();
@@ -74,13 +82,51 @@ const resolveManifestFallbackAssetPath = (
7482
: 'static/js/async/';
7583
const manifestAssets = collectManifestAssetPaths(manifest);
7684
return manifestAssets.find(assetPath => {
77-
if (!assetPath.startsWith(requestedAssetDirectory)) {
85+
const normalizedAssetPath = toNormalizedManifestAssetPath(assetPath);
86+
if (!normalizedAssetPath.startsWith(requestedAssetDirectory)) {
7887
return false;
7988
}
80-
return toCanonicalChunkName(assetPath) === canonicalRequestedChunkName;
89+
return (
90+
toCanonicalChunkName(normalizedAssetPath) === canonicalRequestedChunkName
91+
);
8192
});
8293
};
8394

95+
const createManifestFallbackAssetUrl = ({
96+
remoteOrigin,
97+
fallbackAssetPath,
98+
requestSearch,
99+
}: {
100+
remoteOrigin: string;
101+
fallbackAssetPath: string;
102+
requestSearch: string;
103+
}) => {
104+
let fallbackAssetUrl: URL;
105+
try {
106+
fallbackAssetUrl = new URL(fallbackAssetPath, `${remoteOrigin}/`);
107+
} catch {
108+
return undefined;
109+
}
110+
111+
if (fallbackAssetUrl.origin !== new URL(remoteOrigin).origin) {
112+
return undefined;
113+
}
114+
115+
if (!requestSearch) {
116+
return fallbackAssetUrl.toString();
117+
}
118+
119+
const mergedSearchParams = new URLSearchParams(fallbackAssetUrl.search);
120+
const requestSearchParams = new URLSearchParams(requestSearch);
121+
for (const [key, value] of requestSearchParams.entries()) {
122+
mergedSearchParams.set(key, value);
123+
}
124+
const mergedSearch = mergedSearchParams.toString();
125+
fallbackAssetUrl.search = mergedSearch ? `?${mergedSearch}` : '';
126+
127+
return fallbackAssetUrl.toString();
128+
};
129+
84130
const fetchRemoteManifestFallbackAsset = async ({
85131
remoteOrigin,
86132
pathname,
@@ -121,7 +167,14 @@ const fetchRemoteManifestFallbackAsset = async ({
121167
return undefined;
122168
}
123169

124-
const fallbackAssetUrl = `${remoteOrigin}/${fallbackAssetPath}${search}`;
170+
const fallbackAssetUrl = createManifestFallbackAssetUrl({
171+
remoteOrigin,
172+
fallbackAssetPath,
173+
requestSearch: search,
174+
});
175+
if (!fallbackAssetUrl) {
176+
return undefined;
177+
}
125178
const fallbackAssetResponse = await fetch(fallbackAssetUrl).catch(
126179
(): undefined => undefined,
127180
);

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

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,117 @@ describe('rsc-mf host modern.server middleware contracts', () => {
651651
await expect(context.res?.text()).resolves.toBe('query-fallback-hit');
652652
});
653653

654+
it('supports absolute manifest fallback asset URLs and merges request query params', async () => {
655+
const handler = getProxyMiddlewareHandler();
656+
const next = jest.fn(async (): Promise<void> => undefined);
657+
const fetchMock = installFetchMock(
658+
jest
659+
.fn()
660+
.mockResolvedValueOnce(new Response('not-found', { status: 404 }))
661+
.mockResolvedValueOnce(
662+
new Response(
663+
JSON.stringify({
664+
exposes: [
665+
{
666+
assets: {
667+
js: {
668+
sync: [
669+
'http://127.0.0.1:3999/static/js/async/__federation_expose_RemoteClientCounter.7745fe5f0a.js?manifest=1',
670+
],
671+
async: [],
672+
},
673+
css: {
674+
sync: [],
675+
async: [],
676+
},
677+
},
678+
},
679+
],
680+
}),
681+
{
682+
status: 200,
683+
headers: {
684+
'content-type': 'application/json',
685+
},
686+
},
687+
),
688+
)
689+
.mockResolvedValueOnce(
690+
new Response('absolute-query-fallback-hit', {
691+
status: 200,
692+
headers: {
693+
'content-type': 'application/javascript',
694+
},
695+
}),
696+
),
697+
);
698+
const context: { req: { url: string }; res?: Response } = {
699+
req: {
700+
url: 'http://127.0.0.1:3007/static/js/async/__federation_expose_RemoteClientCounter.js?cache=1',
701+
},
702+
};
703+
704+
await withRemotePort('3999', () => handler(context, next));
705+
706+
expect(fetchMock).toHaveBeenNthCalledWith(
707+
3,
708+
'http://127.0.0.1:3999/static/js/async/__federation_expose_RemoteClientCounter.7745fe5f0a.js?manifest=1&cache=1',
709+
);
710+
expect(next).not.toHaveBeenCalled();
711+
await expect(context.res?.text()).resolves.toBe(
712+
'absolute-query-fallback-hit',
713+
);
714+
});
715+
716+
it('falls through when manifest fallback asset URL points to another origin', async () => {
717+
const handler = getProxyMiddlewareHandler();
718+
const next = jest.fn(async (): Promise<void> => undefined);
719+
const fetchMock = installFetchMock(
720+
jest
721+
.fn()
722+
.mockResolvedValueOnce(new Response('not-found', { status: 404 }))
723+
.mockResolvedValueOnce(
724+
new Response(
725+
JSON.stringify({
726+
exposes: [
727+
{
728+
assets: {
729+
js: {
730+
sync: [
731+
'https://cdn.example.com/static/js/async/__federation_expose_RemoteClientCounter.7745fe5f0a.js',
732+
],
733+
async: [],
734+
},
735+
css: {
736+
sync: [],
737+
async: [],
738+
},
739+
},
740+
},
741+
],
742+
}),
743+
{
744+
status: 200,
745+
headers: {
746+
'content-type': 'application/json',
747+
},
748+
},
749+
),
750+
),
751+
);
752+
const context: { req: { url: string }; res?: Response } = {
753+
req: {
754+
url: 'http://127.0.0.1:3007/static/js/async/__federation_expose_RemoteClientCounter.js',
755+
},
756+
};
757+
758+
await withRemotePort('3999', () => handler(context, next));
759+
760+
expect(fetchMock).toHaveBeenCalledTimes(2);
761+
expect(next).toHaveBeenCalledTimes(1);
762+
expect(context.res).toBeUndefined();
763+
});
764+
654765
it('matches fallback chunks when manifest hash suffix includes non-hex characters', async () => {
655766
const handler = getProxyMiddlewareHandler();
656767
const next = jest.fn(async (): Promise<void> => undefined);

0 commit comments

Comments
 (0)