@fabrika/app 0.6.0 → 0.8.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 (82) hide show
  1. package/package.json +10 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +58 -36
  4. package/skill/references/dev.md +11 -9
  5. package/skill/references/endpoint.md +154 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +450 -255
  10. package/skill/references/spa.md +6 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +14 -8
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +221 -79
  17. package/src/client/index.ts +3 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +168 -14
  21. package/src/contract/build.ts +1282 -619
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +79 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +142 -60
  27. package/src/contract/define.ts +92 -121
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +210 -157
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +6 -5
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/challenge.ts +203 -47
  51. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  52. package/src/implement/dispatch.ts +831 -409
  53. package/src/implement/egress.ts +84 -0
  54. package/src/implement/faults.ts +11 -1
  55. package/src/implement/index.ts +3 -2
  56. package/src/implement/invocation.ts +135 -35
  57. package/src/implement/params.ts +88 -0
  58. package/src/implement/principal.ts +7 -0
  59. package/src/implement/public.ts +167 -0
  60. package/src/implement/tokens.ts +219 -0
  61. package/src/implement/types.ts +24 -3
  62. package/src/implement/urls.ts +78 -0
  63. package/src/implement/verifiers.ts +299 -0
  64. package/src/implement/webhook.ts +136 -55
  65. package/src/implement/workload.ts +16 -14
  66. package/src/index.ts +3 -0
  67. package/src/jobs/mail.ts +98 -10
  68. package/src/jobs/runner.ts +103 -71
  69. package/src/jobs/scrub.ts +4 -1
  70. package/src/jobs/types.ts +19 -2
  71. package/src/react/hooks.ts +2 -2
  72. package/src/react/provider.tsx +2 -2
  73. package/src/testing/challenge.ts +35 -1
  74. package/src/testing/dev.ts +2 -2
  75. package/src/testing/endpoint-access.ts +168 -128
  76. package/src/testing/kit.ts +90 -25
  77. package/src/testing/policy-isolation.ts +55 -29
  78. package/src/testing/proxy.ts +6 -2
  79. package/src/testing/sample-file.ts +30 -0
  80. package/src/client/files.ts +0 -135
  81. package/src/contract/file-schemas.ts +0 -25
  82. package/src/files/collections.ts +0 -848
@@ -1,14 +1,17 @@
1
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.
2
+ // path `compileContract` gives that procedure (`procedurePath`, a public procedure's own `http.path` included). Built
3
+ // from the contract value, so it can call only declared procedures, and it checks each answer against the procedure's
4
+ // output schema before handing it out. An input with a file field goes as `multipart/form-data`: its JSON first, then
5
+ // each file. An attachment procedure and a procedure whose output is a file answer a file, which
6
+ // `download(api.<procedure>, input)` fetches; a GET one also has `url`.
5
7
 
6
8
  import { z } from 'zod/v4'
7
9
  import { type AttachmentBrand, attachmentOf } from '../contract/attachment'
8
10
  import { isProcedure, type ProcedureTreeDeclaration, type ProcedureType, SURFACE_SECTIONS } from '../contract/declaration'
9
- import { joinPath, LINK_API_SEGMENT } from '../contract/defaults'
11
+ import { FILE_INPUT_PART } from '../contract/defaults'
12
+ import { challengePath, declaredChallengeAction, joinPath, LINK_API_SEGMENT, procedurePath } from '../contract/defaults'
10
13
  import type { AnyContract, InputSchema, Procedure, Schema, Surface } from '../contract/define'
11
- import { type FileClient, type FileTransport, type LinkCollections, type LinkFileClient, linkFiles, sessionFiles } from './files'
14
+ import { type ClientInput, type FileBrand, fileOf } from '../contract/file'
12
15
  import { type BounceConfig, type BounceOption, exchange, exchangeFile, resolveBounce, RpcError, type RpcFetch } from './transport'
13
16
 
14
17
  type SurfaceOf<C> = C extends { readonly surface: infer S } ? S : never
@@ -23,40 +26,28 @@ export type ClientMount<C> =
23
26
  * it parses with (`@fabrika/app/react` reads a cached answer back through it).
