@klnap/next-proxy-chain 1.0.3 → 1.0.4

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/CHANGELOG.md ADDED
@@ -0,0 +1,55 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@klnap/next-proxy-chain` will be documented in this file.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [1.0.4] - 2026-08-27
10
+
11
+ ### Breaking
12
+
13
+ - `onError` returning `undefined` / `void` / `null` now **fails closed** with a 500. Continue only by returning `next()`.
14
+ - `x-internal-host-folder` is no longer a magic routing header. Enable it with `hostFolderHeader: "x-internal-host-folder"`.
15
+ - Public exports are limited to the documented API. Internals (`mergeWrites`, `compileScopes`, `withRuleTimeout`, `./core`, …) are no longer exported.
16
+ - Custom `TContext` requires `createContext`. `proxyChain<{ user: string }>(rules)` without a factory is a type error.
17
+ - Removed `PathOptions` and `pathOptions`; `path()` no longer accepts a `locales` option. Match locale-prefixed routes in the pattern string or RegExp.
18
+ - `ruleTimeoutMs`: passing `NaN`, `Infinity`, or `-Infinity` now throws a `RangeError` at proxy-chain configuration time instead of silently disabling the timeout. If you were relying on an unvalidated computed value (e.g. from `parseInt` without a fallback) resulting in a disabled timeout, this is a breaking change — validate your config value before passing it.
19
+
20
+ ### Fixed
21
+
22
+ - `path()` no longer drops `^` / `$` from user RegExp.
23
+ - Trailing `*` matches exactly one segment; `**` matches nested paths.
24
+ - `compileScopes()` runs RegExp matchers through `path()`.
25
+ - `next({ headers: { "set-cookie": … } })` reaches the final response.
26
+ - Cookies with the same name and different `Path` / `Domain` are not collapsed.
27
+ - Custom header merge returning `null` deletes the header from the terminal response.
28
+ - Rewrite continuation no longer mutates the incoming `NextRequest`.
29
+ - Timed-out rules no longer leave an unhandled rejection.
30
+ - `host("*.example.com")` matches one DNS label.
31
+ - `strictOverrideCheck` cache no longer pins the first call’s strictness for later calls.
32
+
33
+ ### Changed
34
+
35
+ - `headerMergeStrategy` applies only to **response** headers. Request overrides stay last-write-wins.
36
+ - Package metadata: `repository`, `bugs`, `homepage`.
37
+ - Tests run on `bun:test`; removed Typedoc site and monorepo tooling from the package repo.
38
+
39
+ ## [1.0.3] - 2026-08-26
40
+
41
+ npm patch. No detailed notes were kept for 1.0.1–1.0.3 at publish time.
42
+
43
+ ## [1.0.0] - 2026-08-23
44
+
45
+ ### Added
46
+
47
+ - Composable Edge-safe proxy pipeline via `proxyChain()`.
48
+ - Transparent Next.js pass-through detection on `x-middleware-next: 1` with request override extraction.
49
+ - Automatic filtering of internal `x-middleware-*` transport headers from client responses.
50
+ - Request header blocklist preventing rule injection of sensitive headers (`host`, `authorization`, `x-middleware-*`).
51
+ - Context threading via isolated per-request `Map` instances (`TContext`).
52
+ - Header and cookie accumulation with `last-write-wins`, `first-write-wins`, and custom merge strategies.
53
+ - Path and host scoping (`include`, `exclude`) with construction-time overlap validation.
54
+ - Cached startup self-test for `NextResponse.next()` request overrides.
55
+ - Error isolation with safe 500 fallback.
package/README.md CHANGED
@@ -16,9 +16,9 @@ Composable request pipeline for Next.js middleware and proxy.
16
16
  - **Deterministic Header & Cookie Merging**: Propagate request header overrides downstream, deduplicate `Set-Cookie` headers, and merge response headers with configurable conflict strategies.
17
17
  - **Route & Host Scoping**: Declarative path matching (globs, parameters, regex) and host filtering with `include` and `exclude` rules.
18
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.
19
+ - **Explicit Flow Control**: Return `next({ request, headers })` to continue, or a terminal `Response` / `NextResponse.redirect` / `NextResponse.json` to stop. `NextResponse.rewrite` **continues** the pipeline (rewrite is applied at the end unless a later rule stops).
20
+ - **Timeouts & Error Recovery**: Optional per-rule timeouts (`ruleTimeoutMs`). `onError` is fail-closed: returning nothing yields 500; return `next()` to continue; return a `Response` to stop.
21
+ - **Zero Runtime Dependencies**: Fully typed in TypeScript with no runtime dependencies (`next` is a peer).
22
22
 
23
23
  ---
24
24
 
@@ -103,10 +103,16 @@ const adminRule = apiAuth({
103
103
 
104
104
  ### Path Patterns
105
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]+\/.*/)`
106
+ - `path("/api/*")` exactly one segment (`/api/users` yes, `/api/users/123` no)
107
+ - `path("/docs/**")` — the prefix plus zero or more extra segments (`/docs`, `/docs/a/b`)
108
+ - `path("/users/:id")` one segment (no capture groups; matching only)
109
+ - `path(/^\/foo$/)` — user anchors are kept (`/foo` yes, `/x/foo` no)
110
+
111
+ ### Host Patterns
112
+
113
+ - `host("admin.example.com")` — exact hostname (case-insensitive, port stripped at match time)
114
+ - `host("*.example.com")` — one DNS label (`foo.example.com` yes, `example.com` no, `a.b.example.com` no)
115
+ - `host(/shop\.localhost$/)` — raw RegExp
110
116
 
