From 26211a2c0b0e5127bf16ba05e85b02f21f76d89f Mon Sep 17 00:00:00 2001 From: Kit Foster Date: Tue, 28 Jul 2026 12:58:21 +0200 Subject: [PATCH 1/4] Fix parameterized path conflict validation --- .../isomorphic/validation.test.ts | 16 ++ .../isomorphic/validation.ts | 159 ++++++++++++++++-- 2 files changed, 165 insertions(+), 10 deletions(-) diff --git a/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.test.ts b/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.test.ts index 2ad4358..4990b76 100644 --- a/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.test.ts +++ b/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.test.ts @@ -426,6 +426,14 @@ describe('validation', () => { 'Invalid paths: Overlapping path detected between "/foo/:path+" of ' + 'applications alternate and "/foo/bar/baz" of applications default', ); + expect(() => { + validateConfigPaths( + createApplicationConfigs('/foo/:slug', '/:section/bar'), + ); + }).toThrow( + 'Invalid paths: Overlapping path detected between "/foo/:slug" of ' + + 'applications default and "/:section/bar" of applications alternate', + ); }); it('should pass on disjoint paths', () => { expect(() => { @@ -434,6 +442,14 @@ describe('validation', () => { expect(() => { validateConfigPaths(createApplicationConfigs('/foo', '/foo/:path+')); }).not.toThrow(); + expect(() => { + validateConfigPaths( + createApplicationConfigs( + '/oss/:path((?!program-badge).*)/:path*', + '/oss/program-badge-:path.svg', + ), + ); + }).not.toThrow(); }); }); diff --git a/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.ts b/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.ts index c2cc05c..94b0e2c 100644 --- a/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.ts +++ b/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.ts @@ -62,31 +62,76 @@ export const validateConfigPaths = ( } } const entries = Array.from(pathsByApplicationId.entries()); + const candidateValues = getCandidateValues(entries.map(([path]) => path)); + const candidatePaths = new Map( + entries.map(([path, { matcher }]) => [ + path, + synthesizeMatchingPaths(path, matcher, candidateValues), + ]), + ); - for (const [path, { applications: ids, matcher, applicationId }] of entries) { + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]; + if (!entry) { + continue; + } + const [path, { applications: ids, matcher, applicationId }] = entry; if (ids.length > 1) { errors.push( `Duplicate path "${path}" for applications "${ids.join(', ')}"`, ); } - for (const [ - matchPath, - { applications: matchIds, applicationId: matchApplicationId }, - ] of entries) { - if (path === matchPath) { - // we're comparing to ourselves, so skip + for ( + let matchIndex = index + 1; + matchIndex < entries.length; + matchIndex++ + ) { + const matchEntry = entries[matchIndex]; + if (!matchEntry) { continue; } + const [ + matchPath, + { + applications: matchIds, + matcher: matchMatcher, + applicationId: matchApplicationId, + }, + ] = matchEntry; if (applicationId === matchApplicationId) { // we're comparing to paths within our own application, which are allowed to overlap, so skip continue; } - if (matcher.test(matchPath)) { - const source = `"${path}" of application${ids.length > 0 ? 's' : ''} ${ids.join(', ')}`; - const destination = `"${matchPath}" of application${matchIds.length > 0 ? 's' : ''} ${matchIds.join(', ')}`; + let sourcePath = path; + let sourceIds = ids; + let destinationPath = matchPath; + let destinationIds = matchIds; + let overlaps = matcher.test(matchPath); + + if (!overlaps && matchMatcher.test(path)) { + overlaps = true; + sourcePath = matchPath; + sourceIds = matchIds; + destinationPath = path; + destinationIds = ids; + } + + if (!overlaps) { + overlaps = + (candidatePaths.get(path) ?? []).some((candidate) => + matchMatcher.test(candidate), + ) || + (candidatePaths.get(matchPath) ?? []).some((candidate) => + matcher.test(candidate), + ); + } + + if (overlaps) { + const source = `"${sourcePath}" of application${sourceIds.length > 0 ? 's' : ''} ${sourceIds.join(', ')}`; + const destination = `"${destinationPath}" of application${destinationIds.length > 0 ? 's' : ''} ${destinationIds.join(', ')}`; errors.push( `Overlapping path detected between ${source} and ${destination}`, @@ -106,6 +151,100 @@ export const validateConfigPaths = ( } }; +const DEFAULT_CANDIDATE_VALUES = [ + 'a', + 'b', + 'foo', + 'bar', + 'baz', + 'path', + '123', + 'file.svg', +]; +const MAX_SYNTHESIZED_PATHS = 1024; + +/** + * Collect useful wildcard values from the literal parts of every expression. + * These let us find intersections where neither expression's source text is a + * valid URL, such as `/foo/:slug` and `/:section/bar`. + */ +function getCandidateValues(paths: string[]): string[] { + const values = new Set(DEFAULT_CANDIDATE_VALUES); + + for (const path of paths) { + const tokens = parsePathRegexp(path); + for (const token of tokens) { + if (typeof token === 'string') { + for (const value of token.match(/[\w.~-]+/g) ?? []) { + values.add(value); + } + } else { + const positiveAlternatives = token.pattern.match( + /^(?[\w\\{}~-]+(?:\|[\w\\{}~-]+)+)$/, + )?.groups?.alternatives; + for (const value of positiveAlternatives?.split('|') ?? []) { + values.add(value.replace(/\\(.)/g, '$1')); + } + } + } + } + + const singleSegmentValues = Array.from(values); + for (const first of singleSegmentValues.slice(0, 8)) { + for (const second of singleSegmentValues.slice(0, 8)) { + values.add(`${first}/${second}`); + } + } + + return Array.from(values); +} + +/** + * Produce concrete examples for a path expression. Path conflicts cannot be + * detected by testing one expression's source text against another matcher: + * source text containing `:parameters` is not a URL. The supported expression + * grammar is deliberately small, so a bounded set of representative wildcard + * values is sufficient to exercise literal, alternative, negative-lookahead, + * and repeated parameters. + */ +function synthesizeMatchingPaths( + path: string, + matcher: RegExp, + candidateValues: string[], +): string[] { + const tokens = parsePathRegexp(path); + let candidates = ['']; + + for (const token of tokens) { + if (typeof token === 'string') { + candidates = candidates.map((candidate) => candidate + token); + continue; + } + + const repetitions = + token.modifier === '*' ? ['', ...candidateValues] : candidateValues; + const renderedValues = repetitions.map((value) => { + if (!value) { + return ''; + } + if (token.modifier !== '*' && token.modifier !== '+') { + return `${token.prefix}${value}${token.suffix}`; + } + return value + .split('/') + .map((part) => `${token.prefix}${part}${token.suffix}`) + .join(''); + }); + + candidates = candidates.flatMap((candidate) => + renderedValues.map((value) => candidate + value), + ); + candidates = candidates.slice(0, MAX_SYNTHESIZED_PATHS); + } + + return candidates.filter((candidate) => matcher.test(candidate)); +} + // From https://github.com/pillarjs/path-to-regexp/blob/75a92c3d7c42159f459ab42f346899152906ea8c/src/index.ts#L183-L184 const PATH_DEFAULT_PATTERNS = ['[^\\/#\\?]+?', '(?:(?!\\.)[^\\/#\\?])+?']; From f4295379e07f715086d4f78394c65b8df116dc10 Mon Sep 17 00:00:00 2001 From: Kit Foster Date: Tue, 28 Jul 2026 13:04:00 +0200 Subject: [PATCH 2/4] Add OSS badge routing regression test --- .../isomorphic/validation.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.test.ts b/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.test.ts index 4990b76..ce61be0 100644 --- a/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.test.ts +++ b/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.test.ts @@ -442,14 +442,21 @@ describe('validation', () => { expect(() => { validateConfigPaths(createApplicationConfigs('/foo', '/foo/:path+')); }).not.toThrow(); + }); + + it('handles the OSS program badge routes from vercel/front#78286', () => { + const landersPath = + '/oss/:path((?!program-badge\\.svg|program-badge-).*)/:path*'; + const marketingPath = '/oss/program-badge-:path.svg'; + const programBadgePath = '/oss/program-badge-2026.svg'; + expect(() => { validateConfigPaths( - createApplicationConfigs( - '/oss/:path((?!program-badge).*)/:path*', - '/oss/program-badge-:path.svg', - ), + createApplicationConfigs(landersPath, marketingPath), ); }).not.toThrow(); + expect(pathToRegexp(landersPath).test(programBadgePath)).toBe(false); + expect(pathToRegexp(marketingPath).test(programBadgePath)).toBe(true); }); }); From 11b8efccc73ccc3131d040916175389633e11409 Mon Sep 17 00:00:00 2001 From: Kit Foster Date: Tue, 28 Jul 2026 13:05:52 +0200 Subject: [PATCH 3/4] Detect ambiguous negative lookahead conflicts --- .../isomorphic/validation.test.ts | 25 +++++--- .../isomorphic/validation.ts | 61 +++++++++++++++++++ 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.test.ts b/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.test.ts index ce61be0..2498f0f 100644 --- a/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.test.ts +++ b/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.test.ts @@ -444,19 +444,28 @@ describe('validation', () => { }).not.toThrow(); }); - it('handles the OSS program badge routes from vercel/front#78286', () => { - const landersPath = - '/oss/:path((?!program-badge\\.svg|program-badge-).*)/:path*'; - const marketingPath = '/oss/program-badge-:path.svg'; - const programBadgePath = '/oss/program-badge-2026.svg'; + it('detects overlaps hidden by incomplete negative-lookahead exclusions', () => { + expect(() => { + validateConfigPaths( + createApplicationConfigs( + '/assets/:path((?!badge).*)/:path*', + '/assets/badge-:year.svg', + ), + ); + }).toThrow( + 'Invalid paths: Overlapping path detected between ' + + '"/assets/:path((?!badge).*)/:path*" of applications default and ' + + '"/assets/badge-:year.svg" of applications alternate', + ); expect(() => { validateConfigPaths( - createApplicationConfigs(landersPath, marketingPath), + createApplicationConfigs( + '/assets/:path((?!badge\\.svg|badge-).*)/:path*', + '/assets/badge-:year.svg', + ), ); }).not.toThrow(); - expect(pathToRegexp(landersPath).test(programBadgePath)).toBe(false); - expect(pathToRegexp(marketingPath).test(programBadgePath)).toBe(true); }); }); diff --git a/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.ts b/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.ts index 94b0e2c..2d5e289 100644 --- a/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.ts +++ b/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.ts @@ -129,6 +129,20 @@ export const validateConfigPaths = ( ); } + if (!overlaps) { + overlaps = + hasAmbiguousNegativeLookaheadOverlap( + path, + matcher, + candidatePaths.get(matchPath) ?? [], + ) || + hasAmbiguousNegativeLookaheadOverlap( + matchPath, + matchMatcher, + candidatePaths.get(path) ?? [], + ); + } + if (overlaps) { const source = `"${sourcePath}" of application${sourceIds.length > 0 ? 's' : ''} ${sourceIds.join(', ')}`; const destination = `"${destinationPath}" of application${destinationIds.length > 0 ? 's' : ''} ${destinationIds.join(', ')}`; @@ -245,6 +259,53 @@ function synthesizeMatchingPaths( return candidates.filter((candidate) => matcher.test(candidate)); } +/** + * Negative lookaheads are also used as routing exclusions by the platform. A + * partial exclusion such as `(?!badge).*` is ambiguous next to a parameterized + * route beginning with `badge-`: the path sources look disjoint to + * path-to-regexp even though the platform can still select both routes. Only + * consider a family excluded when its negative alternative includes the + * separator (`badge-`), or when it names the complete segment. + */ +function hasAmbiguousNegativeLookaheadOverlap( + path: string, + matcher: RegExp, + candidates: string[], +): boolean { + const exclusions = parsePathRegexp(path).flatMap((token) => { + if (typeof token === 'string') { + return []; + } + const disallowed = token.pattern.match( + /^\(\?!(?[\w\\{}.~-]+(?:\|[\w\\{}.~-]+)*)\)\.\*$/, + )?.groups?.disallowed; + return ( + disallowed?.split('|').map((value) => value.replace(/\\(.)/g, '$1')) ?? [] + ); + }); + if (exclusions.length === 0) { + return false; + } + + const relaxedPath = path.replace(/\(\(\?![^()]+\)\.\*\)/g, '(.*)'); + const relaxedMatcher = pathToRegexp(relaxedPath); + + return candidates.some((candidate) => { + if (matcher.test(candidate) || !relaxedMatcher.test(candidate)) { + return false; + } + + const segments = candidate.split('/'); + return !segments.some((segment) => + exclusions.some( + (exclusion) => + segment === exclusion || + (/[-_.~]$/.test(exclusion) && segment.startsWith(exclusion)), + ), + ); + }); +} + // From https://github.com/pillarjs/path-to-regexp/blob/75a92c3d7c42159f459ab42f346899152906ea8c/src/index.ts#L183-L184 const PATH_DEFAULT_PATTERNS = ['[^\\/#\\?]+?', '(?:(?!\\.)[^\\/#\\?])+?']; From a8ebc4c2156ff5badfb716d6c1a465d1024282c4 Mon Sep 17 00:00:00 2001 From: Kit Foster Date: Tue, 28 Jul 2026 13:07:59 +0200 Subject: [PATCH 4/4] Remove speculative negative lookahead handling --- .../isomorphic/validation.test.ts | 24 -------- .../isomorphic/validation.ts | 61 ------------------- 2 files changed, 85 deletions(-) diff --git a/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.test.ts b/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.test.ts index 2498f0f..26666c9 100644 --- a/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.test.ts +++ b/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.test.ts @@ -443,30 +443,6 @@ describe('validation', () => { validateConfigPaths(createApplicationConfigs('/foo', '/foo/:path+')); }).not.toThrow(); }); - - it('detects overlaps hidden by incomplete negative-lookahead exclusions', () => { - expect(() => { - validateConfigPaths( - createApplicationConfigs( - '/assets/:path((?!badge).*)/:path*', - '/assets/badge-:year.svg', - ), - ); - }).toThrow( - 'Invalid paths: Overlapping path detected between ' + - '"/assets/:path((?!badge).*)/:path*" of applications default and ' + - '"/assets/badge-:year.svg" of applications alternate', - ); - - expect(() => { - validateConfigPaths( - createApplicationConfigs( - '/assets/:path((?!badge\\.svg|badge-).*)/:path*', - '/assets/badge-:year.svg', - ), - ); - }).not.toThrow(); - }); }); describe('validateAppPaths', () => { diff --git a/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.ts b/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.ts index 2d5e289..94b0e2c 100644 --- a/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.ts +++ b/packages/microfrontends/src/config/microfrontends-config/isomorphic/validation.ts @@ -129,20 +129,6 @@ export const validateConfigPaths = ( ); } - if (!overlaps) { - overlaps = - hasAmbiguousNegativeLookaheadOverlap( - path, - matcher, - candidatePaths.get(matchPath) ?? [], - ) || - hasAmbiguousNegativeLookaheadOverlap( - matchPath, - matchMatcher, - candidatePaths.get(path) ?? [], - ); - } - if (overlaps) { const source = `"${sourcePath}" of application${sourceIds.length > 0 ? 's' : ''} ${sourceIds.join(', ')}`; const destination = `"${destinationPath}" of application${destinationIds.length > 0 ? 's' : ''} ${destinationIds.join(', ')}`; @@ -259,53 +245,6 @@ function synthesizeMatchingPaths( return candidates.filter((candidate) => matcher.test(candidate)); } -/** - * Negative lookaheads are also used as routing exclusions by the platform. A - * partial exclusion such as `(?!badge).*` is ambiguous next to a parameterized - * route beginning with `badge-`: the path sources look disjoint to - * path-to-regexp even though the platform can still select both routes. Only - * consider a family excluded when its negative alternative includes the - * separator (`badge-`), or when it names the complete segment. - */ -function hasAmbiguousNegativeLookaheadOverlap( - path: string, - matcher: RegExp, - candidates: string[], -): boolean { - const exclusions = parsePathRegexp(path).flatMap((token) => { - if (typeof token === 'string') { - return []; - } - const disallowed = token.pattern.match( - /^\(\?!(?[\w\\{}.~-]+(?:\|[\w\\{}.~-]+)*)\)\.\*$/, - )?.groups?.disallowed; - return ( - disallowed?.split('|').map((value) => value.replace(/\\(.)/g, '$1')) ?? [] - ); - }); - if (exclusions.length === 0) { - return false; - } - - const relaxedPath = path.replace(/\(\(\?![^()]+\)\.\*\)/g, '(.*)'); - const relaxedMatcher = pathToRegexp(relaxedPath); - - return candidates.some((candidate) => { - if (matcher.test(candidate) || !relaxedMatcher.test(candidate)) { - return false; - } - - const segments = candidate.split('/'); - return !segments.some((segment) => - exclusions.some( - (exclusion) => - segment === exclusion || - (/[-_.~]$/.test(exclusion) && segment.startsWith(exclusion)), - ), - ); - }); -} - // From https://github.com/pillarjs/path-to-regexp/blob/75a92c3d7c42159f459ab42f346899152906ea8c/src/index.ts#L183-L184 const PATH_DEFAULT_PATTERNS = ['[^\\/#\\?]+?', '(?:(?!\\.)[^\\/#\\?])+?'];