diff --git a/.changeset/friendly-kangaroos-give.md b/.changeset/friendly-kangaroos-give.md new file mode 100644 index 000000000..f138435bc --- /dev/null +++ b/.changeset/friendly-kangaroos-give.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/aws": patch +--- + +feat: add support for Next15 geolocation diff --git a/packages/open-next/src/core/routing/matcher.ts b/packages/open-next/src/core/routing/matcher.ts index cf592d095..4da683fdb 100644 --- a/packages/open-next/src/core/routing/matcher.ts +++ b/packages/open-next/src/core/routing/matcher.ts @@ -114,31 +114,26 @@ function convertMatch( toDestination: PathFunction, destination: string, ) { - if (match) { - const { params } = match; - const isUsingParams = Object.keys(params).length > 0; - if (isUsingParams) { - return toDestination(params); - } else { - return destination; - } - } else { + if (!match) { return destination; } + + const { params } = match; + const isUsingParams = Object.keys(params).length > 0; + return isUsingParams ? toDestination(params) : destination; } -export function addNextConfigHeaders( +export function getNextConfigHeaders( event: InternalEvent, configHeaders?: Header[] | undefined, -) { - const addedHeaders: Record = {}; +): Record { + if (!configHeaders) { + return {}; + } - if (!configHeaders) return addedHeaders; - const { rawPath, headers, query, cookies } = event; - const matcher = routeHasMatcher(headers, cookies, query); + const matcher = routeHasMatcher(event.headers, event.cookies, event.query); const requestHeaders: Record = {}; - const localizedRawPath = localizePath(event); for (const { @@ -149,7 +144,7 @@ export function addNextConfigHeaders( source, locale, } of configHeaders) { - const path = locale === false ? rawPath : localizedRawPath; + const path = locale === false ? event.rawPath : localizedRawPath; if ( new RegExp(regex).test(path) && checkHas(matcher, has) && @@ -163,7 +158,7 @@ export function addNextConfigHeaders( const value = convertMatch(_match, compile(h.value), h.value); requestHeaders[key] = value; } catch { - debug("Error matching header ", h.key, " with value ", h.value); + debug(`Error matching header ${h.key} with value ${h.value}`); requestHeaders[h.key] = h.value; } }); diff --git a/packages/open-next/src/core/routing/middleware.ts b/packages/open-next/src/core/routing/middleware.ts index 8245b61b8..8d1cc19ca 100644 --- a/packages/open-next/src/core/routing/middleware.ts +++ b/packages/open-next/src/core/routing/middleware.ts @@ -46,38 +46,41 @@ export async function handleMiddleware( internalEvent: InternalEvent, middlewareLoader: MiddlewareLoader = defaultMiddlewareLoader, ): Promise { - const { query } = internalEvent; - const normalizedPath = localizePath(internalEvent); + const headers = internalEvent.headers; + + // We bypass the middleware if the request is internal + if (headers["x-isr"]) return internalEvent; + // We only need the normalizedPath to check if the middleware should run + const normalizedPath = localizePath(internalEvent); const hasMatch = middleMatch.some((r) => r.test(normalizedPath)); if (!hasMatch) return internalEvent; - // We bypass the middleware if the request is internal - if (internalEvent.headers["x-isr"]) return internalEvent; // Retrieve the protocol: // - In lambda, the url only contains the rawPath and the query - default to https // - In cloudflare, the protocol is usually http in dev and https in production const protocol = internalEvent.url.startsWith("http://") ? "http:" : "https:"; - const host = internalEvent.headers.host - ? `${protocol}//${internalEvent.headers.host}` + const host = headers.host + ? `${protocol}//${headers.host}` : "http://localhost:3000"; const initialUrl = new URL(normalizedPath, host); - initialUrl.search = convertToQueryString(query); + initialUrl.search = convertToQueryString(internalEvent.query); const url = initialUrl.toString(); const middleware = await middlewareLoader(); const result: Response = await middleware.default({ + // `geo` is pre Next 15. geo: { - city: internalEvent.headers["x-open-next-city"], - country: internalEvent.headers["x-open-next-country"], - region: internalEvent.headers["x-open-next-region"], - latitude: internalEvent.headers["x-open-next-latitude"], - longitude: internalEvent.headers["x-open-next-longitude"], + city: headers["x-open-next-city"], + country: headers["x-open-next-country"], + region: headers["x-open-next-region"], + latitude: headers["x-open-next-latitude"], + longitude: headers["x-open-next-longitude"], }, - headers: internalEvent.headers, + headers, method: internalEvent.method || "GET", nextConfig: { basePath: NextConfig.basePath, diff --git a/packages/open-next/src/core/routingHandler.ts b/packages/open-next/src/core/routingHandler.ts index 95a11e944..586ee0df6 100644 --- a/packages/open-next/src/core/routingHandler.ts +++ b/packages/open-next/src/core/routingHandler.ts @@ -9,14 +9,16 @@ import type { InternalEvent, InternalResult, Origin } from "types/open-next"; import { debug } from "../adapters/logger"; import { cacheInterceptor } from "./routing/cacheInterceptor"; import { - addNextConfigHeaders, fixDataPage, + getNextConfigHeaders, handleFallbackFalse, handleRedirects, handleRewrites, } from "./routing/matcher"; import { handleMiddleware } from "./routing/middleware"; +export const MIDDLEWARE_HEADER_PREFIX = "x-middleware-response-"; +export const MIDDLEWARE_HEADER_PREFIX_LEN = MIDDLEWARE_HEADER_PREFIX.length; export interface MiddlewareOutputEvent { internalEvent: InternalEvent; isExternalRewrite: boolean; @@ -57,15 +59,27 @@ const dynamicRegexp = RoutesManifest.routes.dynamic.map( ), ); +// Geolocation headers starting from Nextjs 15 +// See https://github.com/vercel/vercel/blob/7714b1c/packages/functions/src/headers.ts +const geoHeaderToNextHeader = { + "x-open-next-city": "x-vercel-ip-city", + "x-open-next-country": "x-vercel-ip-country", + "x-open-next-region": "x-vercel-ip-country-region", + "x-open-next-latitude": "x-vercel-ip-latitude", + "x-open-next-longitude": "x-vercel-ip-longitude", +}; + function applyMiddlewareHeaders( eventHeaders: Record, middlewareHeaders: Record, setPrefix = true, ) { + const keyPrefix = setPrefix ? MIDDLEWARE_HEADER_PREFIX : ""; Object.entries(middlewareHeaders).forEach(([key, value]) => { if (value) { - eventHeaders[`${setPrefix ? "x-middleware-response-" : ""}${key}`] = - Array.isArray(value) ? value.join(",") : value; + eventHeaders[keyPrefix + key] = Array.isArray(value) + ? value.join(",") + : value; } }); } @@ -73,7 +87,17 @@ function applyMiddlewareHeaders( export default async function routingHandler( event: InternalEvent, ): Promise { - const nextHeaders = addNextConfigHeaders(event, ConfigHeaders); + // Add Next geo headers + for (const [openNextGeoName, nextGeoName] of Object.entries( + geoHeaderToNextHeader, + )) { + const value = event.headers[openNextGeoName]; + if (value) { + event.headers[nextGeoName] = value; + } + } + + const nextHeaders = getNextConfigHeaders(event, ConfigHeaders); let internalEvent = fixDataPage(event, BuildId); if ("statusCode" in internalEvent) { diff --git a/packages/open-next/src/overrides/converters/aws-cloudfront.ts b/packages/open-next/src/overrides/converters/aws-cloudfront.ts index 08d490a60..c0d199114 100644 --- a/packages/open-next/src/overrides/converters/aws-cloudfront.ts +++ b/packages/open-next/src/overrides/converters/aws-cloudfront.ts @@ -21,7 +21,7 @@ import { } from "../../core/routing/util"; import type { MiddlewareOutputEvent } from "../../core/routingHandler"; -const CloudFrontBlacklistedHeaders = [ +const cloudfrontBlacklistedHeaders = [ // Disallowed headers, see: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/edge-function-restrictions-all.html#function-restrictions-disallowed-headers "connection", "expect", @@ -86,6 +86,7 @@ async function convertFromCloudFrontRequestEvent( ): Promise { const { method, uri, querystring, body, headers, clientIp } = event.Records[0].cf.request; + return { type: "core", method, @@ -119,7 +120,7 @@ function convertToCloudfrontHeaders( .map(([key, value]) => [key.toLowerCase(), value] as const) .filter( ([key]) => - !CloudFrontBlacklistedHeaders.some((header) => + !cloudfrontBlacklistedHeaders.some((header) => typeof header === "string" ? header === key : header.test(key), ) && // Only remove read-only headers when directly responding in lambda@edge diff --git a/packages/open-next/src/overrides/wrappers/cloudflare.ts b/packages/open-next/src/overrides/wrappers/cloudflare.ts index 91eedbc7f..7c6789a04 100644 --- a/packages/open-next/src/overrides/wrappers/cloudflare.ts +++ b/packages/open-next/src/overrides/wrappers/cloudflare.ts @@ -6,12 +6,20 @@ import type { import type { MiddlewareOutputEvent } from "../../core/routingHandler"; +const cfPropNameToHeaderName = { + city: "x-open-next-city", + country: "x-open-next-country", + region: "x-open-next-region", + latitude: "x-open-next-latitude", + longitude: "x-open-next-longitude", +}; + const handler: WrapperHandler< InternalEvent, InternalResult | ({ type: "middleware" } & MiddlewareOutputEvent) > = async (handler, converter) => - async (event: Request, env: Record): Promise => { + async (request: Request, env: Record): Promise => { globalThis.process = process; // Set the environment variables @@ -22,7 +30,20 @@ const handler: WrapperHandler< } } - const internalEvent = await converter.convertFrom(event); + const internalEvent = await converter.convertFrom(request); + + // Retrieve geo information from the cloudflare request + // See https://developers.cloudflare.com/workers/runtime-apis/request + // Note: This code could be moved to a cloudflare specific converter when one is created. + const cfProperties = (request as any).cf as Record; + for (const [propName, headerName] of Object.entries( + cfPropNameToHeaderName, + )) { + const propValue = cfProperties[propName]; + if (propValue !== null) { + internalEvent.headers[headerName] = propValue; + } + } const response = await handler(internalEvent); diff --git a/packages/tests-unit/tests/core/routing/matcher.test.ts b/packages/tests-unit/tests/core/routing/matcher.test.ts index 57c1f9806..96ff0c87e 100644 --- a/packages/tests-unit/tests/core/routing/matcher.test.ts +++ b/packages/tests-unit/tests/core/routing/matcher.test.ts @@ -1,7 +1,7 @@ import { NextConfig } from "@opennextjs/aws/adapters/config/index.js"; import { - addNextConfigHeaders, fixDataPage, + getNextConfigHeaders, handleRedirects, handleRewrites, } from "@opennextjs/aws/core/routing/matcher.js"; @@ -39,17 +39,17 @@ beforeEach(() => { vi.resetAllMocks(); }); -describe("addNextConfigHeaders", () => { +describe("getNextConfigHeaders", () => { it("should return empty object for undefined configHeaders", () => { const event = createEvent({}); - const result = addNextConfigHeaders(event); + const result = getNextConfigHeaders(event); expect(result).toEqual({}); }); it("should return empty object for empty configHeaders", () => { const event = createEvent({}); - const result = addNextConfigHeaders(event, []); + const result = getNextConfigHeaders(event, []); expect(result).toEqual({}); }); @@ -59,7 +59,7 @@ describe("addNextConfigHeaders", () => { url: "/", }); - const result = addNextConfigHeaders(event, [ + const result = getNextConfigHeaders(event, [ { source: "/", regex: "^/$", @@ -82,7 +82,7 @@ describe("addNextConfigHeaders", () => { url: "/", }); - const result = addNextConfigHeaders(event, [ + const result = getNextConfigHeaders(event, [ { source: "/", regex: "^/$", @@ -98,7 +98,7 @@ describe("addNextConfigHeaders", () => { url: "/hello-world", }); - const result = addNextConfigHeaders(event, [ + const result = getNextConfigHeaders(event, [ { source: "/(.*)", regex: "^(?:/(.*))(?:/)?$", @@ -129,7 +129,7 @@ describe("addNextConfigHeaders", () => { }, }); - const result = addNextConfigHeaders(event, [ + const result = getNextConfigHeaders(event, [ { source: "/(.*)", regex: "^(?:/(.*))(?:/)?$", @@ -156,7 +156,7 @@ describe("addNextConfigHeaders", () => { }, }); - const result = addNextConfigHeaders(event, [ + const result = getNextConfigHeaders(event, [ { source: "/(.*)", regex: "^(?:/(.*))(?:/)?$", @@ -183,7 +183,7 @@ describe("addNextConfigHeaders", () => { }, }); - const result = addNextConfigHeaders(event, [ + const result = getNextConfigHeaders(event, [ { source: "/(.*)", regex: "^(?:/(.*))(?:/)?$",