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…) -``` - -![next-api-bridge workflow](./assets/next_api_bridge_workflow.svg) - -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 ( -
- - - - {state?.message &&

{state.message}

} -
- ); +```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,