@fabrika/app 0.6.0 → 0.7.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 (80) hide show
  1. package/package.json +9 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +54 -36
  4. package/skill/references/dev.md +5 -5
  5. package/skill/references/endpoint.md +152 -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 +387 -236
  10. package/skill/references/spa.md +5 -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 +8 -5
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +184 -74
  17. package/src/client/index.ts +2 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +110 -15
  21. package/src/contract/build.ts +1184 -613
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +71 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +64 -58
  27. package/src/contract/define.ts +76 -124
  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 +177 -159
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +4 -4
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  51. package/src/implement/dispatch.ts +810 -409
  52. package/src/implement/egress.ts +84 -0
  53. package/src/implement/faults.ts +11 -1
  54. package/src/implement/index.ts +3 -2
  55. package/src/implement/invocation.ts +135 -35
  56. package/src/implement/params.ts +88 -0
  57. package/src/implement/principal.ts +7 -0
  58. package/src/implement/public.ts +142 -0
  59. package/src/implement/tokens.ts +219 -0
  60. package/src/implement/types.ts +24 -3
  61. package/src/implement/urls.ts +78 -0
  62. package/src/implement/verifiers.ts +299 -0
  63. package/src/implement/webhook.ts +136 -55
  64. package/src/implement/workload.ts +16 -14
  65. package/src/index.ts +3 -0
  66. package/src/jobs/mail.ts +98 -10
  67. package/src/jobs/runner.ts +103 -71
  68. package/src/jobs/scrub.ts +4 -1
  69. package/src/jobs/types.ts +19 -2
  70. package/src/react/hooks.ts +2 -2
  71. package/src/react/provider.tsx +2 -2
  72. package/src/testing/dev.ts +2 -2
  73. package/src/testing/endpoint-access.ts +167 -128
  74. package/src/testing/kit.ts +79 -24
  75. package/src/testing/policy-isolation.ts +55 -29
  76. package/src/testing/proxy.ts +6 -2
  77. package/src/testing/sample-file.ts +30 -0
  78. package/src/client/files.ts +0 -135
  79. package/src/contract/file-schemas.ts +0 -25
  80. 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 { 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 `/`. */
@@ -149,7 +210,14 @@ function clientTree(
149
210
  for (const [name, node] of Object.entries(tree)) {
150
211
  const path = at.path === '' ? name : `${at.path}.${name}`
151
212
  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 }, {
213
+ ? make({
214
+ id: `${at.mount}.${path}`,
215
+ url: `${at.origin}${procedurePath(at.base, path, node.http)}`,
216
+ method: httpMethodOf(node.http, node.type, node.output),
217
+ type: node.type,
218
+ input: node.input,
219
+ output: node.output,
220
+ }, {
153
221
  ...at,
154
222
  path,
155
223
  })
@@ -175,10 +243,29 @@ export interface AttachmentProcedure<I> {
175
243
  readonly [attachmentInput]?: I
176
244
  }
177
245
 
178
- /** The request each attachment procedure sends and where it lies, kept beside it so the value itself carries no key. */
246
+ /**
247
+ * A procedure whose output is a file (`file('<kind>')`): not a function, because its answer is a file.
248
+ * `download(api.<procedure>, input)` fetches it with the caller's credential, and a GET one's `url(input)` is where a
249
+ * same-origin page links to it (`<img src>`, `<a download>`).
250
+ */
251
+ export interface FileProcedure<I> {
252
+ /** `<mount>.<path>` */
253
+ readonly id: string
254
+ /**
255
+ * 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:
256
+ * a key never goes into a URL, so it downloads instead.
257
+ */
258
+ url(...input: DownloadInput<I>): string
259
+ /** Carries the input schema for `download`; never present at runtime. */
260
+ readonly [attachmentInput]?: I
261
+ }
262
+
263
+ /** The request each file procedure sends and where it lies, kept beside it so the value itself carries no key. */
179
264
  interface DownloadRequest {
180
265
  readonly url: string
181
- readonly filename: string
266
+ readonly method: 'GET' | 'POST'
267
+ /** The name an attachment declares; `null` where the answer names its file. */
268
+ readonly filename: string | null
182
269
  readonly options: ClientOptions
183
270
  readonly bounce: BounceConfig | null
184
271
  readonly at: { readonly mount: string; readonly path: string }
@@ -194,10 +281,51 @@ function attachmentProcedure(
194
281
  bounce: BounceConfig | null,
195
282
  ): AttachmentProcedure<unknown> {
196
283
  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 } })
284
+ downloads.set(procedure, { url: target.url, method: 'POST', filename, options, bounce, at: { mount: at.mount, path: at.path } })
285
+ return procedure
286
+ }
287
+
288
+ function fileProcedure(target: CallTarget, at: TreeAt, options: ClientOptions, bounce: BounceConfig | null): FileProcedure<unknown> {
289
+ const url = (input?: unknown): string => {
290
+ if (target.method !== 'GET') {
291
+ throw new Error(`'${target.id}' answers its file to a POST; fetch it with download(…)`)
292
+ }
293
+ if (options.key !== undefined) {
294
+ throw new Error(`'${target.id}': a client with a key fetches the file with download(…); a key never goes into a URL`)
295
+ }
296
+ return withParams(target.url, input)
297
+ }
298
+ const procedure = Object.freeze({ id: target.id, url })
299
+ downloads.set(procedure, { url: target.url, method: target.method, filename: null, options, bounce, at: { mount: at.mount, path: at.path } })
198
300
  return procedure
199
301
  }
200
302
 
303
+ /** A downloaded file: a `File`, and the page's offer to save it. */
304
+ export class DownloadedFile extends File {
305
+ /** The file as a `Blob`. */
306
+ blob(): Blob {
307
+ return this
308
+ }
309
+
310
+ /** Offers the file as a download under its name; nothing where there is no page. */
311
+ save(): void {
312
+ saveFile(this)
313
+ }
314
+ }
315
+
316
+ /** The name of a file from its `Content-Disposition` (`filename*=UTF-8''…`), or `null`. */
317
+ function dispositionName(disposition: string | null): string | null {
318
+ const encoded = disposition?.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
319
+ if (encoded === undefined) {
320
+ return null
321
+ }
322
+ try {
323
+ return decodeURIComponent(encoded)
324
+ } catch {
325
+ return null
326
+ }
327
+ }
328
+
201
329
  /** The mount and path of an attachment procedure of a client, or `null` for any other value. */
202
330
  export function attachmentAt(value: unknown): { readonly mount: string; readonly path: string } | null {
203
331
  return typeof value === 'object' && value !== null ? (downloads.get(value)?.at ?? null) : null
@@ -207,25 +335,31 @@ export function attachmentAt(value: unknown): { readonly mount: string; readonly
207
335
  export type DownloadInput<I> = [I] extends [InputSchema] ? [input: z.input<I>] : [input?: Record<never, never>]
208
336
 
209
337
  /**
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`.
338
+ * Calls a procedure that answers a file (an attachment, or a `file('<kind>')` output) with the caller's credential, and
339
+ * answers the file: `blob()`, `stream()`, `save()`. An attachment is also offered as a download in a browser, under the
340
+ * declared name. A refusal is an `RpcError`.
212
341
  */
213
- export function download<I>(procedure: AttachmentProcedure<I>, ...input: DownloadInput<I>): Promise<File> {
342
+ export function download<I>(procedure: AttachmentProcedure<I> | FileProcedure<I>, ...input: DownloadInput<I>): Promise<DownloadedFile> {
214
343
  if (!downloads.has(procedure)) {
215
- return Promise.reject(new Error(`'${procedure.id}' is not an attachment procedure of a client`))
344
+ return Promise.reject(new Error(`'${procedure.id}' answers no file`))
216
345
  }
217
346
  return downloadFile(procedure, input[0])
218
347
  }
219
348
 
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> {
349
+ /** `download` of any file procedure of a client, its input unchecked: the provider's `useDownload` runs it. */
350
+ export async function downloadFile(procedure: object, input: unknown): Promise<DownloadedFile> {
222
351
  const request = downloads.get(procedure)
223
352
  if (request === undefined) {
224
- throw new Error('download takes an attachment procedure of a client createClient made')
353
+ throw new Error('download takes a procedure of a client createClient made that answers a file')
354
+ }
355
+ const doFetch = request.options.fetch ?? fetch
356
+ const { body, disposition } = request.method === 'GET'
357
+ ? await exchangeFile(doFetch, withParams(request.url, input), getRequestOf(request.options), request.bounce)
358
+ : await exchangeFile(doFetch, request.url, requestOf(request.options, input), request.bounce)
359
+ const file = new DownloadedFile([body], request.filename ?? dispositionName(disposition) ?? 'download', { type: body.type })
360
+ if (request.filename !== null) {
361
+ saveFile(file)
225
362
  }
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
363
  return file
230
364
  }
231
365
 
@@ -252,24 +386,12 @@ function coversTree(value: unknown, tree: ProcedureTreeDeclaration<Schema, unkno
252
386
  if (!isProcedure(node)) {
253
387
  return coversTree(member, node)
254
388
  }
255
- return attachmentOf(node.output) === null
389
+ return attachmentOf(node.output) === null && fileOf(node.output) === null
256
390
  ? typeof member === 'function' && Reflect.get(member, 'output') === node.output
257
391
  : typeof member === 'object' && member !== null && downloads.has(member)
258
392
  })
259
393
  }
260
394
 
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
395
  /** The page's origin when the client runs in a browser, else `null`. */
274
396
  function pageOrigin(): string | null {
275
397
  const location: unknown = Reflect.get(globalThis, 'location')
@@ -305,27 +427,15 @@ export function createClient<const C extends AnyContract, const M extends Client
305
427
  }
306
428
  const procedures = clientTree(node.procedures, { mount, origin, base, path: '' }, (target, at) => {
307
429
  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)]] : []
430
+ if (mark !== null) {
431
+ return attachmentProcedure(target, at, mark.declaration.filename, options, bounce)
315
432
  }
316
- return node.callers.includes('human') ? [[name, sessionFiles(transport, name)]] : []
433
+ return fileOf(target.output) === null ? caller(target, at, options, bounce) : fileProcedure(target, at, options, bounce)
317
434
  })
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
435
  // 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)) {
436
+ const covers = (value: unknown): value is ContractClient<C, M> => coversTree(value, node.procedures)
437
+ if (!covers(procedures)) {
328
438
  throw new Error(`the client of '${mount}' does not cover its procedures`)
329
439
  }
330
- return client
440
+ return procedures
331
441
  }
@@ -8,12 +8,13 @@ export {
8
8
  type ContractClient,
9
9
  createClient,
10
10
  download,
11
+ DownloadedFile,
11
12
  type DownloadInput,
13
+ type FileProcedure,
12
14
  type InputOf,
13
15
  type LinkClientOptions,
14
16
  type ProcedureCall,
15
17
  type ResultOf,
16
18
  } from './contract'
17
- export type { FileClient, FileList, FilePage, LinkFileClient, UploadOptions } from './files'
18
19
  export { shareKey } from './share'
19
20
  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) {