24
27
  */
25
28
  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>>)
29
+ & ([I] extends [InputSchema] ? (input: ClientInput<z.input<I>>) => Promise<z.output<O>> : (input?: Record<never, never>) => Promise<z.output<O>>)
27
30
  & { readonly output: O }
28
31
 
29
32
  export type ClientTree<T> = {
30
33
  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>
34
+ ? O extends AttachmentBrand ? AttachmentProcedure<I> : O extends FileBrand<string> ? FileProcedure<I> : ProcedureCall<I, O>
32
35
  : ClientTree<T[K]>
33
36
  }
34
37
 
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
38
  type MountOf<C, M> = SurfaceOf<C>[M & keyof SurfaceOf<C>]
48
39
 
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
40
+ /** One function per procedure, and an `AttachmentProcedure` or `FileProcedure` per procedure that answers a file. */
41
+ export type ContractClient<C, M> = MountOf<C, M> extends { readonly procedures: infer T } ? ClientTree<T> : never
53
42
 
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
43
+ /** The answer of a client procedure: `ResultOf<typeof api.tasks.get>`; a procedure that answers a file, a `DownloadedFile`. */
44
+ export type ResultOf<F> = F extends (...args: never[]) => Promise<infer R> ? R
45
+ : F extends AttachmentProcedure<unknown> | FileProcedure<unknown> ? DownloadedFile
46
+ : never
56
47
 
57
48
  /** The input of a client procedure as the caller writes it: `InputOf<typeof api.tasks.create>`. */
58
49
  export type InputOf<F> = F extends (...args: infer A) => Promise<unknown> ? A[0]
59
- : F extends AttachmentProcedure<infer I> ? DownloadInput<I>[0]
50
+ : F extends AttachmentProcedure<infer I> | FileProcedure<infer I> ? DownloadInput<I>[0]
60
51
  : never
61
52
 
