diff --git a/.changeset/brown-suns-clap.md b/.changeset/brown-suns-clap.md new file mode 100644 index 00000000000..3c83476b74d --- /dev/null +++ b/.changeset/brown-suns-clap.md @@ -0,0 +1,13 @@ +--- +"@clerk/astro": patch +"@clerk/nextjs": patch +"@clerk/shared": patch +--- + +Previously the `createPathMatcher()` function was re-implemented both in `@clerk/astro` and `@clerk/nextjs`, this PR moves this logic to `@clerk/shared`. + +You can use it like so: + +```ts +import { createPathMatcher } from '@clerk/shared/pathMatcher' +``` diff --git a/packages/astro/src/server/route-matcher.ts b/packages/astro/src/server/route-matcher.ts index 16f0a747b06..85987b500aa 100644 --- a/packages/astro/src/server/route-matcher.ts +++ b/packages/astro/src/server/route-matcher.ts @@ -1,13 +1,7 @@ -import { pathToRegexp } from '@clerk/shared/pathToRegexp'; -import type { Autocomplete } from '@clerk/types'; +import { createPathMatcher, type PathMatcherParam } from '@clerk/shared/pathMatcher'; -type WithPathPatternWildcard = `${T & string}(.*)`; +export type RouteMatcherParam = PathMatcherParam; -type RouteMatcherRoutes = Autocomplete; - -export type RouteMatcherParam = Array | RegExp | RouteMatcherRoutes; - -// TODO-SHARED: This can be moved to @clerk/shared as an identical implementation exists in @clerk/nextjs /** * Returns a function that accepts a `Request` object and returns whether the request matches the list of * predefined routes that can be passed in as the first argument. @@ -17,11 +11,6 @@ export type RouteMatcherParam = Array | RegExp | Ro * For more information, see: https://clerk.com/docs */ export const createRouteMatcher = (routes: RouteMatcherParam) => { - const routePatterns = [routes || ''].flat().filter(Boolean); - const matchers = precomputePathRegex(routePatterns); - return (req: Request) => matchers.some(matcher => matcher.test(new URL(req.url).pathname)); -}; - -const precomputePathRegex = (patterns: Array) => { - return patterns.map(pattern => (pattern instanceof RegExp ? pattern : pathToRegexp(pattern))); + const matcher = createPathMatcher(routes); + return (req: Request) => matcher(new URL(req.url).pathname); }; diff --git a/packages/nextjs/src/server/routeMatcher.ts b/packages/nextjs/src/server/routeMatcher.ts index b4c05c3bb31..54671283dc5 100644 --- a/packages/nextjs/src/server/routeMatcher.ts +++ b/packages/nextjs/src/server/routeMatcher.ts @@ -1,11 +1,9 @@ -import { pathToRegexp } from '@clerk/shared/pathToRegexp'; +import { createPathMatcher, type WithPathPatternWildcard } from '@clerk/shared/pathMatcher'; import type { Autocomplete } from '@clerk/types'; import type Link from 'next/link'; import type { NextRequest } from 'next/server'; -type WithPathPatternWildcard = `${T & string}(.*)`; type NextTypedRoute['0']['href']> = T extends string ? T : never; - type RouteMatcherWithNextTypedRoutes = Autocomplete | NextTypedRoute>; export type RouteMatcherParam = @@ -27,11 +25,6 @@ export const createRouteMatcher = (routes: RouteMatcherParam) => { return (req: NextRequest) => routes(req); } - const routePatterns = [routes || ''].flat().filter(Boolean); - const matchers = precomputePathRegex(routePatterns); - return (req: NextRequest) => matchers.some(matcher => matcher.test(req.nextUrl.pathname)); -}; - -const precomputePathRegex = (patterns: Array) => { - return patterns.map(pattern => (pattern instanceof RegExp ? pattern : pathToRegexp(pattern))); + const matcher = createPathMatcher(routes); + return (req: NextRequest) => matcher(req.nextUrl.pathname); }; diff --git a/packages/shared/package.json b/packages/shared/package.json index eb0c6959368..22139d1dd5a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -114,7 +114,8 @@ "devBrowser", "object", "oauth", - "web3" + "web3", + "pathMatcher" ], "scripts": { "build": "tsup", diff --git a/packages/shared/src/__tests__/pathMatcher.test.ts b/packages/shared/src/__tests__/pathMatcher.test.ts new file mode 100644 index 00000000000..525aa106d98 --- /dev/null +++ b/packages/shared/src/__tests__/pathMatcher.test.ts @@ -0,0 +1,53 @@ +import { createPathMatcher } from '../pathMatcher'; + +jest.mock('../pathToRegexp', () => ({ + pathToRegexp: (pattern: string) => new RegExp(`^${pattern.replace('(.*)', '.*')}$`), +})); + +describe('createPathMatcher', () => { + test('matches exact paths', () => { + const matcher = createPathMatcher('/foo'); + expect(matcher('/foo')).toBe(true); + expect(matcher('/bar')).toBe(false); + }); + + test('matches wildcard patterns', () => { + const matcher = createPathMatcher('/foo(.*)'); + expect(matcher('/foo')).toBe(true); + expect(matcher('/foo/bar')).toBe(true); + expect(matcher('/foo/bar/baz')).toBe(true); + expect(matcher('/bar')).toBe(false); + }); + + test('matches array of patterns', () => { + const matcher = createPathMatcher(['/foo', '/bar(.*)']); + expect(matcher('/foo')).toBe(true); + expect(matcher('/bar')).toBe(true); + expect(matcher('/bar/baz')).toBe(true); + expect(matcher('/baz')).toBe(false); + expect(matcher('/foo/bar')).toBe(false); + }); + + test('matches RegExp patterns', () => { + const matcher = createPathMatcher(/^\/foo\/.*$/); + expect(matcher('/foo/bar')).toBe(true); + expect(matcher('/foo/baz')).toBe(true); + expect(matcher('/bar/foo')).toBe(false); + }); + + test('handles empty or falsy inputs', () => { + const matcher = createPathMatcher(''); + expect(matcher('/any/path')).toBe(false); + + const nullMatcher = createPathMatcher(null as any); + expect(nullMatcher('/any/path')).toBe(false); + }); + + test('handles mixed pattern types', () => { + const matcher = createPathMatcher(['/foo(.*)', /^\/bar\/.*$/, '/baz']); + expect(matcher('/foo/anything')).toBe(true); + expect(matcher('/bar/anything')).toBe(true); + expect(matcher('/baz')).toBe(true); + expect(matcher('/qux')).toBe(false); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index aa98558a41d..984c4c510c1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -35,3 +35,4 @@ export * from './object'; export * from './logger'; export { createWorkerTimers } from './workerTimers'; export { DEV_BROWSER_JWT_KEY, extractDevBrowserJWTFromURL, setDevBrowserJWTInURL } from './devBrowser'; +export * from './pathMatcher'; diff --git a/packages/shared/src/pathMatcher.ts b/packages/shared/src/pathMatcher.ts new file mode 100644 index 00000000000..f2f07097e3a --- /dev/null +++ b/packages/shared/src/pathMatcher.ts @@ -0,0 +1,23 @@ +import type { Autocomplete } from '@clerk/types'; + +import { pathToRegexp } from './pathToRegexp'; + +export type WithPathPatternWildcard = `${T & string}(.*)`; +export type PathPattern = Autocomplete; +export type PathMatcherParam = Array | RegExp | PathPattern; + +const precomputePathRegex = (patterns: Array) => { + return patterns.map(pattern => (pattern instanceof RegExp ? pattern : pathToRegexp(pattern))); +}; + +/** + * Creates a function that matches paths against a set of patterns. + * + * @param patterns - A string, RegExp, or array of patterns to match against + * @returns A function that takes a pathname and returns true if it matches any of the patterns + */ +export const createPathMatcher = (patterns: PathMatcherParam) => { + const routePatterns = [patterns || ''].flat().filter(Boolean); + const matchers = precomputePathRegex(routePatterns); + return (pathname: string) => matchers.some(matcher => matcher.test(pathname)); +};