@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.
- package/package.json +9 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +54 -36
- package/skill/references/dev.md +5 -5
- package/skill/references/endpoint.md +152 -25
- package/skill/references/errors.md +26 -23
- package/skill/references/files.md +187 -137
- package/skill/references/jobs.md +82 -35
- package/skill/references/public-intake.md +387 -236
- package/skill/references/spa.md +5 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +8 -5
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +184 -74
- package/src/client/index.ts +2 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +110 -15
- package/src/contract/build.ts +1184 -613
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +71 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +64 -58
- package/src/contract/define.ts +76 -124
- package/src/contract/file.ts +78 -0
- package/src/contract/group.ts +244 -37
- package/src/contract/index.ts +18 -0
- package/src/contract/kit.ts +206 -126
- package/src/contract/platform.ts +8 -9
- package/src/contract/redirect.ts +49 -0
- package/src/contract/schema-from-shape.ts +7 -0
- package/src/contract/schema-shape.ts +10 -0
- package/src/contract/snapshot-source.ts +5 -0
- package/src/contract/token.ts +89 -0
- package/src/contract/webhook-binding.ts +26 -0
- package/src/contract/webhook-scheme.ts +32 -0
- package/src/files/handle.ts +22 -0
- package/src/files/kinds.ts +454 -0
- package/src/files/scan.ts +15 -9
- package/src/files/streams.ts +111 -0
- package/src/honeypot.ts +7 -0
- package/src/implement/anonymous.ts +18 -42
- package/src/implement/boot.ts +177 -159
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +4 -4
- package/src/implement/capability.ts +78 -2
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +810 -409
- package/src/implement/egress.ts +84 -0
- package/src/implement/faults.ts +11 -1
- package/src/implement/index.ts +3 -2
- package/src/implement/invocation.ts +135 -35
- package/src/implement/params.ts +88 -0
- package/src/implement/principal.ts +7 -0
- package/src/implement/public.ts +142 -0
- package/src/implement/tokens.ts +219 -0
- package/src/implement/types.ts +24 -3
- package/src/implement/urls.ts +78 -0
- package/src/implement/verifiers.ts +299 -0
- package/src/implement/webhook.ts +136 -55
- package/src/implement/workload.ts +16 -14
- package/src/index.ts +3 -0
- package/src/jobs/mail.ts +98 -10
- package/src/jobs/runner.ts +103 -71
- package/src/jobs/scrub.ts +4 -1
- package/src/jobs/types.ts +19 -2
- package/src/react/hooks.ts +2 -2
- package/src/react/provider.tsx +2 -2
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +167 -128
- package/src/testing/kit.ts +79 -24
- package/src/testing/policy-isolation.ts +55 -29
- package/src/testing/proxy.ts +6 -2
- package/src/testing/sample-file.ts +30 -0
- package/src/client/files.ts +0 -135
- package/src/contract/file-schemas.ts +0 -25
- package/src/files/collections.ts +0 -848
package/src/client/contract.ts
CHANGED
|
@@ -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
|
|
3
|
-
// procedures, and it checks each answer against the procedure's
|
|
4
|
-
//
|
|
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 {
|
|
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
|
|
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
|
|
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`
|
|
50
|
-
export type ContractClient<C, M> = MountOf<C, M> extends { readonly
|
|
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>`;
|
|
55
|
-
export type ResultOf<F> = F extends (...args: never[]) => Promise<infer R> ? R
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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({
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
211
|
-
*
|
|
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<
|
|
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}'
|
|
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
|
|
221
|
-
export async function downloadFile(procedure: object, input: unknown): Promise<
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
326
|
-
|
|
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
|
|
440
|
+
return procedures
|
|
331
441
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -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'
|
package/src/client/transport.ts
CHANGED
|
@@ -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
|
|
111
|
-
* inside the guard, so an input that does not serialize
|
|
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) {
|