@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/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
|
+
}
|