@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
package/src/bun.ts CHANGED
@@ -1,9 +1,30 @@
1
1
  // ─── Bun process adapter ─────────────────────────────────────────────
2
2
  //
3
3
  // A long-running Bun process needs only request dispatch and explicit draining.
4
- // It does not emulate Worker cron, queues, or passThroughOnException().
4
+ // It does not emulate Worker cron, queues, or passThroughOnException(). A
5
+ // contract application is served through `serveContract`, from the generated
6
+ // entrypoint.
5
7
 
6
- import type { FabrikaApp, RequestExecutionContext } from './app.js'
8
+ import { HttpIamMembership } from '@fabrika/auth'
9
+ import type { Database, PostgresOptions } from '@fabrika/db'
10
+ import { isScopedDatabase } from '@fabrika/db/framework'
11
+ import { parseJwks } from '@fabrika/jwks'
12
+ import type { FileObjectStore } from '@fabrika/platform'
13
+ import { applicationRuntimeUrl, S3BlobStore } from '@fabrika/platform-node'
14
+ import { diskAssets } from './disk-assets.js'
15
+ import type { FetchService, RequestExecutionContext } from './fetch.js'
16
+ import { clamscanScanner } from './files/clamscan.js'
17
+ import { boot, BootError, type IamKeySource } from './implement/boot.js'
18
+ import { captureEnvironment } from './implement/environment.js'
19
+ import { type ContractImplementation, isContractImplementation } from './implement/implement.js'
20
+ import { issuerWorkloadTokens, type WorkloadTokenSource } from './implement/workload.js'
21
+ import { JOB_CHECK_INTERVAL_MS, type JobRunOptions, type JobRunResult } from './jobs/runner.js'
22
+ import { makeProcessUndumpable } from './undumpable.js'
23
+
24
+ export { diskAssets } from './disk-assets.js'
25
+ export { clamscanArguments, type ClamscanOptions, clamscanScanner } from './files/clamscan.js'
26
+
27
+ export { markProductionProcess } from '@fabrika/db/production'
7
28
 
