@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 CHANGED
@@ -1,250 +1,221 @@
1
1
  # @klnap/next-proxy-chain
2
2
 
3
- > Composable Next.js 16+ Edge proxy and middleware rule chain with deterministic header/cookie accumulation, isolated per-request context, and a pure Web API core.
4
-
5
- [![npm version](https://img.shields.io/npm/v/@klnap/next-proxy-chain.svg)](https://www.npmjs.com/package/@klnap/next-proxy-chain)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6.svg)](https://www.typescriptlang.org/)
8
- [![Runtime: Edge / Node / Bun / Deno](https://img.shields.io/badge/Runtime-Edge%20%7C%20Node%20%7C%20Bun%20%7C%20Deno-black.svg)](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
- ## Why `@klnap/next-proxy-chain`?
7
+ ## Overview & Motivation
13
8
 
14
- Next.js 16 introduces the `proxy.ts` file convention to replace monolithic middleware files. In non-trivial production applications, middleware pipelines easily devolve into deeply nested wrappers, conflicting `Set-Cookie` headers, race conditions from stateful regular expressions, and leaked transport headers.
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
- `@klnap/next-proxy-chain` solves this by providing:
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
- 1. **Linear Pipeline Execution**: Run multiple independent proxy rules sequentially without nested wrapper callbacks.
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
- ```bash
29
- # Using Bun
30
- bun add @klnap/next-proxy-chain
23
+ Install the package via your preferred package manager:
31
24
 
32
- # Using npm
25
+ ```bash
26
+ # npm
33
27
  npm install @klnap/next-proxy-chain
34
28
 
35
- # Using pnpm
29
+ # bun
30
+ bun add @klnap/next-proxy-chain
31
+
32
+ # pnpm
36
33
  pnpm add @klnap/next-proxy-chain
37
34
 
38
- # Using Yarn
35
+ # yarn
39
36
  yarn add @klnap/next-proxy-chain
40
37
  ```
41
38
 
42
39
  ---
43
40
 
44
- ## Quick Start
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
- ### 1. Define Modular Proxy Rules
72
+ ### Key Architectural Properties
47
73
 
48
- Create self-contained proxy rules in your application:
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
- ```typescript
51
- // src/proxies/request-id.ts
52
- import { defineProxy, next } from "@klnap/next-proxy-chain"
80
+ ---
53
81
 
54
- export const requestIdProxy = defineProxy("requestId", (req) => {
55
- const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID()
82
+ ## Integration Guide
56
83
 
57
- return next({
58
- request: { "x-request-id": requestId },
59
- headers: { "x-request-id": requestId },
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
- // src/proxies/auth.ts
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
- export const authProxy = defineProxy("auth", async (req, ctx) => {
70
- const token = req.cookies.get("session_token")?.value
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
- const loginUrl = new URL("/login", req.url)
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
- // Store user info in isolated per-request context
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 default proxyChain({ debug: isDev }, [
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: "/((?!api|_next|.*\\..*).*)",
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
- Rules can be declaratively scoped by path, host, or combinations. Scoping evaluates exclusions first if an exclusion matches, the rule is skipped immediately.
124
+ In Next.js 16, export the proxy chain as default or named `proxy` export:
119
125
 
120
126
  ```typescript
121
- import { defineProxy, path, host } from "@klnap/next-proxy-chain"
127
+ // proxy.ts
128
+ import { defineProxy, host, next, path, proxyChain } from "@klnap/next-proxy-chain"
129
+ import { NextResponse } from "next/server"
122
130
 
123
- // Scoped to specific path patterns on ANY host
124
- const adminRule = defineProxy("admin", handler, {
125
- include: path("/admin/*"),
126
- })
131
+ const multiTenantProxy = defineProxy("multiTenant", (req, ctx) => {
132
+ const hostname = req.headers.get("host") ?? ""
127
133
 
128
- // Scoped to a specific domain or tenant
129
- const tenantRule = defineProxy("tenant", handler, {
130
- include: { host: "app.example.com" },
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
- // Scoped to specific path on a specific host
134
- const checkoutRule = defineProxy("checkout", handler, {
135
- include: { host: "store.example.com", path: "/checkout/*" },
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
- // Array of clauses (OR evaluation) with strict exclusions
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
- ## Deterministic Header & Cookie Merging
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
- ## Error Handling & Timeout Budgets
200
-
201
- Prevent unresponsive downstream authentication services or external APIs from blocking Edge execution:
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
- ### Exported Functions (`@klnap/next-proxy-chain`)
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
- | `proxyChain(options?, rules)` | Assembles rules into a Next.js proxy/middleware handler. |
230
- | `defineProxy(name, fn, filter?)` | Factory creating a reusable, scope-aware proxy rule function. |
231
- | `withPaths(fn, filter)` | Wraps a raw proxy function with declarative path/host matching rules. |
232
- | `next(options?)` | Returns a continuation action with optional `{ request?, headers? }` mutations. |
233
- | `path(pattern)` | Helper compiling glob or RegExp string into a path matcher. |
234
- | `host(matcher)` | Helper compiling domain or RegExp matcher into a host matcher. |
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 © [klnap](https://github.com/klnap)
221
+ MIT