@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.
- package/package.json +10 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +58 -36
- package/skill/references/dev.md +11 -9
- package/skill/references/endpoint.md +154 -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 +450 -255
- package/skill/references/spa.md +6 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +14 -8
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +221 -79
- package/src/client/index.ts +3 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +168 -14
- package/src/contract/build.ts +1282 -619
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +79 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +142 -60
- package/src/contract/define.ts +92 -121
- 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 +210 -157
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +6 -5
- package/src/implement/capability.ts +78 -2
- package/src/implement/challenge.ts +203 -47
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +831 -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 +167 -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/challenge.ts +35 -1
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +168 -128
- package/src/testing/kit.ts +90 -25
- 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 { 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
|
|
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 `/`. */
|
|
@@ -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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
211
|
-
*
|
|
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<
|
|
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}'
|
|
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
|
|
221
|
-
export async function downloadFile(procedure: object, input: unknown): Promise<
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
326
|
-
|
|
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
|
|
472
|
+
return procedures
|
|
331
473
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -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'
|
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) {
|