@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 ADDED
@@ -0,0 +1,174 @@
1
+ # `@fabrika/app`
2
+
3
+ The server framework for Fabrika applications: Fetch-based HTTP routing,
4
+ middleware, typed RPC, object-level authorization, and a typed browser client.
5
+
6
+ Proxy gates remain the application's structural front door. The app receives a
7
+ verified Fabrika authorization context and uses `.require(action, scope)` for
8
+ checks that depend on application-owned objects.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ bun add @fabrika/app
14
+ ```
15
+
16
+ ## Example
17
+
18
+ ```ts
19
+ import {
20
+ type AuthContext,
21
+ createRpcClient,
22
+ defineApp,
23
+ initRpc,
24
+ type Middleware,
25
+ route,
26
+ } from '@fabrika/app'
27
+ import { z } from 'zod'
28
+
29
+ interface Env {
30
+ ASSETS: { fetch(request: Request): Promise<Response> }
31
+ }
32
+
33
+ interface Ctx {
34
+ env: Env
35
+ auth: AuthContext
36
+ }
37
+
38
+ const rpc = initRpc<Ctx>()
39
+
40
+ const appRouter = rpc.router({
41
+ projects: rpc.router({
42
+ rename: rpc.procedure
43
+ .input(z.object({ id: z.string(), name: z.string() }))
44
+ .require('project:write', (input) => ({
45
+ type: 'project',
46
+ value: input.id,
47
+ }))
48
+ .mutation(async ({ ctx, input }) => {
49
+ await renameProject(ctx.env, input.id, input.name)
50
+ return { ok: true }
51
+ }),
52
+ }),
53
+ })
54
+
55
+ export type AppRouter = typeof appRouter
56
+
57
+ const auth: Middleware<Ctx> = async (_request, ctx, next) => {
58
+ ctx.auth = await readFabrikaAuth(ctx.env)
59
+ return next()
60
+ }
61
+
62
+ export const app = defineApp<Env, Ctx>({
63
+ context: (env) => ({ env, auth: anonymousAuth() }),
64
+ middleware: () => [auth],
65
+ routes: [
66
+ route.get('/health', () => new Response('ok')),
67
+ route.rpc('/api/rpc', appRouter),
68
+ ],
69
+ assets: (env) => env.ASSETS,
70
+ })
71
+
72
+ export const api = createRpcClient<AppRouter>({ baseUrl: '/api/rpc' })
73
+ ```
74
+
75
+ Input and output validators may use any Standard Schema implementation.
76
+
77
+ ## Portable RPC contracts
78
+
79
+ Put only DTOs and type-only procedure declarations in a browser-safe contract
80
+ package. The browser does not need to import the server router, its validators,
81
+ or its context:
82
+
83
+ ```ts
84
+ import type { RpcProcedureContract } from '@fabrika/app'
85
+
86
+ export interface AppRpcContract {
87
+ projects: {
88
+ list: RpcProcedureContract<
89
+ { ownerId: string },
90
+ { id: string; name: string }[]
91
+ >
92
+ }
93
+ }
94
+ ```
95
+
96
+ The server verifies that its router implements the contract, while the browser
97
+ uses the same contract directly:
98
+
99
+ ```ts
100
+ import type { AppRpcContract } from '@example/app-contract'
101
+ import { createRpcClient, initRpc, type RpcRouterFor } from '@fabrika/app'
102
+
103
+ const rpc = initRpc<Ctx>()
104
+
105
+ export const appRouter = rpc.router({
106
+ projects: rpc.router({
107
+ list: rpc.procedure.input(listInput).output(projectList).query(listProjects),
108
+ }),
109
+ }) satisfies RpcRouterFor<Ctx, AppRpcContract>
110
+
111
+ export const api = createRpcClient<AppRpcContract>({ baseUrl: '/api/rpc' })
112
+ ```
113
+
114
+ ## Runtime adapters
115
+
116
+ `defineApp()` builds the runtime-neutral Fetch request pipeline. Runtime
117
+ entrypoints adapt that application explicitly.
118
+
119
+ A Cloudflare Worker module imports `@fabrika/app/cloudflare`:
120
+
121
+ ```ts
122
+ import { createCloudflareWorker } from '@fabrika/app/cloudflare'
123
+ import { app } from './app'
124
+
125
+ export default createCloudflareWorker(app)
126
+ ```
127
+
128
+ A long-running Bun process wraps the app with `createBunHandler()`:
129
+
130
+ ```ts
131
+ import { defineApp, route } from '@fabrika/app'
132
+ import { createBunHandler } from '@fabrika/app/bun'
133
+
134
+ const app = defineApp<Deps, Ctx>({
135
+ context: (deps, request, exec) => ({ deps, request, exec }),
136
+ routes: [route.get('/healthz', () => Response.json({ status: 'ok' }))],
137
+ })
138
+
139
+ const handler = createBunHandler(app, deps, {
140
+ onBackgroundError: () => console.error('background task failed'),
141
+ })
142
+
143
+ const server = Bun.serve({ fetch: handler.fetch })
144
+
145
+ const shutdown = async (): Promise<void> => {
146
+ await server.stop(false)
147
+ await handler.drain()
148
+ await closeApplicationResources()
149
+ }
150
+ ```
151
+
152
+ `drain()` waits for every task registered through the request execution context's
153
+ `waitUntil()`. Stop accepting requests before draining, then close databases and
154
+ other process resources.
155
+
156
+ ## Runtime conformance
157
+
158
+ Use the test-runner-neutral `@fabrika/app/testing` entrypoint to prove that a
159
+ portable route behaves the same through direct Fetch dispatch and both runtime
160
+ adapters:
161
+
162
+ ```ts
163
+ import { assertAppRuntimeConformance } from '@fabrika/app/testing'
164
+
165
+ const response = await assertAppRuntimeConformance({
166
+ app,
167
+ createEnv: () => createIsolatedTestEnv(),
168
+ createRequest: () => new Request('https://app.test/healthz'),
169
+ })
170
+ ```
171
+
172
+ The helper drains registered background work and compares normalized status,
173
+ headers, and text body. Do not use it for routes that exist in only one runtime
174
+ composition.
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@fabrika/app",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/contember/fabrika-platform.git",
9
+ "directory": "packages/app"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "!src/**/__tests__/**"
17
+ ],
18
+ "exports": {
19
+ ".": {
20
+ "bun": "./src/index.ts",
21
+ "types": "./src/index.ts",
22
+ "default": "./src/index.ts"
23
+ },
24
+ "./bun": {
25
+ "bun": "./src/bun.ts",
26
+ "types": "./src/bun.ts",
27
+ "default": "./src/bun.ts"
28
+ },
29
+ "./cloudflare": {
30
+ "bun": "./src/cloudflare.ts",
31
+ "types": "./src/cloudflare.ts",
32
+ "default": "./src/cloudflare.ts"
33
+ },
34
+ "./testing": {
35
+ "bun": "./src/testing.ts",
36
+ "types": "./src/testing.ts",
37
+ "default": "./src/testing.ts"
38
+ }
39
+ },
40
+ "scripts": {
41
+ "typecheck": "tsc --noEmit",
42
+ "test": "bun test"
43
+ },
44
+ "dependencies": {
45
+ "@fabrika/auth": "^0.0.1"
46
+ }
47
+ }
package/src/app.ts ADDED
@@ -0,0 +1,76 @@
1
+ // ─── Application ─────────────────────────────────────────────────────
2
+ //
3
+ // `defineApp` owns the runtime-neutral Fetch request pipeline.
4
+
5
+ import { defaultOnError, jsonResponse } from './errors.js'
6
+ import type { Middleware } from './middleware.js'
7
+ import { runChain } from './middleware.js'
8
+ import { matchRoutes } from './router.js'
9
+ import type { Route } from './router.js'
10
+ import { dispatchRpcRequest } from './rpc/dispatcher.js'
11
+
12
+ /** Runtime-neutral background-work capability available while handling a request. */
13
+ export interface RequestExecutionContext {
14
+ waitUntil(promise: Promise<unknown>): void
15
+ }
16
+
17
+ /** Something that can serve static assets (e.g. a Workers Assets binding). */
18
+ export interface AssetsLike {
19
+ fetch(request: Request): Promise<Response>
20
+ }
21
+
22
+ export interface AppConfig<Env, Ctx> {
23
+ /** Build the base per-request context. Auth is added later by middleware. */
24
+ context: (env: Env, request: Request, exec: RequestExecutionContext) => Ctx | Promise<Ctx>
25
+ /** Middleware, run in declared order around the inner dispatch. */
26
+ middleware?: (env: Env) => Middleware<Ctx>[]
27
+ /** HTTP + RPC routes. */
28
+ routes: Route<Ctx>[]
29
+ /** SPA fallback served for an unmatched GET. */
30
+ assets?: (env: Env) => AssetsLike
31
+ /** Map a thrown value to a `Response`. Defaults to `defaultOnError`. */
32
+ onError?: (err: unknown, request: Request, env: Env) => Response
33
+ }
34
+
35
+ export interface FabrikaApp<Env> {
36
+ fetch(request: Request, env: Env, exec: RequestExecutionContext): Promise<Response>
37
+ }
38
+
39
+ function createFetch<Env, Ctx>(
40
+ config: AppConfig<Env, Ctx>,
41
+ ): (request: Request, env: Env, exec: RequestExecutionContext) => Promise<Response> {
42
+ const onError = config.onError ?? defaultOnError
43
+
44
+ return async (request: Request, env: Env, exec: RequestExecutionContext): Promise<Response> => {
45
+ try {
46
+ const ctx = await config.context(env, request, exec)
47
+ const middleware = config.middleware ? config.middleware(env) : []
48
+
49
+ const dispatch = async (): Promise<Response> => {
50
+ const matched = matchRoutes(config.routes, request)
51
+ if (matched) {
52
+ const inner = (): Promise<Response> => {
53
+ if (matched.route.kind === 'rpc') {
54
+ return dispatchRpcRequest({ router: matched.route.router, ctx, request })
55
+ }
56
+ return Promise.resolve(matched.route.handler(ctx, matched.params))
57
+ }
58
+ // Route-scoped middleware run after the global chain, around the handler.
59
+ return runChain(request, ctx, matched.route.use, inner)
60
+ }
61
+ if (request.method === 'GET' && config.assets) {
62
+ return config.assets(env).fetch(request)
63
+ }
64
+ return jsonResponse({ error: { type: 'not_found', message: 'Not found' } }, 404)
65
+ }
66
+
67
+ return await runChain(request, ctx, middleware, dispatch)
68
+ } catch (err) {
69
+ return onError(err, request, env)
70
+ }
71
+ }
72
+ }
73
+
74
+ export function defineApp<Env, Ctx>(config: AppConfig<Env, Ctx>): FabrikaApp<Env> {
75
+ return { fetch: createFetch(config) }
76
+ }
package/src/bun.ts ADDED
@@ -0,0 +1,63 @@
1
+ // ─── Bun process adapter ─────────────────────────────────────────────
2
+ //
3
+ // A long-running Bun process needs only request dispatch and explicit draining.
4
+ // It does not emulate Worker cron, queues, or passThroughOnException().
5
+
6
+ import type { FabrikaApp, RequestExecutionContext } from './app.js'
7
+
8
+ export interface ManagedBunHandlerOptions {
9
+ /** Required sink for rejected background tasks. Do not log raw errors or secret-bearing values. */
10
+ onBackgroundError(error: unknown): void
11
+ executionContext?: never
12
+ }
13
+
14
+ export interface ExternalBunHandlerOptions {
15
+ /** Existing supervised process context. Its owner remains responsible for draining it. */
16
+ executionContext: RequestExecutionContext
17
+ onBackgroundError?: never
18
+ }
19
+
20
+ export type BunHandlerOptions = ManagedBunHandlerOptions | ExternalBunHandlerOptions
21
+
22
+ export interface BunHandler {
23
+ fetch(request: Request): Promise<Response>
24
+ /** Wait until every task registered through `waitUntil()` has settled. */
25
+ drain(): Promise<void>
26
+ }
27
+
28
+ export function createBunHandler<Env>(app: FabrikaApp<Env>, env: Env, options: BunHandlerOptions): BunHandler {
29
+ if (options.executionContext !== undefined) {
30
+ return {
31
+ fetch: (request) => app.fetch(request, env, options.executionContext),
32
+ drain: () => Promise.resolve(),
33
+ }
34
+ }
35
+
36
+ const pending = new Set<Promise<void>>()
37
+
38
+ const exec: RequestExecutionContext = {
39
+ waitUntil(promise) {
40
+ const tracked = promise.then(
41
+ () => undefined,
42
+ (error) => {
43
+ try {
44
+ options.onBackgroundError(error)
45
+ } catch {
46
+ // Draining must remain reliable even if the reporting hook fails.
47
+ }
48
+ },
49
+ )
50
+ pending.add(tracked)
51
+ void tracked.then(() => pending.delete(tracked))
52
+ },
53
+ }
54
+
55
+ return {
56
+ fetch: (request) => app.fetch(request, env, exec),
57
+ async drain() {
58
+ while (pending.size > 0) {
59
+ await Promise.all(pending)
60
+ }
61
+ },
62
+ }
63
+ }
package/src/client.ts ADDED
@@ -0,0 +1,143 @@
1
+ // ─── RPC client ──────────────────────────────────────────────────────
2
+ //
3
+ // A typed proxy: `client.a.b.c(input)` POSTs `{ method: 'a.b.c', input }` to the
4
+ // configured endpoint and unwraps `{ result }` / `{ error }`. Errors surface as
5
+ // `RpcError` (carrying `type`, `loginUrl`, … off the error envelope).
6
+ //
7
+ // `bounceOnAuth` folds the SSO redirect every SPA needs into the client: on a
8
+ // human-gated 401 (`type: 'auth'` + `loginUrl`) it redirects the browser to the
9
+ // login URL (returning the current page) instead of throwing, with a short
10
+ // re-entry guard so an unprovisioned identity can't loop forever.
11
+
12
+ import type { InferRpcClient } from './rpc/contract.js'
13
+
14
+ export class RpcError extends Error {
15
+ readonly type: string
16
+ readonly issues?: unknown
17
+ readonly loginUrl?: string
18
+ readonly httpStatus?: number
19
+ constructor(payload: { type: string; message: string; issues?: unknown; loginUrl?: string; httpStatus?: number }) {
20
+ super(payload.message)
21
+ this.name = 'RpcError'
22
+ this.type = payload.type
23
+ if (payload.issues !== undefined) this.issues = payload.issues
24
+ if (payload.loginUrl !== undefined) this.loginUrl = payload.loginUrl
25
+ if (payload.httpStatus !== undefined) this.httpStatus = payload.httpStatus
26
+ }
27
+ }
28
+
29
+ /** Minimal Fetch API surface required by the RPC client. */
30
+ export type RpcFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
31
+
32
+ interface BounceConfig {
33
+ readonly sessionKey: string
34
+ readonly windowMs: number
35
+ }
36
+
37
+ export interface RpcClientOptions {
38
+ /** Endpoint the RPC proxy POSTs to (e.g. `/rpc`). */
39
+ readonly baseUrl: string
40
+ /**
41
+ * Auto-redirect to SSO on a human-gated 401 (`type: 'auth'` + `loginUrl`) instead of throwing.
42
+ * `true` uses sane defaults; pass `{ sessionKey, windowMs }` to tune the re-entry guard.
43
+ */
44
+ readonly bounceOnAuth?: boolean | { sessionKey?: string; windowMs?: number }
45
+ /** Override `fetch` (tests / non-browser). Defaults to the global `fetch`. */
46
+ readonly fetch?: RpcFetch
47
+ }
48
+
49
+ function resolveBounce(baseUrl: string, opt: RpcClientOptions['bounceOnAuth']): BounceConfig | null {
50
+ if (!opt) return null
51
+ const tuning = opt === true ? {} : opt
52
+ return { sessionKey: tuning.sessionKey ?? `fabrika:login-bounce:${baseUrl}`, windowMs: tuning.windowMs ?? 10_000 }
53
+ }
54
+
55
+ /** Guarded SSO redirect. Returns true if it navigated (caller should hang), false if guarded/non-browser. */
56
+ function bounceToLogin(loginUrl: string, cfg: BounceConfig): boolean {
57
+ if (typeof window === 'undefined') return false
58
+ const now = Date.now()
59
+ const last = Number(sessionStorage.getItem(cfg.sessionKey) ?? '0')
60
+ if (Number.isFinite(last) && now - last < cfg.windowMs) return false
61
+ sessionStorage.setItem(cfg.sessionKey, String(now))
62
+ const target = new URL(loginUrl)
63
+ target.searchParams.set('redirect', window.location.href)
64
+ window.location.assign(target.toString())
65
+ return true
66
+ }
67
+
68
+ function clearBounce(cfg: BounceConfig | null): void {
69
+ if (cfg && typeof window !== 'undefined') sessionStorage.removeItem(cfg.sessionKey)
70
+ }
71
+
72
+ function isRecord(value: unknown): value is Record<string, unknown> {
73
+ return value !== null && typeof value === 'object'
74
+ }
75
+
76
+ function normalizeError(
77
+ raw: unknown,
78
+ httpStatus: number,
79
+ ): { type: string; message: string; issues?: unknown; loginUrl?: string; httpStatus: number } {
80
+ if (typeof raw === 'string') {
81
+ return { type: httpStatus === 401 ? 'auth' : 'error', message: raw, httpStatus }
82
+ }
83
+ if (isRecord(raw)) {
84
+ const type = typeof raw['type'] === 'string' ? raw['type'] : 'error'
85
+ const message = typeof raw['message'] === 'string' ? raw['message'] : 'Unknown error'
86
+ const issues = raw['issues']
87
+ const loginUrl = typeof raw['loginUrl'] === 'string' ? raw['loginUrl'] : undefined
88
+ return { type, message, httpStatus, ...(issues !== undefined ? { issues } : {}), ...(loginUrl !== undefined ? { loginUrl } : {}) }
89
+ }
90
+ return { type: 'error', message: `HTTP ${httpStatus}`, httpStatus }
91
+ }
92
+
93
+ async function callRpc(opts: RpcClientOptions, bounce: BounceConfig | null, method: string, input: unknown): Promise<unknown> {
94
+ const doFetch = opts.fetch ?? fetch
95
+ let response: Response
96
+ try {
97
+ response = await doFetch(opts.baseUrl, {
98
+ method: 'POST',
99
+ credentials: 'include',
100
+ headers: { 'content-type': 'application/json' },
101
+ body: JSON.stringify({ method, input: input ?? null }),
102
+ })
103
+ } catch {
104
+ throw new RpcError({ type: 'transport', message: 'Network request failed', httpStatus: 0 })
105
+ }
106
+ let data: { result?: unknown; error?: unknown }
107
+ try {
108
+ data = await response.json()
109
+ } catch {
110
+ // Non-JSON response (proxy error, HTML error page, etc.) — surface the HTTP status.
111
+ throw new RpcError({ type: 'transport', message: `${response.status} ${response.statusText}`, httpStatus: response.status })
112
+ }
113
+ if (data.error !== undefined) {
114
+ const normalized = normalizeError(data.error, response.status)
115
+ if (bounce && normalized.type === 'auth' && normalized.loginUrl !== undefined && bounceToLogin(normalized.loginUrl, bounce)) {
116
+ // Navigation is in flight — never resolve, so the caller (a route loader) stays pending.
117
+ return new Promise<never>(() => {})
118
+ }
119
+ throw new RpcError(normalized)
120
+ }
121
+ clearBounce(bounce)
122
+ return data.result
123
+ }
124
+
125
+ function makeProxy(opts: RpcClientOptions, bounce: BounceConfig | null, path: string): unknown {
126
+ const fn = (input: unknown) => callRpc(opts, bounce, path, input)
127
+ return new Proxy(fn, {
128
+ get(_target, prop) {
129
+ if (typeof prop !== 'string') return undefined
130
+ return makeProxy(opts, bounce, path ? `${path}.${prop}` : prop)
131
+ },
132
+ })
133
+ }
134
+
135
+ function isClient<TApi>(value: unknown): value is InferRpcClient<TApi> {
136
+ return typeof value === 'function'
137
+ }
138
+
139
+ export function createRpcClient<TApi>(opts: RpcClientOptions): InferRpcClient<TApi> {
140
+ const proxy = makeProxy(opts, resolveBounce(opts.baseUrl, opts.bounceOnAuth), '')
141
+ if (!isClient<TApi>(proxy)) throw new Error('unreachable')
142
+ return proxy
143
+ }
@@ -0,0 +1,46 @@
1
+ // ─── Cloudflare Workers adapter ──────────────────────────────────────
2
+
3
+ import type { FabrikaApp, RequestExecutionContext } from './app.js'
4
+
5
+ /** Minimal structural view of a Workers `ExecutionContext`. */
6
+ export interface CloudflareExecutionContext extends RequestExecutionContext {
7
+ passThroughOnException(): void
8
+ }
9
+
10
+ /** Minimal structural view of a Workers cron `ScheduledController`. */
11
+ export interface CloudflareScheduledController {
12
+ readonly scheduledTime: number
13
+ readonly cron: string
14
+ noRetry(): void
15
+ }
16
+
17
+ /** Minimal structural view of a Workers queue `MessageBatch`. */
18
+ export interface CloudflareMessageBatch {
19
+ readonly queue: string
20
+ readonly messages: ReadonlyArray<unknown>
21
+ }
22
+
23
+ export interface CloudflareWorkerOptions<Env> {
24
+ scheduled?: (controller: CloudflareScheduledController, env: Env, exec: CloudflareExecutionContext) => Promise<void>
25
+ queue?: (batch: CloudflareMessageBatch, env: Env, exec: CloudflareExecutionContext) => Promise<void>
26
+ }
27
+
28
+ export interface CloudflareWorker<Env> {
29
+ fetch(request: Request, env: Env, exec: CloudflareExecutionContext): Promise<Response>
30
+ scheduled?(controller: CloudflareScheduledController, env: Env, exec: CloudflareExecutionContext): Promise<void>
31
+ queue?(batch: CloudflareMessageBatch, env: Env, exec: CloudflareExecutionContext): Promise<void>
32
+ }
33
+
34
+ /** Adapt a runtime-neutral Fabrika application to a Workers module export. */
35
+ export function createCloudflareWorker<Env>(
36
+ app: FabrikaApp<Env>,
37
+ options: CloudflareWorkerOptions<Env> = {},
38
+ ): CloudflareWorker<Env> {
39
+ const scheduled = options.scheduled
40
+ const queue = options.queue
41
+ return {
42
+ fetch: (request, env, exec) => app.fetch(request, env, exec),
43
+ ...(scheduled ? { scheduled } : {}),
44
+ ...(queue ? { queue } : {}),
45
+ }
46
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,120 @@
1
+ // ─── HTTP errors ─────────────────────────────────────────────────────
2
+ //
3
+ // The app framework never relies on cross-package `instanceof`. The error contract is purely
4
+ // STRUCTURAL: any thrown value MAY expose `httpStatus` / `type` / `message` /
5
+ // `issues` / `loginUrl`, and the mapper below reads those fields off whatever it
6
+ // is handed. The classes here are a convenience for framework code (and apps
7
+ // that want them) — they are not a requirement for interop.
8
+
9
+ interface ErrorOptions {
10
+ readonly issues?: unknown
11
+ readonly loginUrl?: string
12
+ }
13
+
14
+ export class HttpError extends Error {
15
+ readonly httpStatus: number
16
+ readonly type: string
17
+ readonly issues?: unknown
18
+ readonly loginUrl?: string
19
+
20
+ constructor(httpStatus: number, type: string, message: string, options?: ErrorOptions) {
21
+ super(message)
22
+ this.name = new.target.name
23
+ this.httpStatus = httpStatus
24
+ this.type = type
25
+ if (options?.issues !== undefined) this.issues = options.issues
26
+ if (options?.loginUrl !== undefined) this.loginUrl = options.loginUrl
27
+ }
28
+ }
29
+
30
+ export class BadRequestError extends HttpError {
31
+ constructor(message = 'Bad request', options?: ErrorOptions) {
32
+ super(400, 'bad_request', message, options)
33
+ }
34
+ }
35
+
36
+ export class UnauthorizedError extends HttpError {
37
+ constructor(message = 'Unauthorized', options?: ErrorOptions) {
38
+ super(401, 'auth', message, options)
39
+ }
40
+ }
41
+
42
+ export class ForbiddenError extends HttpError {
43
+ constructor(message = 'Forbidden', options?: ErrorOptions) {
44
+ super(403, 'forbidden', message, options)
45
+ }
46
+ }
47
+
48
+ export class NotFoundError extends HttpError {
49
+ constructor(message = 'Not found', options?: ErrorOptions) {
50
+ super(404, 'not_found', message, options)
51
+ }
52
+ }
53
+
54
+ export class ConflictError extends HttpError {
55
+ constructor(message = 'Conflict', options?: ErrorOptions) {
56
+ super(409, 'conflict', message, options)
57
+ }
58
+ }
59
+
60
+ // ─── Structural mapping ──────────────────────────────────────────────
61
+
62
+ export interface StructuralError {
63
+ readonly httpStatus: number
64
+ readonly type: string
65
+ readonly message: string
66
+ readonly issues?: unknown
67
+ readonly loginUrl?: string
68
+ }
69
+
70
+ function isRecord(value: unknown): value is Record<string, unknown> {
71
+ return value !== null && typeof value === 'object'
72
+ }
73
+
74
+ /**
75
+ * Read the structural HTTP-error fields off ANY thrown value. Defaults:
76
+ * `httpStatus → 500`, `type → 'internal'`, `message → the Error message or 'Internal error'`.
77
+ */
78
+ export function readStructuralError(err: unknown): StructuralError {
79
+ const record: Record<string, unknown> = isRecord(err) ? err : {}
80
+ const httpStatus = typeof record['httpStatus'] === 'number' ? record['httpStatus'] : 500
81
+ const type = typeof record['type'] === 'string' ? record['type'] : 'internal'
82
+ const message = err instanceof Error
83
+ ? err.message
84
+ : typeof record['message'] === 'string'
85
+ ? record['message']
86
+ : 'Internal error'
87
+ const issues = record['issues']
88
+ const loginUrl = typeof record['loginUrl'] === 'string' ? record['loginUrl'] : undefined
89
+ return {
90
+ httpStatus,
91
+ type,
92
+ message,
93
+ ...(issues !== undefined ? { issues } : {}),
94
+ ...(loginUrl !== undefined ? { loginUrl } : {}),
95
+ }
96
+ }
97
+
98
+ export function jsonResponse(body: unknown, status: number): Response {
99
+ return new Response(JSON.stringify(body), {
100
+ status,
101
+ headers: { 'content-type': 'application/json' },
102
+ })
103
+ }
104
+
105
+ /** Map any thrown value to a JSON `Response` per the structural error contract. */
106
+ export function toErrorResponse(err: unknown): Response {
107
+ const { httpStatus, type, message, issues, loginUrl } = readStructuralError(err)
108
+ const error = {
109
+ type,
110
+ message,
111
+ ...(issues !== undefined ? { issues } : {}),
112
+ ...(loginUrl !== undefined ? { loginUrl } : {}),
113
+ }
114
+ return jsonResponse({ error }, httpStatus)
115
+ }
116
+
117
+ /** The default `onError` handler — a thin wrapper around {@link toErrorResponse}. */
118
+ export function defaultOnError(err: unknown, _request?: Request): Response {
119
+ return toErrorResponse(err)
120
+ }