8
29
  export interface ManagedBunHandlerOptions {
9
30
  /** Required sink for rejected background tasks. Do not log raw errors or secret-bearing values. */
@@ -25,7 +46,7 @@ export interface BunHandler {
25
46
  drain(): Promise<void>
26
47
  }
27
48
 
28
- export function createBunHandler<Env>(app: FabrikaApp<Env>, env: Env, options: BunHandlerOptions): BunHandler {
49
+ export function createBunHandler<Env>(app: FetchService<Env>, env: Env, options: BunHandlerOptions): BunHandler {
29
50
  if (options.executionContext !== undefined) {
30
51
  return {
31
52
  fetch: (request) => app.fetch(request, env, options.executionContext),
@@ -61,3 +82,281 @@ export function createBunHandler<Env>(app: FabrikaApp<Env>, env: Env, options: B
61
82
  },
62
83
  }
63
84
  }
85
+
86
+ // ─── Contract applications ───────────────────────────────────────────
87
+
88
+ /** IAM's public key set at the issuer's `/.well-known/jwks.json`, for a process with no IAM binding. */
89
+ export function issuerJwks(issuer: unknown): IamKeySource {
90
+ return {
91
+ async getJwks() {
92
+ if (typeof issuer !== 'string') {
93
+ throw new Error('FABRIKA_IAM_ISSUER is not set')
94
+ }
95
+ const response = await fetch(new URL('/.well-known/jwks.json', issuer), { headers: { accept: 'application/json' } })
96
+ const jwks = response.ok ? parseJwks(await response.json()) : null
97
+ if (jwks === null) {
98
+ throw new Error('the issuer published no usable key set')
99
+ }
100
+ return jwks
101
+ },
102
+ }
103
+ }
104
+
105
+ export interface ContractServer {
106
+ /** The port the server listens on; `PORT=0` picks a free one. */
107
+ readonly port: number
108
+ /** Stops accepting requests and waits for the ones in flight. */
109
+ stop(): Promise<void>
110
+ }
111
+
112
+ /**
113
+ * What the adapter takes from `@fabrika/db` before the application loads, so no application module can stand in for
114
+ * it: a replaced `Database.postgres` would receive the database URL.
115
+ */
116
+ interface DataLayerOpeners {
117
+ readonly postgres: (options: PostgresOptions) => Database
118
+ readonly applicationSchema: (appId: string) => string
119
+ }
120
+
121
+ /** Loads `@fabrika/db` before the application: it captures its built-ins and freezes the Kysely compiler at load. */
122
+ async function loadDataLayer(): Promise<DataLayerOpeners> {
123
+ const { Database, applicationSchema } = await import('@fabrika/db')
124
+ return { postgres: Database.postgres, applicationSchema }
125
+ }
126
+
127
+ /** The provisioning credential the platform writes; only the launcher reads it, and the application's process never holds it. */
128
+ const PROVISIONING_DATABASE_URL = 'FABRIKA_APP_DATABASE_URL'
129
+ /** The runtime role's credential, which the launcher derives and hands the application's process in its place. */
130
+ const RUNTIME_DATABASE_URL = 'FABRIKA_APP_RUNTIME_DATABASE_URL'
131
+
132
+ /**
133
+ * The application's connection: its runtime role `run_<FABRIKA_APP_ID>`, whose URL the launcher derived from the
134
+ * provisioning credential, on the schema `app_<FABRIKA_APP_ID>` its migrations created. A URL is a credential, so no
135
+ * message quotes it.
136
+ */
137
+ function openApplicationDatabase(data: DataLayerOpeners, framework: Readonly<Record<string, string>>): Database {
138
+ const url = framework[RUNTIME_DATABASE_URL] ?? ''
139
+ const appId = framework['FABRIKA_APP_ID'] ?? ''
140
+ const problems: string[] = []
141
+ if (url.trim() === '') {
142
+ problems.push('FABRIKA_APP_DATABASE_URL is not set; the platform writes it for an application with a database')
143
+ }
144
+ let schema = ''
145
+ try {
146
+ schema = data.applicationSchema(appId)
147
+ } catch {
148
+ problems.push(`FABRIKA_APP_ID '${appId}' is not an application id (allowed: lowercase letters, digits and -, at most 59)`)
149
+ }
150
+ if (problems.length > 0) {
151
+ throw new BootError(problems)
152
+ }
153
+ return data.postgres({ url, schema })
154
+ }
155
+
156
+ /**
157
+ * Whether this process holds the provisioning credential: then it is the launcher, and the application never runs in
158
+ * it. The application's process gets the runtime credential instead.
159
+ */
160
+ function holdsProvisioningCredential(env: Readonly<Record<string, string | undefined>>): boolean {
161
+ return (env[PROVISIONING_DATABASE_URL] ?? '') !== ''
162
+ }
163
+
164
+ const FORWARDED_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const
165
+
166
+ /**
167
+ * The launcher: before any application module loads, derives the runtime role's credential from the provisioning one
168
+ * and runs this same command again with an environment that carries the runtime credential and not the provisioning
169
+ * one, so neither the application's process nor anything it spawns can read the latter, `/proc/self/environ`
170
+ * included; the launcher makes itself non-dumpable first, so its own `/proc/<pid>/environ` is not readable either. Stdio is inherited, signals are forwarded, and the child's exit becomes this process's.
171
+ */
172
+ async function relaunchAsRuntime(env: Readonly<Record<string, string | undefined>>): Promise<never> {
173
+ let runtimeUrl: string
174
+ try {
175
+ runtimeUrl = applicationRuntimeUrl(env[PROVISIONING_DATABASE_URL] ?? '', env['FABRIKA_APP_ID'] ?? '')
176
+ } catch {
177
+ throw new BootError([
178
+ 'FABRIKA_APP_DATABASE_URL is not a connection URL with a password, or FABRIKA_APP_ID is not an application id; the runtime role derives its credential from both',
179
+ ])
180
+ }
181
+ const environment: Record<string, string> = {}
182
+ for (const [name, value] of Object.entries(env)) {
183
+ if (value !== undefined && name !== PROVISIONING_DATABASE_URL) environment[name] = value
184
+ }
185
+ environment[RUNTIME_DATABASE_URL] = runtimeUrl
186
+ // Otherwise the application could read this process's environment, the provisioning credential in it, through /proc.
187
+ makeProcessUndumpable()
188
+ const child = Bun.spawn([process.execPath, ...process.execArgv, ...process.argv.slice(1)], {
189
+ env: environment,
190
+ stdin: 'inherit',
191
+ stdout: 'inherit',
192
+ stderr: 'inherit',
193
+ })
194
+ for (const signal of FORWARDED_SIGNALS) {
195
+ process.on(signal, () => child.kill(signal))
196
+ }
197
+ await child.exited
198
+ if (child.signalCode !== null) {
199
+ for (const signal of FORWARDED_SIGNALS) process.removeAllListeners(signal)
200
+ process.kill(process.pid, child.signalCode)
201
+ }
202
+ process.exit(child.exitCode ?? 1)
203
+ }
204
+
205
+ const STORAGE_VARIABLES = {
206
+ endpoint: 'FABRIKA_APP_STORAGE_ENDPOINT',
207
+ bucket: 'FABRIKA_APP_STORAGE_BUCKET',
208
+ accessKeyId: 'FABRIKA_APP_STORAGE_ACCESS_KEY_ID',
209
+ secretAccessKey: 'FABRIKA_APP_STORAGE_SECRET_ACCESS_KEY',
210
+ } as const
211
+
212
+ /**
213
+ * The file collections' object storage (Zerops object storage, path-style), when the platform wrote its variables;
214
+ * a partial set stops the boot. The credentials are never quoted in a message.
215
+ */
216
+ export function openFileStorage(framework: Readonly<Record<string, string>>): FileObjectStore | undefined {
217
+ const value = (name: string) => framework[name]?.trim() ?? ''
218
+ const names = Object.values(STORAGE_VARIABLES)
219
+ const missing = names.filter((name) => value(name) === '')
220
+ if (missing.length === names.length) {
221
+ return undefined
222
+ }
223
+ if (missing.length > 0) {
224
+ throw new BootError(missing.map((name) => `${name} is not set; the platform writes every FABRIKA_APP_STORAGE_* variable together`))
225
+ }
226
+ return S3BlobStore.connect({
227
+ endpoint: value(STORAGE_VARIABLES.endpoint),
228
+ bucket: value(STORAGE_VARIABLES.bucket),
229
+ accessKeyId: value(STORAGE_VARIABLES.accessKeyId),
230
+ secretAccessKey: value(STORAGE_VARIABLES.secretAccessKey),
231
+ region: 'us-east-1',
232
+ virtualHostedStyle: false,
233
+ })
234
+ }
235
+
236
+ /**
237
+ * IAM's membership transport, when the platform gives the process `FABRIKA_IAM_RPC_URL` (IAM's public origin) and
238
+ * `FABRIKA_IAM_RPC_KEY` (this environment's membership transport key); without both, there is none, and `boot` refuses
239
+ * a contract that needs one.
240
+ */
241
+ function httpMembership(framework: Readonly<Record<string, string>>): HttpIamMembership | undefined {
242
+ const origin = framework['FABRIKA_IAM_RPC_URL']?.trim() ?? ''
243
+ const key = framework['FABRIKA_IAM_RPC_KEY']?.trim() ?? ''
244
+ return origin === '' || key === '' ? undefined : new HttpIamMembership({ origin, key })
245
+ }
246
+
247
+ /** IAM's public token exchange, at the issuer every deploy names; `boot` reports a missing issuer itself. */
248
+ function workloadTokensOf(framework: Readonly<Record<string, string>>): WorkloadTokenSource | undefined {
249
+ const issuer = framework['FABRIKA_IAM_ISSUER']?.trim() ?? ''
250
+ return issuer === '' ? undefined : issuerWorkloadTokens(issuer)
251
+ }
252
+
253
+ /** The application module, imported only after the environment was captured: the generated entrypoint's `import()`. */
254
+ export type ImplementationLoader = () => Promise<{ readonly default: ContractImplementation }>
255
+
256
+ /**
257
+ * Captures the framework's variables and scrubs them from the process, then imports the application, boots it and
258
+ * serves it on `PORT` (default 3000), on every interface: the project's private network is how the proxy reaches
259
+ * it. Called only by the generated entrypoint, with `process.env`. SIGTERM drains. Given the provisioning credential,
260
+ * it is the launcher instead: it never loads the application and re-runs the entrypoint as the runtime role.
261
+ */
262
+ export async function serveContract(load: ImplementationLoader, env: Readonly<Record<string, string | undefined>>): Promise<ContractServer> {
263
+ if (holdsProvisioningCredential(env)) {
264
+ return relaunchAsRuntime(env)
265
+ }
266
+ const environment = captureEnvironment(env)
267
+ const port = Number.parseInt(environment.application['PORT'] === undefined ? '3000' : String(environment.application['PORT']), 10)
268
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
269
+ throw new Error('PORT must be an integer between 0 and 65535')
270
+ }
271
+ const data = await loadDataLayer()
272
+ const { default: app } = await load()
273
+ const iam = issuerJwks(environment.framework['FABRIKA_IAM_ISSUER'])
274
+ const database = isScopedDatabase(app.database) ? openApplicationDatabase(data, environment.framework) : undefined
275
+ const membership = httpMembership(environment.framework)
276
+ // Zerops is where a Bun application runs, and a namespace there is one private network (ADR-0022 rule 5).
277
+ const files = openFileStorage(environment.framework)
278
+ const workloadTokens = workloadTokensOf(environment.framework)
279
+ const served = boot(app, {
280
+ environment,
281
+ iam,
282
+ assets: diskAssets(process.cwd()),
283
+ database,
284
+ files,
285
+ membership,
286
+ workloadTokens,
287
+ requireProxySecret: true,
288
+ })
289
+ // The work a request leaves running (a failure's report, a job a form kicked) is drained before the database closes.
290
+ const handler = createBunHandler({ fetch: (request, _env: undefined, exec) => served.fetch(request, exec) }, undefined, {
291
+ onBackgroundError: () => console.error('fabrika: a background task of a request failed'),
292
+ })
293
+ const server = Bun.serve({ port, hostname: '0.0.0.0', fetch: (request) => handler.fetch(request) })
294
+ // The crontab may never fire; the serving process notices a job that stopped succeeding without being invoked.
295
+ const check = () => void served.jobs.check()
296
+ const checks = served.jobs.names.length === 0 ? undefined : setInterval(check, JOB_CHECK_INTERVAL_MS)
297
+ if (checks !== undefined) {
298
+ check()
299
+ }
300
+ const stop = async (): Promise<void> => {
301
+ clearInterval(checks)
302
+ await server.stop(false)
303
+ await handler.drain()
304
+ await database?.close()
305
+ }
306
+ process.once('SIGTERM', () => void stop().then(() => process.exit(0)))
307
+ return { port: server.port ?? port, stop }
308
+ }
309
+
310
+ /** The application module at `path`, imported when the loader runs; its default export must be what `serve` returned. */
311
+ export function implementationAt(path: string): ImplementationLoader {
312
+ return async () => {
313
+ const module: unknown = await import(path)
314
+ const value = typeof module === 'object' && module !== null ? Reflect.get(module, 'default') : undefined
315
+ if (!isContractImplementation(value)) {
316
+ throw new BootError([`${path} does not default-export serve(contract, { … })`])
317
+ }
318
+ return { default: value }
319
+ }
320
+ }
321
+
322
+ /**
323
+ * One run of the job `name` (`fabrika job run <name>`, the crontab's command): the framework's variables are captured
324
+ * and scrubbed before the application loads, exactly as `serveContract` does, then `boot` binds the job and the run
325
+ * takes its lease. The connection closes when the run ends. Given the provisioning credential, it re-runs the command
326
+ * as the runtime role, as `serveContract` does.
327
+ */
328
+ export async function runContractJob(
329
+ load: ImplementationLoader,
330
+ env: Readonly<Record<string, string | undefined>>,
331
+ name: string,
332
+ options: JobRunOptions = {},
333
+ ): Promise<JobRunResult> {
334
+ if (holdsProvisioningCredential(env)) {
335
+ return relaunchAsRuntime(env)
336
+ }
337
+ const environment = captureEnvironment(env)
338
+ const data = await loadDataLayer()
339
+ const { default: app } = await load()
340
+ const iam = issuerJwks(environment.framework['FABRIKA_IAM_ISSUER'])
341
+ const database = isScopedDatabase(app.database) ? openApplicationDatabase(data, environment.framework) : undefined
342
+ try {
343
+ const workloadTokens = workloadTokensOf(environment.framework)
344
+ const membership = httpMembership(environment.framework)
345
+ const files = openFileStorage(environment.framework)
346
+ return await boot(app, {
347
+ environment,
348
+ iam,
349
+ assets: diskAssets(process.cwd()),
350
+ database,
351
+ files,
352
+ membership,
353
+ workloadTokens,
354
+ requireProxySecret: true,
355
+ // ClamAV on PATH, as the provider's build installs it for a `scan: true` collection; missing, the scan fails closed.
356
+ scanner: clamscanScanner(),
357
+ })
358
+ .jobs.run(name, options)
359
+ } finally {
360
+ await database?.close()
361
+ }
362
+ }
@@ -0,0 +1,24 @@
1
+ // The caller document every SPA mount serves at `<prefix>/__fabrika-me.json`: who the page's person is and what the
2
+ // token says they hold. A page shows and hides by it; it decides nothing, because every endpoint checks again.
3
+
4
+ import { z } from 'zod/v4'
5
+
6
+ const ScopedActions = z.strictObject({ type: z.string(), value: z.string(), actions: z.array(z.string()) })
7
+
8
+ const PageCallerSchema = z.strictObject({
9
+ label: z.string(),
10
+ email: z.string(),
11
+ actions: z.strictObject({
12
+ /** The actions held as a global grant, so in every value. */
13
+ app: z.array(z.string()),
14
+ /** Each scope value the person holds something in, with those actions; sorted by dimension, then value. */
15
+ scopes: z.array(ScopedActions),
16
+ }),
17
+ })
18
+
19
+ export type PageCaller = z.infer<typeof PageCallerSchema>
20
+
21
+ /** The caller document as the framework answers it; throws on any other value. */
22
+ export function parsePageCaller(value: unknown): PageCaller {
23
+ return PageCallerSchema.parse(value)
24
+ }
@@ -0,0 +1,309 @@
1
+ // The typed client of one contract mount: `api.notes.remove({ id })` POSTs the input to `/api/notes.remove`, the
2
+ // path `compileContract` gives that procedure. Built from the contract value, so it can call only declared
3
+ // procedures, and it checks each answer against the procedure's output schema before handing it out. An attachment
4
+ // procedure answers a file, which `download(api.<procedure>, input)` fetches and saves.
5
+
6
+ import { z } from 'zod/v4'
7
+ import { type AttachmentBrand, attachmentOf } from '../contract/attachment'
8
+ import { isProcedure, type ProcedureTreeDeclaration, type ProcedureType, SURFACE_SECTIONS } from '../contract/declaration'
9
+ import { joinPath, LINK_API_SEGMENT } from '../contract/defaults'
10
+ import type { AnyContract, InputSchema, Procedure, Schema, Surface } from '../contract/define'
11
+ import { type FileClient, type FileTransport, type LinkCollections, type LinkFileClient, linkFiles, sessionFiles } from './files'
12
+ import { type BounceConfig, type BounceOption, exchange, exchangeFile, resolveBounce, RpcError, type RpcFetch } from './transport'
13
+
14
+ type SurfaceOf<C> = C extends { readonly surface: infer S } ? S : never
15
+
16
+ /** The mounts a client calls: RPC mounts and link mounts. */
17
+ export type ClientMount<C> =
18
+ & { [M in keyof SurfaceOf<C>]: SurfaceOf<C>[M] extends { readonly kind: 'rpc' | 'link' } ? M : never }[keyof SurfaceOf<C>]
19
+ & string
20
+
21
+ /**
22
+ * One procedure: its input as the caller writes it (defaults optional), its output as parsed, and the output schema
23
+ * it parses with (`@fabrika/app/react` reads a cached answer back through it).
24
+ */
25
+ export type ProcedureCall<I, O extends Schema> =
26
+ & ([I] extends [InputSchema] ? (input: z.input<I>) => Promise<z.output<O>> : (input?: Record<never, never>) => Promise<z.output<O>>)
27
+ & { readonly output: O }
28
+
29
+ export type ClientTree<T> = {
30
+ readonly [K in keyof T]: T[K] extends Procedure<'query' | 'mutation', infer I, infer O, unknown>
31
+ ? O extends AttachmentBrand ? AttachmentProcedure<I> : ProcedureCall<I, O>
32
+ : ClientTree<T[K]>
33
+ }
34
+
35
+ type FilesOf<C> = SurfaceOf<C> extends { readonly files: infer F } ? F : Record<never, never>
36
+
37
+ /** `files.<name>` of every collection, for the signed-in callers of an RPC mount that admits people. */
38
+ type SessionFiles<C, Callers> = Callers extends readonly (infer Caller)[] ? 'human' extends Caller ? [keyof FilesOf<C>] extends [never] ? unknown
39
+ : { readonly files: { readonly [N in keyof FilesOf<C>]: FileClient } }
40
+ : unknown
41
+ : unknown
42
+
43
+ /** `files.<name>` of every `link: true` collection, for a link mount's holders. */
44
+ type HolderFiles<C, M> = [LinkCollections<FilesOf<C>, M>] extends [never] ? unknown
45
+ : { readonly files: { readonly [N in LinkCollections<FilesOf<C>, M>]: LinkFileClient } }
46
+
47
+ type MountOf<C, M> = SurfaceOf<C>[M & keyof SurfaceOf<C>]
48
+
49
+ /** One function per procedure, an `AttachmentProcedure` per attachment, and `files` where the mount's callers reach collections. */
50
+ export type ContractClient<C, M> = MountOf<C, M> extends { readonly kind: 'link'; readonly procedures: infer T } ? ClientTree<T> & HolderFiles<C, M>
51
+ : MountOf<C, M> extends { readonly callers: infer Callers; readonly procedures: infer T } ? ClientTree<T> & SessionFiles<C, Callers>
52
+ : never
53
+
54
+ /** The answer of a client procedure: `ResultOf<typeof api.tasks.get>`; an attachment procedure answers a `File`. */
55
+ export type ResultOf<F> = F extends (...args: never[]) => Promise<infer R> ? R : F extends AttachmentProcedure<unknown> ? File : never
56
+
57
+ /** The input of a client procedure as the caller writes it: `InputOf<typeof api.tasks.create>`. */
58
+ export type InputOf<F> = F extends (...args: infer A) => Promise<unknown> ? A[0]
59
+ : F extends AttachmentProcedure<infer I> ? DownloadInput<I>[0]
60
+ : never
61
+
62
+ export interface ClientOptions {
63
+ /** The application's origin; procedure paths are joined to it. Defaults to the page's own origin. */
64
+ readonly baseUrl?: string
65
+ /** Override `fetch` (tests, a script). Defaults to the global `fetch`. */
66
+ readonly fetch?: RpcFetch
67
+ /**
68
+ * A `px_` key, sent as `Authorization: Bearer`. A request with a key carries no cookie. In a browser the key goes
69
+ * only to the page's own origin.
70
+ */
71
+ readonly key?: string
72
+ /** On a 401 that carries a login URL, send the browser to sign in instead of throwing. */
73
+ readonly bounceOnAuth?: BounceOption
74
+ }
75
+
76
+ /** A link mount's caller is the holder of its share key, which every request presents. */
77
+ export interface LinkClientOptions extends ClientOptions {
78
+ readonly key: string
79
+ }
80
+
81
+ type OptionsFor<C, M> = SurfaceOf<C>[M & keyof SurfaceOf<C>] extends { readonly kind: 'link' } ? [options: LinkClientOptions]
82
+ : [options?: ClientOptions]
83
+
84
+ interface CallTarget {
85
+ readonly id: string
86
+ readonly url: string
87
+ readonly type: ProcedureType
88
+ readonly input: Schema | null
89
+ readonly output: Schema
90
+ }
91
+
92
+ /**
93
+ * What a value of a client tree is, kept beside it so the value stays a plain function or record: a procedure's
94
+ * `<mount>.<path>`, its type and input schema, or a group's prefix. `@fabrika/app/react` keys its cache by it.
95
+ */
96
+ export interface ClientNode {
97
+ readonly mount: string
98
+ /** The dotted path below the mount; `''` for the client itself. */
99
+ readonly path: string
100
+ readonly procedure: { readonly type: ProcedureType; readonly input: Schema | null } | null
101
+ }
102
+
103
+ const clientNodes = new WeakMap<object, ClientNode>()
104
+
105
+ /** The node a value of a client tree is, or `null` for any other value. */
106
+ export function clientNodeOf(value: unknown): ClientNode | null {
107
+ return (typeof value === 'function' || (typeof value === 'object' && value !== null)) ? (clientNodes.get(value) ?? null) : null
108
+ }
109
+
110
+ /** The request of one procedure call: its input as JSON, with the key as a bearer or the page's session. */
111
+ function requestOf(options: ClientOptions, input: unknown): () => RequestInit {
112
+ return () => {
113
+ const headers = new Headers({ 'Content-Type': 'application/json' })
114
+ if (options.key !== undefined) {
115
+ headers.set('Authorization', `Bearer ${options.key}`)
116
+ }
117
+ return { method: 'POST', credentials: options.key === undefined ? 'same-origin' : 'omit', headers, body: JSON.stringify(input ?? {}) }
118
+ }
119
+ }
120
+
121
+ function caller(target: CallTarget, at: TreeAt, options: ClientOptions, bounce: BounceConfig | null) {
122
+ const call = async (input?: unknown): Promise<unknown> => {
123
+ const result = await exchange(options.fetch ?? fetch, target.url, requestOf(options, input), bounce)
124
+ const parsed = z.safeParse(target.output, result)
125
+ if (!parsed.success) {
126
+ throw new RpcError({ type: 'invalid-response', message: `'${target.id}' answered a value its output schema refuses`, httpStatus: 200 })
127
+ }
128
+ return parsed.data
129
+ }
130
+ Object.defineProperty(call, 'output', { value: target.output, enumerable: true })
131
+ clientNodes.set(call, { mount: at.mount, path: at.path, procedure: { type: target.type, input: target.input } })
132
+ return call
133
+ }
134
+
135
+ interface TreeAt {
136
+ readonly mount: string
137
+ /** The origin the paths are joined to, without a trailing `/`. */
138
+ readonly origin: string
139
+ readonly base: string
140
+ readonly path: string
141
+ }
142
+
143
+ function clientTree(
144
+ tree: ProcedureTreeDeclaration<Schema, unknown>,
145
+ at: TreeAt,
146
+ make: (target: CallTarget, at: TreeAt) => unknown,
147
+ ): Record<string, unknown> {
148
+ const client: Record<string, unknown> = {}
149
+ for (const [name, node] of Object.entries(tree)) {
150
+ const path = at.path === '' ? name : `${at.path}.${name}`
151
+ client[name] = isProcedure(node)
152
+ ? make({ id: `${at.mount}.${path}`, url: `${at.origin}${joinPath(at.base, path)}`, type: node.type, input: node.input, output: node.output }, {
153
+ ...at,
154
+ path,
155
+ })
156
+ : clientTree(node, { ...at, path }, make)
157
+ }
158
+ const group = Object.freeze(client)
159
+ clientNodes.set(group, { mount: at.mount, path: at.path, procedure: null })
160
+ return group
161
+ }
162
+
163
+ declare const attachmentInput: unique symbol
164
+
165
+ /**
166
+ * An attachment procedure of a client: not a function, because its answer is a file. `download(api.<procedure>,
167
+ * input)` fetches and saves it.
168
+ */
169
+ export interface AttachmentProcedure<I> {
170
+ /** `<mount>.<path>` */
171
+ readonly id: string
172
+ /** The file name the contract declares. */
173
+ readonly filename: string
174
+ /** Carries the input schema for `download`; never present at runtime. */
175
+ readonly [attachmentInput]?: I
176
+ }
177
+
178
+ /** The request each attachment procedure sends, kept beside it so the value itself carries no key. */
179
+ const downloads = new WeakMap<object, { readonly url: string; readonly options: ClientOptions; readonly bounce: BounceConfig | null }>()
180
+
181
+ function attachmentProcedure(
182
+ target: CallTarget,
183
+ filename: string,
184
+ options: ClientOptions,
185
+ bounce: BounceConfig | null,
186
+ ): AttachmentProcedure<unknown> {
187
+ const procedure = Object.freeze({ id: target.id, filename })
188
+ downloads.set(procedure, { url: target.url, options, bounce })
189
+ return procedure
190
+ }
191
+
192
+ /** The input `download` takes: the procedure's, or none. */
193
+ export type DownloadInput<I> = [I] extends [InputSchema] ? [input: z.input<I>] : [input?: Record<never, never>]
194
+
195
+ /**
196
+ * Calls an attachment procedure and saves its file: in a browser the page offers it as a download under the declared
197
+ * name. Answers the file, also where there is no page to save it from (a script, a test). A refusal is an `RpcError`.
198
+ */
199
+ export async function download<I>(procedure: AttachmentProcedure<I>, ...input: DownloadInput<I>): Promise<File> {
200
+ const request = downloads.get(procedure)
201
+ if (request === undefined) {
202
+ throw new Error(`'${procedure.id}' is not an attachment procedure of a client`)
203
+ }
204
+ const { body } = await exchangeFile(request.options.fetch ?? fetch, request.url, requestOf(request.options, input[0]), request.bounce)
205
+ const file = new File([body], procedure.filename, { type: body.type })
206
+ saveFile(file)
207
+ return file
208
+ }
209
+
210
+ function saveFile(file: File): void {
211
+ if (typeof document === 'undefined') {
212
+ return
213
+ }
214
+ const url = URL.createObjectURL(file)
215
+ const link = document.createElement('a')
216
+ link.href = url
217
+ link.download = file.name
218
+ // A detached link downloads in every current browser; the page's document stays untouched.
219
+ link.click()
220
+ setTimeout(() => URL.revokeObjectURL(url), 0)
221
+ }
222
+
223
+ /** A function for every procedure of `tree` and a group for every nested tree: every declared call exists. */
224
+ function coversTree(value: unknown, tree: ProcedureTreeDeclaration<Schema, unknown>): boolean {
225
+ if (typeof value !== 'object' || value === null) {
226
+ return false
227
+ }
228
+ return Object.entries(tree).every(([name, node]) => {
229
+ const member: unknown = Reflect.get(value, name)
230
+ if (!isProcedure(node)) {
231
+ return coversTree(member, node)
232
+ }
233
+ return attachmentOf(node.output) === null
234
+ ? typeof member === 'function' && Reflect.get(member, 'output') === node.output
235
+ : typeof member === 'object' && member !== null && downloads.has(member)
236
+ })
237
+ }
238
+
239
+ /** A client of every collection in `names` under `files`, or no `files` at all. */
240
+ function coversFiles(value: unknown, names: readonly string[]): boolean {
241
+ if (typeof value !== 'object' || value === null) {
242
+ return false
243
+ }
244
+ const files: unknown = Reflect.get(value, 'files')
245
+ if (names.length === 0) {
246
+ return true
247
+ }
248
+ return typeof files === 'object' && files !== null && names.every((name) => typeof Reflect.get(files, name) === 'object')
249
+ }
250
+
251
+ /** The page's origin when the client runs in a browser, else `null`. */
252
+ function pageOrigin(): string | null {
253
+ const location: unknown = Reflect.get(globalThis, 'location')
254
+ const origin: unknown = typeof location === 'object' && location !== null ? Reflect.get(location, 'origin') : undefined
255
+ return typeof origin === 'string' ? origin : null
256
+ }
257
+
258
+ /**
259
+ * The client of mount `mount` of `contract`: one function per declared procedure. A link mount's client sends its
260
+ * share key as a bearer, so `options.key` is required there.
261
+ */
262
+ export function createClient<const C extends AnyContract, const M extends ClientMount<C>>(
263
+ contract: C,
264
+ mount: M,
265
+ ...rest: OptionsFor<C, M>
266
+ ): ContractClient<C, M> {
267
+ const options: ClientOptions = rest[0] ?? {}
268
+ const surface: Surface = contract.surface
269
+ const node = SURFACE_SECTIONS.some((section) => section === mount) ? undefined : surface[mount]
270
+ if (typeof node !== 'object' || !('kind' in node) || (node.kind !== 'rpc' && node.kind !== 'link')) {
271
+ throw new Error(`'${mount}' is not an RPC or link mount of the contract`)
272
+ }
273
+ if (node.kind === 'link' && (options.key === undefined || options.key === '')) {
274
+ throw new Error(`'${mount}' is a link mount; its client needs the share key`)
275
+ }
276
+ const base = node.kind === 'link' ? joinPath(node.prefix, LINK_API_SEGMENT) : node.prefix
277
+ const bounce = resolveBounce(`${contract.vocabulary.app}:${mount}`, options.bounceOnAuth)
278
+ const origin = (options.baseUrl ?? '').replace(/\/+$/, '')
279
+ const page = pageOrigin()
280
+ // A page's key goes only to the page's own application; a script names the application it calls.
281
+ if (options.key !== undefined && page !== null && origin !== '' && new URL(origin, page).origin !== page) {
282
+ throw new Error(`a client with a key in a browser calls only ${page}; baseUrl '${origin}' is another origin`)
283
+ }
284
+ const procedures = clientTree(node.procedures, { mount, origin, base, path: '' }, (target, at) => {
285
+ const mark = attachmentOf(target.output)
286
+ return mark === null ? caller(target, at, options, bounce) : attachmentProcedure(target, mark.declaration.filename, options, bounce)
287
+ })
288
+ const transport: FileTransport = { origin, fetch: options.fetch ?? fetch, key: options.key, bounce }
289
+ const collections = Object.entries(surface.files ?? {}).flatMap(([name, collection]): (readonly [string, FileClient | LinkFileClient])[] => {
290
+ if (node.kind === 'link') {
291
+ const serves = collection.link === true || (Array.isArray(collection.link) && collection.link.includes(mount))
292
+ return serves ? [[name, linkFiles(transport, node.prefix, mount, name)]] : []
293
+ }
294
+ return node.callers.includes('human') ? [[name, sessionFiles(transport, name)]] : []
295
+ })
296
+ if (collections.length > 0 && Object.hasOwn(node.procedures, 'files')) {
297
+ throw new Error(`'${mount}' has a procedure group named files, which the client of its file collections takes`)
298
+ }
299
+ const client = collections.length === 0 ? procedures : Object.freeze({ ...procedures, files: Object.freeze(Object.fromEntries(collections)) })
300
+ // The client with its file collections is the mount's root too: `@fabrika/app/react` keys a provider by it.
301
+ if (client !== procedures) clientNodes.set(client, { mount, path: '', procedure: null })
302
+ // The tree was built from this mount's declaration; the check ties the value to the type the contract derives.
303
+ const names = collections.map(([name]) => name)
304
+ const covers = (value: unknown): value is ContractClient<C, M> => coversTree(value, node.procedures) && coversFiles(value, names)
305
+ if (!covers(client)) {
306
+ throw new Error(`the client of '${mount}' does not cover its procedures`)
307
+ }
308
+ return client
309
+ }