@spfn/core 0.3.0-beta.5 → 0.3.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,608 @@
1
+ # @spfn/core/nextjs — Type-safe Next.js RPC client + proxy
2
+
3
+ End-to-end type-safe API client (`createApi<AppRouter>()`) and an RPC proxy route handler
4
+ (`createRpcProxy`) that bridges a Next.js App Router app to a SPFN `define-route` backend.
5
+ No per-route metadata codegen is required for the client — method/path resolution happens
6
+ at the proxy from a generated `routeMap`.
7
+
8
+ ```
9
+ Client (browser / RSC) Proxy (Next.js API route) SPFN backend
10
+ api.getUser.call({...}) ──GET/POST──▶ /api/rpc/{routeName} ──HTTP──▶ resolved method+path
11
+ createApi<AppRouter>() createRpcProxy({ routeMap }) define-route handler
12
+ ```
13
+
14
+ ## Import paths
15
+
16
+ There are **two** entry points. Picking the wrong one breaks the build — `/server` pulls in
17
+ `next/headers` + `next/server` and must never reach a Client Component bundle.
18
+
19
+ ```typescript
20
+ // Client-safe (Client Components, Server Components, anywhere). NO next/headers.
21
+ import { createApi, ApiError } from '@spfn/core/nextjs';
22
+
23
+ // Server-only (API routes, Server Components). Uses next/headers + next/server.
24
+ import { createRpcProxy, registerInterceptors } from '@spfn/core/nextjs/server';
25
+ ```
26
+
27
+ `createRpcProxy` and the interceptor registry are **only** exported from
28
+ `@spfn/core/nextjs/server`. `createApi` / `ApiError` are **only** exported from
29
+ `@spfn/core/nextjs`. There is no barrel that re-exports both.
30
+
31
+ | Path | Environment | Exports |
32
+ |------|-------------|---------|
33
+ | `@spfn/core/nextjs` | Client + Server | `createApi`, `ApiError`, all client types |
34
+ | `@spfn/core/nextjs/server` | Server only | `createRpcProxy`, interceptor registry + helpers, interceptor types |
35
+
36
+ ---
37
+
38
+ ## Public API (complete)
39
+
40
+ From `@spfn/core/nextjs` (client-safe):
41
+
42
+ - Values: `createApi`, `ApiError`
43
+ - Types: `Client`, `RouteClient`, `ApiConfig`, `CallOptions`, `InferRouteInput`,
44
+ `InferRouteOutput`, `RouterInput`, `RouterOutput`, `StructuredInput`,
45
+ `RequestInterceptor`, `ResponseInterceptor`, `CookieOptions`, `SetCookie`
46
+
47
+ From `@spfn/core/nextjs/server` (server-only):
48
+
49
+ - Values: `createRpcProxy`, `registerInterceptors`, `interceptorRegistry`, `matchPath`,
50
+ `matchMethod`, `filterMatchingInterceptors`, `executeRequestInterceptors`,
51
+ `executeResponseInterceptors`
52
+ - Types: `RpcProxyConfig`, `RequestInterceptorContext`, `ResponseInterceptorContext`,
53
+ `RequestInterceptor`, `ResponseInterceptor` (the *proxy* `(ctx, next)` shape — distinct
54
+ from the client interceptor types of the same name), `InterceptorRule`, `ProxyConfig`
55
+
56
+ > The client `RequestInterceptor`/`ResponseInterceptor` (`(url, init)` / `(response, body)`)
57
+ > and the proxy `RequestInterceptor`/`ResponseInterceptor` (`(ctx, next) => Promise<void>`)
58
+ > are **different types that share a name** across the two entry points. Don't cross them.
59
+
60
+ ---
61
+
62
+ ## Quick Start
63
+
64
+ ### 1. Define the router (server)
65
+
66
+ ```typescript
67
+ // server/router.ts
68
+ import { defineRouter, route } from '@spfn/core/route';
69
+ import { Type } from '@sinclair/typebox';
70
+
71
+ export const appRouter = defineRouter({
72
+ getUser: route.get('/users/:id')
73
+ .input({ params: Type.Object({ id: Type.String() }) })
74
+ .handler(async (c) => {
75
+ const { params } = await c.data();
76
+ return { id: params.id, name: 'John' };
77
+ }),
78
+
79
+ createUser: route.post('/users')
80
+ .input({ body: Type.Object({ name: Type.String() }) })
81
+ .handler(async (c) => {
82
+ const { body } = await c.data();
83
+ return { id: '2', name: body.name };
84
+ }),
85
+ });
86
+
87
+ export type AppRouter = typeof appRouter;
88
+ ```
89
+
90
+ ### 2. Create the client (no metadata needed)
91
+
92
+ ```typescript
93
+ // lib/api.ts
94
+ import { createApi } from '@spfn/core/nextjs';
95
+ import type { AppRouter } from '@/server/router';
96
+
97
+ export const api = createApi<AppRouter>();
98
+ ```
99
+
100
+ ### 3. Mount the proxy route
101
+
102
+ ```typescript
103
+ // app/api/rpc/[routeName]/route.ts
104
+ import { createRpcProxy } from '@spfn/core/nextjs/server';
105
+ import { routeMap } from '@/generated/route-map';
106
+
107
+ export const { GET, POST } = createRpcProxy({ routeMap });
108
+ ```
109
+
110
+ ### 4. Call it
111
+
112
+ ```typescript
113
+ const user = await api.getUser.call({ params: { id: '123' } }); // GET /api/rpc/getUser?input=...
114
+ const created = await api.createUser.call({ body: { name: 'A' } }); // POST /api/rpc/createUser
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Client — `createApi`
120
+
121
+ ```typescript
122
+ function createApi<TRouter extends Router<any>>(config?: ApiConfig): Client<TRouter>;
123
+ ```
124
+
125
+ Returns a `Proxy`: every property access (`api.getUser`, `api.foo.bar` for nested routers)
126
+ yields a `RouteClient` builder. Calls are made via `.call(input)`.
127
+
128
+ ### Structured input
129
+
130
+ Input mirrors the route's `.input({...})` definition exactly. Empty sections are omitted
131
+ from the type, so you only pass what the route declares:
132
+
133
+ ```typescript
134
+ await api.getUser.call({ params: { id: '123' } });
135
+ await api.getUser.call({ params: { id: '123' }, query: { include: 'posts' } });
136
+ await api.createUser.call({ body: { name: 'John', email: 'a@b.com' } });
137
+ await api.updateUser.call({ params: { id: '123' }, body: { name: 'Jane' } });
138
+
139
+ // Route with no required input → call() with no argument:
140
+ await api.listAll.call();
141
+ ```
142
+
143
+ ### Method detection (client → proxy URL)
144
+
145
+ The client picks the HTTP method to the **proxy** purely from input shape:
146
+
147
+ - Has `body` **or** non-empty `formData` → `POST /api/rpc/{routeName}` (JSON body, or
148
+ `multipart/form-data` when `formData` is present)
149
+ - Otherwise → `GET /api/rpc/{routeName}?input={encoded}` (browser-cacheable)
150
+
151
+ This is the method to the proxy only. The *actual* backend method (PUT/PATCH/DELETE/…) is
152
+ resolved by the proxy from `routeMap` — see below.
153
+
154
+ ### File upload (`formData`)
155
+
156
+ ```typescript
157
+ await api.uploadAvatar.call({
158
+ params: { id: '123' },
159
+ formData: { file: fileInput.files[0], description: 'Profile photo' },
160
+ });
161
+ ```
162
+
163
+ `File` / `File[]` values go into a `FormData`; the other sections (`params`, `query`,
164
+ `headers`, `cookies`) are bundled into a `__metadata` JSON part. The proxy unpacks
165
+ `__metadata` and forwards a clean `multipart/form-data` body to the backend. `Content-Type`
166
+ is left unset so the boundary is generated automatically.
167
+
168
+ ### Options via method chaining
169
+
170
+ Options are set fluently (each returns a cloned builder) and applied on `.call()`:
171
+
172
+ ```typescript
173
+ const user = await api.getUser
174
+ .headers({ 'X-Custom': 'value' })
175
+ .cookies({ session: 'xxx' })
176
+ .fetchOptions({ next: { revalidate: 60 } })
177
+ .onRequest((url, init) => init) // client RequestInterceptor
178
+ .onResponse((res, body) => ({ response: res, body })) // client ResponseInterceptor
179
+ .call({ params: { id: '123' } });
180
+ ```
181
+
182
+ ### `ApiConfig`
183
+
184
+ ```typescript
185
+ interface ApiConfig {
186
+ baseUrl?: string; // default '/api/rpc'
187
+ headers?: Record<string, string>; // default headers on every request
188
+ timeout?: number; // default env.SERVER_TIMEOUT (120000 ms)
189
+ fetch?: typeof fetch; // custom fetch impl
190
+ onRequest?: RequestInterceptor; // global, (url, init) => init
191
+ onResponse?: ResponseInterceptor; // global, (response, body) => { response, body }
192
+ errorRegistry?: ErrorRegistry | ErrorRegistryInput[]; // custom error deserialization
193
+ debug?: boolean; // default false
194
+ }
195
+ ```
196
+
197
+ `errorRegistry`: the core `errorRegistry` is **always** merged in automatically. Pass an
198
+ array to add your app/package error registries on top:
199
+
200
+ ```typescript
201
+ import { errorRegistry } from '@spfn/core/errors';
202
+ const api = createApi<AppRouter>({ errorRegistry: [errorRegistry, authErrorRegistry] });
203
+ ```
204
+
205
+ ### `CallOptions` (per-call, also settable via chaining)
206
+
207
+ ```typescript
208
+ interface CallOptions {
209
+ timeout?: number;
210
+ headers?: Record<string, string>;
211
+ cookies?: Record<string, string>; // override only; cookies are auto-forwarded otherwise
212
+ onRequest?: RequestInterceptor;
213
+ onResponse?: ResponseInterceptor;
214
+ fetchOptions?: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } };
215
+ }
216
+ ```
217
+
218
+ ### Automatic cookie forwarding (SSR)
219
+
220
+ When `createApi` runs **on the server** (RSC / route handler) it auto-detects request
221
+ cookies via `next/headers` `cookies()` and forwards them as the `Cookie` header. In the
222
+ browser, cookies are sent by the browser automatically. You only need `.cookies({...})` /
223
+ `options.cookies` to **override**. Outside a request context (static generation, build) the
224
+ cookie read silently yields none.
225
+
226
+ ### SSR base URL resolution
227
+
228
+ For absolute fetches on the server the client uses `env.SPFN_APP_URL`. If unset, it falls
229
+ back to reconstructing the origin from the incoming request's `host` /
230
+ `x-forwarded-proto` headers (via `next/headers`); failing that, it uses a relative URL.
231
+
232
+ ---
233
+
234
+ ## Proxy — `createRpcProxy`
235
+
236
+ ```typescript
237
+ function createRpcProxy(config: RpcProxyConfig): {
238
+ GET: (req, ctx) => Promise<NextResponse>;
239
+ POST: (req, ctx) => Promise<NextResponse>;
240
+ };
241
+ ```
242
+
243
+ Returns `{ GET, POST }` handlers for an App Router catch-all route. It:
244
+
245
+ 1. Reads `routeName` from the async `params` (`context.params: Promise<{ routeName?: string }>` — Next.js 15).
246
+ 2. Parses `input` from `?input=` (GET) or the JSON / `multipart/form-data` body (POST).
247
+ 3. Resolves `routeMap[routeName]` → `{ method, path }`; **404** if missing.
248
+ 4. Substitutes `:params` into `path`, appends the query string.
249
+ 5. Forwards to `${apiUrl}${path}${query}` with the resolved backend method, running matching
250
+ interceptors before/after.
251
+ 6. Wraps the backend response in a `NextResponse` (special-casing `204`), forwards response
252
+ headers, and appends any `Set-Cookie` pushed by interceptors.
253
+
254
+ ### `RpcProxyConfig`
255
+
256
+ ```typescript
257
+ interface RpcProxyConfig {
258
+ routeMap: RouteMap; // REQUIRED — Record<routeName, { method, path }>
259
+ apiUrl?: string; // default env.SPFN_API_URL || 'http://localhost:8790'
260
+ timeout?: number; // default env.RPC_PROXY_TIMEOUT (120000 ms); AbortController
261
+ debug?: boolean; // default env.NODE_ENV === 'development'
262
+ headers?: Record<string, string>; // added to every forwarded request
263
+ interceptors?: InterceptorRule[]; // inline interceptors (array, NOT { request, response })
264
+ autoDiscoverInterceptors?: boolean; // default true — pull from registry
265
+ disableAutoInterceptors?: string[]; // e.g. ['auth'] — exclude registered packages
266
+ proxySecret?: string; // default env.SPFN_PROXY_SECRET — HMAC-sign forwarded requests (proxy-guard)
267
+ }
268
+
269
+ interface RouteMapEntry { method: HttpMethod; path: string; }
270
+ type RouteMap = Record<string, RouteMapEntry>;
271
+ ```
272
+
273
+ ### `routeMap` — the proxy's source of truth
274
+
275
+ `routeMap` comes from codegen (`@/generated/route-map`). Merge in route maps exported by
276
+ SPFN packages so their endpoints are reachable through the same proxy:
277
+
278
+ ```typescript
279
+ // app/api/rpc/[routeName]/route.ts
280
+ import '@spfn/auth/nextjs/api'; // side-effect: registers auth interceptors
281
+ import { createRpcProxy } from '@spfn/core/nextjs/server';
282
+ import { authRouteMap } from '@spfn/auth';
283
+ import { eventRouteMap } from '@spfn/core/event';
284
+ import { routeMap } from '@/generated/route-map';
285
+
286
+ export const { GET, POST } = createRpcProxy({
287
+ routeMap: { ...routeMap, ...authRouteMap, ...eventRouteMap },
288
+ });
289
+ ```
290
+
291
+ A `routeName` absent from the merged `routeMap` returns **404** from the proxy (not from the
292
+ backend). After adding routes, re-run codegen and clear `.spfn` cache if stale.
293
+
294
+ ### Header forwarding & client IP
295
+
296
+ Request headers are forwarded to the backend by an **allowlist**, not copied wholesale. Only
297
+ `content-type`, `authorization`, `cookie`, `user-agent`, `accept`, `accept-language`, and
298
+ `origin` pass through; everything else on the inbound request is dropped.
299
+
300
+ `X-Forwarded-For` is **not** in that allowlist, so it is not forwarded verbatim. Instead the
301
+ proxy resolves the real client IP from the inbound `X-Forwarded-For` chain — hop-aware, taking
302
+ the rightmost `TRUSTED_PROXY_HOPS` (default `1`) entries as your own trusted infra — and
303
+ re-emits that single address under a dedicated header, `x-spfn-proxy-client-ip`. This avoids
304
+ passing a client-controllable header straight through to the backend.
305
+
306
+ The backend only **trusts** that header on requests it can prove came through this proxy. Set
307
+ the same **`SPFN_PROXY_SECRET`** on the proxy (or `proxySecret` in config) and on the backend:
308
+ the proxy then HMAC-signs each forwarded request and the backend's proxy-guard marks it
309
+ verified (`clientType ≠ 'untrusted'`), so `getClientIp(c)` returns the real visitor IP from
310
+ `x-spfn-proxy-client-ip`. See [@spfn/core/middleware](../middleware/README.md) proxy-guard +
311
+ `getClientIp`.
312
+
313
+ Without a shared secret the request is unsigned, the backend won't trust that header, and
314
+ `getClientIp` falls back to the raw `X-Forwarded-For` first hop — which the proxy didn't
315
+ forward, so the client IP collapses to the TCP peer or `'unknown'`. If your visitor IP is
316
+ missing in production, set `SPFN_PROXY_SECRET` on both sides (and match `TRUSTED_PROXY_HOPS`
317
+ to your LB/nginx depth) rather than widening the header allowlist — forwarding raw
318
+ `X-Forwarded-For` reintroduces a spoofable IP.
319
+
320
+ ### Full config example
321
+
322
+ ```typescript
323
+ export const { GET, POST } = createRpcProxy({
324
+ routeMap: { ...routeMap, ...authRouteMap },
325
+ apiUrl: process.env.SPFN_API_URL,
326
+ timeout: 60000,
327
+ debug: true,
328
+ headers: { 'X-API-Key': process.env.SPFN_API_KEY! },
329
+ interceptors: [
330
+ {
331
+ pathPattern: '/_auth/*',
332
+ method: 'POST',
333
+ response: async (ctx, next) => {
334
+ if (ctx.response.body?.token) {
335
+ ctx.setCookies.push({
336
+ name: 'session',
337
+ value: ctx.response.body.token,
338
+ options: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 86400 },
339
+ });
340
+ }
341
+ await next();
342
+ },
343
+ },
344
+ ],
345
+ autoDiscoverInterceptors: true,
346
+ disableAutoInterceptors: ['analytics'],
347
+ });
348
+ ```
349
+
350
+ ---
351
+
352
+ ## Interceptors (proxy-side)
353
+
354
+ Proxy interceptors run **inside the proxy route**, around the backend fetch. They use a
355
+ middleware `(ctx, next)` signature and are matched by path/method. Do not confuse them with
356
+ the client `onRequest`/`onResponse` hooks (those run in the calling code, on the proxy URL).
357
+
358
+ ### `InterceptorRule`
359
+
360
+ ```typescript
361
+ interface InterceptorRule {
362
+ pathPattern: string | RegExp; // '/_auth/*' | '/users/:id' | /regex/ | '*' (matches the BACKEND path)
363
+ method?: string | string[]; // 'POST' | ['POST','PUT'] | omit = all
364
+ request?: (ctx: RequestInterceptorContext, next: () => Promise<void>) => Promise<void>;
365
+ response?: (ctx: ResponseInterceptorContext, next: () => Promise<void>) => Promise<void>;
366
+ }
367
+ ```
368
+
369
+ Pattern matching: `'*'` matches all; a `RegExp` is tested directly; a string converts `*`→
370
+ `.*` and `:param`→`[^/]+`. The path matched is the **resolved backend path** (e.g.
371
+ `/users/123`), not `/api/rpc/...`.
372
+
373
+ ### Contexts
374
+
375
+ ```typescript
376
+ interface RequestInterceptorContext {
377
+ path: string; method: string;
378
+ headers: Record<string, string>; // mutable — written back to forwarded headers
379
+ body?: any; // mutable — re-stringified if changed
380
+ query: Record<string, string | string[]>;
381
+ cookies: Map<string, string>; // request cookies
382
+ request: NextRequest;
383
+ metadata: Record<string, any>; // shared with the response interceptor
384
+ }
385
+
386
+ interface ResponseInterceptorContext {
387
+ path: string; method: string;
388
+ request: { headers: Record<string, string>; body?: any };
389
+ response: { ok: boolean; status: number; statusText: string; headers: Headers; body: any }; // body mutable
390
+ cookies: Map<string, string>; // read-only
391
+ setCookies: SetCookie[]; // push to emit Set-Cookie on the NextResponse
392
+ metadata: Record<string, any>;
393
+ }
394
+ ```
395
+
396
+ Interceptors chain in registration order; each must `await next()` to continue (skip it to
397
+ short-circuit). Mutating `ctx.headers` / `ctx.body` (request) and `ctx.response.body` /
398
+ `ctx.setCookies` (response) is how you affect the forwarded request and returned response.
399
+
400
+ ### Package auto-registration
401
+
402
+ Packages register interceptors on import; the proxy auto-discovers them
403
+ (`autoDiscoverInterceptors: true`). The registry lives on `globalThis` to survive HMR and
404
+ de-dupes by package name.
405
+
406
+ ```typescript
407
+ // inside a package, on import
408
+ import { registerInterceptors } from '@spfn/core/nextjs/server';
409
+
410
+ registerInterceptors('auth', [
411
+ {
412
+ pathPattern: '/_auth/*',
413
+ request: async (ctx, next) => {
414
+ const session = ctx.cookies.get('session');
415
+ if (session) ctx.headers['Authorization'] = `Bearer ${session}`;
416
+ await next();
417
+ },
418
+ response: async (ctx, next) => {
419
+ if (ctx.path === '/_auth/login' && ctx.response.body?.token) {
420
+ ctx.setCookies.push({
421
+ name: 'session',
422
+ value: ctx.response.body.token,
423
+ options: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 86400 },
424
+ });
425
+ delete ctx.response.body.token;
426
+ }
427
+ await next();
428
+ },
429
+ },
430
+ ]);
431
+ ```
432
+
433
+ Importing the package's side-effect entry (e.g. `import '@spfn/auth/nextjs/api'`) in the
434
+ proxy route is what triggers registration. Use `disableAutoInterceptors: ['auth']` to opt a
435
+ package out.
436
+
437
+ ---
438
+
439
+ ## Type helpers
440
+
441
+ ```typescript
442
+ import type { RouterOutput, RouterInput, InferRouteInput, InferRouteOutput } from '@spfn/core/nextjs';
443
+ import type { AppRouter } from '@/server/router';
444
+
445
+ type ListData = RouterOutput<AppRouter, 'listExamples'>;
446
+ type CreateInput = RouterInput<AppRouter, 'createExample'>;
447
+ type Item = RouterOutput<AppRouter, 'listExamples'>['items'][number];
448
+ type BodyInput = RouterInput<AppRouter, 'createExample'>['body'];
449
+ ```
450
+
451
+ `RouterOutput`/`RouterInput` take the router type + a route-name key. `InferRouteInput`/
452
+ `InferRouteOutput` take a single `RouteDef`. All inference is compile-time only (zero runtime
453
+ cost).
454
+
455
+ ---
456
+
457
+ ## Error handling
458
+
459
+ `ApiError` is thrown for non-2xx responses, network failures, and timeouts:
460
+
461
+ ```typescript
462
+ class ApiError extends Error {
463
+ constructor(
464
+ message: string,
465
+ public readonly status: number, // HTTP status; 0 for network; 408 for timeout
466
+ public readonly url: string,
467
+ public readonly response?: unknown, // parsed error body
468
+ public readonly errorType?: 'http' | 'network' | 'timeout',
469
+ ) {}
470
+ }
471
+ ```
472
+
473
+ ```typescript
474
+ import { ApiError } from '@spfn/core/nextjs';
475
+
476
+ try {
477
+ await api.getUser.call({ params: { id: '123' } });
478
+ } catch (error) {
479
+ if (error instanceof ApiError) {
480
+ if (error.errorType === 'timeout') { /* 408 */ }
481
+ else if (error.errorType === 'network') { /* status 0 */ }
482
+ else { /* error.status, error.response */ }
483
+ }
484
+ }
485
+ ```
486
+
487
+ **Custom errors**: if the backend body carries a `__type` discriminator and a matching entry
488
+ is registered in the client's `errorRegistry`, the client deserializes and throws the
489
+ **original typed error** instead of a generic `ApiError`. Otherwise it falls back to
490
+ `ApiError`.
491
+
492
+ ---
493
+
494
+ ## Next.js integration
495
+
496
+ ### Server Components with caching
497
+
498
+ ```typescript
499
+ // app/users/[id]/page.tsx
500
+ import { api } from '@/lib/api';
501
+
502
+ export default async function UserPage({ params }: { params: Promise<{ id: string }> }) {
503
+ const { id } = await params;
504
+ const user = await api.getUser
505
+ .fetchOptions({ next: { revalidate: 3600 } })
506
+ .call({ params: { id } });
507
+ return <div>{user.name}</div>;
508
+ }
509
+ ```
510
+
511
+ ### Tag-based revalidation
512
+
513
+ ```typescript
514
+ const posts = await api.getPosts
515
+ .fetchOptions({ next: { tags: ['posts'] } })
516
+ .call({ query: { page: 1 } });
517
+
518
+ // later, in a Server Action:
519
+ import { revalidateTag } from 'next/cache';
520
+ revalidateTag('posts');
521
+ ```
522
+
523
+ ### Client Components / Server Actions
524
+
525
+ ```typescript
526
+ 'use client';
527
+ import { api } from '@/lib/api';
528
+ const created = await api.createUser.call({ body: { name, email } });
529
+ ```
530
+
531
+ ```typescript
532
+ // app/actions.ts
533
+ 'use server';
534
+ import { api } from '@/lib/api';
535
+ export async function createUser(formData: FormData) {
536
+ return api.createUser.call({
537
+ body: { name: formData.get('name') as string, email: formData.get('email') as string },
538
+ });
539
+ }
540
+ ```
541
+
542
+ ---
543
+
544
+ ## Pitfalls & anti-patterns
545
+
546
+ - **Never import `@spfn/core/nextjs/server` in a Client Component.** It pulls in
547
+ `next/headers` + `next/server`; bundling it client-side breaks the build. Client code uses
548
+ `@spfn/core/nextjs` only (`createApi`, `ApiError`, types).
549
+ - **`interceptors` is an `InterceptorRule[]`, not `{ request, response }`.** Older docs
550
+ showed `createRpcProxy({ router, interceptors: { request, response } })` — that config
551
+ shape **does not exist**. The current API is `{ routeMap, interceptors: [{ pathPattern,
552
+ method?, request?, response? }] }`. There is **no** `router` option either; the proxy is
553
+ driven entirely by `routeMap`.
554
+ - **The proxy resolves the real HTTP method from `routeMap`, not from the client call.** The
555
+ client only ever sends GET (no body) or POST (body/formData) to `/api/rpc/...`. A PUT /
556
+ PATCH / DELETE route still works — the backend method comes from `routeMap[routeName]`.
557
+ - **A missing `routeName` in `routeMap` is a proxy 404, not a backend 404.** Merge package
558
+ route maps (`authRouteMap`, `eventRouteMap`, …) and re-run codegen after adding routes;
559
+ delete `.spfn` if the map looks stale.
560
+ - **Interceptor `pathPattern` matches the resolved backend path** (e.g. `/users/123`,
561
+ `/_auth/login`) — *not* the `/api/rpc/{routeName}` URL the client hit.
562
+ - **Two same-named interceptor type pairs.** `RequestInterceptor`/`ResponseInterceptor` from
563
+ `@spfn/core/nextjs` are `(url, init)` / `(response, body)` (client hooks);
564
+ from `@spfn/core/nextjs/server` they are `(ctx, next) => Promise<void>` (proxy middleware).
565
+ Import from the right entry point.
566
+ - **Don't pass auth via the client to set HttpOnly cookies.** HttpOnly session cookies are
567
+ set on the proxy response by a response interceptor (`ctx.setCookies.push(...)`), and
568
+ forwarded automatically on subsequent requests. Client code can't read them by design.
569
+ - **Cookies are auto-forwarded on the server** — only use `.cookies({...})` /
570
+ `options.cookies` to override. Calling outside a request context (static generation) yields
571
+ no cookies, silently.
572
+ - **`createApi` needs no codegen; the *proxy* does.** The client is metadata-free. The
573
+ `routeMap` the proxy consumes is the generated artifact.
574
+ - **Raw `X-Forwarded-For` is not forwarded — don't widen the allowlist to "fix" client IP.**
575
+ The proxy re-emits the resolved client IP as `x-spfn-proxy-client-ip`, trusted only on
576
+ HMAC-signed requests. Missing visitor IP in production means `SPFN_PROXY_SECRET` isn't set on
577
+ both sides (and/or `TRUSTED_PROXY_HOPS` is wrong), not that the header allowlist needs
578
+ `x-forwarded-for` — adding it back makes the IP spoofable. See *Header forwarding & client IP*.
579
+ - **Each `.headers()/.cookies()/.fetchOptions()/.onRequest()/.onResponse()` returns a new
580
+ builder.** Chain them in one expression; a dangling builder without `.call()` does nothing.
581
+
582
+ ---
583
+
584
+ ## Types reference
585
+
586
+ ```typescript
587
+ // client (@spfn/core/nextjs)
588
+ type RequestInterceptor = (url: string, init: RequestInit) => Promise<RequestInit> | RequestInit;
589
+ type ResponseInterceptor = (response: Response, body: any)
590
+ => Promise<{ response: Response; body: any }> | { response: Response; body: any };
591
+
592
+ interface CookieOptions { httpOnly?: boolean; secure?: boolean; sameSite?: 'strict'|'lax'|'none'; maxAge?: number; path?: string; domain?: string; }
593
+ interface SetCookie { name: string; value: string; options?: CookieOptions; }
594
+
595
+ // proxy (@spfn/core/nextjs/server)
596
+ type RequestInterceptor = (ctx: RequestInterceptorContext, next: () => Promise<void>) => Promise<void>;
597
+ type ResponseInterceptor = (ctx: ResponseInterceptorContext, next: () => Promise<void>) => Promise<void>;
598
+ interface RouteMapEntry { method: HttpMethod; path: string; }
599
+ type RouteMap = Record<string, RouteMapEntry>;
600
+ ```
601
+
602
+ ## Related
603
+
604
+ - [@spfn/core/route](../route/README.md) — server-side route + router definitions (source of `AppRouter`)
605
+ - [@spfn/core/codegen](../codegen/README.md) — generates the `routeMap` the proxy consumes
606
+ - [@spfn/core/errors](../errors/README.md) — `ErrorRegistry`, custom error deserialization
607
+ - [@spfn/core/config](../config/README.md) — `SPFN_API_URL`, `SPFN_APP_URL`, `RPC_PROXY_TIMEOUT`, `SERVER_TIMEOUT`
608
+ - [@spfn/auth](../../../auth/README.md) — example package: route map + auto-registered interceptors