@fabrika/app 0.3.3 → 0.4.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 +78 -90
- package/package.json +77 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +370 -0
- package/skill/references/dev.md +90 -0
- package/skill/references/endpoint.md +640 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +746 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +308 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +331 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1932 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +444 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +161 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +824 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +120 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1095 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +760 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +281 -0
- package/src/react/index.ts +34 -0
- package/src/react/provider.tsx +179 -0
- package/src/react/store.ts +215 -0
- package/src/react/testing.ts +144 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/challenge.ts +10 -0
- package/src/testing/dev.ts +99 -0
- package/src/testing/endpoint-access.ts +809 -0
- package/src/testing/index.ts +36 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +585 -0
- package/src/testing/membership.ts +797 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- package/src/testing.ts +0 -119
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The proxy in front of the test kit's application: production's decision, proxy-core's verify service over the
|
|
2
|
+
// endpoint table `contractProxyTable` compiles from the contract, asked the way the Cloudflare proxy Worker asks it.
|
|
3
|
+
|
|
4
|
+
import { type ContractArtifact, DELEGATION_HEADER, ENDPOINT_HEADER, rawRequestTarget } from '@fabrika/auth-core'
|
|
5
|
+
import {
|
|
6
|
+
APP_QUERY_PARAM,
|
|
7
|
+
createVerifyService,
|
|
8
|
+
DEFAULT_VERIFY_PATH,
|
|
9
|
+
type DelegationIssuer,
|
|
10
|
+
FORWARDED_HOST_HEADER,
|
|
11
|
+
FORWARDED_METHOD_HEADER,
|
|
12
|
+
FORWARDED_PROTO_HEADER,
|
|
13
|
+
FORWARDED_URI_HEADER,
|
|
14
|
+
type IamGateway,
|
|
15
|
+
PROXY_TOKEN_HEADER,
|
|
16
|
+
REQUEST_ID_HEADER,
|
|
17
|
+
type TokenCache,
|
|
18
|
+
} from '@fabrika/proxy-core'
|
|
19
|
+
import { contractProxyTable } from '@fabrika/proxy-core/manifest'
|
|
20
|
+
|
|
21
|
+
/** What the proxy did with a request: answered it itself, or forwarded it with what only the proxy may set. */
|
|
22
|
+
export type ProxyOutcome =
|
|
23
|
+
| {
|
|
24
|
+
readonly outcome: 'forward'
|
|
25
|
+
/** Every header only the proxy may assert, with the value the decision gave it. */
|
|
26
|
+
readonly asserted: ReadonlyMap<string, string>
|
|
27
|
+
/** Whether the request's own `Authorization` reaches the application: not on an anonymous endpoint. */
|
|
28
|
+
readonly keepsAuthorization: boolean
|
|
29
|
+
}
|
|
30
|
+
| { readonly outcome: 'answer'; readonly response: Response }
|
|
31
|
+
|
|
32
|
+
/** What the proxy reads of a request; its headers have already lost every one a client may not assert. */
|
|
33
|
+
export interface ProxiedRequest {
|
|
34
|
+
readonly method: string
|
|
35
|
+
readonly url: string
|
|
36
|
+
readonly headers: Headers
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type TestProxy = (request: ProxiedRequest) => Promise<ProxyOutcome>
|
|
40
|
+
|
|
41
|
+
export interface EndpointProxyOptions {
|
|
42
|
+
readonly artifact: ContractArtifact
|
|
43
|
+
readonly appId: string
|
|
44
|
+
readonly host: string
|
|
45
|
+
readonly iam: IamGateway
|
|
46
|
+
readonly issuer: string
|
|
47
|
+
/** Issues the delegation grants of membership and scope-creating endpoints, as IAM does for the proxy. */
|
|
48
|
+
readonly delegations: DelegationIssuer | null
|
|
49
|
+
/** The proxy's token cache, as production keeps one; omitted, every request mints. */
|
|
50
|
+
readonly cache?: TokenCache
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ASSERTED_HEADERS = [PROXY_TOKEN_HEADER, REQUEST_ID_HEADER, ENDPOINT_HEADER, DELEGATION_HEADER]
|
|
54
|
+
|
|
55
|
+
/** The production proxy: the verify service over the contract's endpoint table, asked the way an enforcement point asks it. */
|
|
56
|
+
export function endpointProxy(options: EndpointProxyOptions): TestProxy {
|
|
57
|
+
const verify = createVerifyService({
|
|
58
|
+
manifest: {
|
|
59
|
+
apps: [{ id: options.appId, hosts: [options.host], upstream: 'app', scheme: 'https', ...contractProxyTable(options.artifact) }],
|
|
60
|
+
},
|
|
61
|
+
iam: options.iam,
|
|
62
|
+
issuer: options.issuer,
|
|
63
|
+
delegations: options.delegations,
|
|
64
|
+
...(options.cache === undefined ? {} : { cache: options.cache }),
|
|
65
|
+
})
|
|
66
|
+
const verifyUrl = `https://fabrika-proxy.internal${DEFAULT_VERIFY_PATH}?${APP_QUERY_PARAM}=${encodeURIComponent(options.appId)}`
|
|
67
|
+
return async (request) => {
|
|
68
|
+
const headers = new Headers(request.headers)
|
|
69
|
+
const target = rawRequestTarget(request.url)
|
|
70
|
+
headers.set(FORWARDED_METHOD_HEADER, request.method)
|
|
71
|
+
headers.set(FORWARDED_URI_HEADER, `${target.pathname}${target.search}`)
|
|
72
|
+
headers.set(FORWARDED_HOST_HEADER, new URL(request.url).host)
|
|
73
|
+
headers.set(FORWARDED_PROTO_HEADER, 'https')
|
|
74
|
+
const verification = await verify(new Request(verifyUrl, { method: 'GET', headers }))
|
|
75
|
+
if (!verification.ok) {
|
|
76
|
+
return { outcome: 'answer', response: verification }
|
|
77
|
+
}
|
|
78
|
+
const asserted = new Map<string, string>()
|
|
79
|
+
for (const header of ASSERTED_HEADERS) {
|
|
80
|
+
const value = verification.headers.get(header)
|
|
81
|
+
if (value !== null && value !== '') {
|
|
82
|
+
asserted.set(header, value)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { outcome: 'forward', asserted, keepsAuthorization: asserted.has(PROXY_TOKEN_HEADER) }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The test functions the generated tests register through. The platform's run preloads `@fabrika/app/testing/preload`,
|
|
2
|
+
// which captures Bun's own `test`, `describe`, `expect` and `afterAll` before any application module loads, so a
|
|
3
|
+
// `mock.module('bun:test', …)` in application code cannot replace what the generated tests use. Each case counts
|
|
4
|
+
// the `expect` calls it ran; one that ran none fails, whatever the runner reports.
|
|
5
|
+
|
|
6
|
+
import * as bunTest from 'bun:test'
|
|
7
|
+
|
|
8
|
+
interface TestRunner {
|
|
9
|
+
readonly test: typeof bunTest.test
|
|
10
|
+
readonly describe: typeof bunTest.describe
|
|
11
|
+
readonly expect: typeof bunTest.expect
|
|
12
|
+
readonly afterAll: typeof bunTest.afterAll
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const RUNNER = Symbol.for('fabrika.test-runner')
|
|
16
|
+
|
|
17
|
+
function isRunner(value: unknown): value is TestRunner {
|
|
18
|
+
return typeof value === 'object' && value !== null
|
|
19
|
+
&& ['test', 'describe', 'expect', 'afterAll'].every((name) => typeof Reflect.get(value, name) === 'function')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Captures Bun's test functions process-wide, once; the platform's preload calls it before the application loads. */
|
|
23
|
+
export function installTestRunner(): void {
|
|
24
|
+
if (isRunner(Reflect.get(globalThis, RUNNER))) {
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
const captured: TestRunner = Object.freeze({ test: bunTest.test, describe: bunTest.describe, expect: bunTest.expect, afterAll: bunTest.afterAll })
|
|
28
|
+
Object.defineProperty(globalThis, RUNNER, { value: captured, writable: false, configurable: false, enumerable: false })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The captured functions, or, in a run without the preload, those this module imported. */
|
|
32
|
+
function runner(): TestRunner {
|
|
33
|
+
const installed: unknown = Reflect.get(globalThis, RUNNER)
|
|
34
|
+
return isRunner(installed) ? installed : bunTest
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let expectCalls = 0
|
|
38
|
+
|
|
39
|
+
export function describe(name: string, body: () => void): void {
|
|
40
|
+
runner().describe(name, body)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Registers a case that fails unless its body ran at least one `expect`. */
|
|
44
|
+
export function test(name: string, body: () => Promise<void>): void {
|
|
45
|
+
runner().test(name, async () => {
|
|
46
|
+
const before = expectCalls
|
|
47
|
+
await body()
|
|
48
|
+
if (expectCalls === before) {
|
|
49
|
+
throw new Error(`the generated case '${name}' ran no expect, so it proved nothing`)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function expect<T>(value: T): ReturnType<typeof bunTest.expect<T>> {
|
|
55
|
+
expectCalls++
|
|
56
|
+
return runner().expect(value)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function afterAll(body: () => Promise<void>): void {
|
|
60
|
+
runner().afterAll(body)
|
|
61
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { RpcError } from '../client/transport'
|
|
2
|
+
|
|
3
|
+
/** The status the application refused a call with; a failure that is not its answer (no status) is rethrown. */
|
|
4
|
+
export function refusalStatus(error: unknown): number {
|
|
5
|
+
if (error instanceof RpcError && error.httpStatus !== undefined) {
|
|
6
|
+
return error.httpStatus
|
|
7
|
+
}
|
|
8
|
+
throw error
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A call's status: 200 when it resolves, the status the application refused it with otherwise. */
|
|
12
|
+
export async function statusOf(call: Promise<unknown>): Promise<number> {
|
|
13
|
+
try {
|
|
14
|
+
await call
|
|
15
|
+
return 200
|
|
16
|
+
} catch (error) {
|
|
17
|
+
return refusalStatus(error)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The launcher's own protection: a process that holds the provisioning credential marks itself non-dumpable before it
|
|
2
|
+
// starts the application, so a process of the same user, the application among them, cannot read its
|
|
3
|
+
// `/proc/<pid>/environ` or `/proc/<pid>/mem`. Linux only; elsewhere is a development machine.
|
|
4
|
+
|
|
5
|
+
import { dlopen, FFIType } from 'bun:ffi'
|
|
6
|
+
|
|
7
|
+
/** `PR_SET_DUMPABLE` from `<linux/prctl.h>`. */
|
|
8
|
+
const PR_SET_DUMPABLE = 4
|
|
9
|
+
|
|
10
|
+
/** The C library of a glibc system, then Alpine's musl one. */
|
|
11
|
+
const libraries = (): readonly string[] => [
|
|
12
|
+
'libc.so.6',
|
|
13
|
+
`libc.musl-${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}.so.1`,
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
function prctl(): (option: number, value: number) => number {
|
|
17
|
+
for (const library of libraries()) {
|
|
18
|
+
try {
|
|
19
|
+
const { symbols } = dlopen(library, {
|
|
20
|
+
prctl: { args: [FFIType.i32, FFIType.u64, FFIType.u64, FFIType.u64, FFIType.u64], returns: FFIType.i32 },
|
|
21
|
+
})
|
|
22
|
+
return (option, value) => symbols.prctl(option, value, 0, 0, 0)
|
|
23
|
+
} catch {
|
|
24
|
+
// The next candidate.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw new Error('the launcher found no C library to call prctl in')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Makes this process non-dumpable. On Linux a failure throws: the launcher must not start the application readable.
|
|
32
|
+
* Elsewhere it warns once and returns, because no deployment runs there.
|
|
33
|
+
*/
|
|
34
|
+
export function makeProcessUndumpable(): void {
|
|
35
|
+
if (process.platform !== 'linux') {
|
|
36
|
+
console.warn(`fabrika: ${process.platform} has no prctl; the launcher stays readable by processes of the same user (development only)`)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if (prctl()(PR_SET_DUMPABLE, 0) !== 0) {
|
|
40
|
+
throw new Error('the launcher could not make itself non-dumpable (prctl PR_SET_DUMPABLE)')
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/app.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
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/middleware.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
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
|
-
}
|
package/src/testing.ts
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import type { FabrikaApp, RequestExecutionContext } from './app.js'
|
|
2
|
-
import { createBunHandler } from './bun.js'
|
|
3
|
-
import { type CloudflareExecutionContext, createCloudflareWorker } from './cloudflare.js'
|
|
4
|
-
|
|
5
|
-
export type AppTestRuntime = 'direct' | 'bun' | 'cloudflare'
|
|
6
|
-
|
|
7
|
-
export interface NormalizedAppResponse {
|
|
8
|
-
readonly status: number
|
|
9
|
-
readonly headers: ReadonlyArray<readonly [name: string, value: string]>
|
|
10
|
-
readonly body: string
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface AppRuntimeResponses {
|
|
14
|
-
readonly direct: NormalizedAppResponse
|
|
15
|
-
readonly bun: NormalizedAppResponse
|
|
16
|
-
readonly cloudflare: NormalizedAppResponse
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface AppRuntimeConformanceOptions<Env> {
|
|
20
|
-
readonly app: FabrikaApp<Env>
|
|
21
|
-
/** Create isolated runtime state so one execution cannot affect the next. */
|
|
22
|
-
readonly createEnv: () => Env | Promise<Env>
|
|
23
|
-
/** Create an equivalent fresh request for each runtime. */
|
|
24
|
-
readonly createRequest: () => Request
|
|
25
|
-
/** Optional sink for rejected background work. */
|
|
26
|
-
readonly onBackgroundError?: (runtime: AppTestRuntime, error: unknown) => void
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface TrackedExecution {
|
|
30
|
-
readonly context: RequestExecutionContext
|
|
31
|
-
drain(): Promise<void>
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function trackedExecution(onBackgroundError: (error: unknown) => void): TrackedExecution {
|
|
35
|
-
const pending = new Set<Promise<void>>()
|
|
36
|
-
const waitUntil = (promise: Promise<unknown>): void => {
|
|
37
|
-
const tracked = promise.then(
|
|
38
|
-
() => undefined,
|
|
39
|
-
(error) => {
|
|
40
|
-
try {
|
|
41
|
-
onBackgroundError(error)
|
|
42
|
-
} catch {
|
|
43
|
-
// Reporting failures must not prevent the conformance run from draining.
|
|
44
|
-
}
|
|
45
|
-
},
|
|
46
|
-
)
|
|
47
|
-
pending.add(tracked)
|
|
48
|
-
void tracked.then(() => pending.delete(tracked))
|
|
49
|
-
}
|
|
50
|
-
return {
|
|
51
|
-
context: { waitUntil },
|
|
52
|
-
async drain() {
|
|
53
|
-
while (pending.size > 0) {
|
|
54
|
-
await Promise.all(pending)
|
|
55
|
-
}
|
|
56
|
-
},
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Convert a Fetch response into a deterministic value suitable for equality checks. */
|
|
61
|
-
export async function normalizeAppResponse(response: Response): Promise<NormalizedAppResponse> {
|
|
62
|
-
const headers: Array<readonly [name: string, value: string]> = []
|
|
63
|
-
response.headers.forEach((value, name) => headers.push([name, value]))
|
|
64
|
-
headers.sort(([left], [right]) => left.localeCompare(right))
|
|
65
|
-
return {
|
|
66
|
-
status: response.status,
|
|
67
|
-
headers,
|
|
68
|
-
body: await response.text(),
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function runDirect<Env>(options: AppRuntimeConformanceOptions<Env>): Promise<NormalizedAppResponse> {
|
|
73
|
-
const execution = trackedExecution((error) => options.onBackgroundError?.('direct', error))
|
|
74
|
-
const env = await options.createEnv()
|
|
75
|
-
const request = options.createRequest()
|
|
76
|
-
const response = await options.app.fetch(request, env, execution.context)
|
|
77
|
-
await execution.drain()
|
|
78
|
-
return normalizeAppResponse(response)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async function runBun<Env>(options: AppRuntimeConformanceOptions<Env>): Promise<NormalizedAppResponse> {
|
|
82
|
-
const handler = createBunHandler(options.app, await options.createEnv(), {
|
|
83
|
-
onBackgroundError: (error) => options.onBackgroundError?.('bun', error),
|
|
84
|
-
})
|
|
85
|
-
const response = await handler.fetch(options.createRequest())
|
|
86
|
-
await handler.drain()
|
|
87
|
-
return normalizeAppResponse(response)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async function runCloudflare<Env>(options: AppRuntimeConformanceOptions<Env>): Promise<NormalizedAppResponse> {
|
|
91
|
-
const execution = trackedExecution((error) => options.onBackgroundError?.('cloudflare', error))
|
|
92
|
-
const context: CloudflareExecutionContext = {
|
|
93
|
-
waitUntil: execution.context.waitUntil,
|
|
94
|
-
passThroughOnException() {},
|
|
95
|
-
}
|
|
96
|
-
const env = await options.createEnv()
|
|
97
|
-
const request = options.createRequest()
|
|
98
|
-
const response = await createCloudflareWorker(options.app).fetch(request, env, context)
|
|
99
|
-
await execution.drain()
|
|
100
|
-
return normalizeAppResponse(response)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Execute one request through every supported HTTP adapter. */
|
|
104
|
-
export async function runAppRuntimeConformance<Env>(options: AppRuntimeConformanceOptions<Env>): Promise<AppRuntimeResponses> {
|
|
105
|
-
const direct = await runDirect(options)
|
|
106
|
-
const bun = await runBun(options)
|
|
107
|
-
const cloudflare = await runCloudflare(options)
|
|
108
|
-
return { direct, bun, cloudflare }
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Assert adapter parity without coupling application packages to a specific test runner. */
|
|
112
|
-
export async function assertAppRuntimeConformance<Env>(options: AppRuntimeConformanceOptions<Env>): Promise<NormalizedAppResponse> {
|
|
113
|
-
const responses = await runAppRuntimeConformance(options)
|
|
114
|
-
const direct = JSON.stringify(responses.direct)
|
|
115
|
-
if (JSON.stringify(responses.bun) !== direct || JSON.stringify(responses.cloudflare) !== direct) {
|
|
116
|
-
throw new Error(`application runtime responses differ: ${JSON.stringify(responses)}`)
|
|
117
|
-
}
|
|
118
|
-
return responses.direct
|
|
119
|
-
}
|