Skip to content

Commit 6f798de

Browse files
authored
feat: add support for next15 geolocation (#617)
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.
1 parent 8055c18 commit 6f798de

File tree

7 files changed

+98
-49
lines changed

7 files changed

+98
-49
lines changed

.changeset/friendly-kangaroos-give.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/aws": patch
3+
---
4+
5+
feat: add support for Next15 geolocation

packages/open-next/src/core/routing/matcher.ts

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -114,31 +114,26 @@ function convertMatch(
114114
toDestination: PathFunction,
115115
destination: string,
116116
) {
117-
if (match) {
118-
const { params } = match;
119-
const isUsingParams = Object.keys(params).length > 0;
120-
if (isUsingParams) {
121-
return toDestination(params);
122-
} else {
123-
return destination;
124-
}
125-
} else {
117+
if (!match) {
126118
return destination;
127119
}
120+
121+
const { params } = match;
122+
const isUsingParams = Object.keys(params).length > 0;
123+
return isUsingParams ? toDestination(params) : destination;
128124
}
129125

130-
export function addNextConfigHeaders(
126+
export function getNextConfigHeaders(
131127
event: InternalEvent,
132128
configHeaders?: Header[] | undefined,
133-
) {
134-
const addedHeaders: Record<string, string | undefined> = {};
129+
): Record<string, string | undefined> {
130+
if (!configHeaders) {
131+
return {};
132+
}
135133

136-
if (!configHeaders) return addedHeaders;
137-
const { rawPath, headers, query, cookies } = event;
138-
const matcher = routeHasMatcher(headers, cookies, query);
134+
const matcher = routeHasMatcher(event.headers, event.cookies, event.query);
139135

140136
const requestHeaders: Record<string, string> = {};
141-
142137
const localizedRawPath = localizePath(event);
143138

144139
for (const {
@@ -149,7 +144,7 @@ export function addNextConfigHeaders(
149144
source,
150145
locale,
151146
} of configHeaders) {
152-
const path = locale === false ? rawPath : localizedRawPath;
147+
const path = locale === false ? event.rawPath : localizedRawPath;
153148
if (
154149
new RegExp(regex).test(path) &&
155150
checkHas(matcher, has) &&
@@ -163,7 +158,7 @@ export function addNextConfigHeaders(
163158
const value = convertMatch(_match, compile(h.value), h.value);
164159
requestHeaders[key] = value;
165160
} catch {
166-
debug("Error matching header ", h.key, " with value ", h.value);
161+
debug(`Error matching header ${h.key} with value ${h.value}`);
167162
requestHeaders[h.key] = h.value;
168163
}
169164
});

packages/open-next/src/core/routing/middleware.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,38 +46,41 @@ export async function handleMiddleware(
4646
internalEvent: InternalEvent,
4747
middlewareLoader: MiddlewareLoader = defaultMiddlewareLoader,
4848
): Promise<MiddlewareOutputEvent | InternalResult> {
49-
const { query } = internalEvent;
50-
const normalizedPath = localizePath(internalEvent);
49+
const headers = internalEvent.headers;
50+
51+
// We bypass the middleware if the request is internal
52+
if (headers["x-isr"]) return internalEvent;
53+
5154
// We only need the normalizedPath to check if the middleware should run
55+
const normalizedPath = localizePath(internalEvent);
5256
const hasMatch = middleMatch.some((r) => r.test(normalizedPath));
5357
if (!hasMatch) return internalEvent;
54-
// We bypass the middleware if the request is internal
55-
if (internalEvent.headers["x-isr"]) return internalEvent;
5658

5759
// Retrieve the protocol:
5860
// - In lambda, the url only contains the rawPath and the query - default to https
5961
// - In cloudflare, the protocol is usually http in dev and https in production
6062
const protocol = internalEvent.url.startsWith("http://") ? "http:" : "https:";
6163

62-
const host = internalEvent.headers.host
63-
? `${protocol}//${internalEvent.headers.host}`
64+
const host = headers.host
65+
? `${protocol}//${headers.host}`
6466
: "http://localhost:3000";
6567

6668
const initialUrl = new URL(normalizedPath, host);
67-
initialUrl.search = convertToQueryString(query);
69+
initialUrl.search = convertToQueryString(internalEvent.query);
6870
const url = initialUrl.toString();
6971

7072
const middleware = await middlewareLoader();
7173

7274
const result: Response = await middleware.default({
75+
// `geo` is pre Next 15.
7376
geo: {
74-
city: internalEvent.headers["x-open-next-city"],
75-
country: internalEvent.headers["x-open-next-country"],
76-
region: internalEvent.headers["x-open-next-region"],
77-
latitude: internalEvent.headers["x-open-next-latitude"],
78-
longitude: internalEvent.headers["x-open-next-longitude"],
77+
city: headers["x-open-next-city"],
78+
country: headers["x-open-next-country"],
79+
region: headers["x-open-next-region"],
80+
latitude: headers["x-open-next-latitude"],
81+
longitude: headers["x-open-next-longitude"],
7982
},
80-
headers: internalEvent.headers,
83+
headers,
8184
method: internalEvent.method || "GET",
8285
nextConfig: {
8386
basePath: NextConfig.basePath,

packages/open-next/src/core/routingHandler.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,16 @@ import type { InternalEvent, InternalResult, Origin } from "types/open-next";
99
import { debug } from "../adapters/logger";
1010
import { cacheInterceptor } from "./routing/cacheInterceptor";
1111
import {
12-
addNextConfigHeaders,
1312
fixDataPage,
13+
getNextConfigHeaders,
1414
handleFallbackFalse,
1515
handleRedirects,
1616
handleRewrites,
1717
} from "./routing/matcher";
1818
import { handleMiddleware } from "./routing/middleware";
1919

20+
export const MIDDLEWARE_HEADER_PREFIX = "x-middleware-response-";
21+
export const MIDDLEWARE_HEADER_PREFIX_LEN = MIDDLEWARE_HEADER_PREFIX.length;
2022
export interface MiddlewareOutputEvent {
2123
internalEvent: InternalEvent;
2224
isExternalRewrite: boolean;
@@ -57,23 +59,45 @@ const dynamicRegexp = RoutesManifest.routes.dynamic.map(
5759
),
5860
);
5961

62+
// Geolocation headers starting from Nextjs 15
63+
// See https://github.com/vercel/vercel/blob/7714b1c/packages/functions/src/headers.ts
64+
const geoHeaderToNextHeader = {
65+
"x-open-next-city": "x-vercel-ip-city",
66+
"x-open-next-country": "x-vercel-ip-country",
67+
"x-open-next-region": "x-vercel-ip-country-region",
68+
"x-open-next-latitude": "x-vercel-ip-latitude",
69+
"x-open-next-longitude": "x-vercel-ip-longitude",
70+
};
71+
6072
function applyMiddlewareHeaders(
6173
eventHeaders: Record<string, string | string[]>,
6274
middlewareHeaders: Record<string, string | string[] | undefined>,
6375
setPrefix = true,
6476
) {
77+
const keyPrefix = setPrefix ? MIDDLEWARE_HEADER_PREFIX : "";
6578
Object.entries(middlewareHeaders).forEach(([key, value]) => {
6679
if (value) {
67-
eventHeaders[`${setPrefix ? "x-middleware-response-" : ""}${key}`] =
68-
Array.isArray(value) ? value.join(",") : value;
80+
eventHeaders[keyPrefix + key] = Array.isArray(value)
81+
? value.join(",")
82+
: value;
6983
}
7084
});
7185
}
7286

7387
export default async function routingHandler(
7488
event: InternalEvent,
7589
): Promise<InternalResult | MiddlewareOutputEvent> {
76-
const nextHeaders = addNextConfigHeaders(event, ConfigHeaders);
90+
// Add Next geo headers
91+
for (const [openNextGeoName, nextGeoName] of Object.entries(
92+
geoHeaderToNextHeader,
93+
)) {
94+
const value = event.headers[openNextGeoName];
95+
if (value) {
96+
event.headers[nextGeoName] = value;
97+
}
98+
}
99+
100+
const nextHeaders = getNextConfigHeaders(event, ConfigHeaders);
77101

78102
let internalEvent = fixDataPage(event, BuildId);
79103
if ("statusCode" in internalEvent) {

packages/open-next/src/overrides/converters/aws-cloudfront.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
} from "../../core/routing/util";
2222
import type { MiddlewareOutputEvent } from "../../core/routingHandler";
2323

24-
const CloudFrontBlacklistedHeaders = [
24+
const cloudfrontBlacklistedHeaders = [
2525
// Disallowed headers, see: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/edge-function-restrictions-all.html#function-restrictions-disallowed-headers
2626
"connection",
2727
"expect",
@@ -86,6 +86,7 @@ async function convertFromCloudFrontRequestEvent(
8686
): Promise<InternalEvent> {
8787
const { method, uri, querystring, body, headers, clientIp } =
8888
event.Records[0].cf.request;
89+
8990
return {
9091
type: "core",
9192
method,
@@ -119,7 +120,7 @@ function convertToCloudfrontHeaders(
119120
.map(([key, value]) => [key.toLowerCase(), value] as const)
120121
.filter(
121122
([key]) =>
122-
!CloudFrontBlacklistedHeaders.some((header) =>
123+
!cloudfrontBlacklistedHeaders.some((header) =>
123124
typeof header === "string" ? header === key : header.test(key),
124125
) &&
125126
// Only remove read-only headers when directly responding in lambda@edge

packages/open-next/src/overrides/wrappers/cloudflare.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,20 @@ import type {
66

77
import type { MiddlewareOutputEvent } from "../../core/routingHandler";
88

9+
const cfPropNameToHeaderName = {
10+
city: "x-open-next-city",
11+
country: "x-open-next-country",
12+
region: "x-open-next-region",
13+
latitude: "x-open-next-latitude",
14+
longitude: "x-open-next-longitude",
15+
};
16+
917
const handler: WrapperHandler<
1018
InternalEvent,
1119
InternalResult | ({ type: "middleware" } & MiddlewareOutputEvent)
1220
> =
1321
async (handler, converter) =>
14-
async (event: Request, env: Record<string, string>): Promise<Response> => {
22+
async (request: Request, env: Record<string, string>): Promise<Response> => {
1523
globalThis.process = process;
1624

1725
// Set the environment variables
@@ -22,7 +30,20 @@ const handler: WrapperHandler<
2230
}
2331
}
2432

25-
const internalEvent = await converter.convertFrom(event);
33+
const internalEvent = await converter.convertFrom(request);
34+
35+
// Retrieve geo information from the cloudflare request
36+
// See https://developers.cloudflare.com/workers/runtime-apis/request
37+
// Note: This code could be moved to a cloudflare specific converter when one is created.
38+
const cfProperties = (request as any).cf as Record<string, string | null>;
39+
for (const [propName, headerName] of Object.entries(
40+
cfPropNameToHeaderName,
41+
)) {
42+
const propValue = cfProperties[propName];
43+
if (propValue !== null) {
44+
internalEvent.headers[headerName] = propValue;
45+
}
46+
}
2647

2748
const response = await handler(internalEvent);
2849

packages/tests-unit/tests/core/routing/matcher.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextConfig } from "@opennextjs/aws/adapters/config/index.js";
22
import {
3-
addNextConfigHeaders,
43
fixDataPage,
4+
getNextConfigHeaders,
55
handleRedirects,
66
handleRewrites,
77
} from "@opennextjs/aws/core/routing/matcher.js";
@@ -39,17 +39,17 @@ beforeEach(() => {
3939
vi.resetAllMocks();
4040
});
4141

42-
describe("addNextConfigHeaders", () => {
42+
describe("getNextConfigHeaders", () => {
4343
it("should return empty object for undefined configHeaders", () => {
4444
const event = createEvent({});
45-
const result = addNextConfigHeaders(event);
45+
const result = getNextConfigHeaders(event);
4646

4747
expect(result).toEqual({});
4848
});
4949

5050
it("should return empty object for empty configHeaders", () => {
5151
const event = createEvent({});
52-
const result = addNextConfigHeaders(event, []);
52+
const result = getNextConfigHeaders(event, []);
5353

5454
expect(result).toEqual({});
5555
});
@@ -59,7 +59,7 @@ describe("addNextConfigHeaders", () => {
5959
url: "/",
6060
});
6161

62-
const result = addNextConfigHeaders(event, [
62+
const result = getNextConfigHeaders(event, [
6363
{
6464
source: "/",
6565
regex: "^/$",
@@ -82,7 +82,7 @@ describe("addNextConfigHeaders", () => {
8282
url: "/",
8383
});
8484

85-
const result = addNextConfigHeaders(event, [
85+
const result = getNextConfigHeaders(event, [
8686
{
8787
source: "/",
8888
regex: "^/$",
@@ -98,7 +98,7 @@ describe("addNextConfigHeaders", () => {
9898
url: "/hello-world",
9999
});
100100

101-
const result = addNextConfigHeaders(event, [
101+
const result = getNextConfigHeaders(event, [
102102
{
103103
source: "/(.*)",
104104
regex: "^(?:/(.*))(?:/)?$",
@@ -129,7 +129,7 @@ describe("addNextConfigHeaders", () => {
129129
},
130130
});
131131

132-
const result = addNextConfigHeaders(event, [
132+
const result = getNextConfigHeaders(event, [
133133
{
134134
source: "/(.*)",
135135
regex: "^(?:/(.*))(?:/)?$",
@@ -156,7 +156,7 @@ describe("addNextConfigHeaders", () => {
156156
},
157157
});
158158

159-
const result = addNextConfigHeaders(event, [
159+
const result = getNextConfigHeaders(event, [
160160
{
161161
source: "/(.*)",
162162
regex: "^(?:/(.*))(?:/)?$",
@@ -183,7 +183,7 @@ describe("addNextConfigHeaders", () => {
183183
},
184184
});
185185

186-
const result = addNextConfigHeaders(event, [
186+
const result = getNextConfigHeaders(event, [
187187
{
188188
source: "/(.*)",
189189
regex: "^(?:/(.*))(?:/)?$",

0 commit comments

Comments
 (0)