111
117
  ---
112
118
 
@@ -114,40 +120,51 @@ const adminRule = apiAuth({
114
120
 
115
121
  ### Request Context
116
122
 
117
- Rules share state via an isolated `Map<string, unknown>` created per request:
123
+ Default context is an isolated `Map<string, unknown>` per request. For a typed object, pass `createContext` — TypeScript then follows the factory, not a fake generic:
118
124
 
119
125
  ```typescript
120
- const enrichUser = defineProxy("enrichUser", async (req, ctx) => {
121
- ctx.set("user", { id: "usr_1", role: "admin" })
126
+ type Ctx = { user?: { id: string; role: string } }
127
+
128
+ const enrichUser = defineProxy<Ctx>("enrichUser", async (req, ctx) => {
129
+ ctx.user = { id: "usr_1", role: "admin" }
122
130
  return next()
123
131
  })
124
132
 
125
- const checkPermission = defineProxy("checkPermission", (req, ctx) => {
126
- const user = ctx.get("user") as { id: string; role: string } | undefined
127
- if (user?.role !== "admin") {
133
+ const checkPermission = defineProxy<Ctx>("checkPermission", (req, ctx) => {
134
+ if (ctx.user?.role !== "admin") {
128
135
  return new Response("Forbidden", { status: 403 })
129
136
  }
130
137
  return next()
131
138
  })
139
+
140
+ export default proxyChain({ createContext: (): Ctx => ({}) }, [enrichUser, checkPermission])
132
141
  ```
133
142
 
143
+ Without `createContext`, `ctx` is `ProxyContext` (`Map`). Do not write `proxyChain<{ user: string }>(rules)` — that is a type error.
144
+
134
145
  ### Request and Response Headers
135
146
 
136
- Use `next()` to inject request headers (visible to downstream rules and route handlers) and response headers:
147
+ Use `next()` to inject **request** headers (visible to downstream rules and route handlers) and **response** headers (including `Set-Cookie`):
137
148
 
138
149
  ```typescript
139
150
  return next({
140
- // Downstream request headers (NextRequest & server components)
151
+ // Downstream request headers (NextRequest & server components). Always last-write-wins.
141
152
  request: {
142
153
  "x-user-id": "usr_123",
143
154
  },
144
- // Final outgoing response headers
155
+ // Final outgoing response headers / Set-Cookie
145
156
  headers: {
146
157
  "cache-control": "no-store",
158
+ "set-cookie": "session=abc; Path=/",
147
159
  },
148
160
  })
149
161
  ```
150
162
 
163
+ - `headerMergeStrategy` / `cookieMergeStrategy` apply to **response** headers and cookies only.
164
+ - Request overrides ignore those strategies (last write wins).
165
+ - Cookies are keyed by **name + domain + path**, so `session; Path=/` and `session; Path=/admin` both survive.
166
+ - Custom merge `return null` removes that response header from the final `Response`.
167
+
151
168
  ---
152
169
 
153
170
  ## API Reference
@@ -160,22 +177,40 @@ return next({
160
177
  | `proxyChain(options, rules)` | `(options: ProxyChainOptions, rules: Entry[]) => Handler` | Creates a handler with custom pipeline options. |
161
178
  | `defineProxy(name?, fn)` | `(name?: string, fn: ProxyFn) => DefinedProxy` | Defines a reusable proxy rule with scoping capabilities. |
162
179
  | `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`. |
180
+ | `path(pattern)` | `(pattern: string \| RegExp) => RegExp` | Compiles a path pattern into a stateless `RegExp`. |
181
+ | `host(matcher)` | `(matcher: HostMatcher) => RegExp` | Compiles hostname(s) into an anchored `RegExp` (`*` = one DNS label). |
165
182
  | `createProxyContext()` | `() => ProxyContext` | Creates a new `Map<string, unknown>` context instance. |
183
+ | `isNextResult(value)` | `(value: unknown) => boolean` | Type guard for `next()` continuation tokens. |
184
+ | `RuleTimeoutError` | `Error` | Thrown internally when `ruleTimeoutMs` is exceeded (catchable in `onError`). |
166
185
 
167
186
  ### Configuration Options (`ProxyChainOptions`)
168
187
 
169
188
  | Option | Type | Default | Description |
170
189
  | :--- | :--- | :--- | :--- |
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`). |
190
+ | `createContext` | `(req, event) => TContext` | `() => new Map()` | Factory for custom per-request context. Required for a non-`Map` `TContext`. |
191
+ | `headerMergeStrategy` | `"last-write-wins" \| "first-write-wins" \| Function` | `"last-write-wins"` | Conflict strategy for **response** headers only. |
192
+ | `cookieMergeStrategy` | `"last-write-wins" \| "first-write-wins" \| Function` | `"last-write-wins"` | Conflict strategy for `Set-Cookie` (same name+domain+path). |
193
+ | `ruleTimeoutMs` | `number` | `0` | Wall-clock timeout per rule in ms (`0` disables). Does not abort the rule function; leftover rejections are swallowed. |
194
+ | `onError` | `(err, index, req, ctx) => Response \| NextResult \| void` | `undefined` | Recovery hook. See **Errors** below. |
195
+ | `requestHeaderPolicy` | `{ blocked?: string[], allowed?: string[] }` | `{}` | Blocklist/allowlist for **request** header mutations. |
196
+ | `debug` | `boolean` | `false` | Structured one-line logs per rule. |
197
+ | `logger` | `ChainLogger` | `console` | Custom logger (`debug`, `info`, `warn`, `error`). |
198
+ | `strictOverrideCheck` | `boolean` | `false` | Throw at init if `NextResponse.next()` request overrides look unsupported. |
199
+ | `hostFolderHeader` | `string` | unset | Opt-in: request header name that prefixes rewrites with a physical folder (e.g. `"x-internal-host-folder"`). Off by default. |
200
+
201
+ ### Errors (`onError`)
202
+
203
+ | `onError` returns | Behavior |
204
+ | :--- | :--- |
205
+ | `Response` | Stop and return that response (headers/cookies still merged). |
206
+ | `next()` | Continue to the next rule (rule-level errors only). |
207
+ | `undefined` / `void` / `null` | **Fail closed** — `500 Internal Server Error`. |
208
+ | throws | `500 Internal Server Error`. |
209
+ | omitted | Rule throw → `500`. |
210
+
211
+ `ruleIndex` is `0`-based. `-1` means an engine-level error after context creation; only a `Response` is honored (the pipeline cannot continue).
212
+
213
+ A logging-only `onError: (err) => { console.error(err) }` does **not** skip the failure.
179
214
 
180
215
  ### Return Value Protocol
181
216
 
@@ -183,12 +218,12 @@ A rule (`ProxyFn`) can return:
183
218
 
184
219
  | Return Value | Behavior |
185
220
  | :--- | :--- |
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. |
221
+ | `next({ request?, headers? })` | Continues. Queues request overrides, response headers, and `Set-Cookie`. |
222
+ | `undefined` / `void` | Continues without mutations. |
223
+ | `NextResponse.next()` | Continues; extracts Next.js request override headers and cookies. |
224
+ | `NextResponse.rewrite(url)` | **Continues**. Stages a rewrite for later rules; the incoming `NextRequest` is not mutated. The rewrite is applied when the chain finishes unless a later rule returns a terminal response. |
225
+ | `NextResponse.redirect(url)` | Stops immediately with the redirect. |
226
+ | `Response` / `NextResponse.json()` | Stops immediately with that response. |
192
227
 
193
228
  ---
194
229