@spfn/core/nextjs
End-to-end type-safe API client (createApi<AppRouter>()) and an RPC proxy route handler
(createRpcProxy) that bridges a Next.js App Router app to a SPFN define-route backend.
No per-route metadata codegen is required for the client — method/path resolution happens
at the proxy from a generated routeMap.
Client (browser / RSC) Proxy (Next.js API route) SPFN backend
api.getUser.call({...}) ──GET/POST──▶ /api/rpc/{routeName} ──HTTP──▶ resolved method+path
createApi<AppRouter>() createRpcProxy({ routeMap }) define-route handler
Import paths
There are two entry points. Picking the wrong one breaks the build — /server pulls in
next/headers + next/server and must never reach a Client Component bundle.
// Client-safe (Client Components, Server Components, anywhere). NO next/headers.
import { createApi, ApiError } from '@spfn/core/nextjs';
// Server-only (API routes, Server Components). Uses next/headers + next/server.
import { createRpcProxy, registerInterceptors } from '@spfn/core/nextjs/server';
createRpcProxy and the interceptor registry are only exported from
@spfn/core/nextjs/server. createApi / ApiError are only exported from
@spfn/core/nextjs. There is no barrel that re-exports both.
| Path | Environment | Exports |
|---|---|---|
@spfn/core/nextjs |
Client + Server | createApi, ApiError, all client types |
@spfn/core/nextjs/server |
Server only | createRpcProxy, interceptor registry + helpers, interceptor types |
Public API (complete)
From @spfn/core/nextjs (client-safe):
- Values:
createApi,ApiError - Types:
Client,RouteClient,ApiConfig,ApiOutcome,CallOptions,InferRouteInput,InferRouteOutput,RouterInput,RouterOutput,StructuredInput,RequestInterceptor,ResponseInterceptor,CookieOptions,SetCookie
From @spfn/core/nextjs/server (server-only):
- Values:
createRpcProxy,registerInterceptors,interceptorRegistry,matchPath,matchMethod,filterMatchingInterceptors,executeRequestInterceptors,executeResponseInterceptors - Types:
RpcProxyConfig,RequestInterceptorContext,ResponseInterceptorContext,RequestInterceptor,ResponseInterceptor(the proxy(ctx, next)shape — distinct from the client interceptor types of the same name),InterceptorRule,ProxyConfig
The client
RequestInterceptor/ResponseInterceptor((url, init)/(response, body)) and the proxyRequestInterceptor/ResponseInterceptor((ctx, next) => Promise<void>) are different types that share a name across the two entry points. Don't cross them.
Quick Start
1. Define the router (server)
// server/router.ts
import { defineRouter, route } from '@spfn/core/route';
import { Type } from '@sinclair/typebox';
export const appRouter = defineRouter({
getUser: route.get('/users/:id')
.input({ params: Type.Object({ id: Type.String() }) })
.handler(async (c) => {
const { params } = await c.data();
return { id: params.id, name: 'John' };
}),
createUser: route.post('/users')
.input({ body: Type.Object({ name: Type.String() }) })
.handler(async (c) => {
const { body } = await c.data();
return { id: '2', name: body.name };
}),
});
export type AppRouter = typeof appRouter;
2. Create the client (no metadata needed)
// lib/api.ts
import { createApi } from '@spfn/core/nextjs';
import type { AppRouter } from '@/server/router';
export const api = createApi<AppRouter>();
3. Mount the proxy route
// app/api/rpc/[routeName]/route.ts
import { createRpcProxy } from '@spfn/core/nextjs/server';
import { routeMap } from '@/generated/route-map';
export const { GET, POST } = createRpcProxy({ routeMap });
4. Call it
const user = await api.getUser.call({ params: { id: '123' } }); // GET /api/rpc/getUser?input=...
const created = await api.createUser.call({ body: { name: 'A' } }); // POST /api/rpc/createUser
Client — createApi
function createApi<TRouter extends Router<any>>(config?: ApiConfig): Client<TRouter>;
Returns a Proxy: every property access (api.getUser) yields a RouteClient builder.
Calls are made via .call(input).
The client is one flat namespace, holding exactly the names the server registers. A
route declared inside a nested defineRouter is reached by its own key — registerRoutes
mounts it under that key, the proxy resolves that name against the route map, and
RouterOutput takes the same name:
const appRouter = defineRouter({
getRoot,
users: defineRouter({ getUser }),
});
await api.getUser.call({ params: { id: '123' } }); // ✅ GET /api/rpc/getUser
await api.users.getUser.call({ ... }); // ❌ compile error — `users` is a group
There is no dot notation: a property access never yields another proxy, so a group key
names nothing on either side. A name two branches both declare is left out of the client
rather than resolved to one of them, and .packages() routes stay out on purpose — call
them through the package's own client (authApi, cmsApi).
Structured input
Input mirrors the route's .input({...}) definition exactly. Empty sections are omitted
from the type, so you only pass what the route declares:
await api.getUser.call({ params: { id: '123' } });
await api.getUser.call({ params: { id: '123' }, query: { include: 'posts' } });
await api.createUser.call({ body: { name: 'John', email: 'a@b.com' } });
await api.updateUser.call({ params: { id: '123' }, body: { name: 'Jane' } });
// Route with no required input → call() with no argument:
await api.listAll.call();
Method detection (client → proxy URL)
The client picks the HTTP method to the proxy purely from input shape:
- Has
bodyor non-emptyformData→POST /api/rpc/{routeName}(JSON body, ormultipart/form-datawhenformDatais present) - Otherwise →
GET /api/rpc/{routeName}?input={encoded}(browser-cacheable)
This is the method to the proxy only. The actual backend method (PUT/PATCH/DELETE/…) is
resolved by the proxy from routeMap — see below.
File upload (formData)
await api.uploadAvatar.call({
params: { id: '123' },
formData: { file: fileInput.files[0], description: 'Profile photo' },
});
File / File[] values go into a FormData; the other sections (params, query,
headers, cookies) are bundled into a __metadata JSON part. The proxy unpacks
__metadata and forwards a clean multipart/form-data body to the backend. Content-Type
is left unset so the boundary is generated automatically.
Options via method chaining
Options are set fluently (each returns a cloned builder) and applied on .call():
const user = await api.getUser
.headers({ 'X-Custom': 'value' })
.cookies({ session: 'xxx' })
.fetchOptions({ next: { revalidate: 60 } })
.timeout(5000) // this call only; overrides ApiConfig.timeout
.onRequest((url, init) => init) // client RequestInterceptor
.onResponse((res, body) => ({ response: res, body })) // client ResponseInterceptor
.call({ params: { id: '123' } });
.timeout(ms) throws a TypeError — before any request is made — unless ms is a finite
number greater than 0 and at most 2147483647 (setTimeout's ceiling). The timer covers the
wait for response headers, not the body read: once headers arrive it stops, however long the
body takes.
Cancellation
Pass your own AbortSignal through .fetchOptions({ signal }) (or set init.signal in an
onRequest interceptor — the signal on the final init is the one honoured). Aborting it
rejects the call with an ApiError of type 'aborted', from before the request is sent until
the response body has been read; the timeout keeps running alongside it.
const controller = new AbortController();
const pending = api.searchUsers
.timeout(10_000)
.fetchOptions({ signal: controller.signal })
.call({ query: { q: 'ada' } });
controller.abort('superseded'); // pending rejects: errorType 'aborted', status 0, cause 'superseded'
ApiConfig
interface ApiConfig {
baseUrl?: string; // default '/api/rpc'
headers?: Record<string, string>; // default headers on every request
timeout?: number; // default env.SERVER_TIMEOUT (120000 ms); per call: .timeout(ms)
fetch?: typeof fetch; // custom fetch impl
onRequest?: RequestInterceptor; // global, (url, init) => init
onResponse?: ResponseInterceptor; // global, (response, body) => { response, body }
onOutcome?: (outcome: ApiOutcome) => void; // told once how each call that reached fetch ended
errorRegistry?: ErrorRegistry | ErrorRegistryInput[]; // custom error deserialization
debug?: boolean; // default false
}
errorRegistry: the core errorRegistry is always merged in automatically. Pass an
array to add your app/package error registries on top:
import { errorRegistry } from '@spfn/core/errors';
const api = createApi<AppRouter>({ errorRegistry: [errorRegistry, authErrorRegistry] });
CallOptions (per-call, also settable via chaining)
interface CallOptions {
timeout?: number; // ms until response headers; overrides ApiConfig.timeout
headers?: Record<string, string>;
cookies?: Record<string, string>; // override only; cookies are auto-forwarded otherwise
onRequest?: RequestInterceptor;
onResponse?: ResponseInterceptor;
fetchOptions?: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } };
}
Automatic cookie forwarding (SSR)
When createApi runs on the server (RSC / route handler) it auto-detects request
cookies via next/headers cookies() and forwards them as the Cookie header. In the
browser, cookies are sent by the browser automatically. You only need .cookies({...}) /
options.cookies to override. Outside a request context (static generation, build) the
cookie read silently yields none.
SSR base URL resolution
For absolute fetches on the server the client uses env.SPFN_APP_URL. If unset, it falls
back to reconstructing the origin from the incoming request's host /
x-forwarded-proto headers (via next/headers); failing that, it uses a relative URL.
Proxy — createRpcProxy
function createRpcProxy(config: RpcProxyConfig): {
GET: (req, ctx) => Promise<NextResponse>;
POST: (req, ctx) => Promise<NextResponse>;
};
Returns { GET, POST } handlers for an App Router catch-all route. It:
- Reads
routeNamefrom the asyncparams(context.params: Promise<{ routeName?: string }>— Next.js 15). - Parses
inputfrom?input=(GET) or the JSON /multipart/form-databody (POST). - Resolves
routeMap[routeName]→{ method, path }; 404 if missing. - Substitutes
:paramsintopath, appends the query string. - Forwards to
${apiUrl}${path}${query}with the resolved backend method, running matching interceptors before/after. - Wraps the backend response in a
NextResponse(special-casing204), forwards response headers, and appends anySet-Cookiepushed by interceptors.
RpcProxyConfig
interface RpcProxyConfig {
routeMap: RouteMap; // REQUIRED — Record<routeName, { method, path }>
apiUrl?: string; // default env.SPFN_API_URL || 'http://localhost:8790'
timeout?: number; // default env.RPC_PROXY_TIMEOUT (120000 ms); AbortController
debug?: boolean; // default env.NODE_ENV === 'development'
headers?: Record<string, string>; // added to every forwarded request
interceptors?: InterceptorRule[]; // inline interceptors (array, NOT { request, response })
autoDiscoverInterceptors?: boolean; // default true — pull from registry
disableAutoInterceptors?: string[]; // e.g. ['auth'] — exclude registered packages
proxySecret?: string; // default env.SPFN_PROXY_SECRET — HMAC-sign forwarded requests (proxy-guard)
}
interface RouteMapEntry { method: HttpMethod; path: string; }
type RouteMap = Record<string, RouteMapEntry>;
routeMap — the proxy's source of truth
routeMap comes from codegen (@/generated/route-map) and already holds the routes of every
package router the app router mounts with .packages() — @spfn/auth's included, so
{ ...routeMap, ...authRouteMap } is a no-op and no longer needed. eventRouteMap is still
merged by hand: it is a hand-written constant, not a mounted router.
// app/api/rpc/[routeName]/route.ts
import '@spfn/auth/nextjs/api'; // side-effect: registers auth interceptors
import { createRpcProxy } from '@spfn/core/nextjs/server';
import { eventRouteMap } from '@spfn/core/event';
import { routeMap } from '@/generated/route-map';
export const { GET, POST } = createRpcProxy({
routeMap: { ...routeMap, ...eventRouteMap },
});
A routeName absent from routeMap returns 404 from the proxy (not from the backend).
After adding routes — or after upgrading a mounted package — re-run codegen and clear .spfn
cache if stale.
Header forwarding & client IP
Request headers are forwarded to the backend by an allowlist, not copied wholesale. Only
content-type, authorization, cookie, user-agent, accept, accept-language, and
origin pass through; everything else on the inbound request is dropped.
X-Forwarded-For is not in that allowlist, so it is not forwarded verbatim. Instead the
proxy resolves the real client IP from the inbound X-Forwarded-For chain — hop-aware, taking
the rightmost TRUSTED_PROXY_HOPS (default 1) entries as your own trusted infra — and
re-emits that single address under a dedicated header, x-spfn-proxy-client-ip. This avoids
passing a client-controllable header straight through to the backend.
The backend only trusts that header on requests it can prove came through this proxy. Set
the same SPFN_PROXY_SECRET on the proxy (or proxySecret in config) and on the backend:
the proxy then HMAC-signs each forwarded request and the backend's proxy-guard marks it
verified (clientType ≠ 'untrusted'), so getClientIp(c) returns the real visitor IP from
x-spfn-proxy-client-ip. See @spfn/core/middleware proxy-guard +
getClientIp.
Without a shared secret the request is unsigned, the backend won't trust that header, and
getClientIp falls back to the raw X-Forwarded-For first hop — which the proxy didn't
forward, so the client IP collapses to the TCP peer or 'unknown'. If your visitor IP is
missing in production, set SPFN_PROXY_SECRET on both sides (and match TRUSTED_PROXY_HOPS
to your LB/nginx depth) rather than widening the header allowlist — forwarding raw
X-Forwarded-For reintroduces a spoofable IP.
Full config example
export const { GET, POST } = createRpcProxy({
routeMap: { ...routeMap, ...eventRouteMap },
apiUrl: process.env.SPFN_API_URL,
timeout: 60000,
debug: true,
headers: { 'X-API-Key': process.env.SPFN_API_KEY! },
interceptors: [
{
pathPattern: '/_auth/*',
method: 'POST',
response: async (ctx, next) => {
if (ctx.response.body?.token) {
ctx.setCookies.push({
name: 'session',
value: ctx.response.body.token,
options: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 86400 },
});
}
await next();
},
},
],
autoDiscoverInterceptors: true,
disableAutoInterceptors: ['analytics'],
});
Interceptors (proxy-side)
Proxy interceptors run inside the proxy route, around the backend fetch. They use a
middleware (ctx, next) signature and are matched by path/method. Do not confuse them with
the client onRequest/onResponse hooks (those run in the calling code, on the proxy URL).
InterceptorRule
interface InterceptorRule {
pathPattern: string | RegExp; // '/_auth/*' | '/users/:id' | /regex/ | '*' (matches the BACKEND path)
method?: string | string[]; // 'POST' | ['POST','PUT'] | omit = all
request?: (ctx: RequestInterceptorContext, next: () => Promise<void>) => Promise<void>;
response?: (ctx: ResponseInterceptorContext, next: () => Promise<void>) => Promise<void>;
}
Pattern matching: '*' matches all; a RegExp is tested directly; a string converts *→
.* and :param→[^/]+. The path matched is the resolved backend path (e.g.
/users/123), not /api/rpc/....
Contexts
interface RequestInterceptorContext {
path: string; method: string;
headers: Record<string, string>; // mutable — written back to forwarded headers
body?: any; // mutable — re-stringified if changed
query: Record<string, string | string[]>;
cookies: Map<string, string>; // request cookies
request: NextRequest;
metadata: Record<string, any>; // shared with the response interceptor
}
interface ResponseInterceptorContext {
path: string; method: string;
request: { headers: Record<string, string>; body?: any };
response: { ok: boolean; status: number; statusText: string; headers: Headers; body: any }; // body mutable
cookies: Map<string, string>; // read-only
setCookies: SetCookie[]; // push to emit Set-Cookie on the NextResponse
metadata: Record<string, any>;
}
Interceptors chain in registration order; each must await next() to continue (skip it to
short-circuit). Mutating ctx.headers / ctx.body (request) and ctx.response.body /
ctx.setCookies (response) is how you affect the forwarded request and returned response.
Package auto-registration
Packages register interceptors on import; the proxy auto-discovers them
(autoDiscoverInterceptors: true). The registry lives on globalThis to survive HMR and
de-dupes by package name.
// inside a package, on import
import { registerInterceptors } from '@spfn/core/nextjs/server';
registerInterceptors('auth', [
{
pathPattern: '/_auth/*',
request: async (ctx, next) => {
const session = ctx.cookies.get('session');
if (session) ctx.headers['Authorization'] = `Bearer ${session}`;
await next();
},
response: async (ctx, next) => {
if (ctx.path === '/_auth/login' && ctx.response.body?.token) {
ctx.setCookies.push({
name: 'session',
value: ctx.response.body.token,
options: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 86400 },
});
delete ctx.response.body.token;
}
await next();
},
},
]);
Importing the package's side-effect entry (e.g. import '@spfn/auth/nextjs/api') in the
proxy route is what triggers registration. Use disableAutoInterceptors: ['auth'] to opt a
package out.
Type helpers
import type { RouterOutput, RouterInput, InferRouteInput, InferRouteOutput } from '@spfn/core/nextjs';
import type { AppRouter } from '@/server/router';
type ListData = RouterOutput<AppRouter, 'listExamples'>;
type CreateInput = RouterInput<AppRouter, 'createExample'>;
type Item = RouterOutput<AppRouter, 'listExamples'>['items'][number];
type BodyInput = RouterInput<AppRouter, 'createExample'>['body'];
RouterOutput/RouterInput take the router type + a route-name key. InferRouteInput/
InferRouteOutput take a single RouteDef. All inference is compile-time only (zero runtime
cost).
The route-name key is the flat set of names in the router tree. A route declared inside a
nested defineRouter is named by its own key here, exactly as the server registers it — the
group key is not part of the name and does not appear:
const appRouter = defineRouter({
getRoot,
examples: defineRouter({ listExamples, createExample }),
});
type ListData = RouterOutput<typeof appRouter, 'listExamples'>; // not 'examples.listExamples'
A name two branches both declare is left out of that set rather than resolved to one of them,
so naming it is a compile error where it is named. Routes mounted with .packages() stay out
of the set on purpose — call them through the package's own client.
Client<AppRouter> — what createApi returns — is built from the same flat set, so a route
is named identically whether you call it (api.listExamples) or take its type
(RouterOutput<AppRouter, 'listExamples'>).
Error handling
ApiError is thrown for non-2xx responses, network failures, timeouts, and caller aborts:
class ApiError extends Error {
constructor(
message: string,
public readonly status: number, // HTTP status; 0 for network and aborted; 408 for timeout
public readonly url: string,
public readonly response?: unknown, // parsed error body
public readonly errorType?: 'http' | 'network' | 'timeout' | 'aborted',
options?: { cause?: unknown }, // 'aborted': cause is the signal's reason
) {}
}
import { ApiError } from '@spfn/core/nextjs';
try {
await api.getUser.call({ params: { id: '123' } });
} catch (error) {
if (error instanceof ApiError) {
if (error.errorType === 'timeout') { /* 408 */ }
else if (error.errorType === 'aborted') { /* status 0; you aborted it — error.cause is the reason */ }
else if (error.errorType === 'network') { /* status 0 */ }
else { /* error.status, error.response */ }
}
}
The client classifies an abort by who triggered it, never by the error's name: 'timeout'
when its own timer fired, 'aborted' when your signal did — whichever came first. An
AbortError that a custom fetch throws on its own, with neither of those having fired, is
'timeout' too, as it always was.
Custom errors: if the backend body carries a __type discriminator and a matching entry
is registered in the client's errorRegistry, the client deserializes and throws the
original typed error instead of a generic ApiError. Otherwise it falls back to
ApiError.
Next.js integration
Server Components with caching
// app/users/[id]/page.tsx
import { api } from '@/lib/api';
export default async function UserPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await api.getUser
.fetchOptions({ next: { revalidate: 3600 } })
.call({ params: { id } });
return <div>{user.name}</div>;
}
Tag-based revalidation
const posts = await api.getPosts
.fetchOptions({ next: { tags: ['posts'] } })
.call({ query: { page: 1 } });
// later, in a Server Action:
import { revalidateTag } from 'next/cache';
revalidateTag('posts');
Client Components / Server Actions
'use client';
import { api } from '@/lib/api';
const created = await api.createUser.call({ body: { name, email } });
// app/actions.ts
'use server';
import { api } from '@/lib/api';
export async function createUser(formData: FormData) {
return api.createUser.call({
body: { name: formData.get('name') as string, email: formData.get('email') as string },
});
}
Pitfalls & anti-patterns
- Never import
@spfn/core/nextjs/serverin a Client Component. It pulls innext/headers+next/server; bundling it client-side breaks the build. Client code uses@spfn/core/nextjsonly (createApi,ApiError, types). interceptorsis anInterceptorRule[], not{ request, response }. Older docs showedcreateRpcProxy({ router, interceptors: { request, response } })— that config shape does not exist. The current API is{ routeMap, interceptors: [{ pathPattern, method?, request?, response? }] }. There is norouteroption either; the proxy is driven entirely byrouteMap.- The proxy resolves the real HTTP method from
routeMap, not from the client call. The client only ever sends GET (no body) or POST (body/formData) to/api/rpc/.... A PUT / PATCH / DELETE route still works — the backend method comes fromrouteMap[routeName]. - A missing
routeNameinrouteMapis a proxy 404, not a backend 404. Re-run codegen after adding routes or upgrading a mounted package — the generated map carries the mounted packages' routes — mergeeventRouteMapif you use SSE, and delete.spfnif the map looks stale. - Interceptor
pathPatternmatches the resolved backend path (e.g./users/123,/_auth/login) — not the/api/rpc/{routeName}URL the client hit. - Two same-named interceptor type pairs.
RequestInterceptor/ResponseInterceptorfrom@spfn/core/nextjsare(url, init)/(response, body)(client hooks); from@spfn/core/nextjs/serverthey are(ctx, next) => Promise<void>(proxy middleware). Import from the right entry point. - Don't pass auth via the client to set HttpOnly cookies. HttpOnly session cookies are
set on the proxy response by a response interceptor (
ctx.setCookies.push(...)), and forwarded automatically on subsequent requests. Client code can't read them by design. - Cookies are auto-forwarded on the server — only use
.cookies({...})/options.cookiesto override. Calling outside a request context (static generation) yields no cookies, silently. createApineeds no codegen; the proxy does. The client is metadata-free. TherouteMapthe proxy consumes is the generated artifact.- Raw
X-Forwarded-Foris not forwarded — don't widen the allowlist to "fix" client IP. The proxy re-emits the resolved client IP asx-spfn-proxy-client-ip, trusted only on HMAC-signed requests. Missing visitor IP in production meansSPFN_PROXY_SECRETisn't set on both sides (and/orTRUSTED_PROXY_HOPSis wrong), not that the header allowlist needsx-forwarded-for— adding it back makes the IP spoofable. See Header forwarding & client IP. - Each
.headers()/.cookies()/.fetchOptions()/.timeout()/.onRequest()/.onResponse()returns a new builder. Chain them in one expression; a dangling builder without.call()does nothing. - A client
onResponsemay throw, and the throw is the caller's. It runs outside the transport error handling, soredirect('/login')on a 401 navigates and a domain error raised there is raised as itself — neither is relabelled anApiErrorof type'network'. Only a failed fetch (0/'network'), a timed-out one (408/'timeout') and one the caller aborted (0/'aborted') are.
How do I learn how every call ended?
Give createApi an onOutcome. It is told once how each call that reached fetch ended,
before that call returns or throws:
type ApiOutcome =
| { ok: true; response: Response; body: unknown }
| { ok: false; errorType: 'http'; status: number; error: Error }
| { ok: false; errorType: 'network' | 'timeout' | 'aborted'; status: number; error: ApiError };
- Observed: every answer —
ok: truewith theresponseandbodyyouronResponseinterceptors left, or'http'with the final response's status — and every call that got no answer ('network'0,'timeout'408,'aborted'0). erroris the object the call throws: for'http', the registered class a__typebody names (NotFoundError,UnauthorizedError, …) or else anApiError, henceError; for the transport kinds, theApiError.- Not observed: a throw from an
onRequestoronResponseinterceptor (redirect(),notFound(), your own errors) or from building the request. Those are yours and reach the caller unchanged. - The observer cannot change the call. It is called synchronously and never awaited; a
throw or a rejected promise from it is logged (
onOutcome, the error's name and message) and the call still returns or throws what it would have. - Config only: there is no per-call
onOutcome.
import { createApi } from '@spfn/core/nextjs';
import type { ApiOutcome } from '@spfn/core/nextjs';
import { UnauthorizedError } from '@spfn/core/errors';
type ServerStatus = 'up' | 'unreachable' | 'failing' | 'signed-out';
let serverStatus: ServerStatus = 'up';
function statusOf(outcome: ApiOutcome): ServerStatus
{
if (outcome.ok)
{
return 'up';
}
if (outcome.errorType !== 'http')
{
return outcome.errorType === 'aborted' ? serverStatus : 'unreachable';
}
if (outcome.error instanceof UnauthorizedError)
{
return 'signed-out';
}
return outcome.status >= 500 ? 'failing' : 'up';
}
export const api = createApi<AppRouter>({
onOutcome: (outcome) =>
{
serverStatus = statusOf(outcome);
},
});
Types reference
// client (@spfn/core/nextjs)
type ApiOutcome =
| { ok: true; response: Response; body: unknown }
| { ok: false; errorType: 'http'; status: number; error: Error }
| { ok: false; errorType: 'network' | 'timeout' | 'aborted'; status: number; error: ApiError };
type RequestInterceptor = (url: string, init: RequestInit) => Promise<RequestInit> | RequestInit;
type ResponseInterceptor = (response: Response, body: any)
=> Promise<{ response: Response; body: any }> | { response: Response; body: any };
interface CookieOptions { httpOnly?: boolean; secure?: boolean; sameSite?: 'strict'|'lax'|'none'; maxAge?: number; path?: string; domain?: string; }
interface SetCookie { name: string; value: string; options?: CookieOptions; }
// proxy (@spfn/core/nextjs/server)
type RequestInterceptor = (ctx: RequestInterceptorContext, next: () => Promise<void>) => Promise<void>;
type ResponseInterceptor = (ctx: ResponseInterceptorContext, next: () => Promise<void>) => Promise<void>;
interface RouteMapEntry { method: HttpMethod; path: string; }
type RouteMap = Record<string, RouteMapEntry>;
Related
- @spfn/core/route — server-side route + router definitions (source of
AppRouter) - @spfn/core/codegen — generates the
routeMapthe proxy consumes - @spfn/core/errors —
ErrorRegistry, custom error deserialization - @spfn/core/config —
SPFN_API_URL,SPFN_APP_URL,RPC_PROXY_TIMEOUT,SERVER_TIMEOUT - @spfn/auth — example package: route map + auto-registered interceptors