@spfn/core 0.3.0-beta.2 → 0.3.0-beta.21

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.
Files changed (94) hide show
  1. package/README.md +343 -16
  2. package/dist/app-config/index.d.ts +134 -0
  3. package/dist/app-config/index.js +58 -0
  4. package/dist/app-config/index.js.map +1 -0
  5. package/dist/authz/index.js +1 -381
  6. package/dist/authz/index.js.map +1 -1
  7. package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
  8. package/dist/codegen/index.d.ts +93 -15
  9. package/dist/codegen/index.js +626 -165
  10. package/dist/codegen/index.js.map +1 -1
  11. package/dist/config/index.d.ts +0 -63
  12. package/dist/config/index.js +15 -15
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/contract/index.d.ts +40 -17
  15. package/dist/contract/index.js +147 -23
  16. package/dist/contract/index.js.map +1 -1
  17. package/dist/db/index.d.ts +201 -34
  18. package/dist/db/index.js +294 -158
  19. package/dist/db/index.js.map +1 -1
  20. package/dist/{define-middleware-DfDP39Nq.d.ts → define-middleware-CVKgqo8S.d.ts} +1 -1
  21. package/dist/env/index.d.ts +6 -442
  22. package/dist/env/index.js +13 -16
  23. package/dist/env/index.js.map +1 -1
  24. package/dist/env/loader.js +24 -1
  25. package/dist/env/loader.js.map +1 -1
  26. package/dist/error-TsUIjp_x.d.ts +157 -0
  27. package/dist/errors/index.js +1 -381
  28. package/dist/errors/index.js.map +1 -1
  29. package/dist/event/index.d.ts +5 -5
  30. package/dist/event/index.js +32 -1
  31. package/dist/event/index.js.map +1 -1
  32. package/dist/event/sse/client.d.ts +18 -4
  33. package/dist/event/sse/client.js +38 -2
  34. package/dist/event/sse/client.js.map +1 -1
  35. package/dist/event/sse/index.d.ts +5 -4
  36. package/dist/event/ws/client.d.ts +3 -2
  37. package/dist/event/ws/index.d.ts +4 -3
  38. package/dist/job/index.d.ts +48 -3
  39. package/dist/job/index.js +131 -52
  40. package/dist/job/index.js.map +1 -1
  41. package/dist/logger/index.js +0 -12
  42. package/dist/logger/index.js.map +1 -1
  43. package/dist/middleware/index.d.ts +6 -2
  44. package/dist/middleware/index.js +7 -388
  45. package/dist/middleware/index.js.map +1 -1
  46. package/dist/nextjs/index.d.ts +88 -8
  47. package/dist/nextjs/index.js +138 -62
  48. package/dist/nextjs/index.js.map +1 -1
  49. package/dist/nextjs/server.d.ts +45 -7
  50. package/dist/nextjs/server.js +14 -0
  51. package/dist/nextjs/server.js.map +1 -1
  52. package/dist/ops/index.d.ts +62 -43
  53. package/dist/ops/index.js +638 -97
  54. package/dist/ops/index.js.map +1 -1
  55. package/dist/registry-C2KQCh0m.d.ts +473 -0
  56. package/dist/route/index.d.ts +6 -3
  57. package/dist/route/index.js +97 -28
  58. package/dist/route/index.js.map +1 -1
  59. package/dist/route/types.d.ts +7 -1
  60. package/dist/{router-Qbssr11H.d.ts → route-builder-DRW-MSCJ.d.ts} +52 -127
  61. package/dist/router-4YoIy5fN.d.ts +172 -0
  62. package/dist/{token-manager-BT5EnUAR.d.ts → router-CysBmgv3.d.ts} +81 -127
  63. package/dist/server/index.d.ts +185 -14
  64. package/dist/server/index.js +883 -78
  65. package/dist/server/index.js.map +1 -1
  66. package/dist/surface/index.d.ts +115 -0
  67. package/dist/surface/index.js +1003 -0
  68. package/dist/surface/index.js.map +1 -0
  69. package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
  70. package/dist/{types-D1c57Ko-.d.ts → types-5Rl11_Nb.d.ts} +43 -4
  71. package/dist/{types-ZQODsBft.d.ts → types-B8zAxFsE.d.ts} +2 -1
  72. package/dist/types-CxhPH4s_.d.ts +375 -0
  73. package/dist/{types-2AbaW4Ie.d.ts → types-W4u_S0lF.d.ts} +4 -3
  74. package/docs/file-upload.md +195 -333
  75. package/package.json +20 -9
  76. package/src/cache/README.md +330 -0
  77. package/src/codegen/README.md +634 -0
  78. package/src/config/README.md +333 -0
  79. package/src/contract/README.md +440 -0
  80. package/src/db/README.md +589 -0
  81. package/src/db/manager/README.md +500 -0
  82. package/src/db/schema/README.md +354 -0
  83. package/src/db/transaction/README.md +822 -0
  84. package/src/env/README.md +787 -0
  85. package/src/errors/README.md +429 -0
  86. package/src/event/README.md +789 -0
  87. package/src/job/README.md +613 -0
  88. package/src/logger/README.md +321 -0
  89. package/src/middleware/README.md +634 -0
  90. package/src/nextjs/README.md +688 -0
  91. package/src/route/README.md +763 -0
  92. package/src/security/README.md +100 -0
  93. package/src/server/README.md +704 -0
  94. package/dist/types-9oszaJqp.d.ts +0 -250
