@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.
Files changed (114) hide show
  1. package/README.md +78 -90
  2. package/package.json +68 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +364 -0
  5. package/skill/references/dev.md +67 -0
  6. package/skill/references/endpoint.md +627 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +741 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +305 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +309 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1936 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +437 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +156 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +808 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +115 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1085 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +749 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +239 -0
  90. package/src/react/index.ts +31 -0
  91. package/src/react/provider.tsx +165 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/types.ts +40 -0
  94. package/src/rpc/dispatcher.ts +4 -19
  95. package/src/service/define.ts +121 -0
  96. package/src/service/index.ts +21 -0
  97. package/src/service/serve.ts +99 -0
  98. package/src/testing/caller.ts +123 -0
  99. package/src/testing/dev.ts +73 -0
  100. package/src/testing/endpoint-access.ts +806 -0
  101. package/src/testing/index.ts +23 -0
  102. package/src/testing/isolation.ts +25 -0
  103. package/src/testing/kit.ts +552 -0
  104. package/src/testing/membership.ts +747 -0
  105. package/src/testing/policy-isolation.ts +827 -0
  106. package/src/testing/preload.ts +6 -0
  107. package/src/testing/proxy.ts +87 -0
  108. package/src/testing/runner.ts +61 -0
  109. package/src/testing/status.ts +19 -0
  110. package/src/undumpable.ts +42 -0
  111. package/src/app.ts +0 -76
  112. package/src/middleware.ts +0 -38
  113. package/src/router.ts +0 -149
  114. 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
- // ─── RPC client ──────────────────────────────────────────────────────
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
- // 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'
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
- interface BounceConfig {
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 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 {
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:${baseUrl}`, windowMs: tuning.windowMs ?? 10_000 }
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
- async function callRpc(opts: RpcClientOptions, bounce: BounceConfig | null, method: string, input: unknown): Promise<unknown> {
94
- const doFetch = opts.fetch ?? fetch
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
- 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
- })
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
- let data: { result?: unknown; error?: unknown }
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
- data = await response.json()
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
- 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)
117
+ return await refused(data.error, response, bounce)
120
118
  }
121
119
  clearBounce(bounce)
122
120
  return data.result
123
121
  }
124
122
 
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
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 type { FabrikaApp, RequestExecutionContext } from './app.js'
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 Fabrika application to a Workers module export. */
44
+ /** Adapt a runtime-neutral `{ fetch }` service to a Workers module export. */
35
45
  export function createCloudflareWorker<Env>(
36
- app: FabrikaApp<Env>,
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
+ }