@levo-so/next-middleware 0.1.82 → 0.1.102
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/geoIp.d.ts +37 -0
- package/dist/geoIp.js +129 -0
- package/dist/index.d.ts +11 -3
- package/dist/index.js +25 -14
- package/dist/params.d.ts +17 -0
- package/dist/params.js +28 -0
- package/package.json +6 -6
package/dist/geoIp.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GeoIP header extractor.
|
|
3
|
+
*
|
|
4
|
+
* Pulls visitor country + language from HTTP headers across CDN providers
|
|
5
|
+
* (Cloudflare, Vercel, AWS CloudFront, Azure, BunnyCDN), nginx-ingress, and
|
|
6
|
+
* Traefik GeoIP2. Only surfaces `bestCountry` + `languageData` — the helpers
|
|
7
|
+
* that were historically exported (client IPs, provider-specific getters,
|
|
8
|
+
* raw header maps) are no longer used by any consumer.
|
|
9
|
+
*/
|
|
10
|
+
export interface ICountryInfo {
|
|
11
|
+
countryCode: string;
|
|
12
|
+
city?: string;
|
|
13
|
+
region?: string;
|
|
14
|
+
latitude?: string;
|
|
15
|
+
longitude?: string;
|
|
16
|
+
source: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ILanguageInfo {
|
|
19
|
+
locale: string;
|
|
20
|
+
language: string;
|
|
21
|
+
region?: string;
|
|
22
|
+
quality: number;
|
|
23
|
+
}
|
|
24
|
+
export interface IParsedLanguageData {
|
|
25
|
+
languages: ILanguageInfo[];
|
|
26
|
+
primaryLanguage: string;
|
|
27
|
+
inferredRegions: string[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Main entry: extract visitor country + language preferences.
|
|
31
|
+
* Accepts any header carrier — normalization is internal.
|
|
32
|
+
*/
|
|
33
|
+
export declare const extractGeoAndLanguageData: (headers: Record<string, string | string[]> | Headers | null | undefined) => {
|
|
34
|
+
bestCountry: ICountryInfo | null;
|
|
35
|
+
languageData: IParsedLanguageData;
|
|
36
|
+
};
|
|
37
|
+
//# sourceMappingURL=geoIp.d.ts.map
|
package/dist/geoIp.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GeoIP header extractor.
|
|
3
|
+
*
|
|
4
|
+
* Pulls visitor country + language from HTTP headers across CDN providers
|
|
5
|
+
* (Cloudflare, Vercel, AWS CloudFront, Azure, BunnyCDN), nginx-ingress, and
|
|
6
|
+
* Traefik GeoIP2. Only surfaces `bestCountry` + `languageData` — the helpers
|
|
7
|
+
* that were historically exported (client IPs, provider-specific getters,
|
|
8
|
+
* raw header maps) are no longer used by any consumer.
|
|
9
|
+
*/
|
|
10
|
+
const safeDecode = (value) => {
|
|
11
|
+
try {
|
|
12
|
+
return decodeURIComponent(value);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/** Best country info from CDN (preferred) or nginx/traefik (fallback). */
|
|
19
|
+
const extractCountry = (headers) => {
|
|
20
|
+
// Cloudflare — "XX" is Cloudflare's "unknown" sentinel
|
|
21
|
+
const cf = headers["cf-ipcountry"];
|
|
22
|
+
if (cf && cf !== "XX")
|
|
23
|
+
return { countryCode: cf, source: "Cloudflare CDN" };
|
|
24
|
+
// Vercel Edge — city is URL-encoded (e.g. `San%20Francisco`)
|
|
25
|
+
const vercel = headers["x-vercel-ip-country"];
|
|
26
|
+
if (vercel) {
|
|
27
|
+
const rawCity = headers["x-vercel-ip-city"];
|
|
28
|
+
return {
|
|
29
|
+
countryCode: vercel,
|
|
30
|
+
city: rawCity ? safeDecode(rawCity) : undefined,
|
|
31
|
+
region: headers["x-vercel-ip-country-region"],
|
|
32
|
+
latitude: headers["x-vercel-ip-latitude"],
|
|
33
|
+
longitude: headers["x-vercel-ip-longitude"],
|
|
34
|
+
source: "Vercel Edge",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const aws = headers["cloudfront-viewer-country"];
|
|
38
|
+
if (aws)
|
|
39
|
+
return { countryCode: aws, source: "AWS CloudFront" };
|
|
40
|
+
const azure = headers["x-azure-country"];
|
|
41
|
+
if (azure)
|
|
42
|
+
return { countryCode: azure, source: "Azure CDN" };
|
|
43
|
+
const bunny = headers["x-edge-country"];
|
|
44
|
+
if (bunny)
|
|
45
|
+
return { countryCode: bunny, source: "BunnyCDN" };
|
|
46
|
+
const generic = headers["x-client-country"] || headers["x-forwarded-country"] || headers["x-country"];
|
|
47
|
+
if (generic)
|
|
48
|
+
return { countryCode: generic, source: "Generic CDN" };
|
|
49
|
+
// Infrastructure fallbacks
|
|
50
|
+
const nginx = headers["x-country-code"];
|
|
51
|
+
if (nginx) {
|
|
52
|
+
return {
|
|
53
|
+
countryCode: nginx,
|
|
54
|
+
city: headers["x-city"],
|
|
55
|
+
latitude: headers["x-latitude"],
|
|
56
|
+
longitude: headers["x-longitude"],
|
|
57
|
+
source: "nginx-ingress",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const traefik = headers["x-geoip2-country"];
|
|
61
|
+
if (traefik) {
|
|
62
|
+
return {
|
|
63
|
+
countryCode: traefik,
|
|
64
|
+
city: headers["x-geoip2-city"],
|
|
65
|
+
region: headers["x-geoip2-region"],
|
|
66
|
+
source: "traefik-geoip2",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
};
|
|
71
|
+
/** Parse Accept-Language into quality-sorted list + derived regions. */
|
|
72
|
+
const parseAcceptLanguage = (acceptLanguage) => {
|
|
73
|
+
if (!acceptLanguage) {
|
|
74
|
+
return { languages: [], primaryLanguage: "unknown", inferredRegions: [] };
|
|
75
|
+
}
|
|
76
|
+
const languages = acceptLanguage
|
|
77
|
+
.split(",")
|
|
78
|
+
.map((lang) => {
|
|
79
|
+
const [locale, quality] = lang.trim().split(";q=");
|
|
80
|
+
const [language, region] = (locale || "").split("-");
|
|
81
|
+
return {
|
|
82
|
+
locale: locale?.trim() || "",
|
|
83
|
+
language: language?.trim() || "",
|
|
84
|
+
region: region?.trim().toUpperCase(),
|
|
85
|
+
quality: quality ? parseFloat(quality) : 1.0,
|
|
86
|
+
};
|
|
87
|
+
})
|
|
88
|
+
.filter((l) => l.locale)
|
|
89
|
+
.sort((a, b) => b.quality - a.quality);
|
|
90
|
+
const inferredRegions = languages
|
|
91
|
+
.map((l) => l.region)
|
|
92
|
+
.filter((r) => Boolean(r))
|
|
93
|
+
.filter((r, i, arr) => arr.indexOf(r) === i);
|
|
94
|
+
return {
|
|
95
|
+
languages,
|
|
96
|
+
primaryLanguage: languages[0]?.locale || "unknown",
|
|
97
|
+
inferredRegions,
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Normalize any header carrier (Fetch `Headers`, Node `req.headers`, plain
|
|
102
|
+
* object) into a lowercase-keyed map.
|
|
103
|
+
*/
|
|
104
|
+
const headersToObject = (headers) => {
|
|
105
|
+
if (!headers)
|
|
106
|
+
return {};
|
|
107
|
+
if (typeof headers.entries === "function") {
|
|
108
|
+
const obj = {};
|
|
109
|
+
for (const [k, v] of headers.entries())
|
|
110
|
+
obj[k.toLowerCase()] = v;
|
|
111
|
+
return obj;
|
|
112
|
+
}
|
|
113
|
+
const obj = {};
|
|
114
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
115
|
+
obj[k.toLowerCase()] = Array.isArray(v) ? v.join(", ") : String(v);
|
|
116
|
+
}
|
|
117
|
+
return obj;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Main entry: extract visitor country + language preferences.
|
|
121
|
+
* Accepts any header carrier — normalization is internal.
|
|
122
|
+
*/
|
|
123
|
+
export const extractGeoAndLanguageData = (headers) => {
|
|
124
|
+
const normalized = headersToObject(headers);
|
|
125
|
+
return {
|
|
126
|
+
bestCountry: extractCountry(normalized),
|
|
127
|
+
languageData: parseAcceptLanguage(normalized["accept-language"] || ""),
|
|
128
|
+
};
|
|
129
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import { type NextRequest, NextResponse } from "next/server";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Levo Next.js middleware — rewrites API and SEO file requests to the appropriate backend.
|
|
4
|
+
*
|
|
5
|
+
* @param options.apiUrl - Base URL for the public API proxy. Defaults to `https://public-api.levo.so`.
|
|
6
|
+
* @param options.insightsUrl - Base URL for the insights API proxy. Defaults to `https://insights-api.levo.so`.
|
|
7
|
+
* @param options.fileExclusions - Paths to exclude from the SEO file proxy (robots.txt, llms.txt, sitemaps).
|
|
8
|
+
* Matched exactly against the request pathname.
|
|
9
|
+
* e.g. `["/robots.txt", "/llms.txt"]` prevents those files from being reverse-proxied.
|
|
4
10
|
*/
|
|
5
11
|
declare function middleware(request: NextRequest, options?: {
|
|
6
12
|
apiUrl?: string;
|
|
7
13
|
insightsUrl?: string;
|
|
8
|
-
|
|
14
|
+
fileExclusions?: string[];
|
|
9
15
|
}): NextResponse<unknown> | undefined;
|
|
10
16
|
declare const levoConfig: {
|
|
11
17
|
matcher: string[];
|
|
12
18
|
};
|
|
19
|
+
export * from "./geoIp";
|
|
20
|
+
export * from "./params";
|
|
13
21
|
export default middleware;
|
|
14
|
-
export {
|
|
22
|
+
export { levoConfig, middleware };
|
|
15
23
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Levo Next.js middleware — rewrites API and SEO file requests to the appropriate backend.
|
|
4
|
+
*
|
|
5
|
+
* @param options.apiUrl - Base URL for the public API proxy. Defaults to `https://public-api.levo.so`.
|
|
6
|
+
* @param options.insightsUrl - Base URL for the insights API proxy. Defaults to `https://insights-api.levo.so`.
|
|
7
|
+
* @param options.fileExclusions - Paths to exclude from the SEO file proxy (robots.txt, llms.txt, sitemaps).
|
|
8
|
+
* Matched exactly against the request pathname.
|
|
9
|
+
* e.g. `["/robots.txt", "/llms.txt"]` prevents those files from being reverse-proxied.
|
|
4
10
|
*/
|
|
5
11
|
function middleware(request, options = {}) {
|
|
6
|
-
const { apiUrl = "https://public-api.levo.so", insightsUrl = "https://insights-api.levo.so",
|
|
12
|
+
const { apiUrl = "https://public-api.levo.so", insightsUrl = "https://insights-api.levo.so", fileExclusions = [], } = options;
|
|
7
13
|
const { nextUrl } = request;
|
|
8
14
|
const { pathname, search, hash } = nextUrl;
|
|
9
15
|
const host = request.headers.get("host");
|
|
@@ -48,10 +54,14 @@ function middleware(request, options = {}) {
|
|
|
48
54
|
* such as `text/plain` for `robots.txt` and `application/xml` for sitemaps
|
|
49
55
|
*
|
|
50
56
|
*/
|
|
51
|
-
if
|
|
52
|
-
|
|
57
|
+
// Early return if the path is in the exclusions list
|
|
58
|
+
if (fileExclusions.includes(pathname))
|
|
59
|
+
return NextResponse.next();
|
|
60
|
+
const isFileProxy = pathname.endsWith("robots.txt") ||
|
|
61
|
+
pathname.endsWith("llms.txt") ||
|
|
53
62
|
// check if it is a sitemap xml, can be `sitemap-static.xml`, `sitemap_index.xml` or other sitemap xml files
|
|
54
|
-
(
|
|
63
|
+
(pathname.includes("sitemap") && pathname.endsWith(".xml"));
|
|
64
|
+
if (isFileProxy) {
|
|
55
65
|
const workspaceQuery = workspace ? `&workspace=${workspace}` : "";
|
|
56
66
|
const targetURL = `${apiUrl}/v1/studio/page/get-file-from-path?path=${pathname}${workspaceQuery}`;
|
|
57
67
|
return NextResponse.rewrite(targetURL, {
|
|
@@ -63,15 +73,16 @@ function middleware(request, options = {}) {
|
|
|
63
73
|
}
|
|
64
74
|
const levoConfig = {
|
|
65
75
|
matcher: [
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"/((?!api|_next/static|_next/image|favicon.ico).*)",
|
|
76
|
+
// API proxy routes
|
|
77
|
+
"/.levo/public/api/:path*",
|
|
78
|
+
"/.levo/insights/api/:path*",
|
|
79
|
+
// SEO file proxy routes
|
|
80
|
+
"/robots.txt",
|
|
81
|
+
"/llms.txt",
|
|
82
|
+
"/(.*sitemap.*\\.xml)",
|
|
74
83
|
],
|
|
75
84
|
};
|
|
85
|
+
export * from "./geoIp";
|
|
86
|
+
export * from "./params";
|
|
76
87
|
export default middleware;
|
|
77
|
-
export {
|
|
88
|
+
export { levoConfig, middleware };
|
package/dist/params.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for reading Next.js route `params` produced by the proxy's
|
|
3
|
+
* `/visitor:{...json}/...` rewrite.
|
|
4
|
+
*/
|
|
5
|
+
export interface IParsedRouteParams {
|
|
6
|
+
slug: string[];
|
|
7
|
+
visitorParams: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
/** Parse a `visitor:{...}` segment back into its JSON payload. */
|
|
10
|
+
export declare const parseVisitorParam: (visitor: string | undefined) => Record<string, unknown>;
|
|
11
|
+
/**
|
|
12
|
+
* Pull `visitorParams` + clean `slug` out of a Next.js catch-all `params`.
|
|
13
|
+
* If the first slug segment is a `visitor:{...}` prefix, it is stripped and
|
|
14
|
+
* decoded; otherwise `visitorParams` falls back to `{}`.
|
|
15
|
+
*/
|
|
16
|
+
export declare const parseParams: (params: Record<string, string | string[] | undefined> | undefined) => IParsedRouteParams;
|
|
17
|
+
//# sourceMappingURL=params.d.ts.map
|
package/dist/params.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for reading Next.js route `params` produced by the proxy's
|
|
3
|
+
* `/visitor:{...json}/...` rewrite.
|
|
4
|
+
*/
|
|
5
|
+
/** Parse a `visitor:{...}` segment back into its JSON payload. */
|
|
6
|
+
export const parseVisitorParam = (visitor) => {
|
|
7
|
+
if (typeof visitor !== "string")
|
|
8
|
+
return {};
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(visitor.replace(/^visitor:/, ""));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Pull `visitorParams` + clean `slug` out of a Next.js catch-all `params`.
|
|
18
|
+
* If the first slug segment is a `visitor:{...}` prefix, it is stripped and
|
|
19
|
+
* decoded; otherwise `visitorParams` falls back to `{}`.
|
|
20
|
+
*/
|
|
21
|
+
export const parseParams = (params) => {
|
|
22
|
+
const rawSlug = Array.isArray(params?.slug) ? params.slug : [];
|
|
23
|
+
const [first, ...rest] = rawSlug;
|
|
24
|
+
if (typeof first === "string" && first.startsWith("visitor:")) {
|
|
25
|
+
return { slug: rest, visitorParams: parseVisitorParam(first) };
|
|
26
|
+
}
|
|
27
|
+
return { slug: rawSlug, visitorParams: {} };
|
|
28
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@levo-so/next-middleware",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.102",
|
|
4
4
|
"author": "Levo Engineering <devs@theinternetfolks.com>",
|
|
5
5
|
"description": "Next middleware to add a proxy for levo integration in apps",
|
|
6
6
|
"type": "module",
|
|
@@ -14,15 +14,15 @@
|
|
|
14
14
|
"package.json"
|
|
15
15
|
],
|
|
16
16
|
"peerDependencies": {
|
|
17
|
-
"next": ">13.0.0 <=
|
|
17
|
+
"next": ">13.0.0 <=17"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
|
-
"@types/react": "19.2.
|
|
20
|
+
"@types/react": "19.2.14",
|
|
21
21
|
"@types/react-dom": "19.2.3",
|
|
22
22
|
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
|
23
|
-
"next": "16.
|
|
24
|
-
"react": "19.2.
|
|
25
|
-
"react-dom": "19.2.
|
|
23
|
+
"next": "16.2.3",
|
|
24
|
+
"react": "19.2.4",
|
|
25
|
+
"react-dom": "19.2.4",
|
|
26
26
|
"@levo/ts-config": "0.0.0"
|
|
27
27
|
},
|
|
28
28
|
"main": "./dist/index.js",
|