Skip to content

Commit

Permalink
feat: add support for next15 geolocation (#617)
Browse files Browse the repository at this point in the history
Next15 moves the geolocation information from a geo property on the request to `x-vercel-ip` headers.
`@vercel/functions` has a `geolocation` helper function to access those.
  • Loading branch information
vicb authored Nov 6, 2024
1 parent 8055c18 commit 6f798de
Show file tree
Hide file tree
Showing 7 changed files with 98 additions and 49 deletions.
5 changes: 5 additions & 0 deletions .changeset/friendly-kangaroos-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opennextjs/aws": patch
---

feat: add support for Next15 geolocation
31 changes: 13 additions & 18 deletions packages/open-next/src/core/routing/matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> = {};
): Record<string, string | undefined> {
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<string, string> = {};

const localizedRawPath = localizePath(event);

for (const {
Expand All @@ -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) &&
Expand All @@ -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;
}
});
Expand Down
29 changes: 16 additions & 13 deletions packages/open-next/src/core/routing/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,38 +46,41 @@ export async function handleMiddleware(
internalEvent: InternalEvent,
middlewareLoader: MiddlewareLoader = defaultMiddlewareLoader,
): Promise<MiddlewareOutputEvent | InternalResult> {
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,
Expand Down
32 changes: 28 additions & 4 deletions packages/open-next/src/core/routingHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -57,23 +59,45 @@ 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<string, string | string[]>,
middlewareHeaders: Record<string, string | string[] | undefined>,
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;
}
});
}

export default async function routingHandler(
event: InternalEvent,
): Promise<InternalResult | MiddlewareOutputEvent> {
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) {
Expand Down
5 changes: 3 additions & 2 deletions packages/open-next/src/overrides/converters/aws-cloudfront.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -86,6 +86,7 @@ async function convertFromCloudFrontRequestEvent(
): Promise<InternalEvent> {
const { method, uri, querystring, body, headers, clientIp } =
event.Records[0].cf.request;

return {
type: "core",
method,
Expand Down Expand Up @@ -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
Expand Down
25 changes: 23 additions & 2 deletions packages/open-next/src/overrides/wrappers/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>): Promise<Response> => {
async (request: Request, env: Record<string, string>): Promise<Response> => {
globalThis.process = process;

// Set the environment variables
Expand All @@ -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<string, string | null>;
for (const [propName, headerName] of Object.entries(
cfPropNameToHeaderName,
)) {
const propValue = cfProperties[propName];
if (propValue !== null) {
internalEvent.headers[headerName] = propValue;
}
}

const response = await handler(internalEvent);

Expand Down
20 changes: 10 additions & 10 deletions packages/tests-unit/tests/core/routing/matcher.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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({});
});
Expand All @@ -59,7 +59,7 @@ describe("addNextConfigHeaders", () => {
url: "/",
});

const result = addNextConfigHeaders(event, [
const result = getNextConfigHeaders(event, [
{
source: "/",
regex: "^/$",
Expand All @@ -82,7 +82,7 @@ describe("addNextConfigHeaders", () => {
url: "/",
});

const result = addNextConfigHeaders(event, [
const result = getNextConfigHeaders(event, [
{
source: "/",
regex: "^/$",
Expand All @@ -98,7 +98,7 @@ describe("addNextConfigHeaders", () => {
url: "/hello-world",
});

const result = addNextConfigHeaders(event, [
const result = getNextConfigHeaders(event, [
{
source: "/(.*)",
regex: "^(?:/(.*))(?:/)?$",
Expand Down Expand Up @@ -129,7 +129,7 @@ describe("addNextConfigHeaders", () => {
},
});

const result = addNextConfigHeaders(event, [
const result = getNextConfigHeaders(event, [
{
source: "/(.*)",
regex: "^(?:/(.*))(?:/)?$",
Expand All @@ -156,7 +156,7 @@ describe("addNextConfigHeaders", () => {
},
});

const result = addNextConfigHeaders(event, [
const result = getNextConfigHeaders(event, [
{
source: "/(.*)",
regex: "^(?:/(.*))(?:/)?$",
Expand All @@ -183,7 +183,7 @@ describe("addNextConfigHeaders", () => {
},
});

const result = addNextConfigHeaders(event, [
const result = getNextConfigHeaders(event, [
{
source: "/(.*)",
regex: "^(?:/(.*))(?:/)?$",
Expand Down

0 comments on commit 6f798de

Please sign in to comment.