@klnap/next-proxy-chain 1.0.1 → 1.0.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 klnap
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,41 +1,40 @@
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.
3
+ Composable request pipeline for Next.js middleware and proxy.
4
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)
5
+ [![npm version](https://img.shields.io/npm/v/@klnap/next-proxy-chain)](https://www.npmjs.com/package/@klnap/next-proxy-chain)
6
+ [![npm downloads](https://img.shields.io/npm/dm/@klnap/next-proxy-chain)](https://www.npmjs.com/package/@klnap/next-proxy-chain)
7
+ [![license](https://img.shields.io/npm/l/@klnap/next-proxy-chain)](https://github.com/klnap/next-proxy-chain/blob/main/LICENSE)
9
8
 
10
9
  ---
11
10
 
12
- ## Why `@klnap/next-proxy-chain`?
11
+ ## Features
13
12
 
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.
15
-
16
- `@klnap/next-proxy-chain` solves this by providing:
17
-
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`).
13
+ - **Linear Composition**: Chain independent rules sequentially into a single Next.js middleware or proxy handler.
14
+ - **Next.js 15 & 16**: Supports both `middleware.ts` and `proxy.ts`.
15
+ - **Request-Scoped Context**: Isolated per-request context for sharing state between rules without global state.
16
+ - **Deterministic Header & Cookie Merging**: Propagate request header overrides downstream, deduplicate `Set-Cookie` headers, and merge response headers with configurable conflict strategies.
17
+ - **Route & Host Scoping**: Declarative path matching (globs, parameters, regex) and host filtering with `include` and `exclude` rules.
18
+ - **Safe Pattern Matching**: Normalizes stateful regular expressions to avoid `lastIndex`-related matching issues.
19
+ - **Explicit Flow Control**: Return `next({ request, headers })` to pass data downstream, or return standard `Response` / `NextResponse` (`redirect`, `rewrite`, `json`) to exit early.
20
+ - **Timeouts & Error Recovery**: Optional per-rule timeouts (`ruleTimeoutMs`) and error recovery handlers (`onError`).
21
+ - **Zero Runtime Dependencies**: Fully typed in TypeScript with no runtime dependencies.
23
22
 
24
23
  ---
25
24
 
26
25
  ## Installation
27
26
 
28
27
  ```bash
29
- # Using Bun
30
- bun add @klnap/next-proxy-chain
31
-
32
- # Using npm
33
28
  npm install @klnap/next-proxy-chain
29
+ ```
34
30
 
35
- # Using pnpm
36
- pnpm add @klnap/next-proxy-chain
31
+ Or with your preferred package manager:
37
32
 
38
- # Using Yarn
33
+ ```bash
34
+ pnpm add @klnap/next-proxy-chain
35
+ # or
36
+ bun add @klnap/next-proxy-chain
37
+ # or
39
38
  yarn add @klnap/next-proxy-chain
40
39
  ```
41
40
 
@@ -43,208 +42,164 @@ yarn add @klnap/next-proxy-chain
43
42
 
44
43
  ## Quick Start
45
44
 
46
- ### 1. Define Modular Proxy Rules
47
-
48
- Create self-contained proxy rules in your application:
49
-
50
- ```typescript
51
- // src/proxies/request-id.ts
52
- import { defineProxy, next } from "@klnap/next-proxy-chain"
53
-
54
- export const requestIdProxy = defineProxy("requestId", (req) => {
55
- const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID()
56
-
57
- return next({
58
- request: { "x-request-id": requestId },
59
- headers: { "x-request-id": requestId },
60
- })
61
- })
62
- ```
63
-
64
45
  ```typescript
65
- // src/proxies/auth.ts
66
- import { defineProxy, next } from "@klnap/next-proxy-chain"
46
+ // proxy.ts or middleware.ts
47
+ import { defineProxy, next, path, proxyChain } from "@klnap/next-proxy-chain"
67
48
  import { NextResponse } from "next/server"
68
49
 
69
- export const authProxy = defineProxy("auth", async (req, ctx) => {
70
- const token = req.cookies.get("session_token")?.value
71
-
50
+ const auth = defineProxy("auth", (req, ctx) => {
51
+ const token = req.cookies.get("session")?.value
72
52
  if (!token) {
73
- const loginUrl = new URL("/login", req.url)
74
- loginUrl.searchParams.set("from", req.nextUrl.pathname)
75
- return NextResponse.redirect(loginUrl)
53
+ return NextResponse.redirect(new URL("/login", req.url))
76
54
  }
77
-
78
- // Store user info in isolated per-request context
79
- ctx.set("userId", "user_abc123")
55
+ ctx.set("userId", "usr_123")
80
56
  return next()
81
57
  })
82
- ```
83
58
 
84
- ### 2. Assemble Pipeline in `src/proxy.ts` (Next.js 16)
59
+ const security = defineProxy("security", () => {
60
+ return next({
61
+ headers: { "x-frame-options": "DENY" },
62
+ })
63
+ })
85
64
 
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
-
95
- export default proxyChain({ debug: isDev }, [
96
- // 1. Injects request ID on all incoming traffic
97
- 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,
65
+ export default proxyChain({ debug: process.env.NODE_ENV !== "production" }, [
66
+ auth({ include: path("/dashboard/*") }),
67
+ security,
107
68
  ])
108
69
 
109
70
  export const config = {
110
- matcher: "/((?!api|_next|.*\\..*).*)",
71
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
111
72
  }
112
73
  ```
113
74
 
114
75
  ---
115
76
 
116
- ## Route & Host Scoping
77
+ ## Routing & Scoping
117
78
 
118
- Rules can be declaratively scoped by path, host, or combinations. Scoping evaluates exclusions first — if an exclusion matches, the rule is skipped immediately.
79
+ Scope rules to specific paths, hosts, or combinations using `defineProxy`:
119
80
 
120
81
  ```typescript
121
- import { defineProxy, path, host } from "@klnap/next-proxy-chain"
122
-
123
- // Scoped to specific path patterns on ANY host
124
- const adminRule = defineProxy("admin", handler, {
125
- include: path("/admin/*"),
126
- })
82
+ import { defineProxy, host, next, path } from "@klnap/next-proxy-chain"
127
83
 
128
- // Scoped to a specific domain or tenant
129
- const tenantRule = defineProxy("tenant", handler, {
130
- include: { host: "app.example.com" },
84
+ const apiAuth = defineProxy("apiAuth", async (req, ctx) => {
85
+ // Executed only for /api/* requests except /api/public/*
86
+ return next()
131
87
  })
132
88
 
133
- // Scoped to specific path on a specific host
134
- const checkoutRule = defineProxy("checkout", handler, {
135
- include: { host: "store.example.com", path: "/checkout/*" },
89
+ // Scoped rule configuration
90
+ const scopedRule = apiAuth({
91
+ include: path("/api/*"),
92
+ exclude: path("/api/public/*"),
136
93
  })
137
94
 
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
- ],
95
+ // Combined path and host filter
96
+ const adminRule = apiAuth({
97
+ include: {
98
+ host: host("admin.example.com"),
99
+ path: path("/settings/**"),
100
+ },
148
101
  })
149
102
  ```
150
103
 
104
+ ### Path Patterns
105
+
106
+ - Wildcards: `path("/api/*")` (single segment) or `path("/docs/**")` (multi-segment)
107
+ - Parameterized: `path("/users/:id")`
108
+ - Locales: `path("/dashboard", { locales: ["en", "pl", "de"] })`
109
+ - Regular expressions: `path(/^\/v[0-9]+\/.*/)`
110
+
151
111
  ---
152
112
 
153
- ## Deterministic Header & Cookie Merging
113
+ ## State & Header Mutation
154
114
 
155
- When multiple rules in the chain mutate headers or cookies, `@klnap/next-proxy-chain` merges them deterministically:
115
+ ### Request Context
156
116
 
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.
117
+ Rules share state via an isolated `Map<string, unknown>` created per request:
160
118
 
161
119
  ```typescript
162
- import { defineProxy, next } from "@klnap/next-proxy-chain"
120
+ const enrichUser = defineProxy("enrichUser", async (req, ctx) => {
121
+ ctx.set("user", { id: "usr_1", role: "admin" })
122
+ return next()
123
+ })
163
124
 
164
- export const headerEnricher = defineProxy("enricher", () => {
165
- return next({
166
- request: {
167
- "x-geo-region": "eu-central",
168
- },
169
- headers: {
170
- "x-content-type-options": "nosniff",
171
- "x-frame-options": "DENY",
172
- },
173
- })
125
+ const checkPermission = defineProxy("checkPermission", (req, ctx) => {
126
+ const user = ctx.get("user") as { id: string; role: string } | undefined
127
+ if (user?.role !== "admin") {
128
+ return new Response("Forbidden", { status: 403 })
129
+ }
130
+ return next()
174
131
  })
175
132
  ```
176
133
 
177
- ---
134
+ ### Request and Response Headers
178
135
 
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:
136
+ Use `next()` to inject request headers (visible to downstream rules and route handlers) and response headers:
182
137
 
183
138
  ```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()
139
+ return next({
140
+ // Downstream request headers (NextRequest & server components)
141
+ request: {
142
+ "x-user-id": "usr_123",
143
+ },
144
+ // Final outgoing response headers
145
+ headers: {
146
+ "cache-control": "no-store",
147
+ },
148
+ })
195
149
  ```
196
150
 
197
151
  ---
198
152
 
199
- ## Error Handling & Timeout Budgets
153
+ ## API Reference
200
154
 
201
- Prevent unresponsive downstream authentication services or external APIs from blocking Edge execution:
155
+ ### Core Functions
202
156
 
203
- ```typescript
204
- import { proxyChain } from "@klnap/next-proxy-chain"
205
- import { NextResponse } from "next/server"
157
+ | Function | Signature | Description |
158
+ | :--- | :--- | :--- |
159
+ | `proxyChain(rules)` | `(rules: Entry[]) => Handler` | Creates a middleware/proxy handler from a list of rules. |
160
+ | `proxyChain(options, rules)` | `(options: ProxyChainOptions, rules: Entry[]) => Handler` | Creates a handler with custom pipeline options. |
161
+ | `defineProxy(name?, fn)` | `(name?: string, fn: ProxyFn) => DefinedProxy` | Defines a reusable proxy rule with scoping capabilities. |
162
+ | `next(options?)` | `(options?: NextOptions) => NextResult` | Returns a continuation token with optional header mutations. |
163
+ | `path(pattern, options?)` | `(pattern: string \| RegExp, options?: PathOptions) => RegExp` | Compiles a path pattern into a stateless `RegExp`. |
164
+ | `host(matcher)` | `(matcher: HostMatcher) => RegExp` | Compiles hostname(s) into an anchored `RegExp`. |
165
+ | `createProxyContext()` | `() => ProxyContext` | Creates a new `Map<string, unknown>` context instance. |
206
166
 
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
- )
219
- ```
167
+ ### Configuration Options (`ProxyChainOptions`)
220
168
 
221
- ---
169
+ | Option | Type | Default | Description |
170
+ | :--- | :--- | :--- | :--- |
171
+ | `createContext` | `(req, event) => TContext` | `() => new Map()` | Factory for custom per-request context objects. |
172
+ | `headerMergeStrategy` | `"last-write-wins" \| "first-write-wins" \| Function` | `"last-write-wins"` | Strategy for resolving conflicting response headers. |
173
+ | `cookieMergeStrategy` | `"last-write-wins" \| "first-write-wins" \| Function` | `"last-write-wins"` | Strategy for resolving duplicate `Set-Cookie` directives. |
174
+ | `ruleTimeoutMs` | `number` | `0` | Wall-clock execution timeout per rule in milliseconds (`0` disables timeout). |
175
+ | `onError` | `(err, index, req, ctx) => Response \| void` | `undefined` | Custom error recovery hook when a rule throws. |
176
+ | `requestHeaderPolicy` | `{ blocked?: string[], allowed?: string[] }` | `{}` | Custom blocklist/allowlist for request header mutations. |
177
+ | `debug` | `boolean` | `false` | Enables structured single-line execution logs per rule. |
178
+ | `logger` | `ChainLogger` | `console` | Custom logger instance (`debug`, `info`, `warn`, `error`). |
222
179
 
223
- ## API Reference
180
+ ### Return Value Protocol
224
181
 
225
- ### Exported Functions (`@klnap/next-proxy-chain`)
182
+ A rule (`ProxyFn`) can return:
226
183
 
227
- | Function | Description |
184
+ | Return Value | Behavior |
228
185
  | :--- | :--- |
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.
186
+ | `next({ request?, headers? })` | Continues to next rule, queuing header mutations. |
187
+ | `undefined` / `void` | Continues to next rule without mutating headers. |
188
+ | `NextResponse.next()` | Continues execution while extracting any internal request header overrides. |
189
+ | `NextResponse.rewrite(url)` | Stages a URL rewrite for downstream rules while continuing pipeline execution. |
190
+ | `NextResponse.redirect(url)` | Stops pipeline immediately and returns redirect response. |
191
+ | `Response` / `NextResponse.json()` | Stops pipeline immediately and returns terminal response. |
192
+
193
+ ---
194
+
195
+ ## Links
196
+
197
+ - [GitHub](https://github.com/klnap/next-proxy-chain)
198
+ - [npm](https://www.npmjs.com/package/@klnap/next-proxy-chain?activeTab=code)
245
199
 
246
200
  ---
247
201
 
248
202
  ## License
249
203
 
250
- MIT © [klnap](https://github.com/klnap)
204
+ [MIT](./LICENSE)
205
+