@spfn/core 0.3.0-beta.3 → 0.3.0-beta.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +304 -12
- package/dist/app-config/index.d.ts +39 -1
- package/dist/app-config/index.js.map +1 -1
- package/dist/authz/index.js +47 -391
- package/dist/authz/index.js.map +1 -1
- package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
- package/dist/codegen/index.d.ts +157 -16
- package/dist/codegen/index.js +772 -166
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.js +4 -4
- package/dist/config/index.js.map +1 -1
- package/dist/contract/index.d.ts +39 -16
- package/dist/contract/index.js +147 -23
- package/dist/contract/index.js.map +1 -1
- package/dist/db/index.d.ts +230 -35
- package/dist/db/index.js +316 -160
- package/dist/db/index.js.map +1 -1
- package/dist/env/index.d.ts +6 -442
- package/dist/env/index.js +13 -16
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/error-CIZlYJlL.d.ts +157 -0
- package/dist/errors/index.d.ts +18 -1
- package/dist/errors/index.js +47 -391
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.d.ts +5 -5
- package/dist/event/index.js +202 -3
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +18 -4
- package/dist/event/sse/client.js +94 -18
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +18 -5
- package/dist/event/sse/index.js +452 -114
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +3 -2
- package/dist/event/ws/index.d.ts +4 -3
- package/dist/job/index.d.ts +48 -3
- package/dist/job/index.js +131 -52
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.js +0 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.js +52 -397
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +75 -14
- package/dist/nextjs/index.js +200 -84
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +46 -8
- package/dist/nextjs/server.js +15 -1
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +61 -42
- package/dist/ops/index.js +648 -97
- package/dist/ops/index.js.map +1 -1
- package/dist/registry-C2KQCh0m.d.ts +473 -0
- package/dist/route/index.d.ts +5 -2
- package/dist/route/index.js +107 -28
- package/dist/route/index.js.map +1 -1
- package/dist/route/types.d.ts +7 -1
- package/dist/{router-Cy7rAfmj.d.ts → route-builder-DRW-MSCJ.d.ts} +51 -126
- package/dist/router-C57y-vz1.d.ts +184 -0
- package/dist/router-CX5uVjmU.d.ts +406 -0
- package/dist/server/index.d.ts +130 -7
- package/dist/server/index.js +1174 -164
- package/dist/server/index.js.map +1 -1
- package/dist/state/client.d.ts +206 -0
- package/dist/state/client.js +678 -0
- package/dist/state/client.js.map +1 -0
- package/dist/state/index.d.ts +443 -0
- package/dist/state/index.js +644 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/react.d.ts +49 -0
- package/dist/state/react.js +50 -0
- package/dist/state/react.js.map +1 -0
- package/dist/surface/index.d.ts +115 -0
- package/dist/surface/index.js +1013 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
- package/dist/{types-CfYVhIQ9.d.ts → types-5Rl11_Nb.d.ts} +43 -4
- package/dist/types-CKH0D3Dy.d.ts +441 -0
- package/dist/{types-ZQODsBft.d.ts → types-CurMab2Q.d.ts} +52 -2
- package/dist/{types-2AbaW4Ie.d.ts → types-DncqXfWN.d.ts} +4 -3
- package/docs/file-upload.md +195 -333
- package/package.json +41 -11
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +721 -0
- package/src/config/README.md +333 -0
- package/src/contract/README.md +442 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +354 -0
- package/src/db/transaction/README.md +845 -0
- package/src/env/README.md +787 -0
- package/src/errors/README.md +445 -0
- package/src/event/README.md +994 -0
- package/src/job/README.md +613 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +756 -0
- package/src/route/README.md +763 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
- package/src/state/README.md +806 -0
- package/dist/token-manager-BT5EnUAR.d.ts +0 -278
- package/dist/types-9oszaJqp.d.ts +0 -250
|
@@ -0,0 +1,756 @@
|
|
|
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`, `ApiOutcome`, `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
|
+
onOutcome?: (outcome: ApiOutcome) => void; // told once how each call that reached fetch ended
|
|
237
|
+
errorRegistry?: ErrorRegistry | ErrorRegistryInput[]; // custom error deserialization
|
|
238
|
+
debug?: boolean; // default false
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`errorRegistry`: the core `errorRegistry` is **always** merged in automatically. Pass an
|
|
243
|
+
array to add your app/package error registries on top:
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
import { errorRegistry } from '@spfn/core/errors';
|
|
247
|
+
const api = createApi<AppRouter>({ errorRegistry: [errorRegistry, authErrorRegistry] });
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### `CallOptions` (per-call, also settable via chaining)
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
interface CallOptions {
|
|
254
|
+
timeout?: number; // ms until response headers; overrides ApiConfig.timeout
|
|
255
|
+
headers?: Record<string, string>;
|
|
256
|
+
cookies?: Record<string, string>; // override only; cookies are auto-forwarded otherwise
|
|
257
|
+
onRequest?: RequestInterceptor;
|
|
258
|
+
onResponse?: ResponseInterceptor;
|
|
259
|
+
fetchOptions?: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } };
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Automatic cookie forwarding (SSR)
|
|
264
|
+
|
|
265
|
+
When `createApi` runs **on the server** (RSC / route handler) it auto-detects request
|
|
266
|
+
cookies via `next/headers` `cookies()` and forwards them as the `Cookie` header. In the
|
|
267
|
+
browser, cookies are sent by the browser automatically. You only need `.cookies({...})` /
|
|
268
|
+
`options.cookies` to **override**. Outside a request context (static generation, build) the
|
|
269
|
+
cookie read silently yields none.
|
|
270
|
+
|
|
271
|
+
### SSR base URL resolution
|
|
272
|
+
|
|
273
|
+
For absolute fetches on the server the client uses `env.SPFN_APP_URL`. If unset, it falls
|
|
274
|
+
back to reconstructing the origin from the incoming request's `host` /
|
|
275
|
+
`x-forwarded-proto` headers (via `next/headers`); failing that, it uses a relative URL.
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## Proxy — `createRpcProxy`
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
function createRpcProxy(config: RpcProxyConfig): {
|
|
283
|
+
GET: (req, ctx) => Promise<NextResponse>;
|
|
284
|
+
POST: (req, ctx) => Promise<NextResponse>;
|
|
285
|
+
};
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Returns `{ GET, POST }` handlers for an App Router catch-all route. It:
|
|
289
|
+
|
|
290
|
+
1. Reads `routeName` from the async `params` (`context.params: Promise<{ routeName?: string }>` — Next.js 15).
|
|
291
|
+
2. Parses `input` from `?input=` (GET) or the JSON / `multipart/form-data` body (POST).
|
|
292
|
+
3. Resolves `routeMap[routeName]` → `{ method, path }`; **404** if missing.
|
|
293
|
+
4. Substitutes `:params` into `path`, appends the query string.
|
|
294
|
+
5. Forwards to `${apiUrl}${path}${query}` with the resolved backend method, running matching
|
|
295
|
+
interceptors before/after.
|
|
296
|
+
6. Wraps the backend response in a `NextResponse` (special-casing `204`), forwards response
|
|
297
|
+
headers, and appends any `Set-Cookie` pushed by interceptors.
|
|
298
|
+
|
|
299
|
+
### `RpcProxyConfig`
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
interface RpcProxyConfig {
|
|
303
|
+
routeMap: RouteMap; // REQUIRED — Record<routeName, { method, path }>
|
|
304
|
+
apiUrl?: string; // default env.SPFN_API_URL || 'http://localhost:8790'
|
|
305
|
+
timeout?: number; // default env.RPC_PROXY_TIMEOUT (120000 ms); AbortController
|
|
306
|
+
debug?: boolean; // default env.NODE_ENV === 'development'
|
|
307
|
+
headers?: Record<string, string>; // added to every forwarded request
|
|
308
|
+
interceptors?: InterceptorRule[]; // inline interceptors (array, NOT { request, response })
|
|
309
|
+
autoDiscoverInterceptors?: boolean; // default true — pull from registry
|
|
310
|
+
disableAutoInterceptors?: string[]; // e.g. ['auth'] — exclude registered packages
|
|
311
|
+
proxySecret?: string; // default env.SPFN_PROXY_SECRET — HMAC-sign forwarded requests (proxy-guard)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
interface RouteMapEntry { method: HttpMethod; path: string; }
|
|
315
|
+
type RouteMap = Record<string, RouteMapEntry>;
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### `routeMap` — the proxy's source of truth
|
|
319
|
+
|
|
320
|
+
`routeMap` comes from codegen (`@/generated/route-map`) and already holds the routes of every
|
|
321
|
+
package router the app router mounts with `.packages()` — `@spfn/auth`'s included, so
|
|
322
|
+
`{ ...routeMap, ...authRouteMap }` is a no-op and no longer needed. `eventRouteMap` is still
|
|
323
|
+
merged by hand: it is a hand-written constant, not a mounted router.
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
// app/api/rpc/[routeName]/route.ts
|
|
327
|
+
import '@spfn/auth/nextjs/api'; // side-effect: registers auth interceptors
|
|
328
|
+
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
329
|
+
import { eventRouteMap } from '@spfn/core/event';
|
|
330
|
+
import { routeMap } from '@/generated/route-map';
|
|
331
|
+
|
|
332
|
+
export const { GET, POST } = createRpcProxy({
|
|
333
|
+
routeMap: { ...routeMap, ...eventRouteMap },
|
|
334
|
+
});
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
A `routeName` absent from `routeMap` returns **404** from the proxy (not from the backend).
|
|
338
|
+
After adding routes — or after upgrading a mounted package — re-run codegen and clear `.spfn`
|
|
339
|
+
cache if stale.
|
|
340
|
+
|
|
341
|
+
### Header forwarding & client IP
|
|
342
|
+
|
|
343
|
+
Request headers are forwarded to the backend by an **allowlist**, not copied wholesale. Only
|
|
344
|
+
`content-type`, `authorization`, `cookie`, `user-agent`, `accept`, `accept-language`, and
|
|
345
|
+
`origin` pass through; everything else on the inbound request is dropped.
|
|
346
|
+
|
|
347
|
+
`X-Forwarded-For` is **not** in that allowlist, so it is not forwarded verbatim. Instead the
|
|
348
|
+
proxy resolves the real client IP from the inbound `X-Forwarded-For` chain — hop-aware, taking
|
|
349
|
+
the rightmost `TRUSTED_PROXY_HOPS` (default `1`) entries as your own trusted infra — and
|
|
350
|
+
re-emits that single address under a dedicated header, `x-spfn-proxy-client-ip`. This avoids
|
|
351
|
+
passing a client-controllable header straight through to the backend.
|
|
352
|
+
|
|
353
|
+
The backend only **trusts** that header on requests it can prove came through this proxy. Set
|
|
354
|
+
the same **`SPFN_PROXY_SECRET`** on the proxy (or `proxySecret` in config) and on the backend:
|
|
355
|
+
the proxy then HMAC-signs each forwarded request and the backend's proxy-guard marks it
|
|
356
|
+
verified (`clientType ≠ 'untrusted'`), so `getClientIp(c)` returns the real visitor IP from
|
|
357
|
+
`x-spfn-proxy-client-ip`. See [@spfn/core/middleware](../middleware/README.md) proxy-guard +
|
|
358
|
+
`getClientIp`.
|
|
359
|
+
|
|
360
|
+
Without a shared secret the request is unsigned, the backend won't trust that header, and
|
|
361
|
+
`getClientIp` falls back to the raw `X-Forwarded-For` first hop — which the proxy didn't
|
|
362
|
+
forward, so the client IP collapses to the TCP peer or `'unknown'`. If your visitor IP is
|
|
363
|
+
missing in production, set `SPFN_PROXY_SECRET` on both sides (and match `TRUSTED_PROXY_HOPS`
|
|
364
|
+
to your LB/nginx depth) rather than widening the header allowlist — forwarding raw
|
|
365
|
+
`X-Forwarded-For` reintroduces a spoofable IP.
|
|
366
|
+
|
|
367
|
+
### Full config example
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
export const { GET, POST } = createRpcProxy({
|
|
371
|
+
routeMap: { ...routeMap, ...eventRouteMap },
|
|
372
|
+
apiUrl: process.env.SPFN_API_URL,
|
|
373
|
+
timeout: 60000,
|
|
374
|
+
debug: true,
|
|
375
|
+
headers: { 'X-API-Key': process.env.SPFN_API_KEY! },
|
|
376
|
+
interceptors: [
|
|
377
|
+
{
|
|
378
|
+
pathPattern: '/_auth/*',
|
|
379
|
+
method: 'POST',
|
|
380
|
+
response: async (ctx, next) => {
|
|
381
|
+
if (ctx.response.body?.token) {
|
|
382
|
+
ctx.setCookies.push({
|
|
383
|
+
name: 'session',
|
|
384
|
+
value: ctx.response.body.token,
|
|
385
|
+
options: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 86400 },
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
await next();
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
autoDiscoverInterceptors: true,
|
|
393
|
+
disableAutoInterceptors: ['analytics'],
|
|
394
|
+
});
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
## Interceptors (proxy-side)
|
|
400
|
+
|
|
401
|
+
Proxy interceptors run **inside the proxy route**, around the backend fetch. They use a
|
|
402
|
+
middleware `(ctx, next)` signature and are matched by path/method. Do not confuse them with
|
|
403
|
+
the client `onRequest`/`onResponse` hooks (those run in the calling code, on the proxy URL).
|
|
404
|
+
|
|
405
|
+
### `InterceptorRule`
|
|
406
|
+
|
|
407
|
+
```typescript
|
|
408
|
+
interface InterceptorRule {
|
|
409
|
+
pathPattern: string | RegExp; // '/_auth/*' | '/users/:id' | /regex/ | '*' (matches the BACKEND path)
|
|
410
|
+
method?: string | string[]; // 'POST' | ['POST','PUT'] | omit = all
|
|
411
|
+
request?: (ctx: RequestInterceptorContext, next: () => Promise<void>) => Promise<void>;
|
|
412
|
+
response?: (ctx: ResponseInterceptorContext, next: () => Promise<void>) => Promise<void>;
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
Pattern matching: `'*'` matches all; a `RegExp` is tested directly; a string converts `*`→
|
|
417
|
+
`.*` and `:param`→`[^/]+`. The path matched is the **resolved backend path** (e.g.
|
|
418
|
+
`/users/123`), not `/api/rpc/...`.
|
|
419
|
+
|
|
420
|
+
### Contexts
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
interface RequestInterceptorContext {
|
|
424
|
+
path: string; method: string;
|
|
425
|
+
headers: Record<string, string>; // mutable — written back to forwarded headers
|
|
426
|
+
body?: any; // mutable — re-stringified if changed
|
|
427
|
+
query: Record<string, string | string[]>;
|
|
428
|
+
cookies: Map<string, string>; // request cookies
|
|
429
|
+
request: NextRequest;
|
|
430
|
+
metadata: Record<string, any>; // shared with the response interceptor
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
interface ResponseInterceptorContext {
|
|
434
|
+
path: string; method: string;
|
|
435
|
+
request: { headers: Record<string, string>; body?: any };
|
|
436
|
+
response: { ok: boolean; status: number; statusText: string; headers: Headers; body: any }; // body mutable
|
|
437
|
+
cookies: Map<string, string>; // read-only
|
|
438
|
+
setCookies: SetCookie[]; // push to emit Set-Cookie on the NextResponse
|
|
439
|
+
metadata: Record<string, any>;
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Interceptors chain in registration order; each must `await next()` to continue (skip it to
|
|
444
|
+
short-circuit). Mutating `ctx.headers` / `ctx.body` (request) and `ctx.response.body` /
|
|
445
|
+
`ctx.setCookies` (response) is how you affect the forwarded request and returned response.
|
|
446
|
+
|
|
447
|
+
### Package auto-registration
|
|
448
|
+
|
|
449
|
+
Packages register interceptors on import; the proxy auto-discovers them
|
|
450
|
+
(`autoDiscoverInterceptors: true`). The registry lives on `globalThis` to survive HMR and
|
|
451
|
+
de-dupes by package name.
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
// inside a package, on import
|
|
455
|
+
import { registerInterceptors } from '@spfn/core/nextjs/server';
|
|
456
|
+
|
|
457
|
+
registerInterceptors('auth', [
|
|
458
|
+
{
|
|
459
|
+
pathPattern: '/_auth/*',
|
|
460
|
+
request: async (ctx, next) => {
|
|
461
|
+
const session = ctx.cookies.get('session');
|
|
462
|
+
if (session) ctx.headers['Authorization'] = `Bearer ${session}`;
|
|
463
|
+
await next();
|
|
464
|
+
},
|
|
465
|
+
response: async (ctx, next) => {
|
|
466
|
+
if (ctx.path === '/_auth/login' && ctx.response.body?.token) {
|
|
467
|
+
ctx.setCookies.push({
|
|
468
|
+
name: 'session',
|
|
469
|
+
value: ctx.response.body.token,
|
|
470
|
+
options: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 86400 },
|
|
471
|
+
});
|
|
472
|
+
delete ctx.response.body.token;
|
|
473
|
+
}
|
|
474
|
+
await next();
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
]);
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Importing the package's side-effect entry (e.g. `import '@spfn/auth/nextjs/api'`) in the
|
|
481
|
+
proxy route is what triggers registration. Use `disableAutoInterceptors: ['auth']` to opt a
|
|
482
|
+
package out.
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
## Type helpers
|
|
487
|
+
|
|
488
|
+
```typescript
|
|
489
|
+
import type { RouterOutput, RouterInput, InferRouteInput, InferRouteOutput } from '@spfn/core/nextjs';
|
|
490
|
+
import type { AppRouter } from '@/server/router';
|
|
491
|
+
|
|
492
|
+
type ListData = RouterOutput<AppRouter, 'listExamples'>;
|
|
493
|
+
type CreateInput = RouterInput<AppRouter, 'createExample'>;
|
|
494
|
+
type Item = RouterOutput<AppRouter, 'listExamples'>['items'][number];
|
|
495
|
+
type BodyInput = RouterInput<AppRouter, 'createExample'>['body'];
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
`RouterOutput`/`RouterInput` take the router type + a route-name key. `InferRouteInput`/
|
|
499
|
+
`InferRouteOutput` take a single `RouteDef`. All inference is compile-time only (zero runtime
|
|
500
|
+
cost).
|
|
501
|
+
|
|
502
|
+
The route-name key is the **flat** set of names in the router tree. A route declared inside a
|
|
503
|
+
nested `defineRouter` is named by its own key here, exactly as the server registers it — the
|
|
504
|
+
group key is not part of the name and does not appear:
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
const appRouter = defineRouter({
|
|
508
|
+
getRoot,
|
|
509
|
+
examples: defineRouter({ listExamples, createExample }),
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
type ListData = RouterOutput<typeof appRouter, 'listExamples'>; // not 'examples.listExamples'
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
A name two branches both declare is left out of that set rather than resolved to one of them,
|
|
516
|
+
so naming it is a compile error where it is named. Routes mounted with `.packages()` stay out
|
|
517
|
+
of the set on purpose — call them through the package's own client.
|
|
518
|
+
|
|
519
|
+
`Client<AppRouter>` — what `createApi` returns — is built from the same flat set, so a route
|
|
520
|
+
is named identically whether you call it (`api.listExamples`) or take its type
|
|
521
|
+
(`RouterOutput<AppRouter, 'listExamples'>`).
|
|
522
|
+
|
|
523
|
+
---
|
|
524
|
+
|
|
525
|
+
## Error handling
|
|
526
|
+
|
|
527
|
+
`ApiError` is thrown for non-2xx responses, network failures, timeouts, and caller aborts:
|
|
528
|
+
|
|
529
|
+
```typescript
|
|
530
|
+
class ApiError extends Error {
|
|
531
|
+
constructor(
|
|
532
|
+
message: string,
|
|
533
|
+
public readonly status: number, // HTTP status; 0 for network and aborted; 408 for timeout
|
|
534
|
+
public readonly url: string,
|
|
535
|
+
public readonly response?: unknown, // parsed error body
|
|
536
|
+
public readonly errorType?: 'http' | 'network' | 'timeout' | 'aborted',
|
|
537
|
+
options?: { cause?: unknown }, // 'aborted': cause is the signal's reason
|
|
538
|
+
) {}
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
```typescript
|
|
543
|
+
import { ApiError } from '@spfn/core/nextjs';
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
await api.getUser.call({ params: { id: '123' } });
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if (error instanceof ApiError) {
|
|
549
|
+
if (error.errorType === 'timeout') { /* 408 */ }
|
|
550
|
+
else if (error.errorType === 'aborted') { /* status 0; you aborted it — error.cause is the reason */ }
|
|
551
|
+
else if (error.errorType === 'network') { /* status 0 */ }
|
|
552
|
+
else { /* error.status, error.response */ }
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
The client classifies an abort by who triggered it, never by the error's name: `'timeout'`
|
|
558
|
+
when its own timer fired, `'aborted'` when your signal did — whichever came first. An
|
|
559
|
+
`AbortError` that a custom `fetch` throws on its own, with neither of those having fired, is
|
|
560
|
+
`'timeout'` too, as it always was.
|
|
561
|
+
|
|
562
|
+
**Custom errors**: if the backend body carries a `__type` discriminator and a matching entry
|
|
563
|
+
is registered in the client's `errorRegistry`, the client deserializes and throws the
|
|
564
|
+
**original typed error** instead of a generic `ApiError`. Otherwise it falls back to
|
|
565
|
+
`ApiError`.
|
|
566
|
+
|
|
567
|
+
---
|
|
568
|
+
|
|
569
|
+
## Next.js integration
|
|
570
|
+
|
|
571
|
+
### Server Components with caching
|
|
572
|
+
|
|
573
|
+
```typescript
|
|
574
|
+
// app/users/[id]/page.tsx
|
|
575
|
+
import { api } from '@/lib/api';
|
|
576
|
+
|
|
577
|
+
export default async function UserPage({ params }: { params: Promise<{ id: string }> }) {
|
|
578
|
+
const { id } = await params;
|
|
579
|
+
const user = await api.getUser
|
|
580
|
+
.fetchOptions({ next: { revalidate: 3600 } })
|
|
581
|
+
.call({ params: { id } });
|
|
582
|
+
return <div>{user.name}</div>;
|
|
583
|
+
}
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
### Tag-based revalidation
|
|
587
|
+
|
|
588
|
+
```typescript
|
|
589
|
+
const posts = await api.getPosts
|
|
590
|
+
.fetchOptions({ next: { tags: ['posts'] } })
|
|
591
|
+
.call({ query: { page: 1 } });
|
|
592
|
+
|
|
593
|
+
// later, in a Server Action:
|
|
594
|
+
import { revalidateTag } from 'next/cache';
|
|
595
|
+
revalidateTag('posts');
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### Client Components / Server Actions
|
|
599
|
+
|
|
600
|
+
```typescript
|
|
601
|
+
'use client';
|
|
602
|
+
import { api } from '@/lib/api';
|
|
603
|
+
const created = await api.createUser.call({ body: { name, email } });
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
```typescript
|
|
607
|
+
// app/actions.ts
|
|
608
|
+
'use server';
|
|
609
|
+
import { api } from '@/lib/api';
|
|
610
|
+
export async function createUser(formData: FormData) {
|
|
611
|
+
return api.createUser.call({
|
|
612
|
+
body: { name: formData.get('name') as string, email: formData.get('email') as string },
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
---
|
|
618
|
+
|
|
619
|
+
## Pitfalls & anti-patterns
|
|
620
|
+
|
|
621
|
+
- **Never import `@spfn/core/nextjs/server` in a Client Component.** It pulls in
|
|
622
|
+
`next/headers` + `next/server`; bundling it client-side breaks the build. Client code uses
|
|
623
|
+
`@spfn/core/nextjs` only (`createApi`, `ApiError`, types).
|
|
624
|
+
- **`interceptors` is an `InterceptorRule[]`, not `{ request, response }`.** Older docs
|
|
625
|
+
showed `createRpcProxy({ router, interceptors: { request, response } })` — that config
|
|
626
|
+
shape **does not exist**. The current API is `{ routeMap, interceptors: [{ pathPattern,
|
|
627
|
+
method?, request?, response? }] }`. There is **no** `router` option either; the proxy is
|
|
628
|
+
driven entirely by `routeMap`.
|
|
629
|
+
- **The proxy resolves the real HTTP method from `routeMap`, not from the client call.** The
|
|
630
|
+
client only ever sends GET (no body) or POST (body/formData) to `/api/rpc/...`. A PUT /
|
|
631
|
+
PATCH / DELETE route still works — the backend method comes from `routeMap[routeName]`.
|
|
632
|
+
- **A missing `routeName` in `routeMap` is a proxy 404, not a backend 404.** Re-run codegen
|
|
633
|
+
after adding routes or upgrading a mounted package — the generated map carries the mounted
|
|
634
|
+
packages' routes — merge `eventRouteMap` if you use SSE, and delete `.spfn` if the map
|
|
635
|
+
looks stale.
|
|
636
|
+
- **Interceptor `pathPattern` matches the resolved backend path** (e.g. `/users/123`,
|
|
637
|
+
`/_auth/login`) — *not* the `/api/rpc/{routeName}` URL the client hit.
|
|
638
|
+
- **Two same-named interceptor type pairs.** `RequestInterceptor`/`ResponseInterceptor` from
|
|
639
|
+
`@spfn/core/nextjs` are `(url, init)` / `(response, body)` (client hooks);
|
|
640
|
+
from `@spfn/core/nextjs/server` they are `(ctx, next) => Promise<void>` (proxy middleware).
|
|
641
|
+
Import from the right entry point.
|
|
642
|
+
- **Don't pass auth via the client to set HttpOnly cookies.** HttpOnly session cookies are
|
|
643
|
+
set on the proxy response by a response interceptor (`ctx.setCookies.push(...)`), and
|
|
644
|
+
forwarded automatically on subsequent requests. Client code can't read them by design.
|
|
645
|
+
- **Cookies are auto-forwarded on the server** — only use `.cookies({...})` /
|
|
646
|
+
`options.cookies` to override. Calling outside a request context (static generation) yields
|
|
647
|
+
no cookies, silently.
|
|
648
|
+
- **`createApi` needs no codegen; the *proxy* does.** The client is metadata-free. The
|
|
649
|
+
`routeMap` the proxy consumes is the generated artifact.
|
|
650
|
+
- **Raw `X-Forwarded-For` is not forwarded — don't widen the allowlist to "fix" client IP.**
|
|
651
|
+
The proxy re-emits the resolved client IP as `x-spfn-proxy-client-ip`, trusted only on
|
|
652
|
+
HMAC-signed requests. Missing visitor IP in production means `SPFN_PROXY_SECRET` isn't set on
|
|
653
|
+
both sides (and/or `TRUSTED_PROXY_HOPS` is wrong), not that the header allowlist needs
|
|
654
|
+
`x-forwarded-for` — adding it back makes the IP spoofable. See *Header forwarding & client IP*.
|
|
655
|
+
- **Each `.headers()/.cookies()/.fetchOptions()/.timeout()/.onRequest()/.onResponse()` returns a new
|
|
656
|
+
builder.** Chain them in one expression; a dangling builder without `.call()` does nothing.
|
|
657
|
+
- **A client `onResponse` may throw, and the throw is the caller's.** It runs outside the
|
|
658
|
+
transport error handling, so `redirect('/login')` on a 401 navigates and a domain error
|
|
659
|
+
raised there is raised as itself — neither is relabelled an `ApiError` of type `'network'`.
|
|
660
|
+
Only a failed fetch (`0` / `'network'`), a timed-out one (`408` / `'timeout'`) and one the
|
|
661
|
+
caller aborted (`0` / `'aborted'`) are.
|
|
662
|
+
|
|
663
|
+
### How do I learn how every call ended?
|
|
664
|
+
|
|
665
|
+
Give `createApi` an `onOutcome`. It is told once how each call that reached `fetch` ended,
|
|
666
|
+
before that call returns or throws:
|
|
667
|
+
|
|
668
|
+
```typescript
|
|
669
|
+
type ApiOutcome =
|
|
670
|
+
| { ok: true; response: Response; body: unknown }
|
|
671
|
+
| { ok: false; errorType: 'http'; status: number; error: Error }
|
|
672
|
+
| { ok: false; errorType: 'network' | 'timeout' | 'aborted'; status: number; error: ApiError };
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
- **Observed**: every answer — `ok: true` with the `response` and `body` your `onResponse`
|
|
676
|
+
interceptors left, or `'http'` with the final response's status — and every call that got
|
|
677
|
+
no answer (`'network'` 0, `'timeout'` 408, `'aborted'` 0).
|
|
678
|
+
- **`error` is the object the call throws**: for `'http'`, the registered class a `__type`
|
|
679
|
+
body names (`NotFoundError`, `UnauthorizedError`, …) or else an `ApiError`, hence `Error`;
|
|
680
|
+
for the transport kinds, the `ApiError`.
|
|
681
|
+
- **Not observed**: a throw from an `onRequest` or `onResponse` interceptor (`redirect()`,
|
|
682
|
+
`notFound()`, your own errors) or from building the request. Those are yours and reach the
|
|
683
|
+
caller unchanged.
|
|
684
|
+
- **The observer cannot change the call.** It is called synchronously and never awaited; a
|
|
685
|
+
throw or a rejected promise from it is logged (`onOutcome`, the error's name and message)
|
|
686
|
+
and the call still returns or throws what it would have.
|
|
687
|
+
- **Config only**: there is no per-call `onOutcome`.
|
|
688
|
+
|
|
689
|
+
```typescript
|
|
690
|
+
import { createApi } from '@spfn/core/nextjs';
|
|
691
|
+
import type { ApiOutcome } from '@spfn/core/nextjs';
|
|
692
|
+
import { UnauthorizedError } from '@spfn/core/errors';
|
|
693
|
+
|
|
694
|
+
type ServerStatus = 'up' | 'unreachable' | 'failing' | 'signed-out';
|
|
695
|
+
|
|
696
|
+
let serverStatus: ServerStatus = 'up';
|
|
697
|
+
|
|
698
|
+
function statusOf(outcome: ApiOutcome): ServerStatus
|
|
699
|
+
{
|
|
700
|
+
if (outcome.ok)
|
|
701
|
+
{
|
|
702
|
+
return 'up';
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (outcome.errorType !== 'http')
|
|
706
|
+
{
|
|
707
|
+
return outcome.errorType === 'aborted' ? serverStatus : 'unreachable';
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (outcome.error instanceof UnauthorizedError)
|
|
711
|
+
{
|
|
712
|
+
return 'signed-out';
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return outcome.status >= 500 ? 'failing' : 'up';
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export const api = createApi<AppRouter>({
|
|
719
|
+
onOutcome: (outcome) =>
|
|
720
|
+
{
|
|
721
|
+
serverStatus = statusOf(outcome);
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
---
|
|
727
|
+
|
|
728
|
+
## Types reference
|
|
729
|
+
|
|
730
|
+
```typescript
|
|
731
|
+
// client (@spfn/core/nextjs)
|
|
732
|
+
type ApiOutcome =
|
|
733
|
+
| { ok: true; response: Response; body: unknown }
|
|
734
|
+
| { ok: false; errorType: 'http'; status: number; error: Error }
|
|
735
|
+
| { ok: false; errorType: 'network' | 'timeout' | 'aborted'; status: number; error: ApiError };
|
|
736
|
+
type RequestInterceptor = (url: string, init: RequestInit) => Promise<RequestInit> | RequestInit;
|
|
737
|
+
type ResponseInterceptor = (response: Response, body: any)
|
|
738
|
+
=> Promise<{ response: Response; body: any }> | { response: Response; body: any };
|
|
739
|
+
|
|
740
|
+
interface CookieOptions { httpOnly?: boolean; secure?: boolean; sameSite?: 'strict'|'lax'|'none'; maxAge?: number; path?: string; domain?: string; }
|
|
741
|
+
interface SetCookie { name: string; value: string; options?: CookieOptions; }
|
|
742
|
+
|
|
743
|
+
// proxy (@spfn/core/nextjs/server)
|
|
744
|
+
type RequestInterceptor = (ctx: RequestInterceptorContext, next: () => Promise<void>) => Promise<void>;
|
|
745
|
+
type ResponseInterceptor = (ctx: ResponseInterceptorContext, next: () => Promise<void>) => Promise<void>;
|
|
746
|
+
interface RouteMapEntry { method: HttpMethod; path: string; }
|
|
747
|
+
type RouteMap = Record<string, RouteMapEntry>;
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
## Related
|
|
751
|
+
|
|
752
|
+
- [@spfn/core/route](../route/README.md) — server-side route + router definitions (source of `AppRouter`)
|
|
753
|
+
- [@spfn/core/codegen](../codegen/README.md) — generates the `routeMap` the proxy consumes
|
|
754
|
+
- [@spfn/core/errors](../errors/README.md) — `ErrorRegistry`, custom error deserialization
|
|
755
|
+
- [@spfn/core/config](../config/README.md) — `SPFN_API_URL`, `SPFN_APP_URL`, `RPC_PROXY_TIMEOUT`, `SERVER_TIMEOUT`
|
|
756
|
+
- [@spfn/auth](../../../auth/README.md) — example package: route map + auto-registered interceptors
|