@fabrika/app 0.3.3 → 0.4.0
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 +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -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 +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -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 +309 -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 +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -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 +808 -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 +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -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 +749 -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 +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -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/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -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,135 @@
|
|
|
1
|
+
// The file collections of a typed client: `api.files.<name>` for a signed-in caller of an RPC mount, and
|
|
2
|
+
// `<link client>.files.<name>` for a link holder, whose owner is the link's object. The paths come from
|
|
3
|
+
// `fileCollectionPaths`, the code that gives the artifact its rows; every answer is parsed before the caller sees it.
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod/v4'
|
|
6
|
+
import { fileCollectionPaths, FILES_PREFIX, joinPath, LINK_API_SEGMENT } from '../contract/defaults'
|
|
7
|
+
import type { FileCollection } from '../contract/define'
|
|
8
|
+
import { FileListOutput } from '../contract/file-schemas'
|
|
9
|
+
import { type BounceConfig, exchange, exchangeFile, RpcError, type RpcFetch } from './transport'
|
|
10
|
+
|
|
11
|
+
/** A page of an owner's files, oldest first, as `files.<name>.list` answers it. */
|
|
12
|
+
export type FileList = z.output<typeof FileListOutput>
|
|
13
|
+
|
|
14
|
+
export interface FilePage {
|
|
15
|
+
/** The id of the last file of the previous page. */
|
|
16
|
+
readonly after?: string
|
|
17
|
+
/** 1–100, 50 by default. */
|
|
18
|
+
readonly limit?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface UploadOptions {
|
|
22
|
+
/** The file's name, metadata only; a `File`'s own name by default. */
|
|
23
|
+
readonly name?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One collection, for a signed-in caller: the owner is a parameter, checked against the caller's grants. */
|
|
27
|
+
export interface FileClient {
|
|
28
|
+
/** Uploads `file` (its `type` is the declared media type) to `owner`; answers the new file's id. */
|
|
29
|
+
upload(owner: string, file: Blob, options?: UploadOptions): Promise<{ readonly id: string }>
|
|
30
|
+
/** The owner's files, oldest first. */
|
|
31
|
+
list(input: { readonly owner: string } & FilePage): Promise<FileList>
|
|
32
|
+
/** The file `id`, named as it was uploaded. A refusal is an `RpcError`: 409 `quarantined` while it is being scanned. */
|
|
33
|
+
download(id: string): Promise<File>
|
|
34
|
+
remove(input: { readonly id: string }): Promise<{ readonly id: string }>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One collection, for a link holder: the owner is the object the key is bound to, never a parameter. */
|
|
38
|
+
export interface LinkFileClient {
|
|
39
|
+
upload(file: Blob, options?: UploadOptions): Promise<{ readonly id: string }>
|
|
40
|
+
list(input?: FilePage): Promise<FileList>
|
|
41
|
+
download(id: string): Promise<File>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The collections the holders of link mount `M` reach: those with `link: true`, and those whose `link` names `M`. */
|
|
45
|
+
export type LinkCollections<F, M> = {
|
|
46
|
+
[N in keyof F]: F[N] extends FileCollection<string, infer L>
|
|
47
|
+
? [L] extends [true] ? N : [L] extends [readonly (infer Named)[]] ? M extends Named ? N : never : never
|
|
48
|
+
: never
|
|
49
|
+
}[keyof F]
|
|
50
|
+
|
|
51
|
+
/** What a client sends with every request: its key as a bearer, or the page's session. */
|
|
52
|
+
export interface FileTransport {
|
|
53
|
+
readonly origin: string
|
|
54
|
+
readonly fetch: RpcFetch
|
|
55
|
+
readonly key: string | undefined
|
|
56
|
+
readonly bounce: BounceConfig | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const Uploaded = z.strictObject({ id: z.uuid() })
|
|
60
|
+
|
|
61
|
+
/** The request, built inside the transport's guard: the key as a bearer and no cookie, or the page's session. */
|
|
62
|
+
function init(transport: FileTransport, method: 'GET' | 'POST', contentType: string | null, body?: BodyInit): () => RequestInit {
|
|
63
|
+
return () => {
|
|
64
|
+
const headers = new Headers()
|
|
65
|
+
if (contentType !== null) {
|
|
66
|
+
headers.set('Content-Type', contentType)
|
|
67
|
+
}
|
|
68
|
+
if (transport.key !== undefined) {
|
|
69
|
+
headers.set('Authorization', `Bearer ${transport.key}`)
|
|
70
|
+
}
|
|
71
|
+
return { method, credentials: transport.key === undefined ? 'same-origin' : 'omit', headers, ...(body === undefined ? {} : { body }) }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function call<S extends z.ZodType>(transport: FileTransport, url: string, input: unknown, output: S, id: string): Promise<z.output<S>> {
|
|
76
|
+
const request = init(transport, 'POST', 'application/json', JSON.stringify(input ?? {}))
|
|
77
|
+
const parsed = z.safeParse(output, await exchange(transport.fetch, url, request, transport.bounce))
|
|
78
|
+
if (!parsed.success) {
|
|
79
|
+
throw new RpcError({ type: 'invalid-response', message: `'${id}' answered a value its output schema refuses`, httpStatus: 200 })
|
|
80
|
+
}
|
|
81
|
+
return parsed.data
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function upload(transport: FileTransport, path: string, file: Blob, options: UploadOptions, id: string): Promise<{ readonly id: string }> {
|
|
85
|
+
const name = options.name ?? (file instanceof File ? file.name : '')
|
|
86
|
+
const url = `${transport.origin}${path}${name === '' ? '' : `?${new URLSearchParams({ name })}`}`
|
|
87
|
+
const parsed = z.safeParse(Uploaded, await exchange(transport.fetch, url, init(transport, 'POST', file.type, file), transport.bounce))
|
|
88
|
+
if (!parsed.success) {
|
|
89
|
+
throw new RpcError({ type: 'invalid-response', message: `'${id}' answered a value its output schema refuses`, httpStatus: 200 })
|
|
90
|
+
}
|
|
91
|
+
return parsed.data
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `filename*=UTF-8''…` of a download's `Content-Disposition`, as `files/treatment.ts` writes it. */
|
|
95
|
+
function nameOf(disposition: string | null, fallback: string): string {
|
|
96
|
+
const encoded = disposition?.match(/filename\*=UTF-8''([^;]+)/)?.[1]
|
|
97
|
+
if (encoded === undefined) {
|
|
98
|
+
return fallback
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
return decodeURIComponent(encoded)
|
|
102
|
+
} catch {
|
|
103
|
+
return fallback
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function download(transport: FileTransport, path: string, file: string): Promise<File> {
|
|
108
|
+
const url = `${transport.origin}${path}/${encodeURIComponent(file)}`
|
|
109
|
+
const answer = await exchangeFile(transport.fetch, url, init(transport, 'GET', null), transport.bounce)
|
|
110
|
+
return new File([answer.body], nameOf(answer.disposition, file), { type: answer.body.type })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The collection `name` for a signed-in caller. */
|
|
114
|
+
export function sessionFiles(transport: FileTransport, name: string): FileClient {
|
|
115
|
+
const paths = fileCollectionPaths(FILES_PREFIX, name)
|
|
116
|
+
const id = `files.${name}`
|
|
117
|
+
return Object.freeze({
|
|
118
|
+
upload: (owner: string, file: Blob, options: UploadOptions = {}) =>
|
|
119
|
+
upload(transport, `${paths.upload}/${encodeURIComponent(owner)}`, file, options, `${id}.upload`),
|
|
120
|
+
list: (input: { readonly owner: string } & FilePage) => call(transport, `${transport.origin}${paths.list}`, input, FileListOutput, `${id}.list`),
|
|
121
|
+
download: (file: string) => download(transport, paths.download, file),
|
|
122
|
+
remove: (input: { readonly id: string }) => call(transport, `${transport.origin}${paths.remove}`, input, Uploaded, `${id}.remove`),
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The collection `name` for a holder of a key of the link mount at `prefix`. */
|
|
127
|
+
export function linkFiles(transport: FileTransport, prefix: string, mount: string, name: string): LinkFileClient {
|
|
128
|
+
const paths = fileCollectionPaths(joinPath(prefix, LINK_API_SEGMENT), name)
|
|
129
|
+
const id = `${mount}.files.${name}`
|
|
130
|
+
return Object.freeze({
|
|
131
|
+
upload: (file: Blob, options: UploadOptions = {}) => upload(transport, paths.upload, file, options, `${id}.upload`),
|
|
132
|
+
list: (input: FilePage = {}) => call(transport, `${transport.origin}${paths.list}`, input, FileListOutput, `${id}.list`),
|
|
133
|
+
download: (file: string) => download(transport, paths.download, file),
|
|
134
|
+
})
|
|
135
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @fabrika/app/client — the typed client of a contract application, for its SPA and for scripts. Browser-safe.
|
|
2
|
+
export { type PageCaller, parsePageCaller } from './caller'
|
|
3
|
+
export {
|
|
4
|
+
type AttachmentProcedure,
|
|
5
|
+
type ClientMount,
|
|
6
|
+
type ClientOptions,
|
|
7
|
+
type ClientTree,
|
|
8
|
+
type ContractClient,
|
|
9
|
+
createClient,
|
|
10
|
+
download,
|
|
11
|
+
type DownloadInput,
|
|
12
|
+
type InputOf,
|
|
13
|
+
type LinkClientOptions,
|
|
14
|
+
type ProcedureCall,
|
|
15
|
+
type ResultOf,
|
|
16
|
+
} from './contract'
|
|
17
|
+
export type { FileClient, FileList, FilePage, LinkFileClient, UploadOptions } from './files'
|
|
18
|
+
export { shareKey } from './share'
|
|
19
|
+
export { type BounceOption, RpcError, type RpcFetch } from './transport'
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The RPC client of the `{ method, input }` wire protocol that `dispatchRpcRequest` serves, a typed proxy:
|
|
2
|
+
// `client.a.b.c(input)` POSTs `{ method: 'a.b.c', input }` to the configured endpoint and unwraps `{ result }` /
|
|
3
|
+
// `{ error }`. Errors surface as `RpcError` (carrying `type`, `loginUrl`, … off the error envelope).
|
|
4
|
+
|
|
5
|
+
import type { InferRpcClient } from '../rpc/contract.js'
|
|
6
|
+
import { type BounceConfig, type BounceOption, exchange, resolveBounce, type RpcFetch } from './transport.js'
|
|
7
|
+
|
|
8
|
+
export interface RpcClientOptions {
|
|
9
|
+
/** Endpoint the RPC proxy POSTs to (e.g. `/rpc`). */
|
|
10
|
+
readonly baseUrl: string
|
|
11
|
+
/**
|
|
12
|
+
* Auto-redirect to SSO on a sign-in 401 (`type: 'auth'` + `loginUrl`) instead of throwing.
|
|
13
|
+
* `true` uses sane defaults; pass `{ sessionKey, windowMs }` to tune the re-entry guard.
|
|
14
|
+
*/
|
|
15
|
+
readonly bounceOnAuth?: BounceOption
|
|
16
|
+
/** Override `fetch` (tests / non-browser). Defaults to the global `fetch`. */
|
|
17
|
+
readonly fetch?: RpcFetch
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function callRpc(opts: RpcClientOptions, bounce: BounceConfig | null, method: string, input: unknown): Promise<unknown> {
|
|
21
|
+
return exchange(opts.fetch ?? fetch, opts.baseUrl, () => ({
|
|
22
|
+
method: 'POST',
|
|
23
|
+
credentials: 'include',
|
|
24
|
+
headers: { 'content-type': 'application/json' },
|
|
25
|
+
body: JSON.stringify({ method, input: input ?? null }),
|
|
26
|
+
}), bounce)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeProxy(opts: RpcClientOptions, bounce: BounceConfig | null, path: string): unknown {
|
|
30
|
+
const fn = (input: unknown) => callRpc(opts, bounce, path, input)
|
|
31
|
+
return new Proxy(fn, {
|
|
32
|
+
get(_target, prop) {
|
|
33
|
+
if (typeof prop !== 'string') return undefined
|
|
34
|
+
return makeProxy(opts, bounce, path ? `${path}.${prop}` : prop)
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isClient<TApi>(value: unknown): value is InferRpcClient<TApi> {
|
|
40
|
+
return typeof value === 'function'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createRpcClient<TApi>(opts: RpcClientOptions): InferRpcClient<TApi> {
|
|
44
|
+
const proxy = makeProxy(opts, resolveBounce(opts.baseUrl, opts.bounceOnAuth), '')
|
|
45
|
+
if (!isClient<TApi>(proxy)) throw new Error('unreachable')
|
|
46
|
+
return proxy
|
|
47
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The share SPA's key. The framework serves the SPA's document with a bootstrap that runs before any other script:
|
|
2
|
+
// it takes the key out of the URL fragment (`/s/#<key>`), removes the fragment, and keeps the key in a closure.
|
|
3
|
+
|
|
4
|
+
import { SHARE_KEY_GLOBAL } from '../contract/defaults'
|
|
5
|
+
|
|
6
|
+
/** The key the share link carried, or `null` outside a share SPA or when the link had none. */
|
|
7
|
+
export function shareKey(): string | null {
|
|
8
|
+
const read: unknown = Reflect.get(globalThis, SHARE_KEY_GLOBAL)
|
|
9
|
+
if (typeof read !== 'function') {
|
|
10
|
+
return null
|
|
11
|
+
}
|
|
12
|
+
const key: unknown = read()
|
|
13
|
+
return typeof key === 'string' && key !== '' ? key : null
|
|
14
|
+
}
|
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
//
|
|
1
|
+
// What both browser clients share: the error type, the login bounce, and reading the `{ result }` / `{ error }`
|
|
2
|
+
// envelope off a response.
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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'
|
|
4
|
+
// `bounceOnAuth` folds the SSO redirect every SPA needs into the client: on a 401 for a person who must sign in (`type: 'auth'` +
|
|
5
|
+
// `loginUrl`) it redirects the browser to the login URL (returning the current page) instead of throwing, with a
|
|
6
|
+
// short re-entry guard so an unprovisioned identity can't loop forever.
|
|
13
7
|
|
|
14
8
|
export class RpcError extends Error {
|
|
15
9
|
readonly type: string
|
|
@@ -29,27 +23,21 @@ export class RpcError extends Error {
|
|
|
29
23
|
/** Minimal Fetch API surface required by the RPC client. */
|
|
30
24
|
export type RpcFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
31
25
|
|
|
32
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Auto-redirect to SSO on a sign-in 401 (`type: 'auth'` + `loginUrl`) instead of throwing.
|
|
28
|
+
* `true` uses sane defaults; pass `{ sessionKey, windowMs }` to tune the re-entry guard.
|
|
29
|
+
*/
|
|
30
|
+
export type BounceOption = boolean | { sessionKey?: string; windowMs?: number }
|
|
31
|
+
|
|
32
|
+
export interface BounceConfig {
|
|
33
33
|
readonly sessionKey: string
|
|
34
34
|
readonly windowMs: number
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export
|
|
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 {
|
|
37
|
+
export function resolveBounce(scope: string, opt: BounceOption | undefined): BounceConfig | null {
|
|
50
38
|
if (!opt) return null
|
|
51
39
|
const tuning = opt === true ? {} : opt
|
|
52
|
-
return { sessionKey: tuning.sessionKey ?? `fabrika:login-bounce:${
|
|
40
|
+
return { sessionKey: tuning.sessionKey ?? `fabrika:login-bounce:${scope}`, windowMs: tuning.windowMs ?? 10_000 }
|
|
53
41
|
}
|
|
54
42
|
|
|
55
43
|
/** Guarded SSO redirect. Returns true if it navigated (caller should hang), false if guarded/non-browser. */
|
|
@@ -90,54 +78,63 @@ function normalizeError(
|
|
|
90
78
|
return { type: 'error', message: `HTTP ${httpStatus}`, httpStatus }
|
|
91
79
|
}
|
|
92
80
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
let response: Response
|
|
81
|
+
/** Sends one request; a network failure, or an input `init` cannot serialize, is an `RpcError`. */
|
|
82
|
+
async function send(doFetch: RpcFetch, url: string, init: () => RequestInit): Promise<Response> {
|
|
96
83
|
try {
|
|
97
|
-
|
|
98
|
-
method: 'POST',
|
|
99
|
-
credentials: 'include',
|
|
100
|
-
headers: { 'content-type': 'application/json' },
|
|
101
|
-
body: JSON.stringify({ method, input: input ?? null }),
|
|
102
|
-
})
|
|
84
|
+
return await doFetch(url, init())
|
|
103
85
|
} catch {
|
|
104
86
|
throw new RpcError({ type: 'transport', message: 'Network request failed', httpStatus: 0 })
|
|
105
87
|
}
|
|
106
|
-
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The `{ error }` envelope as an `RpcError`, or a sign-in redirect that never settles. */
|
|
91
|
+
function refused(raw: unknown, response: Response, bounce: BounceConfig | null): Promise<never> {
|
|
92
|
+
const normalized = normalizeError(raw, response.status)
|
|
93
|
+
if (bounce && normalized.type === 'auth' && normalized.loginUrl !== undefined && bounceToLogin(normalized.loginUrl, bounce)) {
|
|
94
|
+
// Navigation is in flight — never resolve, so the caller (a route loader) stays pending.
|
|
95
|
+
return new Promise<never>(() => {})
|
|
96
|
+
}
|
|
97
|
+
throw new RpcError(normalized)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function envelope(response: Response): Promise<{ result?: unknown; error?: unknown }> {
|
|
107
101
|
try {
|
|
108
|
-
|
|
102
|
+
return await response.json()
|
|
109
103
|
} catch {
|
|
110
104
|
// Non-JSON response (proxy error, HTML error page, etc.) — surface the HTTP status.
|
|
111
105
|
throw new RpcError({ type: 'transport', message: `${response.status} ${response.statusText}`, httpStatus: response.status })
|
|
112
106
|
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Sends one request and answers the envelope's `result`; every failure is an `RpcError`. `init` builds the request
|
|
111
|
+
* inside the guard, so an input that does not serialize rejects like a failed request instead of throwing.
|
|
112
|
+
*/
|
|
113
|
+
export async function exchange(doFetch: RpcFetch, url: string, init: () => RequestInit, bounce: BounceConfig | null): Promise<unknown> {
|
|
114
|
+
const response = await send(doFetch, url, init)
|
|
115
|
+
const data = await envelope(response)
|
|
113
116
|
if (data.error !== undefined) {
|
|
114
|
-
|
|
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)
|
|
117
|
+
return await refused(data.error, response, bounce)
|
|
120
118
|
}
|
|
121
119
|
clearBounce(bounce)
|
|
122
120
|
return data.result
|
|
123
121
|
}
|
|
124
122
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return proxy
|
|
123
|
+
/**
|
|
124
|
+
* Sends one request whose success is a file and answers its bytes and its `Content-Disposition`; a failure is read from
|
|
125
|
+
* its `{ error }` envelope.
|
|
126
|
+
*/
|
|
127
|
+
export async function exchangeFile(
|
|
128
|
+
doFetch: RpcFetch,
|
|
129
|
+
url: string,
|
|
130
|
+
init: () => RequestInit,
|
|
131
|
+
bounce: BounceConfig | null,
|
|
132
|
+
): Promise<{ readonly body: Blob; readonly disposition: string | null }> {
|
|
133
|
+
const response = await send(doFetch, url, init)
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
const data = await envelope(response)
|
|
136
|
+
return await refused(data.error ?? `HTTP ${response.status}`, response, bounce)
|
|
137
|
+
}
|
|
138
|
+
clearBounce(bounce)
|
|
139
|
+
return { body: await response.blob(), disposition: response.headers.get('Content-Disposition') }
|
|
143
140
|
}
|
package/src/cloudflare.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
// ─── Cloudflare Workers adapter ──────────────────────────────────────
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import { DecodedIamMembership } from '@fabrika/auth'
|
|
4
|
+
import type { IamMembershipRpc } from '@fabrika/auth-core'
|
|
5
|
+
import { parseJwks } from '@fabrika/jwks'
|
|
6
|
+
import type { FetchService, RequestExecutionContext } from './fetch.js'
|
|
7
|
+
import { boot, type ContractApp, type IamKeySource } from './implement/boot.js'
|
|
8
|
+
import { CONNECT_BINDING_PREFIX, type ConnectFetch } from './implement/connect.js'
|
|
9
|
+
import type { SpaAssets } from './implement/dispatch.js'
|
|
10
|
+
import { captureEnvironment } from './implement/environment.js'
|
|
11
|
+
import type { ContractImplementation } from './implement/implement.js'
|
|
12
|
+
|
|
13
|
+
export { markProductionProcess } from '@fabrika/db/production'
|
|
4
14
|
|
|
5
15
|
/** Minimal structural view of a Workers `ExecutionContext`. */
|
|
6
16
|
export interface CloudflareExecutionContext extends RequestExecutionContext {
|
|
@@ -31,9 +41,9 @@ export interface CloudflareWorker<Env> {
|
|
|
31
41
|
queue?(batch: CloudflareMessageBatch, env: Env, exec: CloudflareExecutionContext): Promise<void>
|
|
32
42
|
}
|
|
33
43
|
|
|
34
|
-
/** Adapt a runtime-neutral
|
|
44
|
+
/** Adapt a runtime-neutral `{ fetch }` service to a Workers module export. */
|
|
35
45
|
export function createCloudflareWorker<Env>(
|
|
36
|
-
app:
|
|
46
|
+
app: FetchService<Env>,
|
|
37
47
|
options: CloudflareWorkerOptions<Env> = {},
|
|
38
48
|
): CloudflareWorker<Env> {
|
|
39
49
|
const scheduled = options.scheduled
|
|
@@ -44,3 +54,129 @@ export function createCloudflareWorker<Env>(
|
|
|
44
54
|
...(queue ? { queue } : {}),
|
|
45
55
|
}
|
|
46
56
|
}
|
|
57
|
+
|
|
58
|
+
// ─── Contract applications ───────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/** The framework's bindings: IAM's application entrypoint and the Workers Assets of the SPA. No handler sees them. */
|
|
61
|
+
const FRAMEWORK_BINDINGS: ReadonlySet<string> = new Set(['IAM', 'ASSETS'])
|
|
62
|
+
|
|
63
|
+
function method(target: unknown, name: string): ((...args: unknown[]) => unknown) | null {
|
|
64
|
+
if (typeof target !== 'object' || target === null || !(name in target)) {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
const found: unknown = Reflect.get(target, name)
|
|
68
|
+
return typeof found === 'function' ? (...args) => Reflect.apply(found, target, args) : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function bindingKeySource(binding: unknown): IamKeySource {
|
|
72
|
+
const getJwks = method(binding, 'getJwks')
|
|
73
|
+
if (getJwks === null) {
|
|
74
|
+
throw new Error('the IAM binding is missing (env.IAM); the platform binds it to every application Worker')
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
async getJwks() {
|
|
78
|
+
const jwks = parseJwks(await getJwks())
|
|
79
|
+
if (jwks === null) {
|
|
80
|
+
throw new Error('IAM returned no usable key set')
|
|
81
|
+
}
|
|
82
|
+
return jwks
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* IAM's membership operations on the `IAM` binding (`PropustkaApplication`), every answer decoded like the HTTP
|
|
89
|
+
* transport's. A binding without them is no membership transport, and `boot` refuses a contract that needs one.
|
|
90
|
+
*/
|
|
91
|
+
function bindingMembership(binding: unknown): IamMembershipRpc | undefined {
|
|
92
|
+
const methods = ['requestDelegation', 'createScope', 'assignMember', 'revokeMember', 'listMembers', 'issueLink', 'revokeLink'] as const
|
|
93
|
+
if (!methods.every((name) => method(binding, name) !== null)) {
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
return new DecodedIamMembership(async (name, input) => {
|
|
97
|
+
const call = method(binding, name)
|
|
98
|
+
if (call === null) {
|
|
99
|
+
throw new Error(`the IAM binding has no ${name}`)
|
|
100
|
+
}
|
|
101
|
+
return await call(input)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function bindingAssets(binding: unknown): SpaAssets | undefined {
|
|
106
|
+
const fetchAsset = method(binding, 'fetch')
|
|
107
|
+
if (fetchAsset === null) {
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
async serve(_mount, request) {
|
|
112
|
+
const response: unknown = await fetchAsset(request)
|
|
113
|
+
if (!(response instanceof Response)) {
|
|
114
|
+
throw new Error('the ASSETS binding answered something that is not a Response')
|
|
115
|
+
}
|
|
116
|
+
return response
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The service bindings to owners' proxy Workers (`FABRIKA_CONNECT_<IMPORT>`), which `captureEnvironment` keeps from
|
|
123
|
+
* the application because no `FABRIKA_*` value reaches it. A binding routes by itself: the URL it is called with
|
|
124
|
+
* carries the owner's host, which the proxy Worker selects the application by.
|
|
125
|
+
*/
|
|
126
|
+
function connectBindings(env: Readonly<Record<string, unknown>>): Record<string, ConnectFetch> {
|
|
127
|
+
const bindings: Record<string, ConnectFetch> = {}
|
|
128
|
+
for (const [name, binding] of Object.entries(env)) {
|
|
129
|
+
// Read, not tested with `in`: a binding stub may answer `fetch` without owning the property (Lopata's does not).
|
|
130
|
+
const fetchOwner: unknown = name.startsWith(CONNECT_BINDING_PREFIX) && typeof binding === 'object' && binding !== null
|
|
131
|
+
? Reflect.get(binding, 'fetch')
|
|
132
|
+
: undefined
|
|
133
|
+
if (typeof fetchOwner === 'function') {
|
|
134
|
+
bindings[name] = async (request) => {
|
|
135
|
+
const response: unknown = await Reflect.apply(fetchOwner, binding, [request])
|
|
136
|
+
if (!(response instanceof Response)) {
|
|
137
|
+
throw new Error(`the ${name} binding answered something that is not a Response`)
|
|
138
|
+
}
|
|
139
|
+
return response
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return bindings
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The application module, imported only after the environment was captured: the generated entrypoint's `import()`. */
|
|
147
|
+
export type ImplementationLoader = () => Promise<{ readonly default: ContractImplementation }>
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A Worker module for a contract application, called only by the generated entrypoint. A Worker receives its
|
|
151
|
+
* environment with the first request, so the framework captures it, scrubs the process environment and only then
|
|
152
|
+
* imports the application and boots it, once per environment object.
|
|
153
|
+
*/
|
|
154
|
+
export function createContractWorker(load: ImplementationLoader): {
|
|
155
|
+
fetch(request: Request, env: Readonly<Record<string, unknown>>, exec: CloudflareExecutionContext): Promise<Response>
|
|
156
|
+
} {
|
|
157
|
+
const booted = new WeakMap<object, Promise<ContractApp>>()
|
|
158
|
+
const bootFor = async (env: Readonly<Record<string, unknown>>): Promise<ContractApp> => {
|
|
159
|
+
const variables = Object.fromEntries(Object.entries(env).filter(([name]) => !FRAMEWORK_BINDINGS.has(name)))
|
|
160
|
+
const environment = captureEnvironment(variables)
|
|
161
|
+
const { default: app } = await load()
|
|
162
|
+
return boot(app, {
|
|
163
|
+
environment,
|
|
164
|
+
iam: bindingKeySource(env['IAM']),
|
|
165
|
+
assets: bindingAssets(env['ASSETS']),
|
|
166
|
+
membership: bindingMembership(env['IAM']),
|
|
167
|
+
connectBindings: connectBindings(env),
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
async fetch(request, env, exec) {
|
|
172
|
+
let contractApp = booted.get(env)
|
|
173
|
+
if (contractApp === undefined) {
|
|
174
|
+
contractApp = bootFor(env)
|
|
175
|
+
booted.set(env, contractApp)
|
|
176
|
+
// A failed boot is not cached: the next request boots again and fails with the same problems.
|
|
177
|
+
contractApp.catch(() => booted.delete(env))
|
|
178
|
+
}
|
|
179
|
+
return (await contractApp).fetch(request, exec)
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
}
|