@@ -0,0 +1,688 @@
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`) yields a `RouteClient` builder.
126
+ Calls are made via `.call(input)`.
127
+
128
+ The client is one **flat** namespace, holding exactly the names the server registers. A
129
+ route declared inside a nested `defineRouter` is reached by its own key — `registerRoutes`
130
+ mounts it under that key, the proxy resolves that name against the route map, and
131
+ `RouterOutput` takes the same name:
132
+
133
+ ```typescript
134
+ const appRouter = defineRouter({
135
+ getRoot,
136
+ users: defineRouter({ getUser }),
137
+ });
138
+
139
+ await api.getUser.call({ params: { id: '123' } }); // ✅ GET /api/rpc/getUser
140
+ await api.users.getUser.call({ ... }); // ❌ compile error — `users` is a group
141
+ ```
142
+
143
+ There is no dot notation: a property access never yields another proxy, so a group key
144
+ names nothing on either side. A name two branches both declare is left out of the client
145
+ rather than resolved to one of them, and `.packages()` routes stay out on purpose — call
146
+ them through the package's own client (`authApi`, `cmsApi`).
147
+
148
+ ### Structured input
149
+
150
+ Input mirrors the route's `.input({...})` definition exactly. Empty sections are omitted
151
+ from the type, so you only pass what the route declares:
152
+
153
+ ```typescript
154
+ await api.getUser.call({ params: { id: '123' } });
155
+ await api.getUser.call({ params: { id: '123' }, query: { include: 'posts' } });
156
+ await api.createUser.call({ body: { name: 'John', email: 'a@b.com' } });
157
+ await api.updateUser.call({ params: { id: '123' }, body: { name: 'Jane' } });
158
+
159
+ // Route with no required input → call() with no argument:
160
+ await api.listAll.call();
161
+ ```
162
+
163
+ ### Method detection (client → proxy URL)
164
+
165
+ The client picks the HTTP method to the **proxy** purely from input shape:
166
+
167
+ - Has `body` **or** non-empty `formData` → `POST /api/rpc/{routeName}` (JSON body, or
168
+ `multipart/form-data` when `formData` is present)
169
+ - Otherwise → `GET /api/rpc/{routeName}?input={encoded}` (browser-cacheable)
170
+
171
+ This is the method to the proxy only. The *actual* backend method (PUT/PATCH/DELETE/…) is
172
+ resolved by the proxy from `routeMap` — see below.
173
+
174
+ ### File upload (`formData`)
175
+
176
+ ```typescript
177
+ await api.uploadAvatar.call({
178
+ params: { id: '123' },
179
+ formData: { file: fileInput.files[0], description: 'Profile photo' },
180
+ });
181
+ ```
182
+
183
+ `File` / `File[]` values go into a `FormData`; the other sections (`params`, `query`,
184
+ `headers`, `cookies`) are bundled into a `__metadata` JSON part. The proxy unpacks
185
+ `__metadata` and forwards a clean `multipart/form-data` body to the backend. `Content-Type`
186
+ is left unset so the boundary is generated automatically.
187
+
188
+ ### Options via method chaining
189
+
190
+ Options are set fluently (each returns a cloned builder) and applied on `.call()`:
191
+
192
+ ```typescript
193
+ const user = await api.getUser
194
+ .headers({ 'X-Custom': 'value' })
195
+ .cookies({ session: 'xxx' })
196
+ .fetchOptions({ next: { revalidate: 60 } })
197
+ .timeout(5000) // this call only; overrides ApiConfig.timeout
198
+ .onRequest((url, init) => init) // client RequestInterceptor
199
+ .onResponse((res, body) => ({ response: res, body })) // client ResponseInterceptor
200
+ .call({ params: { id: '123' } });
201
+ ```
202
+
203
+ `.timeout(ms)` throws a `TypeError` — before any request is made — unless `ms` is a finite
204
+ number greater than 0 and at most 2147483647 (`setTimeout`'s ceiling). The timer covers the
205
+ wait for response headers, not the body read: once headers arrive it stops, however long the
206
+ body takes.
207
+
208
+ #### Cancellation
209
+
210
+ Pass your own `AbortSignal` through `.fetchOptions({ signal })` (or set `init.signal` in an
211
+ `onRequest` interceptor — the signal on the final `init` is the one honoured). Aborting it
212
+ rejects the call with an `ApiError` of type `'aborted'`, from before the request is sent until
213
+ the response body has been read; the timeout keeps running alongside it.
214
+
215
+ ```typescript
216
+ const controller = new AbortController();
217
+
218
+ const pending = api.searchUsers
219
+ .timeout(10_000)
220
+ .fetchOptions({ signal: controller.signal })
221
+ .call({ query: { q: 'ada' } });
222
+
223
+ controller.abort('superseded'); // pending rejects: errorType 'aborted', status 0, cause 'superseded'
224
+ ```
225
+
226
+ ### `ApiConfig`
227
+
228
+ ```typescript
229
+ interface ApiConfig {
230
+ baseUrl?: string; // default '/api/rpc'
231
+ headers?: Record<string, string>; // default headers on every request
232
+ timeout?: number; // default env.SERVER_TIMEOUT (120000 ms); per call: .timeout(ms)
233
+ fetch?: typeof fetch; // custom fetch impl
234
+ onRequest?: RequestInterceptor; // global, (url, init) => init
235
+ onResponse?: ResponseInterceptor; // global, (response, body) => { response, body }
236
+ errorRegistry?: ErrorRegistry | ErrorRegistryInput[]; // custom error deserialization
237
+ debug?: boolean; // default false
238
+ }
239
+ ```
240
+
241
+ `errorRegistry`: the core `errorRegistry` is **always** merged in automatically. Pass an
242
+ array to add your app/package error registries on top:
243
+
244
+ ```typescript
245
+ import { errorRegistry } from '@spfn/core/errors';
246
+ const api = createApi<AppRouter>({ errorRegistry: [errorRegistry, authErrorRegistry] });
247
+ ```
248
+
249
+ ### `CallOptions` (per-call, also settable via chaining)
250
+
251
+ ```typescript
252
+ interface CallOptions {
253
+ timeout?: number; // ms until response headers; overrides ApiConfig.timeout
254
+ headers?: Record<string, string>;
255
+ cookies?: Record<string, string>; // override only; cookies are auto-forwarded otherwise
256
+ onRequest?: RequestInterceptor;
257
+ onResponse?: ResponseInterceptor;
258
+ fetchOptions?: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } };
259
+ }
260
+ ```
261
+
262
+ ### Automatic cookie forwarding (SSR)
263
+
264
+ When `createApi` runs **on the server** (RSC / route handler) it auto-detects request
265
+ cookies via `next/headers` `cookies()` and forwards them as the `Cookie` header. In the
266
+ browser, cookies are sent by the browser automatically. You only need `.cookies({...})` /
267
+ `options.cookies` to **override**. Outside a request context (static generation, build) the
268
+ cookie read silently yields none.
269
+
270
+ ### SSR base URL resolution
271
+
272
+ For absolute fetches on the server the client uses `env.SPFN_APP_URL`. If unset, it falls
273
+ back to reconstructing the origin from the incoming request's `host` /
274
+ `x-forwarded-proto` headers (via `next/headers`); failing that, it uses a relative URL.
275
+
276
+ ---
277
+
278
+ ## Proxy — `createRpcProxy`
279
+
280
+ ```typescript
281
+ function createRpcProxy(config: RpcProxyConfig): {
282
+ GET: (req, ctx) => Promise<NextResponse>;
283
+ POST: (req, ctx) => Promise<NextResponse>;
284
+ };
285
+ ```
286
+
287
+ Returns `{ GET, POST }` handlers for an App Router catch-all route. It:
288
+
289
+ 1. Reads `routeName` from the async `params` (`context.params: Promise<{ routeName?: string }>` — Next.js 15).
290
+ 2. Parses `input` from `?input=` (GET) or the JSON / `multipart/form-data` body (POST).
291
+ 3. Resolves `routeMap[routeName]` → `{ method, path }`; **404** if missing.
292
+ 4. Substitutes `:params` into `path`, appends the query string.
293
+ 5. Forwards to `${apiUrl}${path}${query}` with the resolved backend method, running matching
294
+ interceptors before/after.
295
+ 6. Wraps the backend response in a `NextResponse` (special-casing `204`), forwards response
296
+ headers, and appends any `Set-Cookie` pushed by interceptors.
297
+
298
+ ### `RpcProxyConfig`
299
+
300
+ ```typescript
301
+ interface RpcProxyConfig {
302
+ routeMap: RouteMap; // REQUIRED — Record<routeName, { method, path }>
303
+ apiUrl?: string; // default env.SPFN_API_URL || 'http://localhost:8790'
304
+ timeout?: number; // default env.RPC_PROXY_TIMEOUT (120000 ms); AbortController
305
+ debug?: boolean; // default env.NODE_ENV === 'development'
306
+ headers?: Record<string, string>; // added to every forwarded request
307
+ interceptors?: InterceptorRule[]; // inline interceptors (array, NOT { request, response })
308
+ autoDiscoverInterceptors?: boolean; // default true — pull from registry
309
+ disableAutoInterceptors?: string[]; // e.g. ['auth'] — exclude registered packages
310
+ proxySecret?: string; // default env.SPFN_PROXY_SECRET — HMAC-sign forwarded requests (proxy-guard)
311
+ }
312
+
313
+ interface RouteMapEntry { method: HttpMethod; path: string; }
314
+ type RouteMap = Record<string, RouteMapEntry>;
315
+ ```
316
+
317
+ ### `routeMap` — the proxy's source of truth
318
+
319
+ `routeMap` comes from codegen (`@/generated/route-map`) and already holds the routes of every
320
+ package router the app router mounts with `.packages()` — `@spfn/auth`'s included, so
321
+ `{ ...routeMap, ...authRouteMap }` is a no-op and no longer needed. `eventRouteMap` is still
322
+ merged by hand: it is a hand-written constant, not a mounted router.
323
+
324
+ ```typescript
325
+ // app/api/rpc/[routeName]/route.ts
326
+ import '@spfn/auth/nextjs/api'; // side-effect: registers auth interceptors
327
+ import { createRpcProxy } from '@spfn/core/nextjs/server';
328
+ import { eventRouteMap } from '@spfn/core/event';
329
+ import { routeMap } from '@/generated/route-map';
330
+
331
+ export const { GET, POST } = createRpcProxy({
332
+ routeMap: { ...routeMap, ...eventRouteMap },
333
+ });
334
+ ```
335
+
336
+ A `routeName` absent from `routeMap` returns **404** from the proxy (not from the backend).
337
+ After adding routes — or after upgrading a mounted package — re-run codegen and clear `.spfn`
338
+ cache if stale.
339
+
340
+ ### Header forwarding & client IP
341
+
342
+ Request headers are forwarded to the backend by an **allowlist**, not copied wholesale. Only
343
+ `content-type`, `authorization`, `cookie`, `user-agent`, `accept`, `accept-language`, and
344
+ `origin` pass through; everything else on the inbound request is dropped.
345
+
346
+ `X-Forwarded-For` is **not** in that allowlist, so it is not forwarded verbatim. Instead the
347
+ proxy resolves the real client IP from the inbound `X-Forwarded-For` chain — hop-aware, taking
348
+ the rightmost `TRUSTED_PROXY_HOPS` (default `1`) entries as your own trusted infra — and
349
+ re-emits that single address under a dedicated header, `x-spfn-proxy-client-ip`. This avoids
350
+ passing a client-controllable header straight through to the backend.
351
+
352
+ The backend only **trusts** that header on requests it can prove came through this proxy. Set
353
+ the same **`SPFN_PROXY_SECRET`** on the proxy (or `proxySecret` in config) and on the backend:
354
+ the proxy then HMAC-signs each forwarded request and the backend's proxy-guard marks it
355
+ verified (`clientType ≠ 'untrusted'`), so `getClientIp(c)` returns the real visitor IP from
356
+ `x-spfn-proxy-client-ip`. See [@spfn/core/middleware](../middleware/README.md) proxy-guard +
357
+ `getClientIp`.
358
+
359
+ Without a shared secret the request is unsigned, the backend won't trust that header, and
360
+ `getClientIp` falls back to the raw `X-Forwarded-For` first hop — which the proxy didn't
361
+ forward, so the client IP collapses to the TCP peer or `'unknown'`. If your visitor IP is
362
+ missing in production, set `SPFN_PROXY_SECRET` on both sides (and match `TRUSTED_PROXY_HOPS`
363
+ to your LB/nginx depth) rather than widening the header allowlist — forwarding raw
364
+ `X-Forwarded-For` reintroduces a spoofable IP.
365
+
366
+ ### Full config example
367
+
368
+ ```typescript
369
+ export const { GET, POST } = createRpcProxy({
370
+ routeMap: { ...routeMap, ...eventRouteMap },
371
+ apiUrl: process.env.SPFN_API_URL,
372
+ timeout: 60000,
373
+ debug: true,
374
+ headers: { 'X-API-Key': process.env.SPFN_API_KEY! },
375
+ interceptors: [
376
+ {
377
+ pathPattern: '/_auth/*',
378
+ method: 'POST',
379
+ response: async (ctx, next) => {
380
+ if (ctx.response.body?.token) {
381
+ ctx.setCookies.push({
382
+ name: 'session',
383
+ value: ctx.response.body.token,
384
+ options: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 86400 },
385
+ });
386
+ }
387
+ await next();
388
+ },
389
+ },
390
+ ],
391
+ autoDiscoverInterceptors: true,
392
+ disableAutoInterceptors: ['analytics'],
393
+ });
394
+ ```
395
+
396
+ ---
397
+
398
+ ## Interceptors (proxy-side)
399
+
400
+ Proxy interceptors run **inside the proxy route**, around the backend fetch. They use a
401
+ middleware `(ctx, next)` signature and are matched by path/method. Do not confuse them with
402
+ the client `onRequest`/`onResponse` hooks (those run in the calling code, on the proxy URL).
403
+
404
+ ### `InterceptorRule`
405
+
406
+ ```typescript
407
+ interface InterceptorRule {
408
+ pathPattern: string | RegExp; // '/_auth/*' | '/users/:id' | /regex/ | '*' (matches the BACKEND path)
409
+ method?: string | string[]; // 'POST' | ['POST','PUT'] | omit = all
410
+ request?: (ctx: RequestInterceptorContext, next: () => Promise<void>) => Promise<void>;
411
+ response?: (ctx: ResponseInterceptorContext, next: () => Promise<void>) => Promise<void>;
412
+ }
413
+ ```
414
+
415
+ Pattern matching: `'*'` matches all; a `RegExp` is tested directly; a string converts `*`→
416
+ `.*` and `:param`→`[^/]+`. The path matched is the **resolved backend path** (e.g.
417
+ `/users/123`), not `/api/rpc/...`.
418
+
419
+ ### Contexts
420
+
421
+ ```typescript
422
+ interface RequestInterceptorContext {
423
+ path: string; method: string;
424
+ headers: Record<string, string>; // mutable — written back to forwarded headers
425
+ body?: any; // mutable — re-stringified if changed
426
+ query: Record<string, string | string[]>;
427
+ cookies: Map<string, string>; // request cookies
428
+ request: NextRequest;
429
+ metadata: Record<string, any>; // shared with the response interceptor
430
+ }
431
+
432
+ interface ResponseInterceptorContext {
433
+ path: string; method: string;
434
+ request: { headers: Record<string, string>; body?: any };
435
+ response: { ok: boolean; status: number; statusText: string; headers: Headers; body: any }; // body mutable
436
+ cookies: Map<string, string>; // read-only
437
+ setCookies: SetCookie[]; // push to emit Set-Cookie on the NextResponse
438
+ metadata: Record<string, any>;
439
+ }
440
+ ```
441
+
442
+ Interceptors chain in registration order; each must `await next()` to continue (skip it to
443
+ short-circuit). Mutating `ctx.headers` / `ctx.body` (request) and `ctx.response.body` /
444
+ `ctx.setCookies` (response) is how you affect the forwarded request and returned response.
445
+
446
+ ### Package auto-registration
447
+
448
+ Packages register interceptors on import; the proxy auto-discovers them
449
+ (`autoDiscoverInterceptors: true`). The registry lives on `globalThis` to survive HMR and
450
+ de-dupes by package name.
451
+
452
+ ```typescript
453
+ // inside a package, on import
454
+ import { registerInterceptors } from '@spfn/core/nextjs/server';
455
+
456
+ registerInterceptors('auth', [
457
+ {
458
+ pathPattern: '/_auth/*',
459
+ request: async (ctx, next) => {
460
+ const session = ctx.cookies.get('session');
461
+ if (session) ctx.headers['Authorization'] = `Bearer ${session}`;
462
+ await next();
463
+ },
464
+ response: async (ctx, next) => {
465
+ if (ctx.path === '/_auth/login' && ctx.response.body?.token) {
466
+ ctx.setCookies.push({
467
+ name: 'session',
468
+ value: ctx.response.body.token,
469
+ options: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 86400 },
470
+ });
471
+ delete ctx.response.body.token;
472
+ }
473
+ await next();
474
+ },
475
+ },
476
+ ]);
477
+ ```
478
+
479
+ Importing the package's side-effect entry (e.g. `import '@spfn/auth/nextjs/api'`) in the
480
+ proxy route is what triggers registration. Use `disableAutoInterceptors: ['auth']` to opt a
481
+ package out.
482
+
483
+ ---
484
+
485
+ ## Type helpers
486
+
487
+ ```typescript
488
+ import type { RouterOutput, RouterInput, InferRouteInput, InferRouteOutput } from '@spfn/core/nextjs';
489
+ import type { AppRouter } from '@/server/router';
490
+
491
+ type ListData = RouterOutput<AppRouter, 'listExamples'>;
492
+ type CreateInput = RouterInput<AppRouter, 'createExample'>;
493
+ type Item = RouterOutput<AppRouter, 'listExamples'>['items'][number];
494
+ type BodyInput = RouterInput<AppRouter, 'createExample'>['body'];
495
+ ```
496
+
497
+ `RouterOutput`/`RouterInput` take the router type + a route-name key. `InferRouteInput`/
498
+ `InferRouteOutput` take a single `RouteDef`. All inference is compile-time only (zero runtime
499
+ cost).
500
+
501
+ The route-name key is the **flat** set of names in the router tree. A route declared inside a
502
+ nested `defineRouter` is named by its own key here, exactly as the server registers it — the
503
+ group key is not part of the name and does not appear:
504
+
505
+ ```typescript
506
+ const appRouter = defineRouter({
507
+ getRoot,
508
+ examples: defineRouter({ listExamples, createExample }),
509
+ });
510
+
511
+ type ListData = RouterOutput<typeof appRouter, 'listExamples'>; // not 'examples.listExamples'
512
+ ```
513
+
514
+ A name two branches both declare is left out of that set rather than resolved to one of them,
515
+ so naming it is a compile error where it is named. Routes mounted with `.packages()` stay out
516
+ of the set on purpose — call them through the package's own client.
517
+
518
+ `Client<AppRouter>` — what `createApi` returns — is built from the same flat set, so a route
519
+ is named identically whether you call it (`api.listExamples`) or take its type
520
+ (`RouterOutput<AppRouter, 'listExamples'>`).
521
+
522
+ ---
523
+
524
+ ## Error handling
525
+
526
+ `ApiError` is thrown for non-2xx responses, network failures, timeouts, and caller aborts:
527
+
528
+ ```typescript
529
+ class ApiError extends Error {
530
+ constructor(
531
+ message: string,
532
+ public readonly status: number, // HTTP status; 0 for network and aborted; 408 for timeout
533
+ public readonly url: string,
534
+ public readonly response?: unknown, // parsed error body
535
+ public readonly errorType?: 'http' | 'network' | 'timeout' | 'aborted',
536
+ options?: { cause?: unknown }, // 'aborted': cause is the signal's reason
537
+ ) {}
538
+ }
539
+ ```
540
+
541
+ ```typescript
542
+ import { ApiError } from '@spfn/core/nextjs';
543
+
544
+ try {
545
+ await api.getUser.call({ params: { id: '123' } });
546
+ } catch (error) {
547
+ if (error instanceof ApiError) {
548
+ if (error.errorType === 'timeout') { /* 408 */ }
549
+ else if (error.errorType === 'aborted') { /* status 0; you aborted it — error.cause is the reason */ }
550
+ else if (error.errorType === 'network') { /* status 0 */ }
551
+ else { /* error.status, error.response */ }
552
+ }
553
+ }
554
+ ```
555
+
556
+ The client classifies an abort by who triggered it, never by the error's name: `'timeout'`
557
+ when its own timer fired, `'aborted'` when your signal did — whichever came first. An
558
+ `AbortError` that a custom `fetch` throws on its own, with neither of those having fired, is
559
+ `'timeout'` too, as it always was.
560
+
561
+ **Custom errors**: if the backend body carries a `__type` discriminator and a matching entry
562
+ is registered in the client's `errorRegistry`, the client deserializes and throws the
563
+ **original typed error** instead of a generic `ApiError`. Otherwise it falls back to
564
+ `ApiError`.
565
+
566
+ ---
567
+
568
+ ## Next.js integration
569
+
570
+ ### Server Components with caching
571
+
572
+ ```typescript
573
+ // app/users/[id]/page.tsx
574
+ import { api } from '@/lib/api';
575
+
576
+ export default async function UserPage({ params }: { params: Promise<{ id: string }> }) {
577
+ const { id } = await params;
578
+ const user = await api.getUser
579
+ .fetchOptions({ next: { revalidate: 3600 } })
580
+ .call({ params: { id } });
581
+ return <div>{user.name}</div>;
582
+ }
583
+ ```
584
+
585
+ ### Tag-based revalidation
586
+
587
+ ```typescript
588
+ const posts = await api.getPosts
589
+ .fetchOptions({ next: { tags: ['posts'] } })
590
+ .call({ query: { page: 1 } });
591
+
592
+ // later, in a Server Action:
593
+ import { revalidateTag } from 'next/cache';
594
+ revalidateTag('posts');
595
+ ```
596
+
597
+ ### Client Components / Server Actions
598
+
599
+ ```typescript
600
+ 'use client';
601
+ import { api } from '@/lib/api';
602
+ const created = await api.createUser.call({ body: { name, email } });
603
+ ```
604
+
605
+ ```typescript
606
+ // app/actions.ts
607
+ 'use server';
608
+ import { api } from '@/lib/api';
609
+ export async function createUser(formData: FormData) {
610
+ return api.createUser.call({
611
+ body: { name: formData.get('name') as string, email: formData.get('email') as string },
612
+ });
613
+ }
614
+ ```
615
+
616
+ ---
617
+
618
+ ## Pitfalls & anti-patterns
619
+
620
+ - **Never import `@spfn/core/nextjs/server` in a Client Component.** It pulls in
621
+ `next/headers` + `next/server`; bundling it client-side breaks the build. Client code uses
622
+ `@spfn/core/nextjs` only (`createApi`, `ApiError`, types).
623
+ - **`interceptors` is an `InterceptorRule[]`, not `{ request, response }`.** Older docs
624
+ showed `createRpcProxy({ router, interceptors: { request, response } })` — that config
625
+ shape **does not exist**. The current API is `{ routeMap, interceptors: [{ pathPattern,
626
+ method?, request?, response? }] }`. There is **no** `router` option either; the proxy is
627
+ driven entirely by `routeMap`.
628
+ - **The proxy resolves the real HTTP method from `routeMap`, not from the client call.** The
629
+ client only ever sends GET (no body) or POST (body/formData) to `/api/rpc/...`. A PUT /
630
+ PATCH / DELETE route still works — the backend method comes from `routeMap[routeName]`.
631
+ - **A missing `routeName` in `routeMap` is a proxy 404, not a backend 404.** Re-run codegen
632
+ after adding routes or upgrading a mounted package — the generated map carries the mounted
633
+ packages' routes — merge `eventRouteMap` if you use SSE, and delete `.spfn` if the map
634
+ looks stale.
635
+ - **Interceptor `pathPattern` matches the resolved backend path** (e.g. `/users/123`,
636
+ `/_auth/login`) — *not* the `/api/rpc/{routeName}` URL the client hit.
637
+ - **Two same-named interceptor type pairs.** `RequestInterceptor`/`ResponseInterceptor` from
638
+ `@spfn/core/nextjs` are `(url, init)` / `(response, body)` (client hooks);
639
+ from `@spfn/core/nextjs/server` they are `(ctx, next) => Promise<void>` (proxy middleware).
640
+ Import from the right entry point.
641
+ - **Don't pass auth via the client to set HttpOnly cookies.** HttpOnly session cookies are
642
+ set on the proxy response by a response interceptor (`ctx.setCookies.push(...)`), and
643
+ forwarded automatically on subsequent requests. Client code can't read them by design.
644
+ - **Cookies are auto-forwarded on the server** — only use `.cookies({...})` /
645
+ `options.cookies` to override. Calling outside a request context (static generation) yields
646
+ no cookies, silently.
647
+ - **`createApi` needs no codegen; the *proxy* does.** The client is metadata-free. The
648
+ `routeMap` the proxy consumes is the generated artifact.
649
+ - **Raw `X-Forwarded-For` is not forwarded — don't widen the allowlist to "fix" client IP.**
650
+ The proxy re-emits the resolved client IP as `x-spfn-proxy-client-ip`, trusted only on
651
+ HMAC-signed requests. Missing visitor IP in production means `SPFN_PROXY_SECRET` isn't set on
652
+ both sides (and/or `TRUSTED_PROXY_HOPS` is wrong), not that the header allowlist needs
653
+ `x-forwarded-for` — adding it back makes the IP spoofable. See *Header forwarding & client IP*.
654
+ - **Each `.headers()/.cookies()/.fetchOptions()/.timeout()/.onRequest()/.onResponse()` returns a new
655
+ builder.** Chain them in one expression; a dangling builder without `.call()` does nothing.
656
+ - **A client `onResponse` may throw, and the throw is the caller's.** It runs outside the
657
+ transport error handling, so `redirect('/login')` on a 401 navigates and a domain error
658
+ raised there is raised as itself — neither is relabelled an `ApiError` of type `'network'`.
659
+ Only a failed fetch (`0` / `'network'`), a timed-out one (`408` / `'timeout'`) and one the
660
+ caller aborted (`0` / `'aborted'`) are.
661
+
662
+ ---
663
+
664
+ ## Types reference
665
+
666
+ ```typescript
667
+ // client (@spfn/core/nextjs)
668
+ type RequestInterceptor = (url: string, init: RequestInit) => Promise<RequestInit> | RequestInit;
669
+ type ResponseInterceptor = (response: Response, body: any)
670
+ => Promise<{ response: Response; body: any }> | { response: Response; body: any };
671
+
672
+ interface CookieOptions { httpOnly?: boolean; secure?: boolean; sameSite?: 'strict'|'lax'|'none'; maxAge?: number; path?: string; domain?: string; }
673
+ interface SetCookie { name: string; value: string; options?: CookieOptions; }
674
+
675
+ // proxy (@spfn/core/nextjs/server)
676
+ type RequestInterceptor = (ctx: RequestInterceptorContext, next: () => Promise<void>) => Promise<void>;
677
+ type ResponseInterceptor = (ctx: ResponseInterceptorContext, next: () => Promise<void>) => Promise<void>;
678
+ interface RouteMapEntry { method: HttpMethod; path: string; }
679
+ type RouteMap = Record<string, RouteMapEntry>;
680
+ ```
681
+
682
+ ## Related
683
+
684
+ - [@spfn/core/route](../route/README.md) — server-side route + router definitions (source of `AppRouter`)
685
+ - [@spfn/core/codegen](../codegen/README.md) — generates the `routeMap` the proxy consumes
686
+ - [@spfn/core/errors](../errors/README.md) — `ErrorRegistry`, custom error deserialization
687
+ - [@spfn/core/config](../config/README.md) — `SPFN_API_URL`, `SPFN_APP_URL`, `RPC_PROXY_TIMEOUT`, `SERVER_TIMEOUT`
688
+ - [@spfn/auth](../../../auth/README.md) — example package: route map + auto-registered interceptors