diff --git a/.changeset/spotty-chairs-pretend.md b/.changeset/spotty-chairs-pretend.md new file mode 100644 index 000000000..3613e4ed3 --- /dev/null +++ b/.changeset/spotty-chairs-pretend.md @@ -0,0 +1,8 @@ +--- +"@opennextjs/aws": patch +--- + +Add additional metadata to RoutingResult + +For some future features [#658](https://github.com/opennextjs/opennextjs-aws/issues/658) (and bug fix [#677](https://github.com/opennextjs/opennextjs-aws/issues/677)) we need to add some additional metadata to the RoutingResult. +This PR adds 2 new fields to the RoutingResult: `initialPath` and `resolvedRoutes` \ No newline at end of file diff --git a/packages/open-next/src/adapters/config/index.ts b/packages/open-next/src/adapters/config/index.ts index 6bea482d2..12adfb939 100644 --- a/packages/open-next/src/adapters/config/index.ts +++ b/packages/open-next/src/adapters/config/index.ts @@ -2,6 +2,8 @@ import path from "node:path"; import { debug } from "../logger"; import { + loadAppPathRoutesManifest, + loadAppPathsManifest, loadAppPathsManifestKeys, loadBuildId, loadConfig, @@ -9,7 +11,6 @@ import { loadHtmlPages, loadMiddlewareManifest, loadPrerenderManifest, - // loadPublicAssets, loadRoutesManifest, } from "./util.js"; @@ -31,3 +32,6 @@ export const AppPathsManifestKeys = /* @__PURE__ */ loadAppPathsManifestKeys(NEXT_DIR); export const MiddlewareManifest = /* @__PURE__ */ loadMiddlewareManifest(NEXT_DIR); +export const AppPathsManifest = /* @__PURE__ */ loadAppPathsManifest(NEXT_DIR); +export const AppPathRoutesManifest = + /* @__PURE__ */ loadAppPathRoutesManifest(NEXT_DIR); diff --git a/packages/open-next/src/adapters/config/util.ts b/packages/open-next/src/adapters/config/util.ts index f1543df5a..501a76501 100644 --- a/packages/open-next/src/adapters/config/util.ts +++ b/packages/open-next/src/adapters/config/util.ts @@ -77,7 +77,7 @@ export function loadPrerenderManifest(nextDir: string) { return JSON.parse(json) as PrerenderManifest; } -export function loadAppPathsManifestKeys(nextDir: string) { +export function loadAppPathsManifest(nextDir: string) { const appPathsManifestPath = path.join( nextDir, "server", @@ -86,10 +86,24 @@ export function loadAppPathsManifestKeys(nextDir: string) { const appPathsManifestJson = fs.existsSync(appPathsManifestPath) ? fs.readFileSync(appPathsManifestPath, "utf-8") : "{}"; - const appPathsManifest = JSON.parse(appPathsManifestJson) as Record< - string, - string - >; + return JSON.parse(appPathsManifestJson) as Record; +} + +export function loadAppPathRoutesManifest( + nextDir: string, +): Record { + const appPathRoutesManifestPath = path.join( + nextDir, + "app-path-routes-manifest.json", + ); + if (fs.existsSync(appPathRoutesManifestPath)) { + return JSON.parse(fs.readFileSync(appPathRoutesManifestPath, "utf-8")); + } + return {}; +} + +export function loadAppPathsManifestKeys(nextDir: string) { + const appPathsManifest = loadAppPathsManifest(nextDir); return Object.keys(appPathsManifest).map((key) => { // Remove parallel route let cleanedKey = key.replace(/\/@[^\/]+/g, ""); diff --git a/packages/open-next/src/adapters/middleware.ts b/packages/open-next/src/adapters/middleware.ts index bb9b7c2da..363ce4b21 100644 --- a/packages/open-next/src/adapters/middleware.ts +++ b/packages/open-next/src/adapters/middleware.ts @@ -14,7 +14,10 @@ import { resolveQueue, resolveTagCache, } from "../core/resolve"; -import routingHandler from "../core/routingHandler"; +import routingHandler, { + INTERNAL_HEADER_INITIAL_PATH, + INTERNAL_HEADER_RESOLVED_ROUTES, +} from "../core/routingHandler"; globalThis.internalFetch = fetch; globalThis.__openNextAls = new AsyncLocalStorage(); @@ -57,10 +60,20 @@ const defaultHandler = async ( ); return { type: "middleware", - internalEvent: result.internalEvent, + internalEvent: { + ...result.internalEvent, + headers: { + ...result.internalEvent.headers, + [INTERNAL_HEADER_INITIAL_PATH]: internalEvent.rawPath, + [INTERNAL_HEADER_RESOLVED_ROUTES]: + JSON.stringify(result.resolvedRoutes) ?? "[]", + }, + }, isExternalRewrite: result.isExternalRewrite, origin, isISR: result.isISR, + initialPath: result.initialPath, + resolvedRoutes: result.resolvedRoutes, }; } try { @@ -79,6 +92,8 @@ const defaultHandler = async ( isExternalRewrite: false, origin: false, isISR: result.isISR, + initialPath: result.internalEvent.rawPath, + resolvedRoutes: [{ route: "/500", type: "page" }], }; } } diff --git a/packages/open-next/src/core/requestHandler.ts b/packages/open-next/src/core/requestHandler.ts index 8d1c03944..d2dc2c6f8 100644 --- a/packages/open-next/src/core/requestHandler.ts +++ b/packages/open-next/src/core/requestHandler.ts @@ -5,6 +5,7 @@ import { IncomingMessage } from "http/index.js"; import type { InternalEvent, InternalResult, + ResolvedRoute, RoutingResult, StreamCreator, } from "types/open-next"; @@ -14,6 +15,8 @@ import { debug, error, warn } from "../adapters/logger"; import { patchAsyncStorage } from "./patchAsyncStorage"; import { convertRes, createServerResponse } from "./routing/util"; import routingHandler, { + INTERNAL_HEADER_INITIAL_PATH, + INTERNAL_HEADER_RESOLVED_ROUTES, MIDDLEWARE_HEADER_PREFIX, MIDDLEWARE_HEADER_PREFIX_LEN, } from "./routingHandler"; @@ -28,20 +31,31 @@ export async function openNextHandler( internalEvent: InternalEvent, responseStreaming?: StreamCreator, ): Promise { + const initialHeaders = internalEvent.headers; // We run everything in the async local storage context so that it is available in the middleware as well as in NextServer return runWithOpenNextRequestContext( - { isISRRevalidation: internalEvent.headers["x-isr"] === "1" }, + { isISRRevalidation: initialHeaders["x-isr"] === "1" }, async () => { - if (internalEvent.headers["x-forwarded-host"]) { - internalEvent.headers.host = internalEvent.headers["x-forwarded-host"]; + if (initialHeaders["x-forwarded-host"]) { + initialHeaders.host = initialHeaders["x-forwarded-host"]; } debug("internalEvent", internalEvent); + // These 2 will get overwritten by the routing handler if not using an external middleware + const internalHeaders = { + initialPath: + initialHeaders[INTERNAL_HEADER_INITIAL_PATH] ?? internalEvent.rawPath, + resolvedRoutes: initialHeaders[INTERNAL_HEADER_RESOLVED_ROUTES] + ? JSON.parse(initialHeaders[INTERNAL_HEADER_RESOLVED_ROUTES]) + : ([] as ResolvedRoute[]), + }; + let routingResult: InternalResult | RoutingResult = { internalEvent, isExternalRewrite: false, origin: false, isISR: false, + ...internalHeaders, }; //#override withRouting @@ -94,6 +108,8 @@ export async function openNextHandler( isExternalRewrite: false, isISR: false, origin: false, + initialPath: internalEvent.rawPath, + resolvedRoutes: [{ route: "/500", type: "page" }], }; } } diff --git a/packages/open-next/src/core/routing/routeMatcher.ts b/packages/open-next/src/core/routing/routeMatcher.ts new file mode 100644 index 000000000..c9568ada2 --- /dev/null +++ b/packages/open-next/src/core/routing/routeMatcher.ts @@ -0,0 +1,57 @@ +import { AppPathRoutesManifest, RoutesManifest } from "config/index"; +import type { RouteDefinition } from "types/next-types"; +import type { RouteType } from "types/open-next"; + +// Add the locale prefix to the regex so we correctly match the rawPath +const optionalLocalePrefixRegex = `^/(?:${RoutesManifest.locales.map((locale) => `${locale}/?`).join("|")})?`; + +// Add the basepath prefix to the regex so we correctly match the rawPath +const optionalBasepathPrefixRegex = RoutesManifest.basePath + ? `^${RoutesManifest.basePath}/?` + : "^/"; + +// Add the basePath prefix to the api routes +export const apiPrefix = `${RoutesManifest.basePath ?? ""}/api`; + +const optionalPrefix = optionalLocalePrefixRegex.replace( + "^/", + optionalBasepathPrefixRegex, +); + +function routeMatcher(routeDefinitions: RouteDefinition[]) { + const regexp = routeDefinitions.map((route) => ({ + page: route.page, + regexp: new RegExp(route.regex.replace("^/", optionalPrefix)), + })); + + const appPathsSet = new Set(); + const routePathsSet = new Set(); + // We need to use AppPathRoutesManifest here + for (const [k, v] of Object.entries(AppPathRoutesManifest)) { + if (k.endsWith("page")) { + appPathsSet.add(v); + } else if (k.endsWith("route")) { + routePathsSet.add(v); + } + } + + return function matchRoute(path: string) { + const foundRoutes = regexp.filter((route) => route.regexp.test(path)); + + return foundRoutes.map((foundRoute) => { + let routeType: RouteType = "page"; + if (appPathsSet.has(foundRoute.page)) { + routeType = "app"; + } else if (routePathsSet.has(foundRoute.page)) { + routeType = "route"; + } + return { + route: foundRoute.page, + type: routeType, + }; + }); + }; +} + +export const staticRouteMatcher = routeMatcher(RoutesManifest.routes.static); +export const dynamicRouteMatcher = routeMatcher(RoutesManifest.routes.dynamic); diff --git a/packages/open-next/src/core/routingHandler.ts b/packages/open-next/src/core/routingHandler.ts index e7aec3f2e..b08966b63 100644 --- a/packages/open-next/src/core/routingHandler.ts +++ b/packages/open-next/src/core/routingHandler.ts @@ -7,6 +7,7 @@ import { import type { InternalEvent, InternalResult, + ResolvedRoute, RoutingResult, } from "types/open-next"; @@ -20,42 +21,17 @@ import { handleRewrites, } from "./routing/matcher"; import { handleMiddleware } from "./routing/middleware"; +import { + apiPrefix, + dynamicRouteMatcher, + staticRouteMatcher, +} from "./routing/routeMatcher"; export const MIDDLEWARE_HEADER_PREFIX = "x-middleware-response-"; export const MIDDLEWARE_HEADER_PREFIX_LEN = MIDDLEWARE_HEADER_PREFIX.length; - -// Add the locale prefix to the regex so we correctly match the rawPath -const optionalLocalePrefixRegex = RoutesManifest.locales.length - ? `^/(?:${RoutesManifest.locales.map((locale) => `${locale}/?`).join("|")})?` - : "^/"; - -// Add the basepath prefix to the regex so we correctly match the rawPath -const optionalBasepathPrefixRegex = RoutesManifest.basePath - ? `^${RoutesManifest.basePath}/?` - : "^/"; - -// Add the basePath prefix to the api routes -const apiPrefix = RoutesManifest.basePath - ? `${RoutesManifest.basePath}/api` - : "/api"; - -const staticRegexp = RoutesManifest.routes.static.map( - (route) => - new RegExp( - route.regex - .replace("^/", optionalLocalePrefixRegex) - .replace("^/", optionalBasepathPrefixRegex), - ), -); - -const dynamicRegexp = RoutesManifest.routes.dynamic.map( - (route) => - new RegExp( - route.regex - .replace("^/", optionalLocalePrefixRegex) - .replace("^/", optionalBasepathPrefixRegex), - ), -); +export const INTERNAL_HEADER_PREFIX = "x-opennext-"; +export const INTERNAL_HEADER_INITIAL_PATH = `${INTERNAL_HEADER_PREFIX}initial-path`; +export const INTERNAL_HEADER_RESOLVED_ROUTES = `${INTERNAL_HEADER_PREFIX}resolved-routes`; // Geolocation headers starting from Nextjs 15 // See https://github.com/vercel/vercel/blob/7714b1c/packages/functions/src/headers.ts @@ -95,6 +71,17 @@ export default async function routingHandler( } } + // First we remove internal headers + // We don't want to allow users to set these headers + for (const key of Object.keys(event.headers)) { + if ( + key.startsWith(INTERNAL_HEADER_PREFIX) || + key.startsWith(MIDDLEWARE_HEADER_PREFIX) + ) { + delete event.headers[key]; + } + } + const nextHeaders = getNextConfigHeaders(event, ConfigHeaders); let internalEvent = fixDataPage(event, BuildId); @@ -127,14 +114,10 @@ export default async function routingHandler( internalEvent = beforeRewrites.internalEvent; isExternalRewrite = beforeRewrites.isExternalRewrite; } + const foundStaticRoute = staticRouteMatcher(internalEvent.rawPath); + const isStaticRoute = !isExternalRewrite && foundStaticRoute.length > 0; - const isStaticRoute = - !isExternalRewrite && - staticRegexp.some((route) => - route.test((internalEvent as InternalEvent).rawPath), - ); - - if (!isStaticRoute && !isExternalRewrite) { + if (!(isStaticRoute || isExternalRewrite)) { // Second rewrite to be applied const afterRewrites = handleRewrites( internalEvent, @@ -151,12 +134,10 @@ export default async function routingHandler( ); internalEvent = fallbackEvent; - const isDynamicRoute = - !isExternalRewrite && - dynamicRegexp.some((route) => - route.test((internalEvent as InternalEvent).rawPath), - ); - if (!isDynamicRoute && !isStaticRoute && !isExternalRewrite) { + const foundDynamicRoute = dynamicRouteMatcher(internalEvent.rawPath); + const isDynamicRoute = !isExternalRewrite && foundDynamicRoute.length > 0; + + if (!(isDynamicRoute || isStaticRoute || isExternalRewrite)) { // Fallback rewrite to be applied const fallbackRewrites = handleRewrites( internalEvent, @@ -181,15 +162,13 @@ export default async function routingHandler( // If we still haven't found a route, we show the 404 page // We need to ensure that rewrites are applied before showing the 404 page if ( - !isRouteFoundBeforeAllRewrites && - !isApiRoute && - !isNextImageRoute && - // We need to check again once all rewrites have been applied - !staticRegexp.some((route) => - route.test((internalEvent as InternalEvent).rawPath), - ) && - !dynamicRegexp.some((route) => - route.test((internalEvent as InternalEvent).rawPath), + !( + isRouteFoundBeforeAllRewrites || + isApiRoute || + isNextImageRoute || + // We need to check again once all rewrites have been applied + staticRouteMatcher(internalEvent.rawPath).length > 0 || + dynamicRouteMatcher(internalEvent.rawPath).length > 0 ) ) { internalEvent = { @@ -229,10 +208,19 @@ export default async function routingHandler( ...nextHeaders, }); + const resolvedRoutes: ResolvedRoute[] = [ + ...foundStaticRoute, + ...foundDynamicRoute, + ]; + + debug("resolvedRoutes", resolvedRoutes); + return { internalEvent, isExternalRewrite, origin: false, isISR, + initialPath: event.rawPath, + resolvedRoutes, }; } diff --git a/packages/open-next/src/plugins/edge.ts b/packages/open-next/src/plugins/edge.ts index 83c71c18c..325c5f896 100644 --- a/packages/open-next/src/plugins/edge.ts +++ b/packages/open-next/src/plugins/edge.ts @@ -6,6 +6,8 @@ import type { Plugin } from "esbuild"; import type { MiddlewareInfo } from "types/next-types.js"; import { + loadAppPathRoutesManifest, + loadAppPathsManifest, loadAppPathsManifestKeys, loadBuildId, loadConfig, @@ -167,6 +169,8 @@ ${contents} const PrerenderManifest = loadPrerenderManifest(nextDir); const AppPathsManifestKeys = loadAppPathsManifestKeys(nextDir); const MiddlewareManifest = loadMiddlewareManifest(nextDir); + const AppPathsManifest = loadAppPathsManifest(nextDir); + const AppPathRoutesManifest = loadAppPathRoutesManifest(nextDir); const contents = ` import path from "node:path"; @@ -188,6 +192,9 @@ ${contents} export const PrerenderManifest = ${JSON.stringify(PrerenderManifest)}; export const AppPathsManifestKeys = ${JSON.stringify(AppPathsManifestKeys)}; export const MiddlewareManifest = ${JSON.stringify(MiddlewareManifest)}; + export const AppPathsManifest = ${JSON.stringify(AppPathsManifest)}; + export const AppPathRoutesManifest = ${JSON.stringify(AppPathRoutesManifest)}; + process.env.NEXT_BUILD_ID = BuildId; `; diff --git a/packages/open-next/src/types/open-next.ts b/packages/open-next/src/types/open-next.ts index f24c318ac..74a6572b7 100644 --- a/packages/open-next/src/types/open-next.ts +++ b/packages/open-next/src/types/open-next.ts @@ -107,11 +107,26 @@ export type IncludedConverter = | "sqs-revalidate" | "dummy"; +export type RouteType = "route" | "page" | "app"; + +export interface ResolvedRoute { + route: string; + type: RouteType; +} + export interface RoutingResult { internalEvent: InternalEvent; + // If the request is an external rewrite, if used with an external middleware will be false on every server function isExternalRewrite: boolean; + // Origin is only used in external middleware, will be false on every server function origin: Origin | false; + // If the request is for an ISR route, will be false on every server function. Only used in external middleware isISR: boolean; + // The initial rawPath of the request before applying rewrites, if used with an external middleware will be defined in x-opennext-initial-path header + initialPath: string; + + // The resolved route after applying rewrites, if used with an external middleware will be defined in x-opennext-resolved-routes header as a json encoded array + resolvedRoutes: ResolvedRoute[]; } export interface MiddlewareResult diff --git a/packages/tests-unit/tests/core/routing/routeMatcher.test.ts b/packages/tests-unit/tests/core/routing/routeMatcher.test.ts new file mode 100644 index 000000000..e47e66d25 --- /dev/null +++ b/packages/tests-unit/tests/core/routing/routeMatcher.test.ts @@ -0,0 +1,153 @@ +import { + dynamicRouteMatcher, + staticRouteMatcher, +} from "@opennextjs/aws/core/routing/routeMatcher.js"; +import { vi } from "vitest"; + +vi.mock("@opennextjs/aws/adapters/config/index.js", () => ({ + NextConfig: {}, + AppPathRoutesManifest: { + "/api/app/route": "/api/app", + "/app/page": "/app", + "/catchAll/[...slug]/page": "/catchAll/[...slug]", + }, + RoutesManifest: { + version: 3, + pages404: true, + caseSensitive: false, + basePath: "", + locales: [], + redirects: [], + headers: [], + routes: { + dynamic: [ + { + page: "/catchAll/[...slug]", + regex: "^/catchAll/(.+?)(?:/)?$", + routeKeys: { + nxtPslug: "nxtPslug", + }, + namedRegex: "^/catchAll/(?.+?)(?:/)?$", + }, + { + page: "/page/catchAll/[...slug]", + regex: "^/page/catchAll/(.+?)(?:/)?$", + routeKeys: { + nxtPslug: "nxtPslug", + }, + namedRegex: "^/page/catchAll/(?.+?)(?:/)?$", + }, + ], + static: [ + { + page: "/app", + regex: "^/app(?:/)?$", + routeKeys: {}, + namedRegex: "^/app(?:/)?$", + }, + { + page: "/api/app", + regex: "^/api/app(?:/)?$", + routeKeys: {}, + namedRegex: "^/api/app(?:/)?$", + }, + { + page: "/page", + regex: "^/page(?:/)?$", + routeKeys: {}, + namedRegex: "^/page(?:/)?$", + }, + { + page: "/page/catchAll/static", + regex: "^/page/catchAll/static(?:/)?$", + routeKeys: {}, + namedRegex: "^/page/catchAll/static(?:/)?$", + }, + ], + }, + }, +})); + +describe("routeMatcher", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe("staticRouteMatcher", () => { + it("should match static app route", () => { + const routes = staticRouteMatcher("/app"); + expect(routes).toEqual([ + { + route: "/app", + type: "app", + }, + ]); + }); + + it("should match static api route", () => { + const routes = staticRouteMatcher("/api/app"); + expect(routes).toEqual([ + { + route: "/api/app", + type: "route", + }, + ]); + }); + + it("should not match app dynamic route", () => { + const routes = staticRouteMatcher("/catchAll/slug"); + expect(routes).toEqual([]); + }); + + it("should not match page dynamic route", () => { + const routes = staticRouteMatcher("/page/catchAll/slug"); + expect(routes).toEqual([]); + }); + + it("should not match random route", () => { + const routes = staticRouteMatcher("/random"); + expect(routes).toEqual([]); + }); + }); + + describe("dynamicRouteMatcher", () => { + it("should match dynamic app page", () => { + const routes = dynamicRouteMatcher("/catchAll/slug/b"); + expect(routes).toEqual([ + { + route: "/catchAll/[...slug]", + type: "app", + }, + ]); + }); + + it("should match dynamic page router page", () => { + const routes = dynamicRouteMatcher("/page/catchAll/slug/b"); + expect(routes).toEqual([ + { + route: "/page/catchAll/[...slug]", + type: "page", + }, + ]); + }); + + it("should match both the static and dynamic page", () => { + const pathToMatch = "/page/catchAll/static"; + const dynamicRoutes = dynamicRouteMatcher(pathToMatch); + expect(dynamicRoutes).toEqual([ + { + route: "/page/catchAll/[...slug]", + type: "page", + }, + ]); + + const staticRoutes = staticRouteMatcher(pathToMatch); + expect(staticRoutes).toEqual([ + { + route: "/page/catchAll/static", + type: "page", + }, + ]); + }); + }); +});