@fabrika/app 0.0.1
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 +174 -0
- package/package.json +47 -0
- package/src/app.ts +76 -0
- package/src/bun.ts +63 -0
- package/src/client.ts +143 -0
- package/src/cloudflare.ts +46 -0
- package/src/errors.ts +120 -0
- package/src/index.ts +38 -0
- package/src/middleware.ts +38 -0
- package/src/router.ts +149 -0
- package/src/rpc/builder.ts +128 -0
- package/src/rpc/contract.ts +34 -0
- package/src/rpc/dispatcher.ts +150 -0
- package/src/rpc/types.ts +77 -0
- package/src/standard-schema.ts +75 -0
- package/src/testing.ts +119 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @fabrika/app — the first-party server framework for Fabrika applications:
|
|
2
|
+
// Fetch-based HTTP routing, typed RPC, middleware, and object-level authorization.
|
|
3
|
+
|
|
4
|
+
// ─── RPC ─────────────────────────────────────────────────────────────
|
|
5
|
+
export { initRpc } from './rpc/builder.js'
|
|
6
|
+
export type { InferRpcClient, InferRpcContractClient, RpcProcedureContract, RpcRouterFor } from './rpc/contract.js'
|
|
7
|
+
export type { InferRouter, InferRouterClient } from './rpc/types.js'
|
|
8
|
+
|
|
9
|
+
// ─── Schema contract (validator-agnostic) ────────────────────────────
|
|
10
|
+
export type { StandardSchemaV1 } from './standard-schema.js'
|
|
11
|
+
|
|
12
|
+
// ─── Routing ─────────────────────────────────────────────────────────
|
|
13
|
+
export { matchRoutes, route } from './router.js'
|
|
14
|
+
export type { HttpMethod, HttpRoute, Route, RouteOptions, RouteParams, RpcRoute } from './router.js'
|
|
15
|
+
|
|
16
|
+
// ─── Application ─────────────────────────────────────────────────────
|
|
17
|
+
export { defineApp } from './app.js'
|
|
18
|
+
export type { AppConfig, AssetsLike, FabrikaApp, RequestExecutionContext } from './app.js'
|
|
19
|
+
|
|
20
|
+
// ─── Errors ──────────────────────────────────────────────────────────
|
|
21
|
+
export {
|
|
22
|
+
BadRequestError,
|
|
23
|
+
ConflictError,
|
|
24
|
+
defaultOnError,
|
|
25
|
+
ForbiddenError,
|
|
26
|
+
HttpError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
toErrorResponse,
|
|
29
|
+
UnauthorizedError,
|
|
30
|
+
} from './errors.js'
|
|
31
|
+
|
|
32
|
+
// ─── Middleware + auth contract ──────────────────────────────────────
|
|
33
|
+
export type { AuthContext, Scope } from '@fabrika/auth'
|
|
34
|
+
export type { Middleware } from './middleware.js'
|
|
35
|
+
|
|
36
|
+
// ─── Client ──────────────────────────────────────────────────────────
|
|
37
|
+
export { createRpcClient, RpcError, type RpcFetch } from './client.js'
|
|
38
|
+
export type { RpcClientOptions } from './client.js'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// ─── Middleware ──────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// @fabrika/auth owns the request-time contracts. The app framework consumes and
|
|
4
|
+
// re-exports them instead of maintaining a structurally duplicated copy.
|
|
5
|
+
|
|
6
|
+
import type { Middleware } from '@fabrika/auth'
|
|
7
|
+
|
|
8
|
+
export type { Middleware } from '@fabrika/auth'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A middleware runs in declared order around the inner dispatch. It may mutate
|
|
12
|
+
* `ctx` (e.g. set `ctx.auth`), short-circuit by returning a `Response` without
|
|
13
|
+
* calling `next()`, or wrap `next()` (await it, then mutate the `Response`, e.g.
|
|
14
|
+
* append a `Set-Cookie`).
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Fold `middleware` (in declared order) around an innermost `dispatch`. Each
|
|
18
|
+
* middleware receives a `next` that advances to the following one; the last
|
|
19
|
+
* `next` calls `dispatch`.
|
|
20
|
+
*/
|
|
21
|
+
export function runChain<Ctx>(
|
|
22
|
+
request: Request,
|
|
23
|
+
ctx: Ctx,
|
|
24
|
+
middleware: ReadonlyArray<Middleware<Ctx>>,
|
|
25
|
+
dispatch: () => Promise<Response>,
|
|
26
|
+
): Promise<Response> {
|
|
27
|
+
let lastIndex = -1
|
|
28
|
+
const run = (index: number): Promise<Response> => {
|
|
29
|
+
if (index <= lastIndex) {
|
|
30
|
+
return Promise.reject(new Error('next() called multiple times in a single middleware'))
|
|
31
|
+
}
|
|
32
|
+
lastIndex = index
|
|
33
|
+
const current = middleware[index]
|
|
34
|
+
if (!current) return dispatch()
|
|
35
|
+
return current(request, ctx, () => run(index + 1))
|
|
36
|
+
}
|
|
37
|
+
return run(0)
|
|
38
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// ─── HTTP routing ────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// A tiny method-aware path router. HTTP routes carry a `(ctx, params) => Response`
|
|
4
|
+
// handler; RPC routes carry a `Router`. Params are inferred from the pattern
|
|
5
|
+
// string literal: `/api/:project/envelope` → `{ project: string }`.
|
|
6
|
+
|
|
7
|
+
import type { Middleware } from './middleware.js'
|
|
8
|
+
import type { AnyRouter } from './rpc/types.js'
|
|
9
|
+
|
|
10
|
+
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
|
11
|
+
|
|
12
|
+
/** Per-route options. `use` middleware run AFTER the global chain, scoped to this route only. */
|
|
13
|
+
export interface RouteOptions<Ctx> {
|
|
14
|
+
readonly use?: ReadonlyArray<Middleware<Ctx>>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Extract `:param` and terminal `*rest` names from a pattern literal. */
|
|
18
|
+
export type RouteParams<Pattern extends string> = Pattern extends `${infer _Head}:${infer Param}/${infer Rest}`
|
|
19
|
+
? Record<Param, string> & RouteParams<Rest>
|
|
20
|
+
: Pattern extends `${infer _Head}:${infer Param}` ? Record<Param, string>
|
|
21
|
+
: Pattern extends `${infer _Head}*${infer Rest}` ? Rest extends '' ? {} : Record<Rest, string>
|
|
22
|
+
: {}
|
|
23
|
+
|
|
24
|
+
export type HttpHandler<Ctx, Pattern extends string> = (ctx: Ctx, params: RouteParams<Pattern>) => Response | Promise<Response>
|
|
25
|
+
|
|
26
|
+
export interface HttpRoute<Ctx, Pattern extends string = string> {
|
|
27
|
+
readonly kind: 'http'
|
|
28
|
+
/** `null` matches every method and is intended for mounting an existing Fetch handler. */
|
|
29
|
+
readonly method: HttpMethod | null
|
|
30
|
+
readonly pattern: Pattern
|
|
31
|
+
readonly segments: ReadonlyArray<string>
|
|
32
|
+
readonly use: ReadonlyArray<Middleware<Ctx>>
|
|
33
|
+
handler(ctx: Ctx, params: RouteParams<Pattern>): Response | Promise<Response>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RpcRoute<Ctx> {
|
|
37
|
+
readonly kind: 'rpc'
|
|
38
|
+
readonly pattern: string
|
|
39
|
+
readonly segments: ReadonlyArray<string>
|
|
40
|
+
readonly use: ReadonlyArray<Middleware<Ctx>>
|
|
41
|
+
readonly router: AnyRouter
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type Route<Ctx> = HttpRoute<Ctx, string> | RpcRoute<Ctx>
|
|
45
|
+
|
|
46
|
+
function splitPath(path: string): string[] {
|
|
47
|
+
return path.split('/').filter((segment) => segment.length > 0)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function decodePathSegment(segment: string): string | null {
|
|
51
|
+
try {
|
|
52
|
+
return decodeURIComponent(segment)
|
|
53
|
+
} catch {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeHttpRoute<Ctx, Pattern extends string>(
|
|
59
|
+
method: HttpMethod | null,
|
|
60
|
+
pattern: Pattern,
|
|
61
|
+
handler: HttpHandler<Ctx, Pattern>,
|
|
62
|
+
opts: RouteOptions<Ctx> | undefined,
|
|
63
|
+
): HttpRoute<Ctx, Pattern> {
|
|
64
|
+
const segments = splitPath(pattern)
|
|
65
|
+
const wildcard = segments.findIndex((segment) => segment.startsWith('*'))
|
|
66
|
+
if (wildcard !== -1 && wildcard !== segments.length - 1) {
|
|
67
|
+
throw new Error('a wildcard route segment must be terminal')
|
|
68
|
+
}
|
|
69
|
+
return { kind: 'http', method, pattern, segments, use: opts?.use ?? [], handler }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Route builders. HTTP verbs take `(pattern, handler, opts?)`; `rpc` mounts a
|
|
74
|
+
* `Router` at a path. Annotate the handler's `ctx` (or place the route in a
|
|
75
|
+
* `Route<Ctx>[]`) to type the context. `opts.use` adds route-scoped middleware
|
|
76
|
+
* (run after the global chain) — e.g. a capability/api-key gate on one path group.
|
|
77
|
+
*/
|
|
78
|
+
export const route = {
|
|
79
|
+
all<Ctx, Pattern extends string>(pattern: Pattern, handler: HttpHandler<Ctx, Pattern>, opts?: RouteOptions<Ctx>): HttpRoute<Ctx, Pattern> {
|
|
80
|
+
return makeHttpRoute(null, pattern, handler, opts)
|
|
81
|
+
},
|
|
82
|
+
get<Ctx, Pattern extends string>(pattern: Pattern, handler: HttpHandler<Ctx, Pattern>, opts?: RouteOptions<Ctx>): HttpRoute<Ctx, Pattern> {
|
|
83
|
+
return makeHttpRoute('GET', pattern, handler, opts)
|
|
84
|
+
},
|
|
85
|
+
post<Ctx, Pattern extends string>(pattern: Pattern, handler: HttpHandler<Ctx, Pattern>, opts?: RouteOptions<Ctx>): HttpRoute<Ctx, Pattern> {
|
|
86
|
+
return makeHttpRoute('POST', pattern, handler, opts)
|
|
87
|
+
},
|
|
88
|
+
put<Ctx, Pattern extends string>(pattern: Pattern, handler: HttpHandler<Ctx, Pattern>, opts?: RouteOptions<Ctx>): HttpRoute<Ctx, Pattern> {
|
|
89
|
+
return makeHttpRoute('PUT', pattern, handler, opts)
|
|
90
|
+
},
|
|
91
|
+
patch<Ctx, Pattern extends string>(pattern: Pattern, handler: HttpHandler<Ctx, Pattern>, opts?: RouteOptions<Ctx>): HttpRoute<Ctx, Pattern> {
|
|
92
|
+
return makeHttpRoute('PATCH', pattern, handler, opts)
|
|
93
|
+
},
|
|
94
|
+
delete<Ctx, Pattern extends string>(pattern: Pattern, handler: HttpHandler<Ctx, Pattern>, opts?: RouteOptions<Ctx>): HttpRoute<Ctx, Pattern> {
|
|
95
|
+
return makeHttpRoute('DELETE', pattern, handler, opts)
|
|
96
|
+
},
|
|
97
|
+
rpc<Ctx>(path: string, router: AnyRouter, opts?: RouteOptions<Ctx>): RpcRoute<Ctx> {
|
|
98
|
+
return { kind: 'rpc', pattern: path, segments: splitPath(path), use: opts?.use ?? [], router }
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function matchSegments(routeSegments: ReadonlyArray<string>, pathSegments: ReadonlyArray<string>): Record<string, string> | null {
|
|
103
|
+
const params: Record<string, string> = {}
|
|
104
|
+
for (let i = 0; i < routeSegments.length; i++) {
|
|
105
|
+
const routeSegment = routeSegments[i]
|
|
106
|
+
if (routeSegment === undefined) return null
|
|
107
|
+
if (routeSegment.startsWith('*')) {
|
|
108
|
+
const name = routeSegment.slice(1)
|
|
109
|
+
if (name !== '') {
|
|
110
|
+
const decoded: string[] = []
|
|
111
|
+
for (const segment of pathSegments.slice(i)) {
|
|
112
|
+
const value = decodePathSegment(segment)
|
|
113
|
+
if (value === null) return null
|
|
114
|
+
decoded.push(value)
|
|
115
|
+
}
|
|
116
|
+
params[name] = decoded.join('/')
|
|
117
|
+
}
|
|
118
|
+
return params
|
|
119
|
+
}
|
|
120
|
+
const pathSegment = pathSegments[i]
|
|
121
|
+
if (pathSegment === undefined) return null
|
|
122
|
+
if (routeSegment.startsWith(':')) {
|
|
123
|
+
const value = decodePathSegment(pathSegment)
|
|
124
|
+
if (value === null) return null
|
|
125
|
+
params[routeSegment.slice(1)] = value
|
|
126
|
+
} else if (routeSegment !== pathSegment) {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return routeSegments.length === pathSegments.length ? params : null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Find the first route matching the request's method + path. RPC routes match POST. */
|
|
134
|
+
export function matchRoutes<Ctx>(
|
|
135
|
+
routes: ReadonlyArray<Route<Ctx>>,
|
|
136
|
+
request: Request,
|
|
137
|
+
): { route: Route<Ctx>; params: Record<string, string> } | null {
|
|
138
|
+
const pathSegments = splitPath(new URL(request.url).pathname)
|
|
139
|
+
for (const candidate of routes) {
|
|
140
|
+
if (candidate.kind === 'rpc') {
|
|
141
|
+
if (request.method !== 'POST') continue
|
|
142
|
+
} else if (candidate.method !== null && request.method !== candidate.method) {
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
const params = matchSegments(candidate.segments, pathSegments)
|
|
146
|
+
if (params) return { route: candidate, params }
|
|
147
|
+
}
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// ─── RPC builder ─────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// `initRpc<Ctx>()` returns a `procedure` builder bound to the context type plus a
|
|
4
|
+
// `router` factory. Builders are immutable — each step returns a fresh builder.
|
|
5
|
+
//
|
|
6
|
+
// Two builder shapes keep the output type honest WITHOUT any cast:
|
|
7
|
+
// - ProcedureBuilder (no output schema): the terminal infers the output type
|
|
8
|
+
// from the handler's return — `t.procedure.input(X).query(() => ({ a: 1 }))`
|
|
9
|
+
// gives the client `{ a: number }`, tRPC-style, no `.output()` needed.
|
|
10
|
+
// - OutputProcedureBuilder (after `.output(schema)`): the terminal FIXES the
|
|
11
|
+
// output to the schema type and validates the handler result at runtime.
|
|
12
|
+
|
|
13
|
+
import type { AuthContext, Scope } from '@fabrika/auth'
|
|
14
|
+
import type { StandardSchemaV1 } from '../standard-schema.js'
|
|
15
|
+
import type { AuthRequirement, Procedure, ProcedureKind, Router, RouterDef } from './types.js'
|
|
16
|
+
|
|
17
|
+
// The implicit input schema for a procedure with no `.input()` — accepts only "no input".
|
|
18
|
+
const voidSchema: StandardSchemaV1<unknown, void> = {
|
|
19
|
+
'~standard': {
|
|
20
|
+
version: 1,
|
|
21
|
+
vendor: 'fabrika',
|
|
22
|
+
validate: (input) => (input === undefined || input === null ? { value: undefined } : { issues: [{ message: 'Expected no input' }] }),
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Handler<TContext, TInput, TOutput> = (args: { ctx: TContext; input: TInput }) => TOutput | Promise<TOutput>
|
|
27
|
+
|
|
28
|
+
function makeProcedure<TContext, TInput, TOutput>(
|
|
29
|
+
kind: ProcedureKind,
|
|
30
|
+
input: StandardSchemaV1<unknown, TInput>,
|
|
31
|
+
output: StandardSchemaV1<unknown, TOutput> | null,
|
|
32
|
+
requirements: ReadonlyArray<AuthRequirement<TContext, TInput>>,
|
|
33
|
+
handler: Handler<TContext, TInput, TOutput>,
|
|
34
|
+
): Procedure<TContext, TInput, TOutput> {
|
|
35
|
+
return { _tag: 'procedure', kind, input, output, requirements, handler }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Builder before an output schema is set — the terminal infers the output type from the handler. */
|
|
39
|
+
export class ProcedureBuilder<TContext, TInput> {
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly _input: StandardSchemaV1<unknown, TInput>,
|
|
42
|
+
private readonly _requirements: ReadonlyArray<AuthRequirement<TContext, TInput>>,
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
/** Set the input schema — ANY Standard Schema validator (zod, valibot, arktype, …). Resets requirements. */
|
|
46
|
+
input<TNewInput>(schema: StandardSchemaV1<unknown, TNewInput>): ProcedureBuilder<TContext, TNewInput> {
|
|
47
|
+
return new ProcedureBuilder<TContext, TNewInput>(schema, [])
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Set an output schema → switch to the output builder (handler result validated against it). */
|
|
51
|
+
output<TOutput>(schema: StandardSchemaV1<unknown, TOutput>): OutputProcedureBuilder<TContext, TInput, TOutput> {
|
|
52
|
+
return new OutputProcedureBuilder<TContext, TInput, TOutput>(this._input, schema, this._requirements)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Record an authz requirement, checked BEFORE the handler runs (all must pass).
|
|
57
|
+
* Only available when the context carries `auth: AuthContext`.
|
|
58
|
+
*/
|
|
59
|
+
require<TThis extends { auth: AuthContext }>(
|
|
60
|
+
this: ProcedureBuilder<TThis, TInput>,
|
|
61
|
+
action: string,
|
|
62
|
+
scopeResolver?: (input: TInput, ctx: TThis) => Scope | undefined,
|
|
63
|
+
): ProcedureBuilder<TThis, TInput> {
|
|
64
|
+
const requirement: AuthRequirement<TThis, TInput> = scopeResolver ? { action, scopeResolver } : { action }
|
|
65
|
+
return new ProcedureBuilder<TThis, TInput>(this._input, [...this._requirements, requirement])
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Terminal: a neutral handler (tagged `query`). Output type inferred from the return. */
|
|
69
|
+
handler<TOutput>(fn: Handler<TContext, TInput, TOutput>): Procedure<TContext, TInput, TOutput> {
|
|
70
|
+
return makeProcedure('query', this._input, null, this._requirements, fn)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Terminal: a `query`-tagged handler. Output type inferred from the return. */
|
|
74
|
+
query<TOutput>(fn: Handler<TContext, TInput, TOutput>): Procedure<TContext, TInput, TOutput> {
|
|
75
|
+
return makeProcedure('query', this._input, null, this._requirements, fn)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Terminal: a `mutation`-tagged handler. Output type inferred from the return. */
|
|
79
|
+
mutation<TOutput>(fn: Handler<TContext, TInput, TOutput>): Procedure<TContext, TInput, TOutput> {
|
|
80
|
+
return makeProcedure('mutation', this._input, null, this._requirements, fn)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Builder after `.output(schema)` — the terminal fixes the output to the schema type + validates it. */
|
|
85
|
+
export class OutputProcedureBuilder<TContext, TInput, TOutput> {
|
|
86
|
+
constructor(
|
|
87
|
+
private readonly _input: StandardSchemaV1<unknown, TInput>,
|
|
88
|
+
private readonly _output: StandardSchemaV1<unknown, TOutput>,
|
|
89
|
+
private readonly _requirements: ReadonlyArray<AuthRequirement<TContext, TInput>>,
|
|
90
|
+
) {}
|
|
91
|
+
|
|
92
|
+
/** Record an authz requirement, checked BEFORE the handler runs (all must pass). */
|
|
93
|
+
require<TThis extends { auth: AuthContext }>(
|
|
94
|
+
this: OutputProcedureBuilder<TThis, TInput, TOutput>,
|
|
95
|
+
action: string,
|
|
96
|
+
scopeResolver?: (input: TInput, ctx: TThis) => Scope | undefined,
|
|
97
|
+
): OutputProcedureBuilder<TThis, TInput, TOutput> {
|
|
98
|
+
const requirement: AuthRequirement<TThis, TInput> = scopeResolver ? { action, scopeResolver } : { action }
|
|
99
|
+
return new OutputProcedureBuilder<TThis, TInput, TOutput>(this._input, this._output, [...this._requirements, requirement])
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Terminal: a neutral handler (tagged `query`); result validated against the output schema. */
|
|
103
|
+
handler(fn: Handler<TContext, TInput, TOutput>): Procedure<TContext, TInput, TOutput> {
|
|
104
|
+
return makeProcedure('query', this._input, this._output, this._requirements, fn)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Terminal: a `query`-tagged handler; result validated against the output schema. */
|
|
108
|
+
query(fn: Handler<TContext, TInput, TOutput>): Procedure<TContext, TInput, TOutput> {
|
|
109
|
+
return makeProcedure('query', this._input, this._output, this._requirements, fn)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Terminal: a `mutation`-tagged handler; result validated against the output schema. */
|
|
113
|
+
mutation(fn: Handler<TContext, TInput, TOutput>): Procedure<TContext, TInput, TOutput> {
|
|
114
|
+
return makeProcedure('mutation', this._input, this._output, this._requirements, fn)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function initRpc<TContext>(): {
|
|
119
|
+
procedure: ProcedureBuilder<TContext, void>
|
|
120
|
+
router<TDef extends RouterDef>(def: TDef): Router<TDef>
|
|
121
|
+
} {
|
|
122
|
+
return {
|
|
123
|
+
procedure: new ProcedureBuilder<TContext, void>(voidSchema, []),
|
|
124
|
+
router<TDef extends RouterDef>(def: TDef): Router<TDef> {
|
|
125
|
+
return { _tag: 'router', _def: def }
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// ─── Portable RPC contract ───────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Contracts carry only procedure input and output types. A browser-safe contract
|
|
4
|
+
// package can therefore describe an API without importing its server router,
|
|
5
|
+
// validators, handler context, or runtime dependencies.
|
|
6
|
+
|
|
7
|
+
import type { InferRouterClient, Procedure, Router, RouterDef } from './types.js'
|
|
8
|
+
|
|
9
|
+
/** A type-only RPC procedure declaration for a portable API contract. */
|
|
10
|
+
export interface RpcProcedureContract<TInput, TOutput> {
|
|
11
|
+
readonly '~rpc': {
|
|
12
|
+
readonly input: TInput
|
|
13
|
+
readonly output: TOutput
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Client-facing view of a portable contract. */
|
|
18
|
+
export type InferRpcContractClient<TContract> = TContract extends RpcProcedureContract<infer TInput, infer TOutput>
|
|
19
|
+
? (input: TInput) => Promise<TOutput>
|
|
20
|
+
: TContract extends object ? { [K in keyof TContract]: InferRpcContractClient<TContract[K]> }
|
|
21
|
+
: never
|
|
22
|
+
|
|
23
|
+
/** Client-facing view of either a server router or a portable contract. */
|
|
24
|
+
export type InferRpcClient<TApi> = TApi extends { readonly _tag: 'router'; readonly _def: RouterDef } ? InferRouterClient<TApi>
|
|
25
|
+
: InferRpcContractClient<TApi>
|
|
26
|
+
|
|
27
|
+
type RpcRouterDefinition<TContext, TContract> = {
|
|
28
|
+
readonly [K in keyof TContract]: TContract[K] extends RpcProcedureContract<infer TInput, infer TOutput> ? Procedure<TContext, TInput, TOutput>
|
|
29
|
+
: TContract[K] extends object ? RpcRouterFor<TContext, TContract[K]>
|
|
30
|
+
: never
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Server router shape required to implement a portable contract. */
|
|
34
|
+
export type RpcRouterFor<TContext, TContract> = Router<RpcRouterDefinition<TContext, TContract>>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// ─── RPC dispatcher ──────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Resolves a dotted method path against a router, validates input, enforces
|
|
4
|
+
// authz requirements, runs the handler, and (optionally) validates output. All
|
|
5
|
+
// error mapping goes through the structural contract in `errors.ts`.
|
|
6
|
+
|
|
7
|
+
import type { AuthContext } from '@fabrika/auth'
|
|
8
|
+
import { ForbiddenError, HttpError, jsonResponse, readStructuralError } from '../errors.js'
|
|
9
|
+
import type { StandardSchemaV1 } from '../standard-schema.js'
|
|
10
|
+
import type { AnyProcedure, AnyRouter, RouterDef } from './types.js'
|
|
11
|
+
|
|
12
|
+
interface SingleCall {
|
|
13
|
+
method: string
|
|
14
|
+
input: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface SingleResult {
|
|
18
|
+
result?: unknown
|
|
19
|
+
error?: { type: string; message: string; issues?: unknown; loginUrl?: string }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface DispatchOutcome {
|
|
23
|
+
readonly body: SingleResult
|
|
24
|
+
readonly status: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
28
|
+
return value !== null && typeof value === 'object'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasAuth(ctx: unknown): ctx is { auth: AuthContext } {
|
|
32
|
+
if (!isRecord(ctx)) return false
|
|
33
|
+
const auth = ctx['auth']
|
|
34
|
+
return isRecord(auth) && typeof auth['can'] === 'function' && typeof auth['scopedTo'] === 'function' && typeof auth['audit'] === 'function'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Run a Standard Schema validator and ALWAYS return a Result — a validator that throws (rather than
|
|
39
|
+
* returning issues, as the spec intends) is folded into a single issue so the dispatcher never leaks
|
|
40
|
+
* a raw schema error.
|
|
41
|
+
*/
|
|
42
|
+
async function safeValidate<T>(schema: StandardSchemaV1<unknown, T>, value: unknown): Promise<StandardSchemaV1.Result<T>> {
|
|
43
|
+
try {
|
|
44
|
+
return await schema['~standard'].validate(value)
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return { issues: [{ message: err instanceof Error ? err.message : 'Validation failed' }] }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A short human message from the first issue (path-prefixed); the full list rides in the envelope. */
|
|
51
|
+
function formatIssues(issues: ReadonlyArray<StandardSchemaV1.Issue>): string {
|
|
52
|
+
const first = issues[0]
|
|
53
|
+
if (!first) return 'Validation failed'
|
|
54
|
+
const path = first.path?.map((seg) => (typeof seg === 'object' ? String(seg.key) : String(seg))).join('.')
|
|
55
|
+
return path ? `${path}: ${first.message}` : first.message
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolve(router: AnyRouter, methodPath: string): AnyProcedure | null {
|
|
59
|
+
const parts = methodPath.split('.')
|
|
60
|
+
let node: AnyProcedure | AnyRouter = router
|
|
61
|
+
for (const part of parts) {
|
|
62
|
+
if (node._tag !== 'router') return null
|
|
63
|
+
const def: RouterDef = node._def
|
|
64
|
+
const next = def[part]
|
|
65
|
+
if (!next) return null
|
|
66
|
+
node = next
|
|
67
|
+
}
|
|
68
|
+
return node._tag === 'procedure' ? node : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function invokeProcedure(procedure: AnyProcedure, ctx: unknown, rawInput: unknown): Promise<unknown> {
|
|
72
|
+
// Normalize JSON's `null` to `undefined` for no-input procedures.
|
|
73
|
+
const normalized = rawInput === null ? undefined : rawInput
|
|
74
|
+
const inResult = await safeValidate(procedure.input, normalized)
|
|
75
|
+
if (inResult.issues) {
|
|
76
|
+
throw new HttpError(400, 'validation', formatIssues(inResult.issues), { issues: inResult.issues })
|
|
77
|
+
}
|
|
78
|
+
const input = inResult.value
|
|
79
|
+
|
|
80
|
+
for (const requirement of procedure.requirements) {
|
|
81
|
+
const scope = requirement.scopeResolver?.(input, ctx)
|
|
82
|
+
if (!hasAuth(ctx) || !ctx.auth.can(requirement.action, scope)) {
|
|
83
|
+
throw new ForbiddenError(`Forbidden: ${requirement.action}`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const output = await procedure.handler({ ctx, input })
|
|
88
|
+
if (!procedure.output) return output
|
|
89
|
+
const outResult = await safeValidate(procedure.output, output)
|
|
90
|
+
if (outResult.issues) {
|
|
91
|
+
throw new HttpError(500, 'internal', `Output validation failed: ${formatIssues(outResult.issues)}`)
|
|
92
|
+
}
|
|
93
|
+
return outResult.value
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function dispatchOne(router: AnyRouter, ctx: unknown, call: SingleCall): Promise<DispatchOutcome> {
|
|
97
|
+
const procedure = resolve(router, call.method)
|
|
98
|
+
if (!procedure) {
|
|
99
|
+
return { body: { error: { type: 'method_not_found', message: `Unknown method: ${call.method}` } }, status: 404 }
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const result = await invokeProcedure(procedure, ctx, call.input)
|
|
103
|
+
return { body: { result }, status: 200 }
|
|
104
|
+
} catch (err) {
|
|
105
|
+
// HTTP status comes from the error's OWN structural httpStatus (ForbiddenError → 403,
|
|
106
|
+
// BadRequestError → 400, a custom `{ httpStatus: 503 }` → 503, a plain Error → 500) — not a
|
|
107
|
+
// hardcoded type table — so every error maps correctly without the dispatcher knowing its type.
|
|
108
|
+
const { httpStatus, type, message, issues, loginUrl } = readStructuralError(err)
|
|
109
|
+
return {
|
|
110
|
+
body: { error: { type, message, ...(issues !== undefined ? { issues } : {}), ...(loginUrl !== undefined ? { loginUrl } : {}) } },
|
|
111
|
+
status: httpStatus,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Dispatch an RPC POST. The `ctx` is already built (and decorated by middleware,
|
|
118
|
+
* e.g. `ctx.auth`) by the server before this is called. Handles a single call
|
|
119
|
+
* `{ method, input }` or a `{ batch: [...] }`.
|
|
120
|
+
*/
|
|
121
|
+
export async function dispatchRpcRequest(args: { router: AnyRouter; ctx: unknown; request: Request }): Promise<Response> {
|
|
122
|
+
let rawBody: unknown
|
|
123
|
+
try {
|
|
124
|
+
rawBody = await args.request.json()
|
|
125
|
+
} catch {
|
|
126
|
+
return jsonResponse({ error: { type: 'validation', message: 'Invalid JSON body' } }, 400)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (isRecord(rawBody) && Array.isArray(rawBody['batch'])) {
|
|
130
|
+
const results: SingleResult[] = []
|
|
131
|
+
for (const call of rawBody['batch']) {
|
|
132
|
+
results.push((await dispatchOne(args.router, args.ctx, normalizeCall(call))).body)
|
|
133
|
+
}
|
|
134
|
+
return jsonResponse({ batch: results }, 200)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!isRecord(rawBody) || typeof rawBody['method'] !== 'string') {
|
|
138
|
+
return jsonResponse({ error: { type: 'validation', message: 'Body must be { method, input } or { batch: [...] }' } }, 400)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const single = await dispatchOne(args.router, args.ctx, { method: rawBody['method'], input: rawBody['input'] })
|
|
142
|
+
return jsonResponse(single.body, single.status)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeCall(call: unknown): SingleCall {
|
|
146
|
+
if (isRecord(call) && typeof call['method'] === 'string') {
|
|
147
|
+
return { method: call['method'], input: call['input'] }
|
|
148
|
+
}
|
|
149
|
+
return { method: '', input: undefined }
|
|
150
|
+
}
|
package/src/rpc/types.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// ─── RPC core types ──────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// A minimal tRPC-like engine. A router is a (possibly nested) map of procedures;
|
|
4
|
+
// a procedure carries an input schema, an OPTIONAL output schema, an authz
|
|
5
|
+
// requirement list, and a handler. The server-facing type is `InferRouter`; the
|
|
6
|
+
// client call types are `InferRouterClient`.
|
|
7
|
+
|
|
8
|
+
import type { AuthContext, Scope } from '@fabrika/auth'
|
|
9
|
+
import type { StandardSchemaV1 } from '../standard-schema.js'
|
|
10
|
+
|
|
11
|
+
/** `query` and `mutation` are metadata today — the dispatcher treats them identically. */
|
|
12
|
+
export type ProcedureKind = 'query' | 'mutation'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A recorded authorization requirement. `scopeResolver` is declared as a METHOD
|
|
16
|
+
* (bivariant params) on purpose, so a concrete `Procedure<C, I, O>` stays
|
|
17
|
+
* assignable to `AnyProcedure`.
|
|
18
|
+
*/
|
|
19
|
+
export interface AuthRequirement<TContext, TInput> {
|
|
20
|
+
readonly action: string
|
|
21
|
+
scopeResolver?(input: TInput, ctx: TContext): Scope | undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// `TInput` is the schema's PARSED (output) type — what the handler receives and what the client is
|
|
25
|
+
// typed to send. Modeled as `StandardSchemaV1<unknown, TInput>` (output param = TInput); for plain
|
|
26
|
+
// schemas input === output, so the single param is faithful.
|
|
27
|
+
export interface Procedure<TContext, TInput, TOutput> {
|
|
28
|
+
readonly _tag: 'procedure'
|
|
29
|
+
readonly kind: ProcedureKind
|
|
30
|
+
readonly input: StandardSchemaV1<unknown, TInput>
|
|
31
|
+
readonly output: StandardSchemaV1<unknown, TOutput> | null
|
|
32
|
+
readonly requirements: ReadonlyArray<AuthRequirement<TContext, TInput>>
|
|
33
|
+
handler(args: { ctx: TContext; input: TInput }): Promise<TOutput> | TOutput
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AnyProcedure {
|
|
37
|
+
readonly _tag: 'procedure'
|
|
38
|
+
readonly kind: ProcedureKind
|
|
39
|
+
readonly input: StandardSchemaV1
|
|
40
|
+
readonly output: StandardSchemaV1 | null
|
|
41
|
+
readonly requirements: ReadonlyArray<AuthRequirement<unknown, unknown>>
|
|
42
|
+
handler(args: { ctx: unknown; input: unknown }): Promise<unknown> | unknown
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type RouterDef = {
|
|
46
|
+
readonly [key: string]: AnyProcedure | AnyRouter
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Router<TDef extends RouterDef> {
|
|
50
|
+
readonly _tag: 'router'
|
|
51
|
+
readonly _def: TDef
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type AnyRouter = Router<RouterDef>
|
|
55
|
+
|
|
56
|
+
/** Re-export the auth contract used by procedure requirements. */
|
|
57
|
+
export type { AuthContext, Scope }
|
|
58
|
+
|
|
59
|
+
// ─── Inference ───────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/** Server-facing view of a router: each procedure as its handler signature. */
|
|
62
|
+
export type InferRouter<TRouter> = TRouter extends { _tag: 'router'; _def: infer TDef } ? TDef extends RouterDef ? {
|
|
63
|
+
[K in keyof TDef]: TDef[K] extends Procedure<infer C, infer I, infer O> ? (args: { ctx: C; input: I }) => Promise<O> | O
|
|
64
|
+
: TDef[K] extends Router<RouterDef> ? InferRouter<TDef[K]>
|
|
65
|
+
: never
|
|
66
|
+
}
|
|
67
|
+
: never
|
|
68
|
+
: never
|
|
69
|
+
|
|
70
|
+
/** Client-facing view of a router: each procedure as `(input) => Promise<output>`. */
|
|
71
|
+
export type InferRouterClient<TRouter> = TRouter extends { _tag: 'router'; _def: infer TDef } ? TDef extends RouterDef ? {
|
|
72
|
+
[K in keyof TDef]: TDef[K] extends Procedure<unknown, infer I, infer O> ? (input: I) => Promise<O>
|
|
73
|
+
: TDef[K] extends Router<RouterDef> ? InferRouterClient<TDef[K]>
|
|
74
|
+
: never
|
|
75
|
+
}
|
|
76
|
+
: never
|
|
77
|
+
: never
|