diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..fa5367e
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,64 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ - 'chatgpt/**'
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ quality:
+ name: Node ${{ matrix.node }} / Next ${{ matrix.next }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ node: [20, 22]
+ next: [15, 16]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ cache: npm
+ - run: npm install
+ - run: npm install --no-save next@${{ matrix.next }}
+ - run: npm run test:matrix
+ - name: Upload synchronized lockfile
+ if: matrix.node == 22 && matrix.next == 16
+ uses: actions/upload-artifact@v4
+ with:
+ name: package-lock-0.1.7
+ path: package-lock.json
+
+ e2e:
+ name: E2E Node ${{ matrix.node }} / Next ${{ matrix.next }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - node: 20
+ next: 15
+ - node: 20
+ next: 16
+ - node: 22
+ next: 15
+ - node: 22
+ next: 16
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ cache: npm
+ - run: npm install
+ - name: Run packed production Next.js E2E
+ run: npm run test:e2e
+ env:
+ NEXT_E2E_VERSION: ${{ matrix.next }}
+ CI: 'true'
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 8996e9d..73970aa 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -6,44 +6,51 @@ on:
- 'v*'
workflow_dispatch:
+permissions:
+ contents: read
+
jobs:
- publish:
+ verify:
+ name: Verify Node ${{ matrix.node }} / Next ${{ matrix.next }}
runs-on: ubuntu-latest
- environment: production
-
+ strategy:
+ fail-fast: false
+ matrix:
+ node: [20, 22]
+ next: [15, 16]
steps:
- - name: Checkout
- uses: actions/checkout@v4
+ - uses: actions/checkout@v4
with:
fetch-depth: 0
-
- - name: Check if tag is from main branch
+ - name: Ensure tag commit belongs to main
run: |
- BRANCH=$(git branch --contains $(git rev-parse HEAD) | grep main || true)
- if [ -z "$BRANCH" ]; then
- echo "❌ Tag must be pushed from main branch"
- echo "Current branches containing this commit:"
- git branch --contains $(git rev-parse HEAD)
- exit 1
- fi
- echo "✅ Tag is from main branch"
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
+ git fetch origin main
+ git branch -r --contains HEAD | grep -q 'origin/main'
+ - uses: actions/setup-node@v4
with:
- node-version: '20'
+ node-version: ${{ matrix.node }}
+ cache: npm
registry-url: 'https://registry.npmjs.org'
+ - run: npm install
+ - run: npm install --no-save next@${{ matrix.next }}
+ - run: npm run test:matrix
+ - name: Run packed production E2E
+ run: npm run test:e2e
+ env:
+ NEXT_E2E_VERSION: ${{ matrix.next }}
+ CI: 'true'
- - name: Install dependencies
- run: npm ci
-
- - name: Build
- run: npm run build
-
- - name: Type check
- run: npm run typecheck
-
- - name: Publish to npm
- run: npm publish
+ publish:
+ needs: verify
+ runs-on: ubuntu-latest
+ environment: production
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ registry-url: 'https://registry.npmjs.org'
+ - run: npm install
+ - run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.gitignore b/.gitignore
index a389556..3b10df1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,42 +1,21 @@
-# Dependencies
-node_modules
-# package-lock.json
-# yarn.lock
-# pnpm-lock.yaml
-
-# Build output
-dist
-build
+node_modules/
+dist/
+.test-dist/
+.e2e-pack/
+*.tgz
*.tsbuildinfo
-
-# Environment files
.env
.env.local
.env.*.local
-
-# IDE
-.vscode
-.idea
-*.swp
-*.swo
-*~
-
-# OS
+.vscode/
+.idea/
.DS_Store
Thumbs.db
-
-# Logs
-logs
+logs/
*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-
-# Testing
-coverage
-.nyc_output
-
-# Misc
-.cache
-.temp
-tmp
+coverage/
+playwright-report/
+test-results/
+tests/fixtures/next-app/.next/
+tests/fixtures/next-app/node_modules/
+tests/fixtures/next-app/package-lock.json
diff --git a/README.md b/README.md
index 7d58dec..91aad4f 100644
--- a/README.md
+++ b/README.md
@@ -1,38 +1,16 @@
# next-api-bridge
-> Use Next.js as a real server — not just a React wrapper.
+> Use Next.js as a real server—not just a React wrapper.
-**next-api-bridge** is a cookie-aware API gateway for Next.js App Router. It sits between your UI and your external backend, handling cookies, headers, auth, and session flow entirely on the server.
+`next-api-bridge` is a server-only API bridge for Next.js App Router applications that call an external backend. It forwards namespaced backend cookies, supports explicit bearer and API-key authentication, relays a safe request context, and returns Server Action-serializable responses.
----
+## Requirements
-## The problem
+- Node.js 20 or 22
+- Next.js 15 or 16 App Router
+- Server Actions, Route Handlers, or Server Components
-Most Next.js apps with an external backend fall into this pattern:
-
-```
-Client → useEffect → fetch → Backend API
-```
-
-This means tokens in `localStorage`, broken `HttpOnly` cookies, duplicated API logic, and a client component that knows too much about your auth.
-
-## The solution
-
-```
-Client (UI only)
- ↓
-Server Action (Next.js)
- ↓
-next-api-bridge
- ↓
-External Backend (NestJS · Laravel · Django · Express…)
-```
-
-
-
-Your client triggers actions. Your server owns the session. Your backend never sees the browser directly.
-
----
+The bridge is not a browser client. Keep it in server-only modules.
## Install
@@ -40,19 +18,13 @@ Your client triggers actions. Your server owns the session. Your backend never s
npm install next-api-bridge
```
-**Optional** — for toast helpers:
+Optional toast helpers use Sonner:
```bash
npm install sonner
```
-**Requires:** Next.js 13+ (App Router), Node.js server environment. Server Actions, Route Handlers, or Server Components only — not for browser fetch.
-
----
-
-## Quick start
-
-### 1. Create your API client
+## Basic use
```ts
// src/server/api.ts
@@ -63,249 +35,360 @@ export const api = createNextApiBridge({
});
```
-### 2. Call it from a Server Action
-
```ts
-// server/auth/action.ts
'use server';
-import { redirect } from 'next/navigation';
import { api } from '@/server/api';
-import { getCleanFormData, validateRedirectPath } from 'next-api-bridge/form';
-
-export async function signIn(_prev: unknown, data: FormData) {
- const body = getCleanFormData(data, { delete: ['redirectPath'] });
- const response = await api.post('/auth/login', body);
- if (response.success) redirect(validateRedirectPath(data.get('redirectPath') as string));
-
- return { formdata: body, ...response };
+export async function signIn(_previous: unknown, formData: FormData) {
+ return api.post('/auth/login', {
+ email: formData.get('email'),
+ password: formData.get('password'),
+ }, {
+ operationName: 'auth.login',
+ });
}
```
-### 3. Use it in your form
+The existing request methods remain available:
-```tsx
-// components/login-form.tsx
-'use client';
+```ts
+api.get('/users/me');
+api.post('/auth/login', body);
+api.patch('/users/me', body);
+api.put('/settings', body);
+api.delete('/sessions/current');
+```
-import { useActionState } from 'react';
-import { signIn } from '@/server/auth/action';
+## Response shape
-export default function LoginForm() {
- const [state, action, isPending] = useActionState(signIn, null);
+Responses are safe to return from a Server Action:
- return (
-
- );
+```ts
+interface ApiBridgeResponse {
+ success: boolean;
+ message: string;
+ body: T | null;
+ status: number;
+ statusText?: string;
+ headers?: Record;
+ errorCode?: string;
+ cookieSync?: {
+ attempted: boolean;
+ applied: boolean;
+ reason?:
+ | 'read-only-context'
+ | 'no-set-cookie'
+ | 'invalid-cookie'
+ | 'applied';
+ };
}
```
-No `useEffect`. No `useState` for auth. No token management on the client.
+`headers` is a plain record, never a `Headers` instance. Only these backend response headers are exposed:
----
+- `x-request-id`
+- `retry-after`
+- `x-ratelimit-limit`
+- `x-ratelimit-remaining`
+- `x-ratelimit-reset`
-## Configuration
+`Set-Cookie`, authorization, cookies, proxy authorization, and API-key headers are never exposed through response metadata.
+
+The parser supports JSON, `text/*`, and empty `204`/`205` responses. Backend JSON objects are not mutated; their `success` and `message` fields remain in `body`.
+
+Network failures use `status: 0` and one of these stable codes:
+
+- `NETWORK_ERROR`
+- `REQUEST_TIMEOUT`
+- `REQUEST_ABORTED`
+- `INVALID_RESPONSE`
+
+## Request options
```ts
-createNextApiBridge({
- baseUrl: string; // Required. Your backend URL.
- cookiePrefix?: string; // Default: 'nab_'. Namespaces backend cookies in Next.js.
- apiKey?: string; // Optional API key.
- apiKeyHeader?: string; // Header name for the API key.
- auth?: BearerAuthConfig; // Bearer token from a cookie.
- verbose?: string; // 'request,body,response' for debug logging.
+await api.get('/events', {
+ query: { page: 1, active: false, search: '' },
+ params: ['event/id'],
+ headers: { 'x-tenant-id': tenantId },
+ cache: 'force-cache',
+ next: { revalidate: 300, tags: ['events'] },
+ timeoutMs: 10_000,
+ signal,
+ operationName: 'events.list',
+ responseType: 'json',
});
```
-### Bearer token auth
+Undefined and null query values are omitted. `false`, `0`, and empty strings are preserved. Dates use ISO format, arrays use a consistent comma-separated representation, and keys/values/path parameters are encoded.
-Reads a token from a cookie and adds it as an `Authorization` header automatically:
+Custom request headers are allowlisted by policy. They cannot override package-managed cookie, authorization, host, content length, API-key, or request-context headers. Framework-internal and hop-by-hop headers are rejected.
-```ts
-export const api = createNextApiBridge({
- baseUrl: process.env.API_URL!,
- auth: {
- type: 'bearer',
- tokenCookie: 'accessToken', // reads nab_accessToken cookie
- header: 'Authorization',
- prefix: 'Bearer',
- },
-});
+## Safe request-context forwarding
+
+Request context is enabled by default. The bridge safely forwards:
+
+- `user-agent`
+- `accept-language`
+- `traceparent`
+
+It also sends:
+
+```text
+x-api-bridge: next-api-bridge/0.1.7
```
-### API key auth
+A request ID is preserved from `x-request-id` or generated when absent. `baggage` is supported but must be explicitly enabled.
```ts
-export const api = createNextApiBridge({
+const api = createNextApiBridge({
baseUrl: process.env.API_URL!,
- apiKey: process.env.API_KEY,
- apiKeyHeader: 'X-API-Key',
+ requestContext: {
+ forwardHeaders: [
+ 'user-agent',
+ 'accept-language',
+ 'traceparent',
+ 'baggage',
+ ],
+ requestId: {
+ incomingHeaders: ['x-request-id'],
+ outgoingHeader: 'x-request-id',
+ generateWhenMissing: true,
+ },
+ },
});
```
----
+The package never forwards every incoming header. Values containing CR/LF are rejected and lengths are bounded.
+
+## Trusted client IP forwarding
-## API methods
+Client IP forwarding is disabled by default. Forwarded IP headers are ignored until a trusted proxy mode is configured.
+
+### Vercel
```ts
-api.get('/users/me');
-api.post('/auth/login', body);
-api.patch('/users/me', body);
-api.put('/settings', body);
-api.delete('/sessions/current');
+requestContext: {
+ clientIp: {
+ enabled: true,
+ trustProxy: 'vercel',
+ },
+}
```
-All methods return:
+The bridge reads only Vercel forwarding headers and emits one validated IP as `x-client-ip`.
+
+### Cloudflare
```ts
-{
- success: boolean;
- message: string;
- body: T | null;
- headers?: Headers;
+requestContext: {
+ clientIp: {
+ enabled: true,
+ trustProxy: 'cloudflare',
+ },
}
```
-### Options
+Cloudflare mode prefers `cf-connecting-ip` and validates it as IPv4 or IPv6.
+
+### Self-hosted proxy or Caddy
+
+Configure only headers your own trusted proxy overwrites:
```ts
-// Query params
-api.get('/events', { query: { page: 1, search: 'conf' } });
+requestContext: {
+ clientIp: {
+ enabled: true,
+ trustProxy: {
+ headers: ['x-forwarded-for'],
+ trustedProxyHops: 1,
+ },
+ outgoingHeader: 'x-client-ip',
+ },
+}
+```
-// Optional query values are omitted instead of being sent as "undefined" or "null"
-api.get('/events', { query: { page: undefined, search: null, active: false } });
+The chain is parsed from the right using `trustedProxyHops`. Malformed chains are rejected, and the complete chain is never forwarded by default.
-// Path params
-api.get('/events', { params: ['event-id'] });
+## Safe client-origin forwarding
-// Cache control
-api.get('/static-data', { cache: 'force-cache' });
+Client-origin forwarding is disabled by default. Enabling it requires an allowlist:
-// File upload
-api.post('/upload', formData, { isMultipart: true });
+```ts
+requestContext: {
+ clientOrigin: {
+ enabled: true,
+ allowedHosts: ['app.example.com'],
+ allowedOrigins: ['https://admin.example.com'],
+ outgoingHeader: 'x-client-origin',
+ },
+}
```
----
+Only HTTP(S) origin-only values are accepted. Credentials, paths, query strings, fragments, malformed URLs, and unapproved hosts are rejected.
-## Cookie behavior
+The legacy `client_url` cookie is no longer trusted automatically. To use it during migration, configure the cookie explicitly and keep an allowlist:
-When your backend responds with `Set-Cookie: accessToken=abc123`, next-api-bridge captures it and stores it in Next.js as `nab_accessToken`. On the next request, it strips the prefix and forwards `Cookie: accessToken=abc123` to your backend transparently.
+```ts
+clientOrigin: {
+ enabled: true,
+ cookieName: 'client_url',
+ allowedHosts: ['app.example.com'],
+}
+```
-`HttpOnly` session cookies work out of the box — no workarounds needed.
+## Cookie synchronization and policy
-### Manual cookie management
+Backend cookies are stored in Next.js under `cookiePrefix`, which remains a top-level option:
```ts
-await api.setCookie('sessionid', 'abc123', { httpOnly: true, maxAge: 3600 });
-await api.getCookie('accessToken');
-await api.deleteCookies(['accessToken', 'refreshToken']); // or pass nothing to delete all
+const api = createNextApiBridge({
+ baseUrl: process.env.API_URL!,
+ cookiePrefix: 'nab_',
+});
```
----
+Safe cookie policy defaults:
+
+```ts
+cookiePolicy: {
+ domain: 'drop',
+ path: '/',
+ secure: 'auto',
+ preserveExpires: true,
+ removeLegacyUnprefixedCookies: false,
+}
+```
-## Form helpers
+This means backend domains are dropped, paths are rewritten to `/`, secure cookies are preserved or enabled for secure requests, expiration is retained, and unrelated unprefixed application cookies are never deleted.
-`getCleanFormData` replaces `Object.fromEntries()` with something smarter — it strips empty fields, Next.js internals, and can coerce types:
+Modern cookie options are supported:
```ts
-import { getCleanFormData } from 'next-api-bridge/form';
-
-const body = getCleanFormData(data, {
- delete: ['redirectPath'],
- jsonParse: ['deviceInfo'],
- boolean: ['isActive'],
- number: ['price', 'quantity'],
- date: ['startsAt'],
+await api.setCookie('session', value, {
+ httpOnly: true,
+ secure: true,
+ expires: new Date('2030-10-21T07:28:00Z'),
+ priority: 'high',
+ partitioned: true,
});
```
----
+### Cookie mutation limitation
-## Utility helpers
+Next.js only permits cookie writes in Server Actions and Route Handlers. A Server Component may call the bridge for data, but rotated backend cookies cannot be persisted there. The response reports:
```ts
-import {
- validateRedirectPath, // Returns '/' if path is invalid or external
- buildUrlWithParams, // Builds '/path?key=value' strings
- reloadPage, // Revalidates a page after mutation
-} from 'next-api-bridge/form';
+cookieSync: {
+ attempted: true,
+ applied: false,
+ reason: 'read-only-context',
+}
```
----
+Run login, refresh-token rotation, logout, and other session-mutating calls inside a Server Action or Route Handler.
-## Toast notifications (optional, requires Sonner)
+## Authentication
-```tsx
-// In your root layout
-import { Toaster } from 'sonner';
-
+### Bearer token from a cookie
-// In a client component
-import { showResponseToast, showResponseToastAndReload } from 'next-api-bridge/form';
+```ts
+const api = createNextApiBridge({
+ baseUrl: process.env.API_URL!,
+ auth: {
+ type: 'bearer',
+ tokenCookie: 'accessToken',
+ header: 'Authorization',
+ prefix: 'Bearer',
+ },
+});
+```
+
+### API key
-showResponseToast({ state });
-showResponseToastAndReload({ state, path: '/dashboard' });
+```ts
+const api = createNextApiBridge({
+ baseUrl: process.env.API_URL!,
+ apiKey: process.env.API_KEY,
+ apiKeyHeader: 'x-api-key',
+});
```
----
+`apiKey` and `apiKeyHeader` must be provided together. Secret values are never included in configuration errors or logs.
-## Server Component data fetching
+## Cache behavior
-Server Components can fetch directly — no loading state, no `useEffect`:
+`no-store` remains the default. `RequestOptions.cache` is passed to `fetch.cache`; it is not written into an HTTP `Cache-Control` request header.
```ts
-// app/layout.tsx
-export default async function RootLayout({ children }) {
- const user = await getUser();
- const { body } = await api.get('/memberships');
-
- return (
-
-
- {children}
-
-
- );
-}
+await api.get('/catalog', {
+ cache: 'force-cache',
+ next: {
+ revalidate: 300,
+ tags: ['catalog'],
+ },
+});
```
----
-
-## When to use next-api-bridge vs direct fetch
+Conflicting combinations are rejected, including `no-store` with a positive `revalidate`, and `force-cache` with `revalidate: 0`.
-| Route | Approach |
-|---|---|
-| Auth, forms, mutations | Use next-api-bridge via Server Actions |
-| Protected data reads | Use next-api-bridge in Server Components |
-| Fully public / static data | Direct `fetch()` is fine |
+A context-free public/static client is intentionally deferred to 0.2.
----
+## Logging
-## Exports
+The optional logger receives structured safe entries:
```ts
-// Core
-import { createNextApiBridge, NextApiBridgeClient } from 'next-api-bridge';
-import type { ApiBridgeOptions, ApiBridgeResponse, BearerAuthConfig } from 'next-api-bridge';
-
-// Helpers
-import {
- getCleanFormData,
- reloadPage,
- validateRedirectPath,
- buildUrlWithParams,
- showResponseToast,
- showResponseToastAndReload,
-} from 'next-api-bridge/form';
+const api = createNextApiBridge({
+ baseUrl: process.env.API_URL!,
+ logger: {
+ info(entry) {
+ console.info(entry);
+ },
+ error(entry) {
+ console.error(entry);
+ },
+ },
+});
```
----
+Entries contain only method, sanitized URL, status, duration, request ID, operation name, stable error code, and safe messages. Request or response bodies are not logged by default.
+
+The existing `verbose` option remains supported, but its output is redacted. Authorization, cookies, `Set-Cookie`, API keys, passwords, secrets, tokens, sessions, client secrets, and OTP values are removed.
+
+## Migration from 0.1.6
+
+1. `response.headers` changed from `Headers` to `Record`. Replace `response.headers?.get('x-request-id')` with `response.headers?.['x-request-id']`.
+2. Responses now include `status`, optional `statusText`, optional `errorCode`, and `cookieSync`.
+3. Backend JSON bodies retain their original `success` and `message` fields.
+4. Cache options now control Next.js `fetch` correctly.
+5. The `client_url` cookie is no longer forwarded unless explicitly configured and allowlisted.
+6. Backend cookie domains are dropped and paths are rewritten to `/` by default.
+7. Unprefixed application cookies are not deleted unless `removeLegacyUnprefixedCookies: true` is explicitly enabled.
+8. Client IP forwarding is disabled until a trusted proxy configuration is provided.
+9. Next.js 13 and 14 are no longer claimed as supported for this release; CI targets Next.js 15 and 16 on Node.js 20 and 22.
+
+## Validation and release gates
+
+```bash
+npm run typecheck
+npm run test:unit
+npm run test:integration
+npm run build
+npm run pack:verify
+npm run test:e2e
+```
+
+The E2E suite packs the package, installs the tarball into a real App Router fixture, runs a production `next build` and `next start`, starts a controllable backend, and executes Playwright tests. Publishing must not proceed until these gates pass.
+
+## References
+
+- https://nextjs.org/docs/app/api-reference/functions/cookies
+- https://nextjs.org/docs/app/api-reference/functions/headers
+- https://nextjs.org/docs/app/api-reference/functions/fetch
+- https://nextjs.org/docs/app/api-reference/directives/use-cache
+- https://datatracker.ietf.org/doc/rfc7239/
+- https://vercel.com/docs/headers/request-headers
## License
diff --git a/RELEASE_0.1.7_TASKS.md b/RELEASE_0.1.7_TASKS.md
new file mode 100644
index 0000000..98a3287
--- /dev/null
+++ b/RELEASE_0.1.7_TASKS.md
@@ -0,0 +1,7 @@
+# next-api-bridge 0.1.7 release hardening
+
+The release work preserves the existing bridge factory and request methods while adding safe serializable responses, trusted request-context forwarding, hardened cookie synchronization, redacted logging, cache correctness, and production Next.js test fixtures.
+
+## Release gate
+
+Do not publish 0.1.7 until typechecking, unit tests, integration tests, production Next.js E2E tests for Next 15 and 16, and package tarball verification all pass.
diff --git a/package-lock.json b/package-lock.json
index 544ba81..649362d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "next-api-bridge",
- "version": "0.1.6",
+ "version": "0.1.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "next-api-bridge",
- "version": "0.1.6",
+ "version": "0.1.7",
"license": "MIT",
"dependencies": {
"server-only": "^0.0.1"
@@ -17,8 +17,12 @@
"tsup": "^8.0.0",
"typescript": "^5.0.0"
},
+ "engines": {
+ "node": ">=20"
+ },
"peerDependencies": {
- "next": ">=13.0.0"
+ "next": ">=15.0.0 <17.0.0",
+ "sonner": ">=1.0.0"
},
"peerDependenciesMeta": {
"sonner": {
diff --git a/package.json b/package.json
index 5fa24e5..b0f307c 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "next-api-bridge",
- "version": "0.1.6",
- "description": "Cookie-aware API bridge for Next.js App Router with Server Actions. Handles auth, cookies, and session flow entirely on the server for external backends.",
+ "version": "0.1.7",
+ "description": "Cookie-aware API bridge for Next.js App Router with Server Actions. Handles auth, cookies, safe request context, and session flow entirely on the server for external backends.",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
@@ -16,9 +16,15 @@
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
- "test": "npm run build && node --test tests/*.test.mjs",
"typecheck": "tsc --noEmit",
- "prepublishOnly": "npm run typecheck && npm run build"
+ "build:test": "tsc -p tsconfig.test.json && node tests/write-test-package-type.cjs",
+ "test:unit": "npm run build:test && node --test tests/unit/*.test.cjs",
+ "test:integration": "npm run build:test && node --test tests/integration/*.test.cjs",
+ "test:e2e": "node tests/e2e/run.mjs",
+ "test": "npm run test:unit && npm run test:integration",
+ "test:matrix": "npm run typecheck && npm run test && npm run build && npm run pack:verify",
+ "pack:verify": "node tests/pack-verify.mjs",
+ "prepublishOnly": "npm run typecheck && npm run test:unit && npm run test:integration && npm run build && npm run pack:verify"
},
"keywords": [
"nextjs",
@@ -70,10 +76,16 @@
"types": "./dist/query.d.ts",
"import": "./dist/query.js",
"require": "./dist/query.cjs"
+ },
+ "./testing": {
+ "types": "./dist/testing.d.ts",
+ "import": "./dist/testing.js",
+ "require": "./dist/testing.cjs"
}
},
"peerDependencies": {
- "next": ">=13.0.0"
+ "next": ">=15.0.0 <17.0.0",
+ "sonner": ">=1.0.0"
},
"peerDependenciesMeta": {
"sonner": {
@@ -85,8 +97,11 @@
},
"devDependencies": {
"@types/node": "^20.0.0",
+ "next": "^15.0.0",
"tsup": "^8.0.0",
- "typescript": "^5.0.0",
- "next": "^15.0.0"
+ "typescript": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=20"
}
}
diff --git a/src/client.ts b/src/client.ts
index d5062bc..a74389b 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -1,328 +1,79 @@
import 'server-only';
-import { cookies, headers } from 'next/headers';
-import type { ApiBridgeOptions, RequestOptions, ApiBridgeResponse, PrepareRequestResult, CookieOptions } from './types';
-import { EXCLUDED_QUERY_PARAMS } from './config/constants';
-import { buildBackendCookieHeader } from './cookies/build-cookie-header';
-import { syncResponseCookies } from './cookies/sync-response-cookies';
-import { log, shouldLog } from './logger/logger';
-import { colors } from './logger/colors';
-import { serializeQuery } from './query';
+import { cookies, headers as nextHeaders } from 'next/headers';
+import type { ApiBridgeResponse, CookieOptions, RequestOptions } from './types';
+import type { NormalizedOptions } from './config/validate';
+import type { CookieStoreLike } from './cookies/sync-response-cookies';
+import { executeBridgeRequest } from './request/execute';
+import { isSafeCookiePrefix, validateHeaderValue } from './security/headers';
-/**
- * Normalized options with defaults applied.
- */
-interface NormalizedOptions extends Required> {
- auth?: ApiBridgeOptions['auth'];
- apiKey?: ApiBridgeOptions['apiKey'];
- apiKeyHeader?: ApiBridgeOptions['apiKeyHeader'];
- verbose?: ApiBridgeOptions['verbose'];
-}
-
-/**
- * Next.js API Bridge Client.
- * Provides cookie-aware HTTP requests to external backend APIs.
- */
export class NextApiBridgeClient {
constructor(private readonly options: NormalizedOptions) {}
- /**
- * Sends an HTTP request to the backend server.
- */
- private async request(method: string, path: string, body: any = {}, options: RequestOptions = {}): Promise> {
- const cookieStore = await cookies();
- const { query = {}, params = [], cache = 'no-store', isMultipart = false } = options;
-
- try {
- const { url, fetchOptions } = await this.prepareRequest(method, path, body, query, params, cache, isMultipart, cookieStore);
-
- if (shouldLog('request', this.options.verbose)) {
- console.log(colors.blue(`🚀 [REQUEST] ${method.toUpperCase()} ${url}`));
- console.log(colors.blue(`📋 [HEADERS] ${JSON.stringify(fetchOptions.headers, null, 2)}`));
- }
-
- if (shouldLog('body', this.options.verbose) && body && Object.keys(body).length > 0) {
- console.log(colors.magenta(`📦 [REQUEST BODY] ${JSON.stringify(body, null, 2)}`));
- }
-
- const response = await fetch(url, fetchOptions);
-
- await syncResponseCookies({
- response,
- cookieStore,
- cookiePrefix: this.options.cookiePrefix,
- verbose: this.options.verbose,
- });
-
- if (shouldLog('response', this.options.verbose)) {
- const headers: Record = {};
- response.headers.forEach((value, key) => {
- headers[key] = value;
- });
- console.log(colors.cyan(`📨 [RESPONSE HEADERS] ${JSON.stringify(headers, null, 2)}`));
-
- const setCookieHeaders = response.headers.getSetCookie?.() || [];
- if (setCookieHeaders.length > 0) {
- console.log(colors.yellow(`🍪 [RESPONSE COOKIES] Found ${setCookieHeaders.length} cookie(s) in response`));
- setCookieHeaders.forEach((cookieHeader, index) => {
- console.log(colors.yellow(` Cookie ${index + 1}: ${cookieHeader}`));
- });
- }
- }
-
- const result = await this.parseResponse(response, url, method);
-
- if (shouldLog('response', this.options.verbose) && result.body) {
- console.log(colors.green(`📨 [RESPONSE BODY] ${JSON.stringify(result.body, null, 2)}`));
- }
-
- if (process.env.NODE_ENV === 'development' && result.message) {
- console.log(colors.blue(`💬 [RESPONSE MESSAGE] ${result.message}`));
- }
-
- return result;
- } catch (error: any) {
- const errorMessage = error.message || 'Unknown error';
- log(`Error on ${method.toUpperCase()} ${path}: ${errorMessage}`, undefined, false);
- return { message: errorMessage, success: false, body: null };
- }
- }
-
- /**
- * Gets the client's full URL from headers.
- */
- private async getClientHost(cookieStore: Awaited>): Promise {
- const headersList = await headers();
-
- const clientUrlCookie = cookieStore.get('client_url')?.value;
- if (clientUrlCookie) {
- log(`🔍 [Client URL] Detected from middleware cookie: ${clientUrlCookie}`, undefined, true);
- return clientUrlCookie;
- }
-
- const host = headersList.get('host');
- if (!host) {
- throw new Error('No host header found in request');
- }
-
- const forwardedProto = headersList.get('x-forwarded-proto');
- let protocol = 'http';
- if (forwardedProto && ['http', 'https'].includes(forwardedProto)) {
- protocol = forwardedProto;
- } else {
- protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
- }
-
- const fullUrl = `${protocol}://${host}`;
- log(`🔍 [Client URL] Detected from headers: ${fullUrl}`, undefined, true);
- return fullUrl;
- }
-
- /**
- * Prepares the request by building URL and fetch options.
- */
- private async prepareRequest(
+ private async request(
method: string,
path: string,
- body: any,
- query: Record,
- params: string[],
- cache: 'no-store' | 'force-cache' | 'only-if-cached',
- isMultipart: boolean,
- cookieStore: Awaited>,
- ): Promise {
- let url = `${this.options.baseUrl}${path}`;
-
- if (params && params.length) {
- url += `/${params.join('/')}`;
- }
-
- if (query && Object.keys(query).length) {
- const queryString = serializeQuery(query, EXCLUDED_QUERY_PARAMS);
- if (queryString) {
- url += `?${queryString}`;
- }
- }
-
- const allCookies = cookieStore.getAll();
- const headers: Record = {
- 'Cache-Control': cache,
- };
-
- const clientUrl = await this.getClientHost(cookieStore);
- headers['x-client-url'] = clientUrl;
-
- const cookieHeader = buildBackendCookieHeader(allCookies, this.options.cookiePrefix);
- if (cookieHeader) {
- headers['Cookie'] = cookieHeader;
- }
-
- if (!isMultipart && method !== 'GET') {
- headers['Content-Type'] = 'application/json';
- }
-
- if (this.options.apiKey && this.options.apiKeyHeader) {
- headers[this.options.apiKeyHeader] = this.options.apiKey;
- }
-
- // Bearer token authentication
- if (this.options.auth?.type === 'bearer') {
- const token = await this.getCookie(this.options.auth.tokenCookie);
- if (token) {
- const header = this.options.auth.header ?? 'Authorization';
- const prefix = this.options.auth.prefix ?? 'Bearer';
- headers[header] = `${prefix} ${token}`;
- }
- }
-
- const fetchOptions: RequestInit = {
+ body: unknown = undefined,
+ options: RequestOptions = {},
+ ): Promise> {
+ const cookieStore = await cookies() as unknown as CookieStoreLike;
+ const incomingHeaders = await nextHeaders();
+ return executeBridgeRequest({
+ normalizedOptions: this.options,
method,
- cache: 'no-store',
- headers,
- body: method === 'GET' ? undefined : isMultipart ? body : JSON.stringify(body ?? {}),
- credentials: 'include',
- };
-
- return { url, fetchOptions };
+ path,
+ body,
+ requestOptions: options,
+ cookieStore,
+ incomingHeaders,
+ });
}
- /**
- * Parses the response and returns a standardized response object.
- */
- private async parseResponse(response: Response, url: string, method: string): Promise> {
- const responseStatus = response.status;
- const isSuccess = response.ok;
- log(`${method.toUpperCase()} ${url}`, responseStatus, isSuccess);
-
- let responseData = null;
- try {
- responseData = await response.json();
- } catch (error: any) {
- const errorMessage = error?.message || String(error) || 'Unknown error';
- log(`Error parsing JSON from ${url}: ${errorMessage}`);
- }
-
- const success = responseData?.success ?? response.ok;
- const message = responseData?.message || response.statusText;
-
- if (responseData?.success) {
- delete responseData.success;
- }
- if (responseData?.message) {
- delete responseData.message;
- }
-
- return {
- message: message,
- success: success,
- body: responseData ?? null,
- headers: response.headers,
- };
- }
-
- /**
- * Sends a GET request.
- */
async get(path: string, options?: RequestOptions): Promise> {
- return this.request('GET', path, {}, options);
+ return this.request('GET', path, undefined, options);
}
- /**
- * Sends a POST request.
- */
- async post(path: string, body?: any, options?: RequestOptions): Promise> {
- return this.request('POST', path, body ?? {}, options);
+ async post(path: string, body?: unknown, options?: RequestOptions): Promise> {
+ return this.request('POST', path, body, options);
}
- /**
- * Sends a PATCH request.
- */
- async patch(path: string, body?: any, options?: RequestOptions): Promise> {
- return this.request('PATCH', path, body ?? {}, options);
+ async patch(path: string, body?: unknown, options?: RequestOptions): Promise> {
+ return this.request('PATCH', path, body, options);
}
- /**
- * Sends a PUT request.
- */
- async put(path: string, body?: any, options?: RequestOptions): Promise> {
- return this.request('PUT', path, body ?? {}, options);
+ async put(path: string, body?: unknown, options?: RequestOptions): Promise> {
+ return this.request('PUT', path, body, options);
}
- /**
- * Sends a DELETE request.
- */
- async delete(path: string, body?: any, options?: RequestOptions): Promise> {
- return this.request('DELETE', path, body ?? {}, options);
+ async delete(path: string, body?: unknown, options?: RequestOptions): Promise> {
+ return this.request('DELETE', path, body, options);
}
- /**
- * Sets a cookie that will be sent to the backend.
- */
async setCookie(name: string, value: string, options?: CookieOptions): Promise {
+ if (!isSafeCookiePrefix(name)) throw new Error('next-api-bridge: cookie name is invalid');
+ validateHeaderValue(value, `cookie ${name}`);
const cookieStore = await cookies();
- const prefixedName = `${this.options.cookiePrefix}${name}`;
-
- try {
- cookieStore.set(prefixedName, value, {
- httpOnly: options?.httpOnly,
- secure: options?.secure,
- sameSite: options?.sameSite,
- maxAge: options?.maxAge,
- path: options?.path,
- domain: options?.domain,
- });
-
- cookieStore.delete(name);
- log(`Set cookie: ${prefixedName} (backend: ${name})`, undefined, true);
- } catch (error: any) {
- const errorMessage = error?.message || String(error);
- if (errorMessage.includes('Cookies can only be modified')) {
- return;
- }
- log(`Warning: Failed to set cookie: ${errorMessage}`, undefined, false);
- }
+ cookieStore.set(`${this.options.cookiePrefix}${name}`, value, options);
+ if (this.options.cookiePolicy.removeLegacyUnprefixedCookies) cookieStore.delete(name);
}
- /**
- * Gets a cookie value by name.
- */
async getCookie(name: string): Promise {
+ if (!isSafeCookiePrefix(name)) throw new Error('next-api-bridge: cookie name is invalid');
const cookieStore = await cookies();
- const prefixedName = `${this.options.cookiePrefix}${name}`;
-
- const prefixedCookie = cookieStore.get(prefixedName);
- if (prefixedCookie?.value) {
- return prefixedCookie.value;
- }
-
- const cookie = cookieStore.get(name);
- return cookie?.value;
+ return cookieStore.get(`${this.options.cookiePrefix}${name}`)?.value ?? cookieStore.get(name)?.value;
}
- /**
- * Deletes cookies.
- */
async deleteCookies(cookieNames?: string[]): Promise {
const cookieStore = await cookies();
- const allCookies = cookieStore.getAll();
-
- let deletedCount = 0;
-
- if (cookieNames && cookieNames.length > 0) {
- for (const cookieName of cookieNames) {
- const prefixedName = `${this.options.cookiePrefix}${cookieName}`;
- cookieStore.delete(prefixedName);
- cookieStore.delete(cookieName);
- deletedCount++;
- }
- } else {
- for (const cookie of allCookies) {
- if (cookie.name.startsWith(this.options.cookiePrefix)) {
- cookieStore.delete(cookie.name);
- deletedCount++;
- }
+ if (cookieNames?.length) {
+ for (const name of cookieNames) {
+ if (!isSafeCookiePrefix(name)) throw new Error('next-api-bridge: cookie name is invalid');
+ cookieStore.delete(`${this.options.cookiePrefix}${name}`);
+ if (this.options.cookiePolicy.removeLegacyUnprefixedCookies) cookieStore.delete(name);
}
+ return;
+ }
+ for (const cookie of cookieStore.getAll()) {
+ if (cookie.name.startsWith(this.options.cookiePrefix)) cookieStore.delete(cookie.name);
}
-
- log(`Deleted ${deletedCount} cookie(s)`, undefined, true);
}
}
diff --git a/src/config/constants.ts b/src/config/constants.ts
index 41aff5f..f4f5000 100644
--- a/src/config/constants.ts
+++ b/src/config/constants.ts
@@ -1,11 +1,28 @@
-/**
- * Default cookie prefix for identifying backend cookies.
- * This prefix is used internally in Next.js and removed before sending to the backend.
- */
-export const DEFAULT_COOKIE_PREFIX = 'nab_';
+import type { ForwardableRequestHeader, NormalizedCookiePolicy } from '../types';
-/**
- * System query parameters that should be excluded from backend requests.
- * These are used internally by the frontend and should not be forwarded.
- */
+export const PACKAGE_VERSION = '0.1.7';
+export const DEFAULT_COOKIE_PREFIX = 'nab_';
export const EXCLUDED_QUERY_PARAMS = ['__auth_retry'] as const;
+export const DEFAULT_FORWARD_HEADERS: ForwardableRequestHeader[] = [
+ 'user-agent',
+ 'accept-language',
+ 'traceparent',
+];
+export const DEFAULT_REQUEST_ID_HEADERS = ['x-request-id'];
+export const DEFAULT_REQUEST_ID_OUTGOING_HEADER = 'x-request-id';
+export const DEFAULT_CLIENT_IP_OUTGOING_HEADER = 'x-client-ip';
+export const DEFAULT_CLIENT_ORIGIN_OUTGOING_HEADER = 'x-client-origin';
+export const SAFE_RESPONSE_HEADERS = new Set([
+ 'x-request-id',
+ 'retry-after',
+ 'x-ratelimit-limit',
+ 'x-ratelimit-remaining',
+ 'x-ratelimit-reset',
+]);
+export const DEFAULT_COOKIE_POLICY: NormalizedCookiePolicy = {
+ domain: 'drop',
+ path: '/',
+ secure: 'auto',
+ preserveExpires: true,
+ removeLegacyUnprefixedCookies: false,
+};
diff --git a/src/config/validate.ts b/src/config/validate.ts
new file mode 100644
index 0000000..25d0f4f
--- /dev/null
+++ b/src/config/validate.ts
@@ -0,0 +1,230 @@
+import {
+ DEFAULT_CLIENT_IP_OUTGOING_HEADER,
+ DEFAULT_CLIENT_ORIGIN_OUTGOING_HEADER,
+ DEFAULT_COOKIE_PREFIX,
+ DEFAULT_FORWARD_HEADERS,
+ DEFAULT_REQUEST_ID_HEADERS,
+ DEFAULT_REQUEST_ID_OUTGOING_HEADER,
+} from './constants';
+import { normalizeCookiePolicy } from '../cookies/policy';
+import {
+ assertValidHeaderName,
+ isForbiddenRequestHeader,
+ isSafeCookiePrefix,
+ validateHeaderValue,
+} from '../security/headers';
+import type {
+ ApiBridgeOptions,
+ BridgeLogger,
+ ForwardableRequestHeader,
+ NormalizedCookiePolicy,
+ RequestContextOptions,
+ TrustProxyConfig,
+} from '../types';
+
+export interface NormalizedRequestContextOptions {
+ enabled: boolean;
+ forwardHeaders: ForwardableRequestHeader[];
+ requestId: {
+ incomingHeaders: string[];
+ outgoingHeader: string;
+ generateWhenMissing: boolean;
+ };
+ clientIp: {
+ enabled: boolean;
+ trustProxy: TrustProxyConfig;
+ outgoingHeader: string;
+ };
+ clientOrigin: {
+ enabled: boolean;
+ cookieName?: string;
+ allowedHosts: string[];
+ allowedOrigins: string[];
+ outgoingHeader: string;
+ };
+}
+
+export interface NormalizedOptions {
+ baseUrl: string;
+ cookiePrefix: string;
+ apiKey?: string;
+ apiKeyHeader?: string;
+ auth?: ApiBridgeOptions['auth'];
+ verbose?: string;
+ logger?: BridgeLogger;
+ requestContext: NormalizedRequestContextOptions;
+ cookiePolicy: NormalizedCookiePolicy;
+}
+
+const FORWARDABLE = new Set([
+ 'user-agent',
+ 'accept-language',
+ 'traceparent',
+ 'baggage',
+]);
+
+function validateOutgoingHeader(name: string, label: string): string {
+ const normalized = assertValidHeaderName(name, label);
+ if (isForbiddenRequestHeader(normalized)) {
+ throw new Error(`next-api-bridge: ${label} "${normalized}" is forbidden`);
+ }
+ return normalized;
+}
+
+function normalizeRequestContext(options?: RequestContextOptions): NormalizedRequestContextOptions {
+ const forwardHeaders = options?.forwardHeaders ?? DEFAULT_FORWARD_HEADERS;
+ for (const name of forwardHeaders) {
+ if (!FORWARDABLE.has(name)) {
+ throw new Error(`next-api-bridge: unsupported forwarded header "${String(name)}"`);
+ }
+ }
+
+ const incomingHeaders = options?.requestId?.incomingHeaders ?? DEFAULT_REQUEST_ID_HEADERS;
+ if (!incomingHeaders.length) throw new Error('next-api-bridge: requestId.incomingHeaders must not be empty');
+ const normalizedIncoming = incomingHeaders.map((name) => validateOutgoingHeader(name, 'request ID incoming header'));
+ const requestIdOutgoing = validateOutgoingHeader(
+ options?.requestId?.outgoingHeader ?? DEFAULT_REQUEST_ID_OUTGOING_HEADER,
+ 'request ID outgoing header',
+ );
+
+ let trustProxy = options?.clientIp?.trustProxy ?? false;
+ if (typeof trustProxy === 'object') {
+ if (!trustProxy.headers?.length) {
+ throw new Error('next-api-bridge: custom trustProxy.headers must not be empty');
+ }
+ const normalizedProxyHeaders = trustProxy.headers.map((name) => {
+ const normalized = assertValidHeaderName(name, 'trusted proxy header');
+ if (isForbiddenRequestHeader(normalized)) {
+ throw new Error(`next-api-bridge: trusted proxy header "${normalized}" is forbidden`);
+ }
+ return normalized;
+ });
+ trustProxy = { ...trustProxy, headers: normalizedProxyHeaders };
+ if (trustProxy.trustedProxyHops !== undefined &&
+ (!Number.isInteger(trustProxy.trustedProxyHops) || trustProxy.trustedProxyHops < 0)) {
+ throw new Error('next-api-bridge: trustedProxyHops must be a non-negative integer');
+ }
+ }
+
+ const clientIpEnabled = options?.clientIp?.enabled ?? false;
+ if (clientIpEnabled && trustProxy === false) {
+ throw new Error('next-api-bridge: clientIp.trustProxy must be configured when client IP forwarding is enabled');
+ }
+
+ const clientOriginEnabled = options?.clientOrigin?.enabled ?? false;
+ const clientOriginCookieName = options?.clientOrigin?.cookieName;
+ if (clientOriginCookieName && !isSafeCookiePrefix(clientOriginCookieName)) {
+ throw new Error('next-api-bridge: clientOrigin.cookieName must be a safe cookie name');
+ }
+ const allowedHosts = options?.clientOrigin?.allowedHosts ?? [];
+ if (allowedHosts.some((host) => !host || /[\r\n/?#]/.test(host))) {
+ throw new Error('next-api-bridge: clientOrigin.allowedHosts contains an invalid host');
+ }
+ const allowedOrigins = options?.clientOrigin?.allowedOrigins ?? [];
+ if (clientOriginEnabled && !allowedHosts.length && !allowedOrigins.length) {
+ throw new Error('next-api-bridge: clientOrigin requires allowedHosts or allowedOrigins');
+ }
+ for (const origin of allowedOrigins) {
+ let parsed: URL;
+ try {
+ parsed = new URL(origin);
+ } catch {
+ throw new Error('next-api-bridge: clientOrigin.allowedOrigins contains an invalid URL');
+ }
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) {
+ throw new Error('next-api-bridge: clientOrigin.allowedOrigins must contain origin-only HTTP(S) URLs');
+ }
+ }
+
+ return {
+ enabled: options?.enabled ?? true,
+ forwardHeaders: [...forwardHeaders],
+ requestId: {
+ incomingHeaders: normalizedIncoming,
+ outgoingHeader: requestIdOutgoing,
+ generateWhenMissing: options?.requestId?.generateWhenMissing ?? true,
+ },
+ clientIp: {
+ enabled: clientIpEnabled,
+ trustProxy,
+ outgoingHeader: validateOutgoingHeader(
+ options?.clientIp?.outgoingHeader ?? DEFAULT_CLIENT_IP_OUTGOING_HEADER,
+ 'client IP outgoing header',
+ ),
+ },
+ clientOrigin: {
+ enabled: clientOriginEnabled,
+ cookieName: clientOriginCookieName,
+ allowedHosts: [...allowedHosts],
+ allowedOrigins: [...allowedOrigins],
+ outgoingHeader: validateOutgoingHeader(
+ options?.clientOrigin?.outgoingHeader ?? DEFAULT_CLIENT_ORIGIN_OUTGOING_HEADER,
+ 'client origin outgoing header',
+ ),
+ },
+ };
+}
+
+export function validateAndNormalizeOptions(options: ApiBridgeOptions): NormalizedOptions {
+ if (!options || typeof options !== 'object') {
+ throw new Error('next-api-bridge: options are required');
+ }
+
+ let base: URL;
+ try {
+ base = new URL(options.baseUrl);
+ } catch {
+ throw new Error('next-api-bridge: baseUrl must be a valid absolute URL');
+ }
+ if (!['http:', 'https:'].includes(base.protocol)) {
+ throw new Error('next-api-bridge: baseUrl must use http or https');
+ }
+ if (base.username || base.password) {
+ throw new Error('next-api-bridge: baseUrl must not contain credentials');
+ }
+ if (base.search || base.hash) {
+ throw new Error('next-api-bridge: baseUrl must not contain a query string or fragment');
+ }
+ base.pathname = base.pathname.replace(/\/+$/, '') || '/';
+
+ const cookiePrefix = options.cookiePrefix ?? DEFAULT_COOKIE_PREFIX;
+ if (!isSafeCookiePrefix(cookiePrefix)) {
+ throw new Error('next-api-bridge: cookiePrefix must be a non-empty safe cookie-name prefix');
+ }
+
+ if (Boolean(options.apiKey) !== Boolean(options.apiKeyHeader)) {
+ throw new Error('next-api-bridge: apiKey and apiKeyHeader must be provided together');
+ }
+ if (options.apiKey) validateHeaderValue(options.apiKey, 'apiKey');
+ const apiKeyHeader = options.apiKeyHeader
+ ? validateOutgoingHeader(options.apiKeyHeader, 'API-key header')
+ : undefined;
+
+ if (options.auth?.type === 'bearer') {
+ if (!options.auth.tokenCookie || !isSafeCookiePrefix(options.auth.tokenCookie)) {
+ throw new Error('next-api-bridge: auth.tokenCookie must be a safe cookie name');
+ }
+ if (options.auth.prefix) validateHeaderValue(options.auth.prefix, 'bearer auth prefix');
+ if (options.auth.header) {
+ const authHeader = assertValidHeaderName(options.auth.header, 'bearer auth header');
+ if (authHeader !== 'authorization' && isForbiddenRequestHeader(authHeader)) {
+ throw new Error(`next-api-bridge: bearer auth header "${authHeader}" is forbidden`);
+ }
+ }
+ if (apiKeyHeader && (options.auth.header ?? 'authorization').toLowerCase() === apiKeyHeader) {
+ throw new Error('next-api-bridge: API-key and bearer authentication cannot use the same header');
+ }
+ }
+
+ return {
+ baseUrl: base.toString(),
+ cookiePrefix,
+ apiKey: options.apiKey,
+ apiKeyHeader,
+ auth: options.auth,
+ verbose: options.verbose,
+ logger: options.logger,
+ requestContext: normalizeRequestContext(options.requestContext),
+ cookiePolicy: normalizeCookiePolicy(options.cookiePolicy),
+ };
+}
diff --git a/src/cookies/build-cookie-header.ts b/src/cookies/build-cookie-header.ts
index f60604d..9eeb906 100644
--- a/src/cookies/build-cookie-header.ts
+++ b/src/cookies/build-cookie-header.ts
@@ -1,17 +1,19 @@
-/**
- * Builds the Cookie header for outgoing requests.
- * Filters cookies by prefix and removes the prefix before sending to backend.
- */
+import { validateHeaderValue } from '../security/headers';
+
+const COOKIE_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
+
export function buildBackendCookieHeader(
allCookies: { name: string; value: string }[],
- cookiePrefix: string
+ cookiePrefix: string,
): string | undefined {
const backendCookies = allCookies
.filter((cookie) => cookie.name.startsWith(cookiePrefix))
.map((cookie) => {
- const backendCookieName = cookie.name.substring(cookiePrefix.length);
- return `${backendCookieName}=${cookie.value}`;
+ const name = cookie.name.slice(cookiePrefix.length);
+ if (!COOKIE_NAME_PATTERN.test(name)) {
+ throw new Error('next-api-bridge: backend cookie name is invalid');
+ }
+ return `${name}=${validateHeaderValue(cookie.value, `cookie ${name}`)}`;
});
-
- return backendCookies.length > 0 ? backendCookies.join('; ') : undefined;
+ return backendCookies.length ? backendCookies.join('; ') : undefined;
}
diff --git a/src/cookies/parse-set-cookie.ts b/src/cookies/parse-set-cookie.ts
index a3e6469..9a7b899 100644
--- a/src/cookies/parse-set-cookie.ts
+++ b/src/cookies/parse-set-cookie.ts
@@ -1,99 +1,67 @@
import type { ParsedCookie } from '../types';
-/**
- * Parses a Set-Cookie header string into an array of cookie objects.
- * Handles multiple cookies separated by commas with proper parsing.
- */
-export function parseSetCookieHeader(setCookieHeader: string): ParsedCookie[] {
- const cookies: ParsedCookie[] = [];
-
- // Split by comma, but be careful - cookie values can contain commas
- // We'll split on ", " (comma followed by space) which is the standard separator
- // and check if the next part looks like a cookie name (contains "=" before any ";")
- let currentCookie = '';
- const parts = setCookieHeader.split(', ');
+const COOKIE_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
- for (let i = 0; i < parts.length; i++) {
- const part = parts[i];
- // Check if this part starts a new cookie (has "=" before ";")
- const equalsIndex = part.indexOf('=');
- const semicolonIndex = part.indexOf(';');
+export function splitSetCookieHeader(header: string): string[] {
+ return header
+ .split(/,(?=\s*[^;,\s]+=)/g)
+ .map((part) => part.trim())
+ .filter(Boolean);
+}
- if (equalsIndex !== -1 && (semicolonIndex === -1 || equalsIndex < semicolonIndex)) {
- // This is a new cookie
- if (currentCookie) {
- parseCookieString(currentCookie, cookies);
- }
- currentCookie = part;
- } else {
- // This is a continuation of the current cookie
- currentCookie += ', ' + part;
+export function parseSetCookieHeader(header: string): ParsedCookie[] {
+ const cookies: ParsedCookie[] = [];
+ for (const value of splitSetCookieHeader(header)) {
+ try {
+ cookies.push(parseSetCookieString(value));
+ } catch {
+ // Invalid cookies are reported by the synchronization result.
}
}
-
- // Parse the last cookie
- if (currentCookie) {
- parseCookieString(currentCookie, cookies);
- }
-
return cookies;
}
-/**
- * Parses a single cookie string into a cookie object.
- */
export function parseSetCookieString(cookieString: string): ParsedCookie {
- const parts = cookieString.split(';').map((p) => p.trim());
- const [nameValue] = parts;
- const equalsIndex = nameValue.indexOf('=');
+ const parts = cookieString.split(';').map((part) => part.trim());
+ const first = parts.shift();
+ if (!first) throw new Error('Invalid Set-Cookie header');
+ const equalsIndex = first.indexOf('=');
+ if (equalsIndex <= 0) throw new Error('Invalid Set-Cookie name/value');
- if (equalsIndex === -1) {
- throw new Error('Invalid cookie string: no equals sign found');
+ const name = first.slice(0, equalsIndex).trim();
+ const value = first.slice(equalsIndex + 1).trim();
+ if (!COOKIE_NAME_PATTERN.test(name) || /[\r\n]/.test(value)) {
+ throw new Error('Invalid Set-Cookie value');
}
- const name = nameValue.substring(0, equalsIndex).trim();
- const value = nameValue.substring(equalsIndex + 1).trim();
-
const cookie: ParsedCookie = { name, value };
- for (let i = 1; i < parts.length; i++) {
- const part = parts[i].toLowerCase();
- if (part === 'httponly') {
- cookie.httpOnly = true;
- } else if (part === 'secure') {
- cookie.secure = true;
- } else if (part.startsWith('samesite=')) {
- cookie.sameSite = part.split('=')[1];
- } else if (part.startsWith('max-age=')) {
- const maxAge = parseInt(part.split('=')[1], 10);
- if (!isNaN(maxAge)) {
- cookie.maxAge = maxAge;
- }
- } else if (part.startsWith('expires=')) {
- const expiresValue = parts[i].substring(8); // Get value after "expires=" (case-sensitive)
- try {
- cookie.expires = new Date(expiresValue);
- } catch (e) {
- // Invalid date format, ignore
- }
- } else if (part.startsWith('path=')) {
- cookie.path = part.split('=')[1];
- } else if (part.startsWith('domain=')) {
- cookie.domain = part.split('=')[1];
+ for (const rawPart of parts) {
+ const separator = rawPart.indexOf('=');
+ const rawName = separator === -1 ? rawPart : rawPart.slice(0, separator);
+ const rawValue = separator === -1 ? '' : rawPart.slice(separator + 1);
+ const attribute = rawName.trim().toLowerCase();
+ const attributeValue = rawValue.trim();
+
+ if (attribute === 'httponly') cookie.httpOnly = true;
+ else if (attribute === 'secure') cookie.secure = true;
+ else if (attribute === 'partitioned') cookie.partitioned = true;
+ else if (attribute === 'samesite') {
+ const valueLower = attributeValue.toLowerCase();
+ if (valueLower === 'strict' || valueLower === 'lax' || valueLower === 'none') cookie.sameSite = valueLower;
+ } else if (attribute === 'max-age') {
+ const valueNumber = Number.parseInt(attributeValue, 10);
+ if (Number.isFinite(valueNumber)) cookie.maxAge = valueNumber;
+ } else if (attribute === 'expires') {
+ const date = new Date(attributeValue);
+ if (!Number.isNaN(date.getTime())) cookie.expires = date;
+ } else if (attribute === 'path') cookie.path = attributeValue;
+ else if (attribute === 'domain') cookie.domain = attributeValue;
+ else if (attribute === 'priority') {
+ const priority = attributeValue.toLowerCase();
+ if (priority === 'low' || priority === 'medium' || priority === 'high') cookie.priority = priority;
}
}
return cookie;
}
-
-/**
- * Internal helper to parse cookie string and push to array.
- */
-function parseCookieString(cookieString: string, cookies: ParsedCookie[]): void {
- try {
- const cookie = parseSetCookieString(cookieString);
- cookies.push(cookie);
- } catch (e) {
- // Skip invalid cookies
- }
-}
diff --git a/src/cookies/policy.ts b/src/cookies/policy.ts
new file mode 100644
index 0000000..16c00d9
--- /dev/null
+++ b/src/cookies/policy.ts
@@ -0,0 +1,32 @@
+import { DEFAULT_COOKIE_POLICY } from '../config/constants';
+import type { CookieOptions, CookiePolicyOptions, NormalizedCookiePolicy, ParsedCookie } from '../types';
+
+export function normalizeCookiePolicy(policy?: CookiePolicyOptions): NormalizedCookiePolicy {
+ return { ...DEFAULT_COOKIE_POLICY, ...policy };
+}
+
+export function applyCookiePolicy(
+ cookie: ParsedCookie,
+ policy: NormalizedCookiePolicy,
+ requestIsSecure: boolean,
+): CookieOptions {
+ const options: CookieOptions = {
+ httpOnly: cookie.httpOnly,
+ sameSite: cookie.sameSite,
+ maxAge: cookie.maxAge,
+ priority: cookie.priority,
+ partitioned: cookie.partitioned,
+ };
+
+ if (policy.preserveExpires) options.expires = cookie.expires;
+ if (policy.domain === 'preserve') options.domain = cookie.domain;
+ options.path = policy.path === 'preserve' ? cookie.path : '/';
+ options.secure = policy.secure === 'preserve' ? cookie.secure : requestIsSecure || cookie.secure === true;
+
+ return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)) as CookieOptions;
+}
+
+export function shouldDeleteCookie(cookie: ParsedCookie, now = Date.now()): boolean {
+ return cookie.value === '' || cookie.value === '""' || (cookie.maxAge !== undefined && cookie.maxAge <= 0) ||
+ (cookie.expires !== undefined && cookie.expires.getTime() <= now);
+}
diff --git a/src/cookies/sync-response-cookies.ts b/src/cookies/sync-response-cookies.ts
index 693c121..dba5beb 100644
--- a/src/cookies/sync-response-cookies.ts
+++ b/src/cookies/sync-response-cookies.ts
@@ -1,196 +1,83 @@
-import { cookies } from 'next/headers';
-import { parseSetCookieHeader } from './parse-set-cookie';
-import { shouldLog } from '../logger/logger';
-import { colors } from '../logger/colors';
+import type { CookieOptions, CookieSyncInfo, NormalizedCookiePolicy, ParsedCookie } from '../types';
+import { applyCookiePolicy, shouldDeleteCookie } from './policy';
+import { parseSetCookieHeader, parseSetCookieString } from './parse-set-cookie';
-/**
- * Syncs cookies from the API response to Next.js cookies.
- * Handles both getSetCookie() and fallback manual parsing.
- */
-export async function syncResponseCookies({
- response,
- cookieStore,
- cookiePrefix,
- verbose,
-}: {
- response: Response;
- cookieStore: Awaited>;
- cookiePrefix: string;
- verbose?: string;
-}): Promise {
- try {
- // Try to use getSetCookie() if available (modern fetch API - Node.js 18+)
- if (typeof response.headers.getSetCookie === 'function') {
- const setCookieHeaders = response.headers.getSetCookie();
- if (setCookieHeaders && setCookieHeaders.length > 0) {
- for (const cookieString of setCookieHeaders) {
- parseAndSetCookie(cookieString, cookieStore, cookiePrefix, verbose);
- }
- return;
- }
- }
-
- // Fallback: parse Set-Cookie header manually
- const setCookieHeader = response.headers.get('set-cookie');
- if (setCookieHeader) {
- const cookies = parseSetCookieHeader(setCookieHeader);
- for (const cookie of cookies) {
- const shouldDelete =
- (cookie.maxAge !== undefined && cookie.maxAge <= 0) ||
- (cookie.expires !== undefined && cookie.expires.getTime() < Date.now()) ||
- cookie.value === '' ||
- cookie.value === '""';
-
- const prefixedCookieName = `${cookiePrefix}${cookie.name}`;
-
- if (shouldDelete) {
- cookieStore.delete(prefixedCookieName);
- cookieStore.delete(cookie.name);
- if (shouldLog('response', verbose)) {
- console.log(colors.red(`🗑️ [COOKIE DELETE] ${cookie.name} (prefixed: ${prefixedCookieName})`));
- }
- } else {
- const existingCookie = cookieStore.get(prefixedCookieName);
- const isUpdate = existingCookie?.value !== undefined;
-
- cookieStore.set(prefixedCookieName, cookie.value, {
- httpOnly: cookie.httpOnly,
- secure: cookie.secure,
- sameSite: cookie.sameSite as 'strict' | 'lax' | 'none' | undefined,
- maxAge: cookie.maxAge,
- path: cookie.path,
- domain: cookie.domain,
- });
- cookieStore.delete(cookie.name);
+export interface CookieStoreLike {
+ get(name: string): { name: string; value: string } | undefined;
+ getAll(): { name: string; value: string }[];
+ set(name: string, value: string, options?: CookieOptions): unknown;
+ delete(name: string): unknown;
+}
- if (shouldLog('response', verbose)) {
- const action = isUpdate ? '🔄 [COOKIE UPDATE]' : '✅ [COOKIE SET]';
- const details = [
- `name: ${cookie.name}`,
- `value: ${cookie.value.substring(0, 20)}${cookie.value.length > 20 ? '...' : ''}`,
- `prefixed: ${prefixedCookieName}`,
- cookie.httpOnly ? 'httpOnly' : '',
- cookie.secure ? 'secure' : '',
- cookie.sameSite ? `sameSite=${cookie.sameSite}` : '',
- cookie.maxAge ? `maxAge=${cookie.maxAge}s` : '',
- cookie.path ? `path=${cookie.path}` : '',
- cookie.domain ? `domain=${cookie.domain}` : '',
- ]
- .filter(Boolean)
- .join(', ');
+function getSetCookieValues(headers: Headers): { values: string[]; invalid: boolean } {
+ const enhanced = headers as Headers & { getSetCookie?: () => string[] };
+ if (typeof enhanced.getSetCookie === 'function') {
+ const values = enhanced.getSetCookie();
+ return { values, invalid: false };
+ }
+ const combined = headers.get('set-cookie');
+ return combined ? { values: [combined], invalid: false } : { values: [], invalid: false };
+}
- console.log(colors.yellow(`${action} ${details}`));
- }
- }
- }
+function parseCookies(values: string[]): { cookies: ParsedCookie[]; invalid: boolean } {
+ const cookies: ParsedCookie[] = [];
+ let invalid = false;
+ for (const value of values) {
+ if (value.includes(',') && !value.includes('\n')) {
+ const parsed = parseSetCookieHeader(value);
+ if (!parsed.length) invalid = true;
+ cookies.push(...parsed);
+ continue;
}
- } catch (error: any) {
- // Cookies can only be modified in Server Actions or Route Handlers.
- // If we're in a Server Component context, silently skip cookie handling.
- const errorMessage = error?.message || String(error);
- if (errorMessage.includes('Cookies can only be modified')) {
- return;
+ try {
+ cookies.push(parseSetCookieString(value));
+ } catch {
+ invalid = true;
}
- // Log other cookie-related errors
- console.error(`Warning: Failed to handle cookies: ${errorMessage}`);
}
+ return { cookies, invalid };
}
-/**
- * Parses and sets a single cookie from a Set-Cookie header string.
- */
-function parseAndSetCookie(
- cookieString: string,
- cookieStore: Awaited>,
- cookiePrefix: string,
- verbose?: string
-): void {
- try {
- const parts = cookieString.split(';').map((p) => p.trim());
- const [nameValue] = parts;
- const [name, ...valueParts] = nameValue.split('=');
- const value = valueParts.join('='); // Handle values that contain "="
-
- const cookieOptions: any = {};
- let shouldDelete = false;
- let expiresDate: Date | null = null;
-
- for (let i = 1; i < parts.length; i++) {
- const part = parts[i].toLowerCase();
- if (part === 'httponly') {
- cookieOptions.httpOnly = true;
- } else if (part === 'secure') {
- cookieOptions.secure = true;
- } else if (part.startsWith('samesite=')) {
- const sameSiteValue = part.split('=')[1];
- if (['strict', 'lax', 'none'].includes(sameSiteValue)) {
- cookieOptions.sameSite = sameSiteValue as 'strict' | 'lax' | 'none';
- }
- } else if (part.startsWith('max-age=')) {
- const maxAge = parseInt(part.split('=')[1], 10);
- if (!isNaN(maxAge)) {
- cookieOptions.maxAge = maxAge;
- if (maxAge <= 0) {
- shouldDelete = true;
- }
- }
- } else if (part.startsWith('path=')) {
- cookieOptions.path = part.split('=')[1];
- } else if (part.startsWith('domain=')) {
- cookieOptions.domain = part.split('=')[1];
- } else if (part.startsWith('expires=')) {
- const expiresValue = parts[i].substring(8);
- try {
- expiresDate = new Date(expiresValue);
- if (expiresDate.getTime() < Date.now()) {
- shouldDelete = true;
- }
- } catch (e) {
- // Invalid date format, ignore
- }
- }
- }
-
- const cookieName = name.trim();
- const prefixedCookieName = `${cookiePrefix}${cookieName}`;
-
- if (shouldDelete || value === '' || value === '""') {
- cookieStore.delete(prefixedCookieName);
- cookieStore.delete(cookieName);
- if (shouldLog('response', verbose)) {
- console.log(colors.red(`🗑️ [COOKIE DELETE] ${cookieName} (prefixed: ${prefixedCookieName})`));
- }
- } else {
- const existingCookie = cookieStore.get(prefixedCookieName);
- const isUpdate = existingCookie?.value !== undefined;
+function isReadOnlyCookieError(error: unknown): boolean {
+ const message = error instanceof Error ? error.message : String(error);
+ return message.includes('Cookies can only be modified') || message.includes('cookie') && message.includes('Server Action');
+}
- cookieStore.set(prefixedCookieName, value, cookieOptions);
- cookieStore.delete(cookieName);
+export async function syncResponseCookies({
+ response,
+ cookieStore,
+ cookiePrefix,
+ cookiePolicy,
+ requestIsSecure,
+}: {
+ response: Response;
+ cookieStore: CookieStoreLike;
+ cookiePrefix: string;
+ cookiePolicy: NormalizedCookiePolicy;
+ requestIsSecure: boolean;
+}): Promise {
+ const { values } = getSetCookieValues(response.headers);
+ if (!values.length) return { attempted: false, applied: false, reason: 'no-set-cookie' };
- if (shouldLog('response', verbose)) {
- const action = isUpdate ? '🔄 [COOKIE UPDATE]' : '✅ [COOKIE SET]';
- const details = [
- `name: ${cookieName}`,
- `value: ${value.substring(0, 20)}${value.length > 20 ? '...' : ''}`,
- `prefixed: ${prefixedCookieName}`,
- cookieOptions.httpOnly ? 'httpOnly' : '',
- cookieOptions.secure ? 'secure' : '',
- cookieOptions.sameSite ? `sameSite=${cookieOptions.sameSite}` : '',
- cookieOptions.maxAge ? `maxAge=${cookieOptions.maxAge}s` : '',
- cookieOptions.path ? `path=${cookieOptions.path}` : '',
- cookieOptions.domain ? `domain=${cookieOptions.domain}` : '',
- ]
- .filter(Boolean)
- .join(', ');
+ const { cookies, invalid } = parseCookies(values);
+ if (!cookies.length) return { attempted: true, applied: false, reason: 'invalid-cookie' };
- console.log(colors.yellow(`${action} ${details}`));
+ try {
+ for (const cookie of cookies) {
+ const prefixedName = `${cookiePrefix}${cookie.name}`;
+ const options = applyCookiePolicy(cookie, cookiePolicy, requestIsSecure);
+ if (shouldDeleteCookie(cookie)) {
+ cookieStore.set(prefixedName, '', { ...options, maxAge: 0, expires: new Date(0) });
+ } else {
+ cookieStore.set(prefixedName, cookie.value, options);
}
+ if (cookiePolicy.removeLegacyUnprefixedCookies) cookieStore.delete(cookie.name);
}
- } catch (error: any) {
- const errorMessage = error?.message || String(error);
- if (errorMessage.includes('Cookies can only be modified')) {
- return;
+ return { attempted: true, applied: true, reason: 'applied' };
+ } catch (error) {
+ if (isReadOnlyCookieError(error)) {
+ return { attempted: true, applied: false, reason: 'read-only-context' };
}
- console.error(`Warning: Failed to parse/set cookie: ${errorMessage}`);
+ return { attempted: true, applied: false, reason: invalid ? 'invalid-cookie' : 'invalid-cookie' };
}
}
diff --git a/src/create-client.ts b/src/create-client.ts
index 8af149b..e0d7a78 100644
--- a/src/create-client.ts
+++ b/src/create-client.ts
@@ -1,18 +1,7 @@
import { NextApiBridgeClient } from './client';
-import { DEFAULT_COOKIE_PREFIX } from './config/constants';
+import { validateAndNormalizeOptions } from './config/validate';
import type { ApiBridgeOptions } from './types';
-/**
- * Creates a new API bridge client instance.
- * Validates required options and applies defaults.
- */
export function createNextApiBridge(options: ApiBridgeOptions): NextApiBridgeClient {
- if (!options.baseUrl) {
- throw new Error('next-api-bridge: baseUrl is required');
- }
-
- return new NextApiBridgeClient({
- cookiePrefix: DEFAULT_COOKIE_PREFIX,
- ...options,
- });
+ return new NextApiBridgeClient(validateAndNormalizeOptions(options));
}
diff --git a/src/index.ts b/src/index.ts
index 3153bf7..0c3e635 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,9 +6,18 @@ export { serializeQuery } from './query';
export type {
ApiBridgeOptions,
- RequestOptions,
ApiBridgeResponse,
- FormActionResponse,
- CookieOptions,
BearerAuthConfig,
-} from './types/index';
+ BridgeLogger,
+ CookieOptions,
+ CookiePolicyOptions,
+ CookieSyncInfo,
+ CookieSyncReason,
+ FormActionResponse,
+ ForwardableRequestHeader,
+ NextCacheOptions,
+ RequestContextOptions,
+ RequestOptions,
+ SafeLogEntry,
+ TrustProxyConfig,
+} from './types';
diff --git a/src/logger/logger.ts b/src/logger/logger.ts
index bce1fb7..abcd9c8 100644
--- a/src/logger/logger.ts
+++ b/src/logger/logger.ts
@@ -1,41 +1,47 @@
-import { colors } from './colors';
+import type { BridgeLogger, SafeLogEntry } from '../types';
+import { redactValue } from './redact';
export type VerboseLogOption = 'request' | 'body' | 'response';
-/**
- * Check if specific verbose logging option is enabled.
- */
export function shouldLog(option: VerboseLogOption, verbose?: string): boolean {
return (verbose ?? '')
.toLowerCase()
.split(',')
- .map((opt) => opt.trim())
+ .map((item) => item.trim())
.filter(Boolean)
.includes(option);
}
-/**
- * Logs a message with optional status and success flag.
- * Determines color based on success flag and status code.
- */
-export function log(message: string, status?: number, success?: boolean): void {
- const nodeEnv = process.env.NODE_ENV;
- const shouldLogError = nodeEnv !== 'test';
- const shouldLogSuccess = nodeEnv === 'development';
+function safeEntry(entry: SafeLogEntry): SafeLogEntry {
+ return redactValue(entry) as SafeLogEntry;
+}
- let coloredMessage = message;
+function defaultWrite(level: 'debug' | 'info' | 'warn' | 'error', entry: SafeLogEntry): void {
+ if (process.env.NODE_ENV === 'test') return;
+ if (level === 'debug' && process.env.NODE_ENV !== 'development') return;
+ const writer = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
+ writer(`[next-api-bridge] ${JSON.stringify(safeEntry(entry))}`);
+}
- if (status !== undefined) {
- coloredMessage = `${message} ${colors.yellow(String(status))}`;
+export function emitLog(
+ logger: BridgeLogger | undefined,
+ level: 'debug' | 'info' | 'warn' | 'error',
+ entry: SafeLogEntry,
+): void {
+ const sanitized = safeEntry(entry);
+ const custom = logger?.[level];
+ if (custom) {
+ custom(sanitized);
+ return;
}
+ defaultWrite(level, sanitized);
+}
- if (success === true) {
- if (shouldLogSuccess) console.log(colors.green(coloredMessage));
- } else if (success === false) {
- if (shouldLogError) console.error(colors.red(coloredMessage));
- } else if (status !== undefined && (status < 200 || status >= 300)) {
- if (shouldLogError) console.error(colors.red(coloredMessage));
- } else {
- if (shouldLogError) console.log(coloredMessage);
- }
+/** @deprecated Kept for 0.1.x compatibility. */
+export function log(message: string, status?: number, success?: boolean): void {
+ emitLog(undefined, success === false ? 'error' : 'info', {
+ event: success === false ? 'error' : 'response',
+ message,
+ status,
+ });
}
diff --git a/src/logger/redact.ts b/src/logger/redact.ts
new file mode 100644
index 0000000..63caff4
--- /dev/null
+++ b/src/logger/redact.ts
@@ -0,0 +1,40 @@
+const SENSITIVE_KEY_PATTERN = /(^|[-_])(authorization|cookie|set-cookie|proxy-authorization|password|pass|secret|token|access.?token|refresh.?token|client.?secret|session|otp|api.?key)($|[-_])/i;
+
+export function isSensitiveKey(key: string): boolean {
+ return SENSITIVE_KEY_PATTERN.test(key);
+}
+
+export function redactHeaders(headers: Record): Record {
+ const result: Record = {};
+ for (const [key, value] of Object.entries(headers)) {
+ result[key] = isSensitiveKey(key) ? '[REDACTED]' : value;
+ }
+ return result;
+}
+
+export function redactValue(value: unknown, key = ''): unknown {
+ if (key && isSensitiveKey(key)) return '[REDACTED]';
+ if (Array.isArray(value)) return value.map((item) => redactValue(item));
+ if (value && typeof value === 'object') {
+ const result: Record = {};
+ for (const [childKey, childValue] of Object.entries(value as Record)) {
+ result[childKey] = redactValue(childValue, childKey);
+ }
+ return result;
+ }
+ return value;
+}
+
+export function sanitizeUrlForLog(rawUrl: string): string {
+ try {
+ const url = new URL(rawUrl);
+ url.username = '';
+ url.password = '';
+ for (const key of Array.from(url.searchParams.keys())) {
+ if (isSensitiveKey(key)) url.searchParams.set(key, '[REDACTED]');
+ }
+ return url.toString();
+ } catch {
+ return rawUrl.replace(/[\r\n]/g, '');
+ }
+}
diff --git a/src/query.ts b/src/query.ts
index 45c1297..5d76185 100644
--- a/src/query.ts
+++ b/src/query.ts
@@ -1,48 +1,30 @@
-/**
- * Serializes query values without turning JavaScript absence into wire data.
- *
- * `undefined` and `null` mean that a query parameter was not supplied. Other
- * falsy values such as `false`, `0`, and an empty string remain meaningful.
- */
+/** Serializes query values while omitting only JavaScript absence. */
export function serializeQuery(
query: Record,
excludedKeys: readonly string[] = [],
): string {
const excluded = new Set(excludedKeys);
- const queryPairs: string[] = [];
+ const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
- if (excluded.has(key) || value === undefined || value === null) {
- continue;
- }
-
- const serializedValue = serializeQueryValue(value);
- if (serializedValue === undefined) {
- continue;
- }
-
- queryPairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(serializedValue)}`);
+ if (excluded.has(key) || value === undefined || value === null) continue;
+ const serialized = serializeQueryValue(value);
+ if (serialized !== undefined) params.append(key, serialized);
}
- return queryPairs.join('&');
+ return params.toString();
}
function serializeQueryValue(value: unknown): string | undefined {
if (Array.isArray(value)) {
const values = value
.filter((item) => item !== undefined && item !== null)
- .map((item) => serializeScalarQueryValue(item));
-
- return values.length > 0 ? values.join(',') : undefined;
+ .map(serializeScalarQueryValue);
+ return values.length ? values.join(',') : undefined;
}
-
return serializeScalarQueryValue(value);
}
function serializeScalarQueryValue(value: unknown): string {
- if (value instanceof Date) {
- return value.toISOString();
- }
-
- return String(value);
+ return value instanceof Date ? value.toISOString() : String(value);
}
diff --git a/src/request/cache.ts b/src/request/cache.ts
new file mode 100644
index 0000000..9697a48
--- /dev/null
+++ b/src/request/cache.ts
@@ -0,0 +1,18 @@
+import type { RequestOptions } from '../types';
+
+export function validateCacheOptions(options: RequestOptions): void {
+ const cache = options.cache ?? 'no-store';
+ const revalidate = options.next?.revalidate;
+ if (cache === 'no-store' && typeof revalidate === 'number' && revalidate > 0) {
+ throw new Error('next-api-bridge: cache "no-store" conflicts with a positive next.revalidate value');
+ }
+ if (cache === 'force-cache' && revalidate === 0) {
+ throw new Error('next-api-bridge: cache "force-cache" conflicts with next.revalidate=0');
+ }
+ if (typeof revalidate === 'number' && (!Number.isFinite(revalidate) || revalidate < 0)) {
+ throw new Error('next-api-bridge: next.revalidate must be false or a non-negative finite number');
+ }
+ if (options.next?.tags?.some((tag) => !tag || tag.length > 256)) {
+ throw new Error('next-api-bridge: cache tags must be non-empty and at most 256 characters');
+ }
+}
diff --git a/src/request/context.ts b/src/request/context.ts
new file mode 100644
index 0000000..9c361e2
--- /dev/null
+++ b/src/request/context.ts
@@ -0,0 +1,69 @@
+import { randomUUID } from 'node:crypto';
+import { PACKAGE_VERSION } from '../config/constants';
+import type { NormalizedRequestContextOptions } from '../config/validate';
+import { getSafeIncomingHeader, validateHeaderValue } from '../security/headers';
+import { resolveClientIp } from '../security/ip';
+import { deriveClientOrigin, validateClientOrigin } from '../security/origin';
+
+export interface ReadonlyCookieStoreLike {
+ get(name: string): { name: string; value: string } | undefined;
+}
+
+export interface BuiltRequestContext {
+ headers: Record;
+ requestId?: string;
+ requestIsSecure: boolean;
+}
+
+function resolveRequestId(incoming: Headers, options: NormalizedRequestContextOptions['requestId']): string | undefined {
+ for (const name of options.incomingHeaders) {
+ const value = getSafeIncomingHeader(incoming, name);
+ if (value) return value.slice(0, 256);
+ }
+ return options.generateWhenMissing ? randomUUID() : undefined;
+}
+
+function isSecureRequest(headers: Headers): boolean {
+ const proto = headers.get('x-forwarded-proto')?.split(',')[0]?.trim().toLowerCase();
+ if (proto === 'https') return true;
+ if (proto === 'http') return false;
+ const origin = headers.get('origin');
+ return origin?.startsWith('https://') ?? process.env.NODE_ENV === 'production';
+}
+
+export function buildRequestContextHeaders(
+ incoming: Headers,
+ cookieStore: ReadonlyCookieStoreLike,
+ options: NormalizedRequestContextOptions,
+): BuiltRequestContext {
+ const outgoing: Record = {
+ 'x-api-bridge': `next-api-bridge/${PACKAGE_VERSION}`,
+ };
+ const requestIsSecure = isSecureRequest(incoming);
+
+ if (!options.enabled) return { headers: outgoing, requestIsSecure };
+
+ for (const name of options.forwardHeaders) {
+ const value = getSafeIncomingHeader(incoming, name);
+ if (value) outgoing[name] = value;
+ }
+
+ const requestId = resolveRequestId(incoming, options.requestId);
+ if (requestId) outgoing[options.requestId.outgoingHeader] = validateHeaderValue(requestId, 'request ID');
+
+ if (options.clientIp.enabled) {
+ const ip = resolveClientIp(incoming, options.clientIp.trustProxy);
+ if (ip) outgoing[options.clientIp.outgoingHeader] = ip;
+ }
+
+ if (options.clientOrigin.enabled) {
+ const cookieCandidate = options.clientOrigin.cookieName
+ ? cookieStore.get(options.clientOrigin.cookieName)?.value
+ : undefined;
+ const candidate = cookieCandidate ?? deriveClientOrigin(incoming);
+ const origin = candidate ? validateClientOrigin(candidate, options.clientOrigin) : undefined;
+ if (origin) outgoing[options.clientOrigin.outgoingHeader] = origin;
+ }
+
+ return { headers: outgoing, requestId, requestIsSecure };
+}
diff --git a/src/request/execute.ts b/src/request/execute.ts
new file mode 100644
index 0000000..64313a1
--- /dev/null
+++ b/src/request/execute.ts
@@ -0,0 +1,226 @@
+import type {
+ ApiBridgeResponse,
+ PrepareRequestResult,
+ RequestOptions,
+} from '../types';
+import type { NormalizedOptions } from '../config/validate';
+import { buildBackendCookieHeader } from '../cookies/build-cookie-header';
+import { syncResponseCookies, type CookieStoreLike } from '../cookies/sync-response-cookies';
+import { emitLog, shouldLog } from '../logger/logger';
+import { sanitizeUrlForLog } from '../logger/redact';
+import { assertAllowedCustomHeaders, validateHeaderValue } from '../security/headers';
+import { buildRequestContextHeaders } from './context';
+import { validateCacheOptions } from './cache';
+import { combineAbortSignals } from './signal';
+import { buildRequestUrl } from './url';
+import { parseApiResponse } from '../response/parse-response';
+
+function isFormData(value: unknown): value is FormData {
+ return typeof FormData !== 'undefined' && value instanceof FormData;
+}
+
+function isBlob(value: unknown): value is Blob {
+ return typeof Blob !== 'undefined' && value instanceof Blob;
+}
+
+function isReadableStream(value: unknown): value is ReadableStream {
+ return typeof ReadableStream !== 'undefined' && value instanceof ReadableStream;
+}
+
+export function serializeRequestBody(
+ method: string,
+ body: unknown,
+ isMultipart: boolean,
+): { body?: BodyInit; contentType?: string } {
+ if (method === 'GET' || method === 'HEAD' || body === undefined) return {};
+ if (isMultipart || isFormData(body)) return { body: body as BodyInit };
+ if (body instanceof URLSearchParams || isBlob(body) || body instanceof ArrayBuffer ||
+ ArrayBuffer.isView(body) || isReadableStream(body)) {
+ return { body: body as BodyInit };
+ }
+ return { body: JSON.stringify(body), contentType: 'application/json' };
+}
+
+export async function prepareBridgeRequest({
+ normalizedOptions,
+ method,
+ path,
+ body,
+ requestOptions,
+ cookieStore,
+ incomingHeaders,
+}: {
+ normalizedOptions: NormalizedOptions;
+ method: string;
+ path: string;
+ body: unknown;
+ requestOptions: RequestOptions;
+ cookieStore: CookieStoreLike;
+ incomingHeaders: Headers;
+}): Promise {
+ validateCacheOptions(requestOptions);
+ const url = buildRequestUrl(
+ normalizedOptions.baseUrl,
+ path,
+ requestOptions.params,
+ requestOptions.query,
+ );
+ const context = buildRequestContextHeaders(
+ incomingHeaders,
+ cookieStore,
+ normalizedOptions.requestContext,
+ );
+ const authHeader = normalizedOptions.auth?.header?.toLowerCase() ?? 'authorization';
+ const managedHeaders = new Set([
+ 'cookie',
+ 'host',
+ 'content-length',
+ 'x-api-bridge',
+ authHeader,
+ ...(normalizedOptions.apiKeyHeader ? [normalizedOptions.apiKeyHeader] : []),
+ ...Object.keys(context.headers),
+ ]);
+ const outgoingHeaders: Record = {
+ ...assertAllowedCustomHeaders(requestOptions.headers, managedHeaders),
+ ...context.headers,
+ };
+
+ const cookieHeader = buildBackendCookieHeader(cookieStore.getAll(), normalizedOptions.cookiePrefix);
+ if (cookieHeader) outgoingHeaders.cookie = cookieHeader;
+
+ if (normalizedOptions.auth?.type === 'bearer') {
+ const token = cookieStore.get(`${normalizedOptions.cookiePrefix}${normalizedOptions.auth.tokenCookie}`)?.value
+ ?? cookieStore.get(normalizedOptions.auth.tokenCookie)?.value;
+ if (token) {
+ const prefix = normalizedOptions.auth.prefix ?? 'Bearer';
+ outgoingHeaders[authHeader] = validateHeaderValue(`${prefix} ${token}`, authHeader);
+ }
+ }
+
+ if (normalizedOptions.apiKey && normalizedOptions.apiKeyHeader) {
+ outgoingHeaders[normalizedOptions.apiKeyHeader] = validateHeaderValue(normalizedOptions.apiKey, normalizedOptions.apiKeyHeader);
+ }
+
+ const serialized = serializeRequestBody(method, body, requestOptions.isMultipart ?? false);
+ if (serialized.contentType) outgoingHeaders['content-type'] = serialized.contentType;
+ const combinedSignal = combineAbortSignals(requestOptions.signal, requestOptions.timeoutMs);
+ const fetchOptions: RequestInit & { next?: RequestOptions['next'] } = {
+ method,
+ cache: requestOptions.cache ?? 'no-store',
+ credentials: 'include',
+ headers: outgoingHeaders,
+ body: serialized.body,
+ signal: combinedSignal.signal,
+ };
+ if (requestOptions.next) fetchOptions.next = requestOptions.next;
+
+ return {
+ url,
+ fetchOptions,
+ requestId: context.requestId,
+ cleanupSignal: combinedSignal.cleanup,
+ didTimeout: combinedSignal.didTimeout,
+ };
+}
+
+function shouldEmitRequestLog(options: NormalizedOptions): boolean {
+ return Boolean(options.logger) || shouldLog('request', options.verbose) || shouldLog('body', options.verbose);
+}
+
+function shouldEmitResponseLog(options: NormalizedOptions): boolean {
+ return Boolean(options.logger) || shouldLog('response', options.verbose);
+}
+
+export async function executeBridgeRequest({
+ normalizedOptions,
+ method,
+ path,
+ body,
+ requestOptions = {},
+ cookieStore,
+ incomingHeaders,
+ fetchImpl = fetch,
+}: {
+ normalizedOptions: NormalizedOptions;
+ method: string;
+ path: string;
+ body?: unknown;
+ requestOptions?: RequestOptions;
+ cookieStore: CookieStoreLike;
+ incomingHeaders: Headers;
+ fetchImpl?: typeof fetch;
+}): Promise> {
+ const prepared = await prepareBridgeRequest({
+ normalizedOptions,
+ method,
+ path,
+ body,
+ requestOptions,
+ cookieStore,
+ incomingHeaders,
+ });
+ const startedAt = Date.now();
+ const logUrl = sanitizeUrlForLog(prepared.url);
+
+ if (shouldEmitRequestLog(normalizedOptions)) {
+ emitLog(normalizedOptions.logger, 'debug', {
+ event: 'request',
+ method,
+ url: logUrl,
+ requestId: prepared.requestId,
+ operationName: requestOptions.operationName,
+ });
+ }
+
+ try {
+ const response = await fetchImpl(prepared.url, prepared.fetchOptions);
+ const cookieSync = await syncResponseCookies({
+ response,
+ cookieStore,
+ cookiePrefix: normalizedOptions.cookiePrefix,
+ cookiePolicy: normalizedOptions.cookiePolicy,
+ requestIsSecure: incomingHeaders.get('x-forwarded-proto')?.split(',')[0]?.trim() === 'https' ||
+ process.env.NODE_ENV === 'production',
+ });
+ const result = await parseApiResponse(response, requestOptions.responseType);
+ result.cookieSync = cookieSync;
+
+ if (shouldEmitResponseLog(normalizedOptions)) {
+ emitLog(normalizedOptions.logger, response.ok ? 'info' : 'warn', {
+ event: 'response',
+ method,
+ url: logUrl,
+ status: result.status,
+ durationMs: Date.now() - startedAt,
+ requestId: prepared.requestId,
+ operationName: requestOptions.operationName,
+ });
+ }
+ return result;
+ } catch (error) {
+ const timedOut = prepared.didTimeout();
+ const aborted = !timedOut && (requestOptions.signal?.aborted || (error instanceof Error && error.name === 'AbortError'));
+ const errorCode = timedOut ? 'REQUEST_TIMEOUT' : aborted ? 'REQUEST_ABORTED' : 'NETWORK_ERROR';
+ const message = timedOut ? 'Backend request timed out' : aborted ? 'Backend request was aborted' : 'Backend request failed';
+ emitLog(normalizedOptions.logger, 'error', {
+ event: 'error',
+ method,
+ url: logUrl,
+ durationMs: Date.now() - startedAt,
+ requestId: prepared.requestId,
+ operationName: requestOptions.operationName,
+ errorCode,
+ message,
+ });
+ return {
+ success: false,
+ message,
+ body: null,
+ status: 0,
+ errorCode,
+ cookieSync: { attempted: false, applied: false, reason: 'no-set-cookie' },
+ };
+ } finally {
+ prepared.cleanupSignal();
+ }
+}
diff --git a/src/request/signal.ts b/src/request/signal.ts
new file mode 100644
index 0000000..b7f5acc
--- /dev/null
+++ b/src/request/signal.ts
@@ -0,0 +1,39 @@
+export interface CombinedSignal {
+ signal?: AbortSignal;
+ cleanup(): void;
+ didTimeout(): boolean;
+}
+
+export function combineAbortSignals(callerSignal?: AbortSignal, timeoutMs?: number): CombinedSignal {
+ if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
+ throw new Error('next-api-bridge: timeoutMs must be a positive finite number');
+ }
+
+ if (!callerSignal && timeoutMs === undefined) {
+ return { signal: undefined, cleanup() {}, didTimeout: () => false };
+ }
+
+ const controller = new AbortController();
+ let timeoutTriggered = false;
+ let timer: ReturnType | undefined;
+ const onAbort = () => controller.abort(callerSignal?.reason);
+
+ if (callerSignal?.aborted) controller.abort(callerSignal.reason);
+ else callerSignal?.addEventListener('abort', onAbort, { once: true });
+
+ if (timeoutMs !== undefined) {
+ timer = setTimeout(() => {
+ timeoutTriggered = true;
+ controller.abort(new Error('Request timed out'));
+ }, timeoutMs);
+ }
+
+ return {
+ signal: controller.signal,
+ cleanup() {
+ if (timer) clearTimeout(timer);
+ callerSignal?.removeEventListener('abort', onAbort);
+ },
+ didTimeout: () => timeoutTriggered,
+ };
+}
diff --git a/src/request/url.ts b/src/request/url.ts
new file mode 100644
index 0000000..777a493
--- /dev/null
+++ b/src/request/url.ts
@@ -0,0 +1,24 @@
+import { EXCLUDED_QUERY_PARAMS } from '../config/constants';
+import { serializeQuery } from '../query';
+
+export function buildRequestUrl(
+ baseUrl: string,
+ path: string,
+ params: string[] = [],
+ query: Record = {},
+): string {
+ if (/[?#]/.test(path)) {
+ throw new Error('next-api-bridge: request path must not contain a query string or fragment');
+ }
+
+ const url = new URL(baseUrl);
+ const baseSegments = url.pathname.split('/').filter(Boolean);
+ const pathSegments = path.split('/').filter(Boolean);
+ const encodedParams = params.map((param) => encodeURIComponent(String(param)));
+ url.pathname = `/${[...baseSegments, ...pathSegments, ...encodedParams].join('/')}`;
+
+ const queryString = serializeQuery(query, EXCLUDED_QUERY_PARAMS);
+ url.search = queryString;
+ url.hash = '';
+ return url.toString();
+}
diff --git a/src/response/parse-response.ts b/src/response/parse-response.ts
new file mode 100644
index 0000000..59bc836
--- /dev/null
+++ b/src/response/parse-response.ts
@@ -0,0 +1,61 @@
+import type { ApiBridgeResponse } from '../types';
+import { collectSafeResponseHeaders } from '../security/headers';
+
+export async function parseApiResponse(
+ response: Response,
+ responseType?: 'json' | 'text',
+): Promise> {
+ const status = response.status;
+ const statusText = response.statusText || undefined;
+ const headers = collectSafeResponseHeaders(response.headers);
+
+ if (status === 204 || status === 205) {
+ return {
+ success: response.ok,
+ message: statusText ?? '',
+ body: null,
+ status,
+ statusText,
+ headers,
+ };
+ }
+
+ const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
+ const shouldUseText = responseType === 'text' || (responseType !== 'json' && contentType.startsWith('text/'));
+ let body: unknown = null;
+
+ try {
+ if (shouldUseText) {
+ body = await response.text();
+ } else if (contentType.includes('application/json') || responseType === 'json') {
+ const text = await response.text();
+ body = text ? JSON.parse(text) : null;
+ } else {
+ const text = await response.text();
+ body = text || null;
+ }
+ } catch {
+ return {
+ success: false,
+ message: 'Invalid backend response',
+ body: null,
+ status,
+ statusText,
+ headers,
+ errorCode: 'INVALID_RESPONSE',
+ };
+ }
+
+ const envelope = body && typeof body === 'object' ? body as Record : undefined;
+ const success = typeof envelope?.success === 'boolean' ? envelope.success : response.ok;
+ const message = typeof envelope?.message === 'string' ? envelope.message : statusText ?? (success ? 'Success' : 'Request failed');
+
+ return {
+ success,
+ message,
+ body: body as T | null,
+ status,
+ statusText,
+ headers,
+ };
+}
diff --git a/src/security/headers.ts b/src/security/headers.ts
new file mode 100644
index 0000000..5437156
--- /dev/null
+++ b/src/security/headers.ts
@@ -0,0 +1,107 @@
+import { SAFE_RESPONSE_HEADERS } from '../config/constants';
+
+const TOKEN_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
+const CRLF_PATTERN = /[\r\n]/;
+const MAX_HEADER_VALUE_LENGTH = 8192;
+
+const FORBIDDEN_EXACT = new Set([
+ 'authorization',
+ 'cookie',
+ 'set-cookie',
+ 'host',
+ 'content-length',
+ 'connection',
+ 'keep-alive',
+ 'transfer-encoding',
+ 'upgrade',
+ 'proxy-authorization',
+ 'proxy-authenticate',
+ 'te',
+ 'trailer',
+ 'accept-encoding',
+ 'next-action',
+ 'next-router-state-tree',
+ 'next-url',
+ 'rsc',
+]);
+
+const FORBIDDEN_PREFIXES = [
+ 'x-middleware-',
+ 'x-nextjs-',
+ 'sec-fetch-',
+ 'sec-websocket-',
+];
+
+export function normalizeHeaderName(name: string): string {
+ return name.trim().toLowerCase();
+}
+
+export function isValidHeaderName(name: string): boolean {
+ return TOKEN_PATTERN.test(name);
+}
+
+export function assertValidHeaderName(name: string, label = 'header'): string {
+ const normalized = normalizeHeaderName(name);
+ if (!normalized || !isValidHeaderName(normalized)) {
+ throw new Error(`next-api-bridge: invalid ${label} name`);
+ }
+ return normalized;
+}
+
+export function isForbiddenRequestHeader(name: string): boolean {
+ const normalized = normalizeHeaderName(name);
+ return FORBIDDEN_EXACT.has(normalized) || FORBIDDEN_PREFIXES.some((prefix) => normalized.startsWith(prefix));
+}
+
+export function validateHeaderValue(value: string, name = 'header'): string {
+ if (CRLF_PATTERN.test(value)) {
+ throw new Error(`next-api-bridge: ${name} value contains prohibited CR/LF characters`);
+ }
+ if (value.length > MAX_HEADER_VALUE_LENGTH) {
+ throw new Error(`next-api-bridge: ${name} value exceeds ${MAX_HEADER_VALUE_LENGTH} characters`);
+ }
+ return value;
+}
+
+export function assertAllowedCustomHeaders(
+ headers: Record | undefined,
+ managedHeaders: Iterable = [],
+): Record {
+ if (!headers) return {};
+
+ const managed = new Set(Array.from(managedHeaders, normalizeHeaderName));
+ const result: Record = {};
+
+ for (const [name, value] of Object.entries(headers)) {
+ const normalized = assertValidHeaderName(name, 'custom header');
+ if (isForbiddenRequestHeader(normalized) || managed.has(normalized)) {
+ throw new Error(`next-api-bridge: custom header "${normalized}" is managed or forbidden`);
+ }
+ result[normalized] = validateHeaderValue(String(value), normalized);
+ }
+
+ return result;
+}
+
+export function getSafeIncomingHeader(headers: Headers, name: string): string | undefined {
+ const normalized = assertValidHeaderName(name, 'incoming header');
+ if (isForbiddenRequestHeader(normalized)) return undefined;
+ const value = headers.get(normalized);
+ if (value === null || value === '') return undefined;
+ return validateHeaderValue(value, normalized);
+}
+
+export function collectSafeResponseHeaders(headers: Headers): Record | undefined {
+ const result: Record = {};
+ for (const name of SAFE_RESPONSE_HEADERS) {
+ const value = headers.get(name);
+ if (value !== null && !CRLF_PATTERN.test(value) && value.length <= MAX_HEADER_VALUE_LENGTH) {
+ result[name] = value;
+ }
+ }
+ return Object.keys(result).length ? result : undefined;
+}
+
+export function isSafeCookiePrefix(prefix: string): boolean {
+ return prefix.length > 0 && TOKEN_PATTERN.test(prefix);
+}
diff --git a/src/security/ip.ts b/src/security/ip.ts
new file mode 100644
index 0000000..7655272
--- /dev/null
+++ b/src/security/ip.ts
@@ -0,0 +1,72 @@
+import { isIP } from 'node:net';
+import type { TrustProxyConfig } from '../types';
+import { assertValidHeaderName, validateHeaderValue } from './headers';
+
+export function normalizeIp(value: string): string | undefined {
+ let candidate = value.trim();
+ if (!candidate) return undefined;
+
+ if (candidate.startsWith('[')) {
+ const close = candidate.indexOf(']');
+ if (close === -1) return undefined;
+ candidate = candidate.slice(1, close);
+ } else if (candidate.includes(':') && candidate.includes('.') && candidate.lastIndexOf(':') > candidate.lastIndexOf('.')) {
+ const possiblePort = candidate.slice(candidate.lastIndexOf(':') + 1);
+ if (/^\d+$/.test(possiblePort)) candidate = candidate.slice(0, candidate.lastIndexOf(':'));
+ }
+
+ const zoneIndex = candidate.indexOf('%');
+ if (zoneIndex !== -1) candidate = candidate.slice(0, zoneIndex);
+ return isIP(candidate) ? candidate.toLowerCase() : undefined;
+}
+
+export function parseForwardedChain(value: string): string[] | undefined {
+ try {
+ validateHeaderValue(value, 'forwarded IP');
+ } catch {
+ return undefined;
+ }
+
+ const parts = value.split(',').map((part) => normalizeIp(part));
+ if (parts.some((part) => !part)) return undefined;
+ return parts as string[];
+}
+
+function read(headers: Headers, name: string): string | undefined {
+ const value = headers.get(name);
+ return value ? value.trim() : undefined;
+}
+
+export function resolveClientIp(headers: Headers, trustProxy: TrustProxyConfig): string | undefined {
+ if (trustProxy === false) return undefined;
+
+ if (trustProxy === 'cloudflare') {
+ const value = read(headers, 'cf-connecting-ip');
+ return value ? normalizeIp(value) : undefined;
+ }
+
+ if (trustProxy === 'vercel') {
+ const vercelForwarded = read(headers, 'x-vercel-forwarded-for');
+ if (vercelForwarded) {
+ const chain = parseForwardedChain(vercelForwarded);
+ if (chain?.length) return chain[0];
+ }
+ const realIp = read(headers, 'x-real-ip');
+ return realIp ? normalizeIp(realIp) : undefined;
+ }
+
+ const hops = trustProxy.trustedProxyHops ?? 0;
+ if (!Number.isInteger(hops) || hops < 0) return undefined;
+
+ for (const rawName of trustProxy.headers) {
+ const name = assertValidHeaderName(rawName, 'trusted proxy header');
+ const value = read(headers, name);
+ if (!value) continue;
+ const chain = parseForwardedChain(value);
+ if (!chain?.length) continue;
+ const index = chain.length - 1 - hops;
+ if (index >= 0) return chain[index];
+ }
+
+ return undefined;
+}
diff --git a/src/security/origin.ts b/src/security/origin.ts
new file mode 100644
index 0000000..9a8403f
--- /dev/null
+++ b/src/security/origin.ts
@@ -0,0 +1,57 @@
+export interface ClientOriginValidationOptions {
+ allowedHosts?: string[];
+ allowedOrigins?: string[];
+}
+
+function normalizeHost(host: string): string {
+ return host.trim().toLowerCase().replace(/\.$/, '');
+}
+
+export function validateClientOrigin(
+ value: string,
+ options: ClientOriginValidationOptions,
+): string | undefined {
+ let url: URL;
+ try {
+ url = new URL(value);
+ } catch {
+ return undefined;
+ }
+
+ if (!['http:', 'https:'].includes(url.protocol)) return undefined;
+ if (url.username || url.password) return undefined;
+ if (url.pathname !== '/' || url.search || url.hash) return undefined;
+
+ const origin = url.origin;
+ const allowedOrigins = new Set((options.allowedOrigins ?? []).map((item) => {
+ try {
+ return new URL(item).origin;
+ } catch {
+ return '';
+ }
+ }).filter(Boolean));
+ const allowedHosts = new Set((options.allowedHosts ?? []).map(normalizeHost));
+
+ if (allowedOrigins.has(origin)) return origin;
+ if (allowedHosts.has(normalizeHost(url.host)) || allowedHosts.has(normalizeHost(url.hostname))) return origin;
+ return undefined;
+}
+
+export function deriveClientOrigin(headers: Headers): string | undefined {
+ const directOrigin = headers.get('origin');
+ if (directOrigin && !/[\r\n]/.test(directOrigin)) {
+ try {
+ const parsed = new URL(directOrigin);
+ if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && !parsed.username && !parsed.password) return parsed.origin;
+ } catch {
+ // Continue with trusted host/proto headers.
+ }
+ }
+ const host = (headers.get('x-forwarded-host') ?? headers.get('host'))?.split(',')[0]?.trim();
+ if (!host || /[\r\n/]/.test(host)) return undefined;
+ const forwardedProto = headers.get('x-forwarded-proto')?.split(',')[0]?.trim().toLowerCase();
+ const protocol = forwardedProto === 'http' || forwardedProto === 'https'
+ ? forwardedProto
+ : /^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(host) ? 'http' : 'https';
+ return `${protocol}://${host}`;
+}
diff --git a/src/testing.ts b/src/testing.ts
new file mode 100644
index 0000000..19845a1
--- /dev/null
+++ b/src/testing.ts
@@ -0,0 +1,20 @@
+export { validateAndNormalizeOptions } from './config/validate';
+export { parseSetCookieHeader, parseSetCookieString, splitSetCookieHeader } from './cookies/parse-set-cookie';
+export { applyCookiePolicy, normalizeCookiePolicy, shouldDeleteCookie } from './cookies/policy';
+export { syncResponseCookies } from './cookies/sync-response-cookies';
+export { redactHeaders, redactValue, sanitizeUrlForLog } from './logger/redact';
+export { validateCacheOptions } from './request/cache';
+export { buildRequestContextHeaders } from './request/context';
+export { combineAbortSignals } from './request/signal';
+export { executeBridgeRequest, prepareBridgeRequest, serializeRequestBody } from './request/execute';
+export { buildRequestUrl } from './request/url';
+export { parseApiResponse } from './response/parse-response';
+export {
+ assertAllowedCustomHeaders,
+ collectSafeResponseHeaders,
+ isForbiddenRequestHeader,
+ isValidHeaderName,
+ validateHeaderValue,
+} from './security/headers';
+export { normalizeIp, parseForwardedChain, resolveClientIp } from './security/ip';
+export { deriveClientOrigin, validateClientOrigin } from './security/origin';
diff --git a/src/types/auth.ts b/src/types/auth.ts
index 5dd0ba9..51276ae 100644
--- a/src/types/auth.ts
+++ b/src/types/auth.ts
@@ -1,13 +1,6 @@
-/**
- * Bearer token authentication configuration.
- * Automatically reads token from cookies and adds it as an Authorization header.
- */
export interface BearerAuthConfig {
type: 'bearer';
- /** Cookie name to read the token from (e.g., 'accessToken') */
tokenCookie: string;
- /** Header name to set (default: 'Authorization') */
header?: string;
- /** Prefix to use (default: 'Bearer') */
prefix?: string;
}
diff --git a/src/types/client.ts b/src/types/client.ts
index cc17070..43dee37 100644
--- a/src/types/client.ts
+++ b/src/types/client.ts
@@ -1,41 +1,85 @@
import type { BearerAuthConfig } from './auth';
+import type { CookiePolicyOptions } from './cookies';
+import type { SafeLogEntry } from './logging';
+
+export type ForwardableRequestHeader =
+ | 'user-agent'
+ | 'accept-language'
+ | 'traceparent'
+ | 'baggage';
+
+export type TrustProxyConfig =
+ | false
+ | 'vercel'
+ | 'cloudflare'
+ | {
+ headers: string[];
+ trustedProxyHops?: number;
+ };
+
+export interface RequestContextOptions {
+ enabled?: boolean;
+ forwardHeaders?: ForwardableRequestHeader[];
+ requestId?: {
+ incomingHeaders?: string[];
+ outgoingHeader?: string;
+ generateWhenMissing?: boolean;
+ };
+ clientIp?: {
+ enabled?: boolean;
+ trustProxy: TrustProxyConfig;
+ outgoingHeader?: string;
+ };
+ clientOrigin?: {
+ enabled?: boolean;
+ cookieName?: string;
+ allowedHosts?: string[];
+ allowedOrigins?: string[];
+ outgoingHeader?: string;
+ };
+}
+
+export interface BridgeLogger {
+ debug?(entry: SafeLogEntry): void;
+ info?(entry: SafeLogEntry): void;
+ warn?(entry: SafeLogEntry): void;
+ error?(entry: SafeLogEntry): void;
+}
-/**
- * Configuration options for creating an API bridge client.
- */
export interface ApiBridgeOptions {
- /** Base URL of the backend API (required) */
baseUrl: string;
- /** Prefix for backend cookies (default: 'nab_') */
cookiePrefix?: string;
- /** Optional API key for authentication */
apiKey?: string;
- /** Header name for API key (required if apiKey is provided) */
apiKeyHeader?: string;
- /** Optional Bearer token authentication configuration */
auth?: BearerAuthConfig;
- /** Comma-separated verbose logging options (e.g., 'request,body,response') */
verbose?: string;
+ logger?: BridgeLogger;
+ requestContext?: RequestContextOptions;
+ cookiePolicy?: CookiePolicyOptions;
+}
+
+export interface NextCacheOptions {
+ revalidate?: number | false;
+ tags?: string[];
}
-/**
- * Request options for API calls.
- */
export interface RequestOptions {
- /** Query parameters to append to the URL */
query?: Record;
- /** Path parameters to insert into the URL */
params?: string[];
- /** Cache control option */
cache?: 'no-store' | 'force-cache' | 'only-if-cached';
- /** Whether the request is multipart/form-data */
isMultipart?: boolean;
+ next?: NextCacheOptions;
+ headers?: Record;
+ signal?: AbortSignal;
+ timeoutMs?: number;
+ operationName?: string;
+ responseType?: 'json' | 'text';
}
-/**
- * Result of request preparation.
- */
export interface PrepareRequestResult {
url: string;
- fetchOptions: RequestInit;
+ fetchOptions: RequestInit & { next?: NextCacheOptions };
+ requestId?: string;
+ cleanupSignal(): void;
+ didTimeout(): boolean;
}
diff --git a/src/types/cookies.ts b/src/types/cookies.ts
index 7a8dad0..de01113 100644
--- a/src/types/cookies.ts
+++ b/src/types/cookies.ts
@@ -1,26 +1,32 @@
-/**
- * Cookie options for setting cookies.
- */
export interface CookieOptions {
httpOnly?: boolean;
secure?: boolean;
sameSite?: 'strict' | 'lax' | 'none';
maxAge?: number;
+ expires?: Date;
path?: string;
domain?: string;
+ priority?: 'low' | 'medium' | 'high';
+ partitioned?: boolean;
}
-/**
- * Parsed cookie from Set-Cookie header.
- */
-export interface ParsedCookie {
+export interface ParsedCookie extends CookieOptions {
name: string;
value: string;
- httpOnly?: boolean;
- secure?: boolean;
- sameSite?: string;
- maxAge?: number;
- expires?: Date;
- path?: string;
- domain?: string;
+}
+
+export interface CookiePolicyOptions {
+ domain?: 'drop' | 'preserve';
+ path?: '/' | 'preserve';
+ secure?: 'auto' | 'preserve';
+ preserveExpires?: boolean;
+ removeLegacyUnprefixedCookies?: boolean;
+}
+
+export interface NormalizedCookiePolicy {
+ domain: 'drop' | 'preserve';
+ path: '/' | 'preserve';
+ secure: 'auto' | 'preserve';
+ preserveExpires: boolean;
+ removeLegacyUnprefixedCookies: boolean;
}
diff --git a/src/types/index.ts b/src/types/index.ts
index bac79c2..63a840c 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,19 +1,27 @@
export type {
ApiBridgeOptions,
- RequestOptions,
+ BridgeLogger,
+ ForwardableRequestHeader,
+ NextCacheOptions,
PrepareRequestResult,
+ RequestContextOptions,
+ RequestOptions,
+ TrustProxyConfig,
} from './client';
export type {
ApiBridgeResponse,
+ CookieSyncInfo,
+ CookieSyncReason,
FormActionResponse,
} from './response';
export type {
CookieOptions,
+ CookiePolicyOptions,
+ NormalizedCookiePolicy,
ParsedCookie,
} from './cookies';
-export type {
- BearerAuthConfig,
-} from './auth';
+export type { SafeLogEntry } from './logging';
+export type { BearerAuthConfig } from './auth';
diff --git a/src/types/logging.ts b/src/types/logging.ts
new file mode 100644
index 0000000..2e34363
--- /dev/null
+++ b/src/types/logging.ts
@@ -0,0 +1,12 @@
+export interface SafeLogEntry {
+ event: 'request' | 'response' | 'error' | 'cookie';
+ method?: string;
+ url?: string;
+ status?: number;
+ durationMs?: number;
+ requestId?: string;
+ operationName?: string;
+ message?: string;
+ errorCode?: string;
+ details?: Record;
+}
diff --git a/src/types/response.ts b/src/types/response.ts
index 09ec358..708a2bf 100644
--- a/src/types/response.ts
+++ b/src/types/response.ts
@@ -1,16 +1,26 @@
-/**
- * Standard API response format.
- */
+export type CookieSyncReason =
+ | 'read-only-context'
+ | 'no-set-cookie'
+ | 'invalid-cookie'
+ | 'applied';
+
+export interface CookieSyncInfo {
+ attempted: boolean;
+ applied: boolean;
+ reason?: CookieSyncReason;
+}
+
export interface ApiBridgeResponse {
success: boolean;
message: string;
body: T | null;
- headers?: Headers;
+ status: number;
+ statusText?: string;
+ headers?: Record;
+ errorCode?: string;
+ cookieSync?: CookieSyncInfo;
}
-/**
- * Form action response format for Server Actions.
- */
export type FormActionResponse = Promise<{
formdata: Partial;
success: boolean;
diff --git a/tests/e2e/run.mjs b/tests/e2e/run.mjs
new file mode 100644
index 0000000..9c27db0
--- /dev/null
+++ b/tests/e2e/run.mjs
@@ -0,0 +1,83 @@
+import { spawn, spawnSync } from 'node:child_process';
+import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { setTimeout as sleep } from 'node:timers/promises';
+
+const root = resolve(fileURLToPath(new URL('../..', import.meta.url)));
+const fixture = resolve(root, 'tests/fixtures/next-app');
+const backendFile = resolve(root, 'tests/fixtures/backend/server.mjs');
+const packDir = resolve(root, '.e2e-pack');
+const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
+const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
+const nextVersion = process.env.NEXT_E2E_VERSION ?? '15';
+const env = {
+ ...process.env,
+ API_URL: 'http://127.0.0.1:4100/v1',
+ BACKEND_PORT: '4100',
+ NEXT_APP_URL: 'http://127.0.0.1:3100',
+ NEXT_TELEMETRY_DISABLED: '1',
+};
+
+function run(command, args, options = {}) {
+ const result = spawnSync(command, args, {
+ cwd: options.cwd ?? root,
+ env: options.env ?? env,
+ stdio: 'inherit',
+ });
+ if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} failed with status ${result.status}`);
+}
+
+async function waitFor(url, timeoutMs = 60_000) {
+ const started = Date.now();
+ while (Date.now() - started < timeoutMs) {
+ try {
+ const response = await fetch(url);
+ if (response.status < 500) return;
+ } catch {
+ // Service is not ready yet.
+ }
+ await sleep(250);
+ }
+ throw new Error(`Timed out waiting for ${url}`);
+}
+
+let backend;
+let next;
+try {
+ run(npm, ['run', 'build']);
+ rmSync(packDir, { recursive: true, force: true });
+ mkdirSync(packDir, { recursive: true });
+ run(npm, ['pack', '--ignore-scripts', '--pack-destination', packDir]);
+ const tarballName = readdirSync(packDir).find((name) => name.endsWith('.tgz'));
+ if (!tarballName) throw new Error('npm pack did not create a tarball');
+ const tarball = resolve(packDir, tarballName);
+ if (!existsSync(tarball)) throw new Error('npm pack tarball is missing');
+
+ rmSync(resolve(fixture, 'node_modules'), { recursive: true, force: true });
+ rmSync(resolve(fixture, '.next'), { recursive: true, force: true });
+ rmSync(resolve(fixture, 'package-lock.json'), { force: true });
+ run(npm, [
+ 'install', '--no-save', '--no-package-lock',
+ tarball,
+ `next@${nextVersion}`,
+ 'react@19',
+ 'react-dom@19',
+ '@playwright/test',
+ 'typescript@5.8.2',
+ '@types/react@19',
+ '@types/react-dom@19',
+ ], { cwd: fixture });
+
+ run(npx, ['playwright', 'install', ...(process.env.CI ? ['--with-deps'] : []), 'chromium'], { cwd: fixture });
+
+ backend = spawn(process.execPath, [backendFile], { cwd: root, env, stdio: 'inherit' });
+ await waitFor('http://127.0.0.1:4100/v1/empty');
+ run(npm, ['run', 'build'], { cwd: fixture });
+ next = spawn(npm, ['run', 'start', '--', '-p', '3100'], { cwd: fixture, env, stdio: 'inherit' });
+ await waitFor('http://127.0.0.1:3100');
+ run(npx, ['playwright', 'test'], { cwd: fixture });
+} finally {
+ next?.kill('SIGTERM');
+ backend?.kill('SIGTERM');
+}
diff --git a/tests/fixtures/backend/server.mjs b/tests/fixtures/backend/server.mjs
new file mode 100644
index 0000000..359c51f
--- /dev/null
+++ b/tests/fixtures/backend/server.mjs
@@ -0,0 +1,80 @@
+import http from 'node:http';
+
+const port = Number(process.env.BACKEND_PORT ?? 4100);
+const counters = new Map();
+
+function json(res, status, body, headers = {}) {
+ res.writeHead(status, { 'content-type': 'application/json', ...headers });
+ res.end(JSON.stringify(body));
+}
+
+const server = http.createServer((req, res) => {
+ const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
+ const route = url.pathname.replace(/^\/v1/, '') || '/';
+
+ if (route === '/login') {
+ json(res, 200, { success: true, message: 'logged-in', user: { id: 'user-1' } }, {
+ 'set-cookie': 'session=login-session; Expires=Wed, 21 Oct 2030 07:28:00 GMT; Path=/api; Domain=backend.invalid; HttpOnly; SameSite=Lax',
+ 'x-request-id': req.headers['x-request-id'] ?? 'backend-login',
+ });
+ return;
+ }
+
+ if (route === '/rotate') {
+ json(res, 200, { success: true, message: 'rotated' }, {
+ 'set-cookie': 'session=rotated-session; Expires=Wed, 21 Oct 2030 07:28:00 GMT; Path=/auth; Domain=backend.invalid; HttpOnly; SameSite=Lax',
+ 'x-request-id': req.headers['x-request-id'] ?? 'backend-rotate',
+ });
+ return;
+ }
+
+ if (route === '/echo') {
+ json(res, 200, {
+ success: true,
+ message: 'echo',
+ userAgent: req.headers['user-agent'] ?? null,
+ acceptLanguage: req.headers['accept-language'] ?? null,
+ traceparent: req.headers.traceparent ?? null,
+ requestId: req.headers['x-request-id'] ?? null,
+ clientIp: req.headers['x-client-ip'] ?? null,
+ clientOrigin: req.headers['x-client-origin'] ?? null,
+ bridge: req.headers['x-api-bridge'] ?? null,
+ query: Object.fromEntries(url.searchParams),
+ }, { 'x-request-id': req.headers['x-request-id'] ?? 'backend-echo' });
+ return;
+ }
+
+ if (route === '/empty') {
+ res.writeHead(204, { 'x-request-id': req.headers['x-request-id'] ?? 'backend-empty' });
+ res.end();
+ return;
+ }
+
+ if (route.startsWith('/status/')) {
+ const status = Number(route.split('/').pop());
+ json(res, status, { success: false, message: `status-${status}`, status }, {
+ 'x-request-id': `status-${status}`,
+ 'retry-after': status === 429 ? '10' : '0',
+ 'set-cookie': 'should-not-be-exposed=secret; HttpOnly',
+ });
+ return;
+ }
+
+ if (route === '/cache') {
+ const key = url.searchParams.get('key') ?? 'default';
+ const count = (counters.get(key) ?? 0) + 1;
+ counters.set(key, count);
+ json(res, 200, { success: true, message: 'cache', key, count });
+ return;
+ }
+
+ json(res, 404, { success: false, message: 'not-found' });
+});
+
+server.listen(port, '127.0.0.1', () => {
+ console.log(`backend fixture listening on ${port}`);
+});
+
+for (const signal of ['SIGINT', 'SIGTERM']) {
+ process.on(signal, () => server.close(() => process.exit(0)));
+}
diff --git a/tests/fixtures/next-app/app/actions.ts b/tests/fixtures/next-app/app/actions.ts
new file mode 100644
index 0000000..1aa04ea
--- /dev/null
+++ b/tests/fixtures/next-app/app/actions.ts
@@ -0,0 +1,46 @@
+'use server';
+
+import { cookies } from 'next/headers';
+import { api, originApi, trustedApi } from '../lib/api';
+
+export async function loginAction(_previous: unknown, _formData: FormData) {
+ const store = await cookies();
+ store.set('app_theme', 'dark', { path: '/' });
+ return api.post('/login', { email: 'user@example.com' }, { operationName: 'e2e.login' });
+}
+
+export async function rotateAction(_previous: unknown, _formData: FormData) {
+ return api.post('/rotate', undefined, { operationName: 'e2e.rotate' });
+}
+
+export async function inspectAction(_previous: unknown, _formData: FormData) {
+ return api.get('/echo', {
+ operationName: 'e2e.inspect',
+ query: { missing: undefined, nil: null, active: false, page: 0, search: '' },
+ });
+}
+
+export async function trustedAction(_previous: unknown, _formData: FormData) {
+ return trustedApi.get('/echo', { operationName: 'e2e.trusted' });
+}
+
+export async function originAction(_previous: unknown, _formData: FormData) {
+ return originApi.get('/echo', { operationName: 'e2e.origin' });
+}
+
+export async function statusAction(_previous: unknown, formData: FormData) {
+ const status = String(formData.get('status') ?? '500');
+ return api.get(`/status/${status}`, { operationName: `e2e.status.${status}` });
+}
+
+export async function emptyAction(_previous: unknown, _formData: FormData) {
+ return api.get('/empty', { operationName: 'e2e.empty' });
+}
+
+export async function cacheAction(_previous: unknown, _formData: FormData) {
+ const forceOne = await api.get('/cache', { cache: 'force-cache', query: { key: 'force' } });
+ const forceTwo = await api.get('/cache', { cache: 'force-cache', query: { key: 'force' } });
+ const freshOne = await api.get('/cache', { cache: 'no-store', query: { key: 'fresh' } });
+ const freshTwo = await api.get('/cache', { cache: 'no-store', query: { key: 'fresh' } });
+ return { forceOne, forceTwo, freshOne, freshTwo };
+}
diff --git a/tests/fixtures/next-app/app/client-harness.tsx b/tests/fixtures/next-app/app/client-harness.tsx
new file mode 100644
index 0000000..2822cc5
--- /dev/null
+++ b/tests/fixtures/next-app/app/client-harness.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import { useActionState } from 'react';
+import {
+ cacheAction,
+ emptyAction,
+ inspectAction,
+ loginAction,
+ originAction,
+ rotateAction,
+ statusAction,
+ trustedAction,
+} from './actions';
+
+function Result({ id, value }: { id: string; value: unknown }) {
+ return {JSON.stringify(value)};
+}
+
+export function ClientHarness() {
+ const [login, loginForm] = useActionState(loginAction, null);
+ const [rotate, rotateForm] = useActionState(rotateAction, null);
+ const [inspect, inspectForm] = useActionState(inspectAction, null);
+ const [trusted, trustedForm] = useActionState(trustedAction, null);
+ const [origin, originForm] = useActionState(originAction, null);
+ const [status, statusForm] = useActionState(statusAction, null);
+ const [empty, emptyForm] = useActionState(emptyAction, null);
+ const [cache, cacheForm] = useActionState(cacheAction, null);
+
+ return
+
+
+
+
+
+
+
+
+ ;
+}
diff --git a/tests/fixtures/next-app/app/layout.tsx b/tests/fixtures/next-app/app/layout.tsx
new file mode 100644
index 0000000..0e3f772
--- /dev/null
+++ b/tests/fixtures/next-app/app/layout.tsx
@@ -0,0 +1,3 @@
+export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
+ return {children};
+}
diff --git a/tests/fixtures/next-app/app/page.tsx b/tests/fixtures/next-app/app/page.tsx
new file mode 100644
index 0000000..81042f5
--- /dev/null
+++ b/tests/fixtures/next-app/app/page.tsx
@@ -0,0 +1,12 @@
+import { api } from '../lib/api';
+import { ClientHarness } from './client-harness';
+
+export const dynamic = 'force-dynamic';
+
+export default async function Page() {
+ const serverComponentResult = await api.get('/rotate', { operationName: 'e2e.server-component' });
+ return <>
+ {JSON.stringify(serverComponentResult)}
+
+ >;
+}
diff --git a/tests/fixtures/next-app/lib/api.ts b/tests/fixtures/next-app/lib/api.ts
new file mode 100644
index 0000000..00e0c2a
--- /dev/null
+++ b/tests/fixtures/next-app/lib/api.ts
@@ -0,0 +1,22 @@
+import { createNextApiBridge } from 'next-api-bridge';
+
+const baseUrl = process.env.API_URL!;
+
+export const api = createNextApiBridge({ baseUrl });
+
+export const trustedApi = createNextApiBridge({
+ baseUrl,
+ requestContext: {
+ clientIp: { enabled: true, trustProxy: 'cloudflare' },
+ },
+});
+
+export const originApi = createNextApiBridge({
+ baseUrl,
+ requestContext: {
+ clientOrigin: {
+ enabled: true,
+ allowedHosts: ['127.0.0.1', 'localhost'],
+ },
+ },
+});
diff --git a/tests/fixtures/next-app/next-env.d.ts b/tests/fixtures/next-app/next-env.d.ts
new file mode 100644
index 0000000..6080add
--- /dev/null
+++ b/tests/fixtures/next-app/next-env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/tests/fixtures/next-app/next.config.mjs b/tests/fixtures/next-app/next.config.mjs
new file mode 100644
index 0000000..b50b442
--- /dev/null
+++ b/tests/fixtures/next-app/next.config.mjs
@@ -0,0 +1,5 @@
+/** @type {import('next').NextConfig} */
+const config = {
+ reactStrictMode: true,
+};
+export default config;
diff --git a/tests/fixtures/next-app/package.json b/tests/fixtures/next-app/package.json
new file mode 100644
index 0000000..0772e9e
--- /dev/null
+++ b/tests/fixtures/next-app/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "next-api-bridge-e2e-fixture",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "next build",
+ "start": "next start"
+ }
+}
diff --git a/tests/fixtures/next-app/playwright.config.mjs b/tests/fixtures/next-app/playwright.config.mjs
new file mode 100644
index 0000000..d02949e
--- /dev/null
+++ b/tests/fixtures/next-app/playwright.config.mjs
@@ -0,0 +1,17 @@
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './tests',
+ timeout: 60_000,
+ use: {
+ baseURL: process.env.NEXT_APP_URL ?? 'http://127.0.0.1:3100',
+ userAgent: 'next-api-bridge-e2e-browser/1.0',
+ locale: 'en-KE',
+ extraHTTPHeaders: {
+ 'x-request-id': 'browser-request-id',
+ 'cf-connecting-ip': '203.0.113.42',
+ 'x-forwarded-for': '198.51.100.99',
+ traceparent: '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01'
+ }
+ }
+});
diff --git a/tests/fixtures/next-app/tests/bridge.spec.mjs b/tests/fixtures/next-app/tests/bridge.spec.mjs
new file mode 100644
index 0000000..4330d73
--- /dev/null
+++ b/tests/fixtures/next-app/tests/bridge.spec.mjs
@@ -0,0 +1,101 @@
+import { expect, test } from '@playwright/test';
+
+async function submitAndRead(page, buttonId, resultId) {
+ const result = page.getByTestId(resultId);
+ const previous = await result.textContent();
+ await page.getByTestId(buttonId).click();
+ await expect.poll(async () => result.textContent()).not.toBe(previous);
+ const text = await result.textContent();
+ if (!text || text === 'null') throw new Error(`No result produced for ${buttonId}`);
+ return JSON.parse(text);
+}
+
+test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+});
+
+test('Server Component reports read-only cookie synchronization', async ({ page }) => {
+ const result = JSON.parse(await page.getByTestId('server-component-sync').textContent());
+ expect(result.cookieSync).toEqual({ attempted: true, applied: false, reason: 'read-only-context' });
+ expect(result.headers).not.toHaveProperty('set-cookie');
+});
+
+test('useActionState consumes serializable login response and cookie policy is enforced', async ({ page, context }) => {
+ const result = await submitAndRead(page, 'login', 'login-result');
+ expect(result.status).toBe(200);
+ expect(result.success).toBe(true);
+ expect(result.headers).toEqual({ 'x-request-id': 'browser-request-id' });
+ expect(JSON.stringify(result)).not.toContain('Set-Cookie');
+ expect(JSON.stringify(result)).not.toContain('login-session');
+
+ const cookies = await context.cookies();
+ const session = cookies.find((cookie) => cookie.name === 'nab_session');
+ const unrelated = cookies.find((cookie) => cookie.name === 'app_theme');
+ expect(session).toBeTruthy();
+ expect(session.value).toBe('login-session');
+ expect(session.path).toBe('/');
+ expect(session.domain).toBe('127.0.0.1');
+ expect(session.expires).toBeGreaterThan(Date.now() / 1000);
+ expect(unrelated?.value).toBe('dark');
+});
+
+test('rotated authentication cookies persist from a Server Action', async ({ page, context }) => {
+ await submitAndRead(page, 'login', 'login-result');
+ const result = await submitAndRead(page, 'rotate', 'rotate-result');
+ expect(result.cookieSync).toEqual({ attempted: true, applied: true, reason: 'applied' });
+ const session = (await context.cookies()).find((cookie) => cookie.name === 'nab_session');
+ expect(session?.value).toBe('rotated-session');
+ expect(session?.path).toBe('/');
+});
+
+test('browser context is forwarded safely while spoofed IP is ignored by default', async ({ page }) => {
+ const result = await submitAndRead(page, 'inspect', 'inspect-result');
+ expect(result.body.userAgent).toContain('next-api-bridge-e2e-browser/1.0');
+ expect(result.body.acceptLanguage.toLowerCase()).toContain('en-ke');
+ expect(result.body.traceparent).toBe('00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01');
+ expect(result.body.requestId).toBe('browser-request-id');
+ expect(result.body.bridge).toBe('next-api-bridge/0.1.7');
+ expect(result.body.clientIp).toBeNull();
+ expect(result.body.query).toEqual({ active: 'false', page: '0', search: '' });
+ expect(JSON.stringify(result.body.query)).not.toMatch(/undefined|null/);
+});
+
+test('trusted client IP and approved client origin are forwarded explicitly', async ({ page }) => {
+ const trusted = await submitAndRead(page, 'trusted', 'trusted-result');
+ expect(trusted.body.clientIp).toBe('203.0.113.42');
+
+ const origin = await submitAndRead(page, 'origin', 'origin-result');
+ expect(origin.body.clientOrigin).toMatch(/^http:\/\/127\.0\.0\.1:3100$/);
+});
+
+test('HTTP status codes and empty responses remain distinguishable', async ({ page }) => {
+ for (const status of [401, 403, 404, 409, 422, 429, 500]) {
+ await page.getByTestId('status-input').fill(String(status));
+ const result = await submitAndRead(page, 'status', 'status-result');
+ expect(result.status).toBe(status);
+ expect(result.body.status).toBe(status);
+ expect(result.headers).not.toHaveProperty('set-cookie');
+ }
+
+ const empty = await submitAndRead(page, 'empty', 'empty-result');
+ expect(empty.status).toBe(204);
+ expect(empty.body).toBeNull();
+ expect(empty.errorCode).toBeUndefined();
+});
+
+test('force-cache reuses backend data and no-store stays fresh', async ({ page }) => {
+ const result = await submitAndRead(page, 'cache', 'cache-result');
+ expect(result.forceOne.body.count).toBe(result.forceTwo.body.count);
+ expect(result.freshTwo.body.count).toBe(result.freshOne.body.count + 1);
+});
+
+test('Server Action responses contain plain records and no transport secrets', async ({ page }) => {
+ const result = await submitAndRead(page, 'inspect', 'inspect-result');
+ expect(Object.getPrototypeOf(result.headers)).toBe(Object.prototype);
+ const serialized = JSON.stringify(result);
+ expect(result.headers).not.toHaveProperty('authorization');
+ expect(result.headers).not.toHaveProperty('cookie');
+ expect(result.headers).not.toHaveProperty('set-cookie');
+ expect(serialized).not.toContain('login-session');
+ expect(serialized).not.toContain('rotated-session');
+});
diff --git a/tests/fixtures/next-app/tsconfig.json b/tests/fixtures/next-app/tsconfig.json
new file mode 100644
index 0000000..a0533d6
--- /dev/null
+++ b/tests/fixtures/next-app/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }]
+ },
+ "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
+ "exclude": ["node_modules"]
+}
diff --git a/tests/integration/bridge.test.cjs b/tests/integration/bridge.test.cjs
new file mode 100644
index 0000000..563135c
--- /dev/null
+++ b/tests/integration/bridge.test.cjs
@@ -0,0 +1,220 @@
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const test = require('node:test');
+const testing = require('../../.test-dist/testing.js');
+
+class MemoryCookies {
+ constructor(values = {}) {
+ this.values = new Map(Object.entries(values));
+ this.writes = [];
+ this.deletes = [];
+ }
+ get(name) {
+ const value = this.values.get(name);
+ return value === undefined ? undefined : { name, value };
+ }
+ getAll() {
+ return Array.from(this.values, ([name, value]) => ({ name, value }));
+ }
+ set(name, value, options = {}) {
+ this.values.set(name, value);
+ this.writes.push({ name, value, options });
+ }
+ delete(name) {
+ this.values.delete(name);
+ this.deletes.push(name);
+ }
+}
+
+let server;
+let baseUrl;
+let lastRequest;
+
+function readBody(req) {
+ return new Promise((resolve) => {
+ let body = '';
+ req.setEncoding('utf8');
+ req.on('data', (chunk) => body += chunk);
+ req.on('end', () => resolve(body));
+ });
+}
+
+test.before(async () => {
+ server = http.createServer(async (req, res) => {
+ const body = await readBody(req);
+ const route = req.url.replace(/^\/v1/, '') || '/';
+ lastRequest = { method: req.method, url: route, headers: req.headers, body };
+ if (route.startsWith('/empty')) {
+ res.statusCode = 204;
+ res.end();
+ return;
+ }
+ if (route.startsWith('/text')) {
+ res.setHeader('content-type', 'text/plain');
+ res.end('plain response');
+ return;
+ }
+ if (route.startsWith('/status/')) {
+ const status = Number(route.split('/').pop());
+ res.statusCode = status;
+ res.setHeader('content-type', 'application/json');
+ res.setHeader('x-request-id', 'backend-request');
+ res.setHeader('x-ratelimit-remaining', '4');
+ res.setHeader('set-cookie', 'rotated=secret; Path=/api; Domain=backend.invalid; Expires=Wed, 21 Oct 2030 07:28:00 GMT; HttpOnly');
+ res.end(JSON.stringify({ success: false, message: `status-${status}`, status }));
+ return;
+ }
+ res.setHeader('content-type', 'application/json');
+ res.setHeader('x-request-id', req.headers['x-request-id'] || 'generated-backend');
+ res.end(JSON.stringify({
+ success: true,
+ message: 'ok',
+ method: req.method,
+ url: route,
+ }));
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ baseUrl = `http://127.0.0.1:${server.address().port}/v1`;
+});
+
+test.after(async () => {
+ await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
+});
+
+async function request({ options = {}, incoming = {}, cookies = {}, path = '/echo', method = 'GET', body, requestOptions = {} } = {}) {
+ const cookieStore = new MemoryCookies(cookies);
+ const normalizedOptions = testing.validateAndNormalizeOptions({ baseUrl, ...options });
+ const response = await testing.executeBridgeRequest({
+ normalizedOptions,
+ method,
+ path,
+ body,
+ requestOptions,
+ cookieStore,
+ incomingHeaders: new Headers(incoming),
+ });
+ return { response, cookieStore };
+}
+
+test('cookie forwarding uses only the configured prefix and bearer/API-key auth still work', async () => {
+ const { response } = await request({
+ options: {
+ cookiePrefix: 'bridge_',
+ auth: { type: 'bearer', tokenCookie: 'accessToken' },
+ apiKey: 'api-secret',
+ apiKeyHeader: 'x-api-key',
+ },
+ cookies: {
+ bridge_accessToken: 'bearer-secret',
+ bridge_session: 'backend-session',
+ application_cookie: 'must-not-forward',
+ },
+ });
+ assert.equal(response.status, 200);
+ assert.equal(lastRequest.headers.cookie, 'accessToken=bearer-secret; session=backend-session');
+ assert.equal(lastRequest.headers.authorization, 'Bearer bearer-secret');
+ assert.equal(lastRequest.headers['x-api-key'], 'api-secret');
+ assert.doesNotMatch(lastRequest.headers.cookie, /application_cookie/);
+ assert.equal(JSON.stringify(response).includes('bearer-secret'), false);
+});
+
+test('browser context headers and request IDs reach the backend', async () => {
+ const { response } = await request({
+ incoming: {
+ 'user-agent': 'Integration Browser/1.0',
+ 'accept-language': 'en-KE,en;q=0.9',
+ traceparent: '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01',
+ 'x-request-id': 'incoming-request-id',
+ },
+ });
+ assert.equal(lastRequest.headers['user-agent'], 'Integration Browser/1.0');
+ assert.equal(lastRequest.headers['accept-language'], 'en-KE,en;q=0.9');
+ assert.equal(lastRequest.headers.traceparent, '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01');
+ assert.equal(lastRequest.headers['x-request-id'], 'incoming-request-id');
+ assert.equal(lastRequest.headers['x-api-bridge'], 'next-api-bridge/0.1.7');
+
+ const generated = await request();
+ assert.match(lastRequest.headers['x-request-id'], /^[0-9a-f-]{36}$/i);
+});
+
+test('client IP is absent by default and only present for configured trusted providers', async () => {
+ const absent = await request({ incoming: { 'x-forwarded-for': '203.0.113.5', 'cf-connecting-ip': '203.0.113.6' } });
+ assert.equal(lastRequest.headers['x-client-ip'], undefined);
+
+ const cloudflare = await request({
+ options: { requestContext: { clientIp: { enabled: true, trustProxy: 'cloudflare' } } },
+ incoming: { 'x-forwarded-for': '198.51.100.7', 'cf-connecting-ip': '203.0.113.6' },
+ });
+ assert.equal(lastRequest.headers['x-client-ip'], '203.0.113.6');
+
+ const spoofed = await request({
+ options: { requestContext: { clientIp: { enabled: true, trustProxy: 'cloudflare' } } },
+ incoming: { 'x-forwarded-for': '198.51.100.7' },
+ });
+ assert.equal(lastRequest.headers['x-client-ip'], undefined);
+});
+
+test('forbidden custom headers are rejected before fetch', async () => {
+ await assert.rejects(() => request({ requestOptions: { headers: { authorization: 'Bearer attacker' } } }), /managed or forbidden/);
+ await assert.rejects(() => request({ requestOptions: { headers: { host: 'evil.test' } } }), /managed or forbidden/);
+});
+
+test('response status and safe headers are serializable while Set-Cookie stays hidden', async () => {
+ const { response, cookieStore } = await request({ path: '/status/429', cookies: { unrelated: 'keep-me' } });
+ assert.equal(response.status, 429);
+ assert.equal(response.message, 'status-429');
+ assert.deepEqual(response.headers, { 'x-request-id': 'backend-request', 'x-ratelimit-remaining': '4' });
+ assert.equal(Object.getPrototypeOf(response.headers), Object.prototype);
+ assert.equal(response.headers['set-cookie'], undefined);
+ assert.equal(JSON.stringify(response).includes('secret'), false);
+ assert.equal(cookieStore.get('nab_rotated').value, 'secret');
+ assert.equal(cookieStore.get('unrelated').value, 'keep-me');
+ assert.equal(cookieStore.writes[0].options.domain, undefined);
+ assert.equal(cookieStore.writes[0].options.path, '/');
+ assert.equal(cookieStore.writes[0].options.expires.toISOString(), '2030-10-21T07:28:00.000Z');
+});
+
+test('custom logger never receives request credentials or response bodies', async () => {
+ const entries = [];
+ const logger = {
+ debug: (entry) => entries.push(entry),
+ info: (entry) => entries.push(entry),
+ warn: (entry) => entries.push(entry),
+ error: (entry) => entries.push(entry),
+ };
+ await request({
+ options: { logger, apiKey: 'api-secret', apiKeyHeader: 'x-api-key', auth: { type: 'bearer', tokenCookie: 'accessToken' } },
+ cookies: { nab_accessToken: 'bearer-secret', nab_session: 'cookie-secret' },
+ requestOptions: { operationName: 'integration.echo' },
+ });
+ const serialized = JSON.stringify(entries);
+ assert.equal(serialized.includes('api-secret'), false);
+ assert.equal(serialized.includes('bearer-secret'), false);
+ assert.equal(serialized.includes('cookie-secret'), false);
+ assert.equal(serialized.includes('set-cookie'), false);
+ assert.equal(entries.every((entry) => entry.operationName === 'integration.echo'), true);
+});
+
+test('empty, JSON, text and distinguishable error statuses work', async () => {
+ const empty = await request({ path: '/empty' });
+ assert.equal(empty.response.status, 204);
+ assert.equal(empty.response.body, null);
+
+ const text = await request({ path: '/text' });
+ assert.equal(text.response.status, 200);
+ assert.equal(text.response.body, 'plain response');
+
+ for (const status of [401, 403, 404, 409, 422, 429, 500]) {
+ const result = await request({ path: `/status/${status}` });
+ assert.equal(result.response.status, status);
+ assert.equal(result.response.body.status, status);
+ }
+});
+
+test('query absence never reaches the backend as literal undefined or null', async () => {
+ const { response } = await request({ requestOptions: { query: { missing: undefined, nil: null, active: false, page: 0, search: '' } } });
+ assert.match(lastRequest.url, /active=false/);
+ assert.match(lastRequest.url, /page=0/);
+ assert.match(lastRequest.url, /search=/);
+ assert.doesNotMatch(lastRequest.url, /undefined|null/);
+});
diff --git a/tests/pack-verify.mjs b/tests/pack-verify.mjs
new file mode 100644
index 0000000..ebae96c
--- /dev/null
+++ b/tests/pack-verify.mjs
@@ -0,0 +1,20 @@
+import assert from 'node:assert/strict';
+import { existsSync, readFileSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+
+const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
+for (const [subpath, target] of Object.entries(pkg.exports)) {
+ for (const field of ['types', 'import', 'require']) {
+ assert.equal(existsSync(target[field]), true, `${subpath} ${field} target is missing: ${target[field]}`);
+ }
+}
+
+const output = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { encoding: 'utf8' });
+const packed = JSON.parse(output)[0];
+const packedPaths = new Set(packed.files.map((file) => `./${file.path}`));
+for (const target of Object.values(pkg.exports)) {
+ for (const field of ['types', 'import', 'require']) {
+ assert.equal(packedPaths.has(target[field]), true, `Tarball is missing declared export: ${target[field]}`);
+ }
+}
+console.log(`Verified ${Object.keys(pkg.exports).length} export groups in ${packed.filename}`);
diff --git a/tests/unit/core.test.cjs b/tests/unit/core.test.cjs
new file mode 100644
index 0000000..767eecd
--- /dev/null
+++ b/tests/unit/core.test.cjs
@@ -0,0 +1,237 @@
+const assert = require('node:assert/strict');
+const test = require('node:test');
+
+const query = require('../../.test-dist/query.js');
+const testing = require('../../.test-dist/testing.js');
+
+class MemoryCookies {
+ constructor(values = {}) {
+ this.values = new Map(Object.entries(values));
+ this.writes = [];
+ this.deletes = [];
+ }
+ get(name) {
+ const value = this.values.get(name);
+ return value === undefined ? undefined : { name, value };
+ }
+ getAll() {
+ return Array.from(this.values, ([name, value]) => ({ name, value }));
+ }
+ set(name, value, options = {}) {
+ this.values.set(name, value);
+ this.writes.push({ name, value, options });
+ }
+ delete(name) {
+ this.values.delete(name);
+ this.deletes.push(name);
+ }
+}
+
+test('query omission and falsy preservation', () => {
+ assert.equal(query.serializeQuery({ missing: undefined, nil: null, active: false, page: 0, search: '' }), 'active=false&page=0&search=');
+});
+
+test('query dates and arrays are encoded consistently', () => {
+ assert.equal(
+ query.serializeQuery({ at: new Date('2026-07-24T06:51:02.515Z'), relations: ['pool', undefined, 'prices'] }),
+ 'at=2026-07-24T06%3A51%3A02.515Z&relations=pool%2Cprices',
+ );
+});
+
+test('URL construction preserves base paths and encodes path params', () => {
+ const url = testing.buildRequestUrl('https://api.example.com/v1/', '/events', ['a/b', 'hello world'], { q: 'x/y', empty: '' });
+ assert.equal(url, 'https://api.example.com/v1/events/a%2Fb/hello%20world?q=x%2Fy&empty=');
+});
+
+test('configuration validates base URL, cookie prefix, auth pairs, and cache context', () => {
+ assert.throws(() => testing.validateAndNormalizeOptions({ baseUrl: 'ftp://example.com' }), /http or https/);
+ assert.throws(() => testing.validateAndNormalizeOptions({ baseUrl: 'https://user:pass@example.com' }), /credentials/);
+ assert.throws(() => testing.validateAndNormalizeOptions({ baseUrl: 'https://example.com', cookiePrefix: '' }), /cookiePrefix/);
+ assert.throws(() => testing.validateAndNormalizeOptions({ baseUrl: 'https://example.com', apiKey: 'secret' }), /provided together/);
+ assert.throws(() => testing.validateAndNormalizeOptions({
+ baseUrl: 'https://example.com',
+ requestContext: { clientIp: { enabled: true, trustProxy: false } },
+ }), /trustProxy/);
+ assert.throws(() => testing.validateAndNormalizeOptions({
+ baseUrl: 'https://example.com',
+ requestContext: { clientOrigin: { enabled: true } },
+ }), /allowedHosts or allowedOrigins/);
+});
+
+test('safe response header allowlist excludes secrets', () => {
+ const headers = new Headers({
+ 'x-request-id': 'req-1',
+ 'retry-after': '30',
+ 'set-cookie': 'session=secret',
+ authorization: 'Bearer secret',
+ 'x-api-key': 'secret',
+ });
+ assert.deepEqual(testing.collectSafeResponseHeaders(headers), {
+ 'x-request-id': 'req-1',
+ 'retry-after': '30',
+ });
+});
+
+test('redaction removes credentials recursively and sanitizes URLs', () => {
+ const redacted = testing.redactValue({
+ password: 'secret',
+ nested: { accessToken: 'token', value: 'safe' },
+ headers: { authorization: 'Bearer abc', accept: 'json' },
+ });
+ assert.equal(redacted.password, '[REDACTED]');
+ assert.equal(redacted.nested.accessToken, '[REDACTED]');
+ assert.equal(redacted.nested.value, 'safe');
+ assert.equal(redacted.headers.authorization, '[REDACTED]');
+ assert.equal(testing.sanitizeUrlForLog('https://api.test/path?token=abc&q=ok'), 'https://api.test/path?token=%5BREDACTED%5D&q=ok');
+});
+
+test('forbidden request headers and CRLF values are rejected', () => {
+ for (const name of ['authorization', 'cookie', 'set-cookie', 'host', 'content-length', 'next-action', 'x-nextjs-data', 'sec-fetch-site']) {
+ assert.equal(testing.isForbiddenRequestHeader(name), true, name);
+ }
+ assert.throws(() => testing.assertAllowedCustomHeaders({ cookie: 'x=y' }), /managed or forbidden/);
+ assert.throws(() => testing.assertAllowedCustomHeaders({ 'x-safe': 'ok\r\nInjected: yes' }), /CR\/LF/);
+});
+
+test('client origin accepts only approved origin-only values', () => {
+ const options = { allowedHosts: ['app.example.com'], allowedOrigins: ['https://admin.example.com'] };
+ assert.equal(testing.validateClientOrigin('https://app.example.com', options), 'https://app.example.com');
+ assert.equal(testing.validateClientOrigin('https://admin.example.com', options), 'https://admin.example.com');
+ assert.equal(testing.validateClientOrigin('https://app.example.com/path', options), undefined);
+ assert.equal(testing.validateClientOrigin('https://user:pass@app.example.com', options), undefined);
+ assert.equal(testing.validateClientOrigin('https://evil.example.com', options), undefined);
+});
+
+test('IPv4, IPv6 and forwarded chains are validated', () => {
+ assert.equal(testing.normalizeIp('203.0.113.10'), '203.0.113.10');
+ assert.equal(testing.normalizeIp('[2001:db8::1]:443'), '2001:db8::1');
+ assert.equal(testing.normalizeIp('not-an-ip'), undefined);
+ assert.deepEqual(testing.parseForwardedChain('203.0.113.10, 10.0.0.1'), ['203.0.113.10', '10.0.0.1']);
+ assert.equal(testing.parseForwardedChain('203.0.113.10, bad'), undefined);
+});
+
+test('Vercel, Cloudflare and custom trusted proxy resolution', () => {
+ assert.equal(testing.resolveClientIp(new Headers({ 'x-vercel-forwarded-for': '203.0.113.10, 10.0.0.1' }), 'vercel'), '203.0.113.10');
+ assert.equal(testing.resolveClientIp(new Headers({ 'cf-connecting-ip': '2001:db8::1' }), 'cloudflare'), '2001:db8::1');
+ assert.equal(testing.resolveClientIp(new Headers({ 'x-forwarded-for': '203.0.113.10, 10.0.0.1, 10.0.0.2' }), {
+ headers: ['x-forwarded-for'], trustedProxyHops: 1,
+ }), '10.0.0.1');
+ assert.equal(testing.resolveClientIp(new Headers({ 'x-forwarded-for': 'spoofed' }), false), undefined);
+});
+
+test('cookie parsing preserves expires, priority and partitioned', () => {
+ const cookie = testing.parseSetCookieString('session=abc==; Expires=Wed, 21 Oct 2030 07:28:00 GMT; Path=/api; Domain=api.example.com; HttpOnly; Secure; SameSite=Lax; Priority=High; Partitioned');
+ assert.equal(cookie.value, 'abc==');
+ assert.equal(cookie.expires.toISOString(), '2030-10-21T07:28:00.000Z');
+ assert.equal(cookie.priority, 'high');
+ assert.equal(cookie.partitioned, true);
+});
+
+test('cookie policy drops domain, rewrites path and preserves expiration', () => {
+ const parsed = testing.parseSetCookieString('session=abc; Expires=Wed, 21 Oct 2030 07:28:00 GMT; Path=/api; Domain=api.example.com; Secure');
+ const policy = testing.normalizeCookiePolicy();
+ const options = testing.applyCookiePolicy(parsed, policy, true);
+ assert.equal(options.domain, undefined);
+ assert.equal(options.path, '/');
+ assert.equal(options.secure, true);
+ assert.equal(options.expires.toISOString(), '2030-10-21T07:28:00.000Z');
+});
+
+test('cookie synchronization protects unprefixed cookies', async () => {
+ const store = new MemoryCookies({ session: 'application-cookie' });
+ const response = new Response('{}', { headers: { 'content-type': 'application/json', 'set-cookie': 'session=backend; Path=/api; Domain=api.example.com; Expires=Wed, 21 Oct 2030 07:28:00 GMT' } });
+ const result = await testing.syncResponseCookies({
+ response,
+ cookieStore: store,
+ cookiePrefix: 'nab_',
+ cookiePolicy: testing.normalizeCookiePolicy(),
+ requestIsSecure: true,
+ });
+ assert.deepEqual(result, { attempted: true, applied: true, reason: 'applied' });
+ assert.equal(store.get('session').value, 'application-cookie');
+ assert.equal(store.get('nab_session').value, 'backend');
+ assert.deepEqual(store.deletes, []);
+ assert.equal(store.writes[0].options.domain, undefined);
+ assert.equal(store.writes[0].options.path, '/');
+});
+
+test('read-only cookie writes are reported instead of hidden', async () => {
+ const store = new MemoryCookies();
+ store.set = () => { throw new Error('Cookies can only be modified in a Server Action or Route Handler'); };
+ const result = await testing.syncResponseCookies({
+ response: new Response('{}', { headers: { 'set-cookie': 'session=abc' } }),
+ cookieStore: store,
+ cookiePrefix: 'nab_',
+ cookiePolicy: testing.normalizeCookiePolicy(),
+ requestIsSecure: true,
+ });
+ assert.deepEqual(result, { attempted: true, applied: false, reason: 'read-only-context' });
+});
+
+test('204, JSON and text response parsing', async () => {
+ const empty = await testing.parseApiResponse(new Response(null, { status: 204 }));
+ assert.equal(empty.status, 204);
+ assert.equal(empty.body, null);
+
+ const json = await testing.parseApiResponse(new Response(JSON.stringify({ success: false, message: 'Denied', value: 1 }), {
+ status: 403, headers: { 'content-type': 'application/json', 'x-request-id': 'req-2', 'set-cookie': 'secret=x' },
+ }));
+ assert.equal(json.success, false);
+ assert.equal(json.message, 'Denied');
+ assert.deepEqual(json.body, { success: false, message: 'Denied', value: 1 });
+ assert.deepEqual(json.headers, { 'x-request-id': 'req-2' });
+
+ const text = await testing.parseApiResponse(new Response('hello', { headers: { 'content-type': 'text/plain' } }));
+ assert.equal(text.body, 'hello');
+});
+
+test('timeout and caller abort signals are combined safely', async () => {
+ const timed = testing.combineAbortSignals(undefined, 10);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ assert.equal(timed.signal.aborted, true);
+ assert.equal(timed.didTimeout(), true);
+ timed.cleanup();
+
+ const caller = new AbortController();
+ const combined = testing.combineAbortSignals(caller.signal, 1000);
+ caller.abort();
+ assert.equal(combined.signal.aborted, true);
+ assert.equal(combined.didTimeout(), false);
+ combined.cleanup();
+});
+
+test('conflicting cache options are rejected', () => {
+ assert.throws(() => testing.validateCacheOptions({ cache: 'no-store', next: { revalidate: 30 } }), /conflicts/);
+ assert.throws(() => testing.validateCacheOptions({ cache: 'force-cache', next: { revalidate: 0 } }), /conflicts/);
+ assert.doesNotThrow(() => testing.validateCacheOptions({ cache: 'force-cache', next: { revalidate: 30, tags: ['events'] } }));
+});
+
+test('request context forwards only configured safe values and generates IDs', () => {
+ const options = testing.validateAndNormalizeOptions({
+ baseUrl: 'https://api.example.com',
+ requestContext: {
+ clientIp: { enabled: true, trustProxy: 'cloudflare' },
+ clientOrigin: { enabled: true, allowedHosts: ['app.example.com'], cookieName: 'client_url' },
+ },
+ });
+ const result = testing.buildRequestContextHeaders(
+ new Headers({
+ 'user-agent': 'Browser UA',
+ 'accept-language': 'en-KE',
+ traceparent: '00-abc-def-01',
+ baggage: 'not-forwarded-by-default',
+ 'cf-connecting-ip': '203.0.113.10',
+ host: 'app.example.com',
+ }),
+ new MemoryCookies({ client_url: 'https://app.example.com' }),
+ options.requestContext,
+ );
+ assert.equal(result.headers['user-agent'], 'Browser UA');
+ assert.equal(result.headers['accept-language'], 'en-KE');
+ assert.equal(result.headers.traceparent, '00-abc-def-01');
+ assert.equal(result.headers.baggage, undefined);
+ assert.equal(result.headers['x-client-ip'], '203.0.113.10');
+ assert.equal(result.headers['x-client-origin'], 'https://app.example.com');
+ assert.match(result.headers['x-request-id'], /^[0-9a-f-]{36}$/i);
+ assert.equal(result.headers['x-api-bridge'], 'next-api-bridge/0.1.7');
+});
diff --git a/tests/write-test-package-type.cjs b/tests/write-test-package-type.cjs
new file mode 100644
index 0000000..74a80a9
--- /dev/null
+++ b/tests/write-test-package-type.cjs
@@ -0,0 +1,5 @@
+const fs = require('node:fs');
+const path = require('node:path');
+
+fs.mkdirSync(path.resolve('.test-dist'), { recursive: true });
+fs.writeFileSync(path.resolve('.test-dist/package.json'), JSON.stringify({ type: 'commonjs' }));
diff --git a/tsconfig.json b/tsconfig.json
index 0d349b0..bf1d2c6 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,13 +1,13 @@
{
- "compilerOptions": {
- "target": "ES2022",
- "module": "ESNext",
- "moduleResolution": "Bundler",
- "declaration": true,
- "strict": true,
- "skipLibCheck": true
- },
- "include": [
- "src"
- ]
-}
\ No newline at end of file
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "declaration": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["node"]
+ },
+ "include": ["src"]
+}
diff --git a/tsconfig.test.json b/tsconfig.test.json
new file mode 100644
index 0000000..8479bb6
--- /dev/null
+++ b/tsconfig.test.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "module": "CommonJS",
+ "moduleResolution": "Node",
+ "outDir": ".test-dist",
+ "declaration": false,
+ "noEmit": false,
+ "rootDir": "src"
+ },
+ "include": ["src"]
+}
diff --git a/tsup.config.ts b/tsup.config.ts
index 01605e8..f51ac83 100644
--- a/tsup.config.ts
+++ b/tsup.config.ts
@@ -1,7 +1,13 @@
import { defineConfig } from 'tsup';
export default defineConfig({
- entry: ['src/index.ts', 'src/form/index.ts', 'src/cache.ts', 'src/query.ts'],
+ entry: [
+ 'src/index.ts',
+ 'src/form/index.ts',
+ 'src/cache.ts',
+ 'src/query.ts',
+ 'src/testing.ts',
+ ],
format: ['esm', 'cjs'],
dts: true,
clean: true,