62
53
  export interface ClientOptions {
@@ -84,6 +75,8 @@ type OptionsFor<C, M> = SurfaceOf<C>[M & keyof SurfaceOf<C>] extends { readonly
84
75
  interface CallTarget {
85
76
  readonly id: string
86
77
  readonly url: string
78
+ /** `GET` for a procedure whose `http` says so: its input travels in the URL. */
79
+ readonly method: 'GET' | 'POST'
87
80
  readonly type: ProcedureType
88
81
  readonly input: Schema | null
89
82
  readonly output: Schema
@@ -108,19 +101,75 @@ export function clientNodeOf(value: unknown): ClientNode | null {
108
101
  }
109
102
 
110
103
  /** The request of one procedure call: its input as JSON, with the key as a bearer or the page's session. */
104
+ /** A GET call: its input travels in the URL, its credential as a POST's does. */
105
+ function getRequestOf(options: ClientOptions): () => RequestInit {
106
+ return () => {
107
+ const headers = new Headers({ Accept: 'application/json' })
108
+ if (options.key !== undefined) {
109
+ headers.set('Authorization', `Bearer ${options.key}`)
110
+ }
111
+ return { method: 'GET', credentials: options.key === undefined ? 'same-origin' : 'omit', headers }
112
+ }
113
+ }
114
+
111
115
  function requestOf(options: ClientOptions, input: unknown): () => RequestInit {
112
116
  return () => {
113
- const headers = new Headers({ 'Content-Type': 'application/json' })
117
+ const files = fileValues(input)
118
+ // A multipart body's boundary is the one `fetch` writes, so its Content-Type is left to it.
119
+ // `Accept` asks a `redirect()` procedure for its target as JSON instead of a redirect the fetch would follow.
120
+ const headers = new Headers(files === null ? { 'Content-Type': 'application/json', Accept: 'application/json' } : { Accept: 'application/json' })
114
121
  if (options.key !== undefined) {
115
122
  headers.set('Authorization', `Bearer ${options.key}`)
116
123
  }
117
- return { method: 'POST', credentials: options.key === undefined ? 'same-origin' : 'omit', headers, body: JSON.stringify(input ?? {}) }
124
+ const body = files === null ? JSON.stringify(input ?? {}) : multipartBody(files)
125
+ return { method: 'POST', credentials: options.key === undefined ? 'same-origin' : 'omit', headers, body }
126
+ }
127
+ }
128
+
129
+ /** An input's file fields and the rest: `null` when it holds no `Blob`, so it goes as JSON. */
130
+ function fileValues(input: unknown): { readonly json: Record<string, unknown>; readonly files: readonly (readonly [string, Blob])[] } | null {
131
+ if (typeof input !== 'object' || input === null) {
132
+ return null
133
+ }
134
+ const json: Record<string, unknown> = {}
135
+ const files: (readonly [string, Blob])[] = []
136
+ for (const [name, value] of Object.entries(input)) {
137
+ if (value instanceof Blob) {
138
+ files.push([name, value])
139
+ } else if (Array.isArray(value) && value.length > 0 && value.every((item) => item instanceof Blob)) {
140
+ for (const item of value) files.push([name, item])
141
+ } else {
142
+ json[name] = value
143
+ }
144
+ }
145
+ return files.length === 0 ? null : { json, files }
146
+ }
147
+
148
+ /** The multipart body of an input with files: the JSON input part first, then each file under its field's name. */
149
+ function multipartBody(input: { readonly json: Record<string, unknown>; readonly files: readonly (readonly [string, Blob])[] }): FormData {
150
+ const form = new FormData()
151
+ form.append(FILE_INPUT_PART, JSON.stringify(input.json))
152
+ for (const [name, file] of input.files) {
153
+ form.append(name, file, file instanceof File ? file.name : name)
118
154
  }
155
+ return form
156
+ }
157
+
158
+ /** A GET's input as the URL's parameters: each scalar field in its canonical spelling; an absent one left out. */
159
+ function withParams(url: string, input: unknown): string {
160
+ const params = new URLSearchParams()
161
+ for (const [name, value] of Object.entries(typeof input === 'object' && input !== null ? input : {})) {
162
+ if (value !== undefined) params.set(name, value === null ? 'null' : String(value))
163
+ }
164
+ const query = params.toString()
165
+ return query === '' ? url : `${url}?${query}`
119
166
  }
120
167
 
121
168
  function caller(target: CallTarget, at: TreeAt, options: ClientOptions, bounce: BounceConfig | null) {
122
169
  const call = async (input?: unknown): Promise<unknown> => {
123
- const result = await exchange(options.fetch ?? fetch, target.url, requestOf(options, input), bounce)
170
+ const result = target.method === 'GET'
171
+ ? await exchange(options.fetch ?? fetch, withParams(target.url, input), getRequestOf(options), bounce)
172
+ : await exchange(options.fetch ?? fetch, target.url, requestOf(options, input), bounce)
124
173
  const parsed = z.safeParse(target.output, result)
125
174
  if (!parsed.success) {
126
175
  throw new RpcError({ type: 'invalid-response', message: `'${target.id}' answered a value its output schema refuses`, httpStatus: 200 })
@@ -132,6 +181,18 @@ function caller(target: CallTarget, at: TreeAt, options: ClientOptions, bounce:
132
181
  return call
133
182
  }
134
183
 
184
+ /**
185
+ * The method a procedure's `http` declares: `GET` where written, and for a query that answers a file or declares
186
+ * `http.cache`; `POST` otherwise.
187
+ */
188
+ function httpMethodOf(http: unknown, type: ProcedureType, output: Schema): 'GET' | 'POST' {
189
+ if (typeof http === 'object' && http !== null && Reflect.get(http, 'method') !== undefined) {
190
+ return Reflect.get(http, 'method') === 'GET' ? 'GET' : 'POST'
191
+ }
192
+ const caches = typeof http === 'object' && http !== null && Reflect.get(http, 'cache') !== undefined
193
+ return type === 'query' && (fileOf(output) !== null || caches) ? 'GET' : 'POST'
194
+ }
195
+
135
196
  interface TreeAt {
136
197
  readonly mount: string
137
198
  /** The origin the paths are joined to, without a trailing `/`. */
@@ -140,6 +201,30 @@ interface TreeAt {
140
201
  readonly path: string
141
202
  }
142
203
 
204
+ /** Where each procedure of a client with an ALTCHA challenge fetches it, by the procedure's value. */
205
+ const challengeUrls = new WeakMap<object, string>()
206
+
207
+ /**
208
+ * The URL a page's ALTCHA widget fetches the challenge of `procedure` from (`GET /__fabrika/challenge/<action>`), on the
209
+ * client's `baseUrl`. Throws for a procedure whose `admission.challenge` is not `provider: 'altcha'`.
210
+ */
211
+ export function challengeUrl(procedure: object): string {
212
+ const url = challengeUrls.get(procedure)
213
+ if (url === undefined) {
214
+ throw new Error("challengeUrl takes a procedure of a client createClient made whose admission.challenge is provider: 'altcha'")
215
+ }
216
+ return url
217
+ }
218
+
219
+ /** The action of a procedure's declared ALTCHA challenge, or `null`; `buildArtifact` refuses every other shape. */
220
+ function altchaActionOf(admission: unknown, id: string): string | null {
221
+ const challenge: unknown = typeof admission === 'object' && admission !== null ? Reflect.get(admission, 'challenge') : undefined
222
+ if (typeof challenge !== 'object' || challenge === null || Reflect.get(challenge, 'provider') !== 'altcha') {
223
+ return null
224
+ }
225
+ return declaredChallengeAction(id, Reflect.get(challenge, 'action'))
226
+ }
227
+
143
228
  function clientTree(
144
229
  tree: ProcedureTreeDeclaration<Schema, unknown>,
145
230
  at: TreeAt,
@@ -148,12 +233,27 @@ function clientTree(
148
233
  const client: Record<string, unknown> = {}
149
234
  for (const [name, node] of Object.entries(tree)) {
150
235
  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)
236
+ if (!isProcedure(node)) {
237
+ client[name] = clientTree(node, { ...at, path }, make)
238
+ continue
239
+ }
240
+ const id = `${at.mount}.${path}`
241
+ const made = make({
242
+ id,
243
+ url: `${at.origin}${procedurePath(at.base, path, node.http)}`,
244
+ method: httpMethodOf(node.http, node.type, node.output),
245
+ type: node.type,
246
+ input: node.input,
247
+ output: node.output,
248
+ }, {
249
+ ...at,
250
+ path,
251
+ })
252
+ const action = altchaActionOf(node.admission, id)
253
+ if (action !== null && (typeof made === 'function' || (typeof made === 'object' && made !== null))) {
254
+ challengeUrls.set(made, `${at.origin}${challengePath(action)}`)
255
+ }
256
+ client[name] = made
157
257
  }
158
258
  const group = Object.freeze(client)
159
259
  clientNodes.set(group, { mount: at.mount, path: at.path, procedure: null })
@@ -175,10 +275,29 @@ export interface AttachmentProcedure<I> {
175
275
  readonly [attachmentInput]?: I
176
276
  }
177
277
 
178
- /** The request each attachment procedure sends and where it lies, kept beside it so the value itself carries no key. */
278
+ /**
279
+ * A procedure whose output is a file (`file('<kind>')`): not a function, because its answer is a file.
280
+ * `download(api.<procedure>, input)` fetches it with the caller's credential, and a GET one's `url(input)` is where a
281
+ * same-origin page links to it (`<img src>`, `<a download>`).
282
+ */
283
+ export interface FileProcedure<I> {
284
+ /** `<mount>.<path>` */
285
+ readonly id: string
286
+ /**
287
+ * The URL of the file a GET procedure answers, for a page signed in on the same origin. A client with a key has none:
288
+ * a key never goes into a URL, so it downloads instead.
289
+ */
290
+ url(...input: DownloadInput<I>): string
291
+ /** Carries the input schema for `download`; never present at runtime. */
292
+ readonly [attachmentInput]?: I
293
+ }
294
+
295
+ /** The request each file procedure sends and where it lies, kept beside it so the value itself carries no key. */
179
296
  interface DownloadRequest {
180
297
  readonly url: string
181
- readonly filename: string
298
+ readonly method: 'GET' | 'POST'
299
+ /** The name an attachment declares; `null` where the answer names its file. */
300
+ readonly filename: string | null
182
301
  readonly options: ClientOptions
183
302
  readonly bounce: BounceConfig | null
184
303
  readonly at: { readonly mount: string; readonly path: string }
@@ -194,10 +313,51 @@ function attachmentProcedure(
194
313
  bounce: BounceConfig | null,
195
314
  ): AttachmentProcedure<unknown> {
196
315
  const procedure = Object.freeze({ id: target.id, filename })
197
- downloads.set(procedure, { url: target.url, filename, options, bounce, at: { mount: at.mount, path: at.path } })
316
+ downloads.set(procedure, { url: target.url, method: 'POST', filename, options, bounce, at: { mount: at.mount, path: at.path } })
317
+ return procedure
318
+ }
319
+
320
+ function fileProcedure(target: CallTarget, at: TreeAt, options: ClientOptions, bounce: BounceConfig | null): FileProcedure<unknown> {
321
+ const url = (input?: unknown): string => {
322
+ if (target.method !== 'GET') {
323
+ throw new Error(`'${target.id}' answers its file to a POST; fetch it with download(…)`)
324
+ }
325
+ if (options.key !== undefined) {
326
+ throw new Error(`'${target.id}': a client with a key fetches the file with download(…); a key never goes into a URL`)
327
+ }
328
+ return withParams(target.url, input)
329
+ }
330
+ const procedure = Object.freeze({ id: target.id, url })
331
+ downloads.set(procedure, { url: target.url, method: target.method, filename: null, options, bounce, at: { mount: at.mount, path: at.path } })
198
332
  return procedure
199
333
  }
200
334
 
335
+ /** A downloaded file: a `File`, and the page's offer to save it. */
336
+ export class DownloadedFile extends File {
337
+ /** The file as a `Blob`. */
338
+ blob(): Blob {
339
+ return this
340
+ }
341
+
342
+ /** Offers the file as a download under its name; nothing where there is no page. */
343
+ save(): void {
344
+ saveFile(this)
345
+ }
346
+ }
347
+
348
+ /** The name of a file from its `Content-Disposition` (`filename*=UTF-8''…`), or `null`. */
349
+ function dispositionName(disposition: string | null): string | null {
350
+ const encoded = disposition?.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
351
+ if (encoded === undefined) {
352
+ return null
353
+ }
354
+ try {
355
+ return decodeURIComponent(encoded)
356
+ } catch {
357
+ return null
358
+ }
359
+ }
360
+
201
361
  /** The mount and path of an attachment procedure of a client, or `null` for any other value. */
202
362
  export function attachmentAt(value: unknown): { readonly mount: string; readonly path: string } | null {
203
363
  return typeof value === 'object' && value !== null ? (downloads.get(value)?.at ?? null) : null
@@ -207,25 +367,31 @@ export function attachmentAt(value: unknown): { readonly mount: string; readonly
207
367
  export type DownloadInput<I> = [I] extends [InputSchema] ? [input: z.input<I>] : [input?: Record<never, never>]
208
368
 
209
369
  /**
210
- * Calls an attachment procedure and saves its file: in a browser the page offers it as a download under the declared
211
- * name. Answers the file, also where there is no page to save it from (a script, a test). A refusal is an `RpcError`.
370
+ * Calls a procedure that answers a file (an attachment, or a `file('<kind>')` output) with the caller's credential, and
371
+ * answers the file: `blob()`, `stream()`, `save()`. An attachment is also offered as a download in a browser, under the
372
+ * declared name. A refusal is an `RpcError`.
212
373
  */
213
- export function download<I>(procedure: AttachmentProcedure<I>, ...input: DownloadInput<I>): Promise<File> {
374
+ export function download<I>(procedure: AttachmentProcedure<I> | FileProcedure<I>, ...input: DownloadInput<I>): Promise<DownloadedFile> {
214
375
  if (!downloads.has(procedure)) {
215
- return Promise.reject(new Error(`'${procedure.id}' is not an attachment procedure of a client`))
376
+ return Promise.reject(new Error(`'${procedure.id}' answers no file`))
216
377
  }
217
378
  return downloadFile(procedure, input[0])
218
379
  }
219
380
 
220
- /** `download` of any attachment procedure of a client, its input unchecked: the provider's `useDownload` runs it. */
221
- export async function downloadFile(procedure: object, input: unknown): Promise<File> {
381
+ /** `download` of any file procedure of a client, its input unchecked: the provider's `useDownload` runs it. */
382
+ export async function downloadFile(procedure: object, input: unknown): Promise<DownloadedFile> {
222
383
  const request = downloads.get(procedure)
223
384
  if (request === undefined) {
224
- throw new Error('download takes an attachment procedure of a client createClient made')
385
+ throw new Error('download takes a procedure of a client createClient made that answers a file')
386
+ }
387
+ const doFetch = request.options.fetch ?? fetch
388
+ const { body, disposition } = request.method === 'GET'
389
+ ? await exchangeFile(doFetch, withParams(request.url, input), getRequestOf(request.options), request.bounce)
390
+ : await exchangeFile(doFetch, request.url, requestOf(request.options, input), request.bounce)
391
+ const file = new DownloadedFile([body], request.filename ?? dispositionName(disposition) ?? 'download', { type: body.type })
392
+ if (request.filename !== null) {
393
+ saveFile(file)
225
394
  }
226
- const { body } = await exchangeFile(request.options.fetch ?? fetch, request.url, requestOf(request.options, input), request.bounce)
227
- const file = new File([body], request.filename, { type: body.type })
228
- saveFile(file)
229
395
  return file
230
396
  }
231
397
 
@@ -252,24 +418,12 @@ function coversTree(value: unknown, tree: ProcedureTreeDeclaration<Schema, unkno
252
418
  if (!isProcedure(node)) {
253
419
  return coversTree(member, node)
254
420
  }
255
- return attachmentOf(node.output) === null
421
+ return attachmentOf(node.output) === null && fileOf(node.output) === null
256
422
  ? typeof member === 'function' && Reflect.get(member, 'output') === node.output
257
423
  : typeof member === 'object' && member !== null && downloads.has(member)
258
424
  })
259
425
  }
260
426
 
261
- /** A client of every collection in `names` under `files`, or no `files` at all. */
262
- function coversFiles(value: unknown, names: readonly string[]): boolean {
263
- if (typeof value !== 'object' || value === null) {
264
- return false
265
- }
266
- const files: unknown = Reflect.get(value, 'files')
267
- if (names.length === 0) {
268
- return true
269
- }
270
- return typeof files === 'object' && files !== null && names.every((name) => typeof Reflect.get(files, name) === 'object')
271
- }
272
-
273
427
  /** The page's origin when the client runs in a browser, else `null`. */
274
428
  function pageOrigin(): string | null {
275
429
  const location: unknown = Reflect.get(globalThis, 'location')
@@ -305,27 +459,15 @@ export function createClient<const C extends AnyContract, const M extends Client
305
459
  }
306
460
  const procedures = clientTree(node.procedures, { mount, origin, base, path: '' }, (target, at) => {
307
461
  const mark = attachmentOf(target.output)
308
- return mark === null ? caller(target, at, options, bounce) : attachmentProcedure(target, at, mark.declaration.filename, options, bounce)
309
- })
310
- const transport: FileTransport = { origin, fetch: options.fetch ?? fetch, key: options.key, bounce }
311
- const collections = Object.entries(surface.files ?? {}).flatMap(([name, collection]): (readonly [string, FileClient | LinkFileClient])[] => {
312
- if (node.kind === 'link') {
313
- const serves = collection.link === true || (Array.isArray(collection.link) && collection.link.includes(mount))
314
- return serves ? [[name, linkFiles(transport, node.prefix, mount, name)]] : []
462
+ if (mark !== null) {
463
+ return attachmentProcedure(target, at, mark.declaration.filename, options, bounce)
315
464
  }
316
- return node.callers.includes('human') ? [[name, sessionFiles(transport, name)]] : []
465
+ return fileOf(target.output) === null ? caller(target, at, options, bounce) : fileProcedure(target, at, options, bounce)
317
466
  })
318
- if (collections.length > 0 && Object.hasOwn(node.procedures, 'files')) {
319
- throw new Error(`'${mount}' has a procedure group named files, which the client of its file collections takes`)
320
- }
321
- const client = collections.length === 0 ? procedures : Object.freeze({ ...procedures, files: Object.freeze(Object.fromEntries(collections)) })
322
- // The client with its file collections is the mount's root too: `@fabrika/app/react` keys a provider by it.
323
- if (client !== procedures) clientNodes.set(client, { mount, path: '', procedure: null })
324
467
  // The tree was built from this mount's declaration; the check ties the value to the type the contract derives.
325
- const names = collections.map(([name]) => name)
326
- const covers = (value: unknown): value is ContractClient<C, M> => coversTree(value, node.procedures) && coversFiles(value, names)
327
- if (!covers(client)) {
468
+ const covers = (value: unknown): value is ContractClient<C, M> => coversTree(value, node.procedures)
469
+ if (!covers(procedures)) {
328
470
  throw new Error(`the client of '${mount}' does not cover its procedures`)
329
471
  }
330
- return client
472
+ return procedures
331
473
  }
@@ -2,18 +2,20 @@
2
2
  export { type PageCaller, parsePageCaller } from './caller'
3
3
  export {
4
4
  type AttachmentProcedure,
5
+ challengeUrl,
5
6
  type ClientMount,
6
7
  type ClientOptions,
7
8
  type ClientTree,
8
9
  type ContractClient,
9
10
  createClient,
10
11
  download,
12
+ DownloadedFile,
11
13
  type DownloadInput,
14
+ type FileProcedure,
12
15
  type InputOf,
13
16
  type LinkClientOptions,
14
17
  type ProcedureCall,
15
18
  type ResultOf,
16
19
  } from './contract'
17
- export type { FileClient, FileList, FilePage, LinkFileClient, UploadOptions } from './files'
18
20
  export { shareKey } from './share'
19
21
  export { type BounceOption, RpcError, type RpcFetch } from './transport'
@@ -107,8 +107,9 @@ async function envelope(response: Response): Promise<{ result?: unknown; error?:
107
107
  }
108
108
 
109
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.
110
+ * Sends one request and answers the envelope's `result`; every failure is an `RpcError`, and so is every answer that is
111
+ * not 2xx, whatever its body holds. `init` builds the request inside the guard, so an input that does not serialize
112
+ * rejects like a failed request instead of throwing.
112
113
  */
113
114
  export async function exchange(doFetch: RpcFetch, url: string, init: () => RequestInit, bounce: BounceConfig | null): Promise<unknown> {
114
115
  const response = await send(doFetch, url, init)
@@ -116,6 +117,10 @@ export async function exchange(doFetch: RpcFetch, url: string, init: () => Reque
116
117
  if (data.error !== undefined) {
117
118
  return await refused(data.error, response, bounce)
118
119
  }
120
+ if (!response.ok) {
121
+ // A JSON answer that is not 2xx and carries no error envelope came from something other than an RPC surface.
122
+ throw new RpcError({ type: 'transport', message: `HTTP ${response.status}`, httpStatus: response.status })
123
+ }
119
124
  clearBounce(bounce)
120
125
  return data.result
121
126
  }
package/src/cloudflare.ts CHANGED
@@ -190,6 +190,8 @@ export function createContractWorker(load: ImplementationLoader, options: Contra
190
190
  assets: bindingAssets(env['ASSETS']),
191
191
  membership: bindingMembership(env['IAM']),
192
192
  connectBindings: connectBindings(env),
193
+ // The proxy Worker sets `X-Fabrika-Client-Ip` from `CF-Connecting-IP`, the visitor Cloudflare saw.
194
+ clientAddress: 'vouched',
193
195
  ...(options.operationsFetch === undefined ? {} : { operationsFetch: options.operationsFetch }),
194
196
  })
195
197
  } catch (error) {