@klnap/next-proxy-chain 1.0.1 → 1.0.2
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/README.md +146 -175
- package/dist/index.cjs +196 -174
- package/dist/index.d.cts +255 -35
- package/dist/index.d.ts +255 -35
- package/dist/index.js +443 -81
- package/package.json +16 -12
- package/dist/chunk-PTAOIFNB.js +0 -412
- package/dist/core.cjs +0 -467
- package/dist/core.d.cts +0 -210
- package/dist/core.d.ts +0 -210
- package/dist/core.d.ts.map +0 -1
- package/dist/core.js +0 -68
- package/dist/core.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,250 +1,221 @@
|
|
|
1
1
|
# @klnap/next-proxy-chain
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
[](https://www.npmjs.com/package/@klnap/next-proxy-chain)
|
|
6
|
-
[](https://opensource.org/licenses/MIT)
|
|
7
|
-
[](https://www.typescriptlang.org/)
|
|
8
|
-
[](https://bun.sh)
|
|
3
|
+
A composable request-chaining and proxy engine for Next.js architectures with deterministic header and cookie accumulation, isolated request context, and a pure core design.
|
|
9
4
|
|
|
10
5
|
---
|
|
11
6
|
|
|
12
|
-
##
|
|
7
|
+
## Overview & Motivation
|
|
13
8
|
|
|
14
|
-
Next.js
|
|
9
|
+
In modern Next.js architectures, edge routing, backend-for-frontend (BFF) layers, multi-tenant resolution, and security policies frequently require multi-hop request processing. Standard monolithic middleware implementations lead to architectural challenges:
|
|
15
10
|
|
|
16
|
-
|
|
11
|
+
- **Nested Wrapper Hell**: Composing authentication, localization, rate limiting, and multi-tenant routing often results in deeply nested higher-order functions with fragile control flow.
|
|
12
|
+
- **Header Mutation Conflicts**: Independent middleware steps often overwrite request headers (`x-middleware-request-*`) or drop previously injected values.
|
|
13
|
+
- **Cookie Directive Overwrites**: Multiple intermediate steps setting `Set-Cookie` directives risk clobbering each other or emitting duplicate headers that CDNs and browsers handle unpredictably.
|
|
14
|
+
- **Stateful Concurrency Hazards**: Shared state or stateful regular expressions (such as `RegExp` instances with global `g` or sticky `y` flags) cause non-deterministic route matching under high concurrency in Edge worker runtimes.
|
|
15
|
+
- **Header Injection Vulnerabilities**: Untrusted incoming requests can attempt to manipulate protected transport headers such as `Host`, `Authorization`, or Next.js internal headers (`x-middleware-*`).
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
2. **Deterministic Header & Cookie Merging**: Combines request mutations (`x-middleware-request-*`), outgoing response headers, and multiple `Set-Cookie` directives without silent overwrites.
|
|
20
|
-
3. **Pure Web API Core**: Zero Next.js dependencies in `@klnap/next-proxy-chain/core` — fully testable on Cloudflare Workers, Fastly, Bun, Deno, and standard Web runtimes.
|
|
21
|
-
4. **Edge Runtime Hardening**: Strips stateful RegExp flags (`g`, `y`) to eliminate concurrent request cross-talk in multi-tenant edge workers, enforces per-rule timeouts, and protects against prototype pollution via `Map` context.
|
|
22
|
-
5. **Header Poisoning Defense**: Protects client responses by stripping sensitive transport headers (`x-middleware-*`) and blocks unauthorized override of protected headers (`Host`, `Authorization`, `Connection`).
|
|
17
|
+
`next-proxy-chain` provides a linear, deterministic execution pipeline with zero framework-specific adapter boilerplate. It executes natively in both Next.js 15 (`middleware.ts`) and Next.js 16 (`proxy.ts`).
|
|
23
18
|
|
|
24
19
|
---
|
|
25
20
|
|
|
26
21
|
## Installation
|
|
27
22
|
|
|
28
|
-
|
|
29
|
-
# Using Bun
|
|
30
|
-
bun add @klnap/next-proxy-chain
|
|
23
|
+
Install the package via your preferred package manager:
|
|
31
24
|
|
|
32
|
-
|
|
25
|
+
```bash
|
|
26
|
+
# npm
|
|
33
27
|
npm install @klnap/next-proxy-chain
|
|
34
28
|
|
|
35
|
-
#
|
|
29
|
+
# bun
|
|
30
|
+
bun add @klnap/next-proxy-chain
|
|
31
|
+
|
|
32
|
+
# pnpm
|
|
36
33
|
pnpm add @klnap/next-proxy-chain
|
|
37
34
|
|
|
38
|
-
#
|
|
35
|
+
# yarn
|
|
39
36
|
yarn add @klnap/next-proxy-chain
|
|
40
37
|
```
|
|
41
38
|
|
|
42
39
|
---
|
|
43
40
|
|
|
44
|
-
##
|
|
41
|
+
## Core Architecture
|
|
42
|
+
|
|
43
|
+
`next-proxy-chain` orchestrates incoming requests through a linear pipeline of isolated rules:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
[Incoming Request]
|
|
47
|
+
│
|
|
48
|
+
▼
|
|
49
|
+
┌──────────────┐
|
|
50
|
+
│ createContext│ ───> Map<string, unknown> (Isolated per request)
|
|
51
|
+
└──────┬───────┘
|
|
52
|
+
│
|
|
53
|
+
▼
|
|
54
|
+
┌──────────────┐ (Matched by path/host filter)
|
|
55
|
+
│ Rule 1 │ ───> return next({ request, headers })
|
|
56
|
+
└──────┬───────┘
|
|
57
|
+
│ Materializes mutated NextRequest only when next rule executes
|
|
58
|
+
▼
|
|
59
|
+
┌──────────────┐ (Matched by path/host filter)
|
|
60
|
+
│ Rule 2 │ ───> return next() | NextResponse.redirect()
|
|
61
|
+
└──────┬───────┘
|
|
62
|
+
│
|
|
63
|
+
▼
|
|
64
|
+
┌──────────────┐
|
|
65
|
+
│ Final Merge │ ───> Merges all request overrides, response headers, & cookies
|
|
66
|
+
└──────┬───────┘
|
|
67
|
+
│
|
|
68
|
+
▼
|
|
69
|
+
[Outgoing NextResponse / Response]
|
|
70
|
+
```
|
|
45
71
|
|
|
46
|
-
###
|
|
72
|
+
### Key Architectural Properties
|
|
47
73
|
|
|
48
|
-
|
|
74
|
+
1. **Pure Core**: The underlying rule engine operates strictly on Web Standards (`Request`, `Response`, `Headers`, `URL`, `Map`).
|
|
75
|
+
2. **Deterministic Conflict Resolution**: Outgoing response headers and cookies are merged according to configurable strategies (`last-write-wins`, `first-write-wins`, or custom resolvers).
|
|
76
|
+
3. **Lazy Request Materialization**: Cloned `NextRequest` objects are only instantiated when downstream rules read mutated headers, minimizing memory allocations.
|
|
77
|
+
4. **Edge Runtime Hardening**: Strips `g` and `y` flags from regular expressions to prevent race conditions during concurrent request processing.
|
|
78
|
+
5. **Fail-Safe Recovery**: Unhandled rule exceptions are caught and routed through optional `onError` handlers, falling back to a safe HTTP 500 response.
|
|
49
79
|
|
|
50
|
-
|
|
51
|
-
// src/proxies/request-id.ts
|
|
52
|
-
import { defineProxy, next } from "@klnap/next-proxy-chain"
|
|
80
|
+
---
|
|
53
81
|
|
|
54
|
-
|
|
55
|
-
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID()
|
|
82
|
+
## Integration Guide
|
|
56
83
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
})
|
|
61
|
-
})
|
|
62
|
-
```
|
|
84
|
+
### Next.js 15 Integration (`middleware.ts`)
|
|
85
|
+
|
|
86
|
+
In Next.js 15, export the proxy chain directly as the middleware handler:
|
|
63
87
|
|
|
64
88
|
```typescript
|
|
65
|
-
//
|
|
66
|
-
import { defineProxy, next } from "@klnap/next-proxy-chain"
|
|
89
|
+
// middleware.ts
|
|
90
|
+
import { defineProxy, next, path, proxyChain } from "@klnap/next-proxy-chain"
|
|
67
91
|
import { NextResponse } from "next/server"
|
|
68
92
|
|
|
69
|
-
|
|
70
|
-
const
|
|
93
|
+
const requestIdProxy = defineProxy("requestId", (req) => {
|
|
94
|
+
const id = req.headers.get("x-request-id") ?? crypto.randomUUID()
|
|
95
|
+
return next({
|
|
96
|
+
request: { "x-request-id": id },
|
|
97
|
+
headers: { "x-request-id": id },
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const authProxy = defineProxy("auth", async (req, ctx) => {
|
|
102
|
+
const token = req.cookies.get("session")?.value
|
|
71
103
|
|
|
72
104
|
if (!token) {
|
|
73
|
-
|
|
74
|
-
loginUrl.searchParams.set("from", req.nextUrl.pathname)
|
|
75
|
-
return NextResponse.redirect(loginUrl)
|
|
105
|
+
return NextResponse.redirect(new URL("/login", req.url))
|
|
76
106
|
}
|
|
77
107
|
|
|
78
|
-
|
|
79
|
-
ctx.set("userId", "user_abc123")
|
|
108
|
+
ctx.set("userId", "usr_100")
|
|
80
109
|
return next()
|
|
81
110
|
})
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
### 2. Assemble Pipeline in `src/proxy.ts` (Next.js 16)
|
|
85
|
-
|
|
86
|
-
```typescript
|
|
87
|
-
// src/proxy.ts
|
|
88
|
-
import { proxyChain, path } from "@klnap/next-proxy-chain"
|
|
89
|
-
import { requestIdProxy } from "./proxies/request-id"
|
|
90
|
-
import { authProxy } from "./proxies/auth"
|
|
91
|
-
import { i18nProxy } from "./proxies/i18n"
|
|
92
|
-
|
|
93
|
-
const isDev = process.env.NODE_ENV !== "production"
|
|
94
111
|
|
|
95
|
-
export
|
|
96
|
-
// 1. Injects request ID on all incoming traffic
|
|
112
|
+
export const middleware = proxyChain([
|
|
97
113
|
requestIdProxy,
|
|
98
|
-
|
|
99
|
-
// 2. Guards dashboard routes, excluding public preview assets
|
|
100
|
-
authProxy({
|
|
101
|
-
include: path("/dashboard/*"),
|
|
102
|
-
exclude: path("/dashboard/public/*"),
|
|
103
|
-
}),
|
|
104
|
-
|
|
105
|
-
// 3. Handles internationalization routing
|
|
106
|
-
i18nProxy,
|
|
114
|
+
authProxy({ include: path("/dashboard/*") }),
|
|
107
115
|
])
|
|
108
116
|
|
|
109
117
|
export const config = {
|
|
110
|
-
matcher: "/((?!
|
|
118
|
+
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
111
119
|
}
|
|
112
120
|
```
|
|
113
121
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
## Route & Host Scoping
|
|
122
|
+
### Next.js 16 Integration (`proxy.ts`)
|
|
117
123
|
|
|
118
|
-
|
|
124
|
+
In Next.js 16, export the proxy chain as default or named `proxy` export:
|
|
119
125
|
|
|
120
126
|
```typescript
|
|
121
|
-
|
|
127
|
+
// proxy.ts
|
|
128
|
+
import { defineProxy, host, next, path, proxyChain } from "@klnap/next-proxy-chain"
|
|
129
|
+
import { NextResponse } from "next/server"
|
|
122
130
|
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
include: path("/admin/*"),
|
|
126
|
-
})
|
|
131
|
+
const multiTenantProxy = defineProxy("multiTenant", (req, ctx) => {
|
|
132
|
+
const hostname = req.headers.get("host") ?? ""
|
|
127
133
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
134
|
+
if (hostname.startsWith("admin.")) {
|
|
135
|
+
ctx.set("tenant", "admin")
|
|
136
|
+
return next({
|
|
137
|
+
request: { "x-tenant-id": "admin-portal" },
|
|
138
|
+
})
|
|
139
|
+
}
|
|
132
140
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
141
|
+
if (hostname.startsWith("app.")) {
|
|
142
|
+
ctx.set("tenant", "app")
|
|
143
|
+
return next({
|
|
144
|
+
request: { "x-tenant-id": "customer-app" },
|
|
145
|
+
})
|
|
146
|
+
}
|
|
137
147
|
|
|
138
|
-
|
|
139
|
-
const apiRule = defineProxy("apiGuard", handler, {
|
|
140
|
-
include: [
|
|
141
|
-
path("/api/v1/*"),
|
|
142
|
-
{ host: "api.example.com" },
|
|
143
|
-
],
|
|
144
|
-
exclude: [
|
|
145
|
-
path("/api/v1/health"),
|
|
146
|
-
{ host: "internal.example.com" },
|
|
147
|
-
],
|
|
148
|
+
return next()
|
|
148
149
|
})
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
---
|
|
152
150
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
When multiple rules in the chain mutate headers or cookies, `@klnap/next-proxy-chain` merges them deterministically:
|
|
156
|
-
|
|
157
|
-
- **Request Overrides (`next({ request: { ... } })`)**: Downstream rules and final page route handlers receive the accumulated mutated headers via Next.js request override encoding.
|
|
158
|
-
- **Response Headers (`next({ headers: { ... } })`)**: Accumulated using last-write-wins strategy, written to the terminal response.
|
|
159
|
-
- **Cookies (`Set-Cookie`)**: Multiple `Set-Cookie` directives across different rules are deduplicated by name and path attributes without dropping earlier cookies.
|
|
160
|
-
|
|
161
|
-
```typescript
|
|
162
|
-
import { defineProxy, next } from "@klnap/next-proxy-chain"
|
|
163
|
-
|
|
164
|
-
export const headerEnricher = defineProxy("enricher", () => {
|
|
151
|
+
const securityHeadersProxy = defineProxy("securityHeaders", () => {
|
|
165
152
|
return next({
|
|
166
|
-
request: {
|
|
167
|
-
"x-geo-region": "eu-central",
|
|
168
|
-
},
|
|
169
153
|
headers: {
|
|
170
154
|
"x-content-type-options": "nosniff",
|
|
171
155
|
"x-frame-options": "DENY",
|
|
172
156
|
},
|
|
173
157
|
})
|
|
174
158
|
})
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
---
|
|
178
|
-
|
|
179
|
-
## Pure Web API Core (`@klnap/next-proxy-chain/core`)
|
|
180
|
-
|
|
181
|
-
For non-Next.js environments (Cloudflare Workers, Fastly Compute, Deno Deploy, Bun HTTP servers), use the pure core subpath:
|
|
182
|
-
|
|
183
|
-
```typescript
|
|
184
|
-
import {
|
|
185
|
-
createProxyContext,
|
|
186
|
-
mergeWrites,
|
|
187
|
-
withPaths,
|
|
188
|
-
next,
|
|
189
|
-
isNextResult,
|
|
190
|
-
toStatelessRegExp
|
|
191
|
-
} from "@klnap/next-proxy-chain/core"
|
|
192
|
-
|
|
193
|
-
// Zero dependencies on next/server — standard Fetch API only
|
|
194
|
-
const context = createProxyContext()
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
---
|
|
198
159
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
```typescript
|
|
204
|
-
import { proxyChain } from "@klnap/next-proxy-chain"
|
|
205
|
-
import { NextResponse } from "next/server"
|
|
206
|
-
|
|
207
|
-
export default proxyChain(
|
|
208
|
-
{
|
|
209
|
-
timeoutMs: 3000, // Global timeout budget per rule in milliseconds
|
|
210
|
-
onError: (error, ruleIndex, req, ctx) => {
|
|
211
|
-
console.error(`Rule at index ${ruleIndex} failed:`, error)
|
|
212
|
-
return new NextResponse("Service Temporarily Unavailable", { status: 503 })
|
|
213
|
-
},
|
|
214
|
-
},
|
|
215
|
-
[
|
|
216
|
-
// rules...
|
|
217
|
-
]
|
|
218
|
-
)
|
|
160
|
+
export default proxyChain({ debug: process.env.NODE_ENV !== "production" }, [
|
|
161
|
+
multiTenantProxy,
|
|
162
|
+
securityHeadersProxy,
|
|
163
|
+
])
|
|
219
164
|
```
|
|
220
165
|
|
|
221
166
|
---
|
|
222
167
|
|
|
223
168
|
## API Reference
|
|
224
169
|
|
|
225
|
-
###
|
|
226
|
-
|
|
227
|
-
| Function | Description |
|
|
170
|
+
### Core Functions
|
|
171
|
+
|
|
172
|
+
| Function | Parameters | Return Type | Description |
|
|
173
|
+
| :--- | :--- | :--- | :--- |
|
|
174
|
+
| `proxyChain(rules)` | `Entry<TContext>[]` | `(req: NextRequest, event: NextFetchEvent) => Promise<NextResponse \| Response>` | Creates an execution pipeline from an array of rules. |
|
|
175
|
+
| `proxyChain(options, rules)` | `ProxyChainOptions<TContext>, Entry<TContext>[]` | `(req: NextRequest, event: NextFetchEvent) => Promise<NextResponse \| Response>` | Creates an execution pipeline with custom configuration. |
|
|
176
|
+
| `defineProxy(name?, fn)` | `string?, ProxyFn<TContext>` | `DefinedProxy<TContext>` | Creates a reusable rule with path and host scoping capabilities. |
|
|
177
|
+
| `next(options?)` | `NextOptions?` | `NextResult` | Generates a pipeline continuation token with optional header mutations. |
|
|
178
|
+
| `path(pattern, options?)` | `string \| RegExp, PathOptions?` | `RegExp` | Compiles a glob pattern, parameterized route, or regex into a stateless path matcher. |
|
|
179
|
+
| `host(matcher)` | `HostMatcher` | `RegExp` | Compiles a single or list of hostnames/patterns into an anchored host matcher. |
|
|
180
|
+
| `createProxyContext()` | `None` | `ProxyContext` | Instantiates a new Map instance for isolated request context storage. |
|
|
181
|
+
|
|
182
|
+
### Configuration Options (`ProxyChainOptions`)
|
|
183
|
+
|
|
184
|
+
| Option | Type | Default | Description |
|
|
185
|
+
| :--- | :--- | :--- | :--- |
|
|
186
|
+
| `createContext` | `(req: NextRequest, event: NextFetchEvent) => TContext` | `() => new Map()` | Custom factory function for request context instantiation. |
|
|
187
|
+
| `cookieMergeStrategy` | `MergeStrategy` | `"last-write-wins"` | Strategy for resolving conflicting `Set-Cookie` directives across rules. |
|
|
188
|
+
| `headerMergeStrategy` | `MergeStrategy` | `"last-write-wins"` | Strategy for resolving conflicting response headers. |
|
|
189
|
+
| `debug` | `boolean` | `false` | Enables structured single-line execution logs for each rule. |
|
|
190
|
+
| `ruleTimeoutMs` | `number` | `0` | Wall-clock execution budget per rule in milliseconds (`0` disables timeout). |
|
|
191
|
+
| `requestHeaderPolicy` | `RequestHeaderPolicy` | `{}` | Custom policy defining blocked and allowed request header mutations. |
|
|
192
|
+
| `strictOverrideCheck` | `boolean` | `false` | Throws an exception at initialization if platform request overrides are unsupported. |
|
|
193
|
+
| `onError` | `(err, index, req, ctx) => Response \| undefined` | `undefined` | Hook invoked when a rule throws an exception to allow custom error recovery. |
|
|
194
|
+
|
|
195
|
+
### Rule Scoping Options (`ProxyPathFilter`)
|
|
196
|
+
|
|
197
|
+
| Property | Type | Description |
|
|
198
|
+
| :--- | :--- | :--- |
|
|
199
|
+
| `include` | `RouteScopeList` | Routes or hosts where the rule must execute. |
|
|
200
|
+
| `exclude` | `RouteScopeList` | Routes or hosts where the rule must be skipped (takes precedence over `include`). |
|
|
201
|
+
| `name` | `string` | Human-readable identifier used for logs and diagnostic metrics. |
|
|
202
|
+
| `pathOptions` | `PathOptions` | Route compilation options, such as locale prefixes (`locales`). |
|
|
203
|
+
|
|
204
|
+
### Return Value Protocol
|
|
205
|
+
|
|
206
|
+
A `ProxyFn` rule function must return one of the following:
|
|
207
|
+
|
|
208
|
+
| Return Value | Pipeline Behavior |
|
|
228
209
|
| :--- | :--- |
|
|
229
|
-
| `
|
|
230
|
-
| `
|
|
231
|
-
| `
|
|
232
|
-
| `
|
|
233
|
-
| `
|
|
234
|
-
| `
|
|
235
|
-
| `createProxyContext()` | Creates a clean, isolated `Map<string, unknown>` context. |
|
|
236
|
-
| `decodeOverriddenRequestHeaders(headers)` | Extracts internal Next.js middleware request header overrides. |
|
|
237
|
-
|
|
238
|
-
### Exported Types
|
|
239
|
-
|
|
240
|
-
- `ProxyFn<TContext>`: Contract for individual proxy execution functions.
|
|
241
|
-
- `ProxyRule<TContext>`: Composed rule containing execution handler and compiled scope filters.
|
|
242
|
-
- `ProxyContext`: Isolated `Map<string, unknown>` instance passed to each rule.
|
|
243
|
-
- `NextResult`: Continuation action containing mutation payloads.
|
|
244
|
-
- `ProxyPathFilter`: Declarative configuration containing `include` and `exclude` clauses.
|
|
210
|
+
| `next({ request?, headers? })` | Continues execution, staging request header mutations and response headers. |
|
|
211
|
+
| `undefined` or `void` | Continues execution without mutating headers or state. |
|
|
212
|
+
| `NextResponse.next()` | Continues execution while extracting any internal request header overrides. |
|
|
213
|
+
| `NextResponse.rewrite(url)` | Continues execution while setting the rewrite target for downstream rules. |
|
|
214
|
+
| `NextResponse.redirect(url)` | Halts pipeline execution immediately and returns the redirect response. |
|
|
215
|
+
| `NextResponse.json(...)` or `Response` | Halts pipeline execution immediately and returns the terminal response. |
|
|
245
216
|
|
|
246
217
|
---
|
|
247
218
|
|
|
248
219
|
## License
|
|
249
220
|
|
|
250
|
-
MIT
|
|
221
|
+
MIT
|