@fabrika/app 0.3.3 → 0.4.1
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/README.md +78 -90
- package/package.json +77 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +370 -0
- package/skill/references/dev.md +90 -0
- package/skill/references/endpoint.md +640 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +746 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +308 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +331 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1932 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +444 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +161 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +824 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +120 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1095 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +760 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +281 -0
- package/src/react/index.ts +34 -0
- package/src/react/provider.tsx +179 -0
- package/src/react/store.ts +215 -0
- package/src/react/testing.ts +144 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/challenge.ts +10 -0
- package/src/testing/dev.ts +99 -0
- package/src/testing/endpoint-access.ts +809 -0
- package/src/testing/index.ts +36 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +585 -0
- package/src/testing/membership.ts +797 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- package/src/testing.ts +0 -119
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// The file an `attachment()` output answers: the rows the handler returned, encoded as CSV (RFC 4180) in UTF-8, or as
|
|
2
|
+
// XLSX (`xlsx.ts`). The whole file is encoded and measured before a byte is sent, so a file over its limit fails the
|
|
3
|
+
// invocation, which then rolls back, instead of sending a partial body. The limits are met while the rows are read,
|
|
4
|
+
// never after.
|
|
5
|
+
|
|
6
|
+
import type { AttachmentShape } from '@fabrika/auth-core'
|
|
7
|
+
import { z } from 'zod/v4'
|
|
8
|
+
import { attachmentOf } from '../contract/attachment'
|
|
9
|
+
import { ATTACHMENT_LIMITS, CSV_CONTENT_TYPE, XLSX_CONTENT_TYPE } from '../contract/defaults'
|
|
10
|
+
import type { Schema } from '../contract/define'
|
|
11
|
+
import { contentDisposition } from '../files/treatment'
|
|
12
|
+
import { ServerFault } from './faults'
|
|
13
|
+
import { project, ProjectionRefusedError } from './project'
|
|
14
|
+
import { encodeXlsx } from './xlsx'
|
|
15
|
+
|
|
16
|
+
/** A byte order mark: without it a spreadsheet reads the file in the machine's legacy code page. */
|
|
17
|
+
const BYTE_ORDER_MARK = '\uFEFF'
|
|
18
|
+
const LINE_END = '\r\n'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A text a spreadsheet would run as a formula (or as a DDE command): one that starts with `=`, `+`, `-`, `@`, a tab,
|
|
22
|
+
* a carriage return or a line feed, also after leading whitespace, and their full-width forms.
|
|
23
|
+
*/
|
|
24
|
+
const FORMULA_START = /^\s*[=+\-@\t\r\n\uFF1D\uFF0B\uFF0D\uFF20]/
|
|
25
|
+
|
|
26
|
+
/** RFC 4180: a field with a quote, a comma or a line break is quoted, and each quote inside doubled. */
|
|
27
|
+
const NEEDS_QUOTES = /[",\r\n]/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The response an attachment answers. `rows` is what the handler returned: each row is projected and validated with
|
|
31
|
+
* the row schema of `output` only when its turn to be encoded comes.
|
|
32
|
+
*/
|
|
33
|
+
export function attachmentResponse(endpoint: string, shape: AttachmentShape, output: Schema, rows: unknown): Response {
|
|
34
|
+
const mark = attachmentOf(output)
|
|
35
|
+
if (mark === null) {
|
|
36
|
+
throw new ServerFault('contract_inconsistent', `'${endpoint}' answers an attachment, and its output schema is no attachment()`)
|
|
37
|
+
}
|
|
38
|
+
const rowOf = (row: unknown) => validRow(endpoint, mark.row, row)
|
|
39
|
+
const xlsx = shape.format === 'xlsx'
|
|
40
|
+
const body = xlsx ? encodeXlsx(endpoint, shape, rows, rowOf) : encodeCsv(endpoint, shape, rows, rowOf)
|
|
41
|
+
return new Response(body, {
|
|
42
|
+
status: 200,
|
|
43
|
+
headers: {
|
|
44
|
+
'Content-Type': xlsx ? XLSX_CONTENT_TYPE : CSV_CONTENT_TYPE,
|
|
45
|
+
'Content-Length': String(body.byteLength),
|
|
46
|
+
'Content-Disposition': contentDisposition(xlsx ? XLSX_CONTENT_TYPE : 'text/csv', shape.filename),
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validRow(endpoint: string, schema: Schema, row: unknown): unknown {
|
|
52
|
+
let projected: unknown
|
|
53
|
+
try {
|
|
54
|
+
projected = project(schema, row)
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error instanceof ProjectionRefusedError) {
|
|
57
|
+
throw new ServerFault('output_refused', `'${endpoint}' returned a row no branch of its row schema accepts`)
|
|
58
|
+
}
|
|
59
|
+
throw error
|
|
60
|
+
}
|
|
61
|
+
const parsed = z.safeParse(schema, projected)
|
|
62
|
+
if (!parsed.success) {
|
|
63
|
+
throw new ServerFault('output_refused', `'${endpoint}' returned a row its row schema refuses`)
|
|
64
|
+
}
|
|
65
|
+
return parsed.data
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The header line of the column names, then one line per row, each row read through `rowOf` only when its turn
|
|
70
|
+
* comes. The row count is checked before any row is read. Each field is encoded on its own, with its separator, and
|
|
71
|
+
* counted against `maxBytes` before the next one is read, so neither a long result nor a wide row is built past the
|
|
72
|
+
* limit; only then is the whole file joined.
|
|
73
|
+
*/
|
|
74
|
+
export function encodeCsv(endpoint: string, shape: AttachmentShape, rows: unknown, rowOf: (row: unknown) => unknown): Uint8Array<ArrayBuffer> {
|
|
75
|
+
if (!Array.isArray(rows)) {
|
|
76
|
+
throw new ServerFault('output_refused', `'${endpoint}' is an attachment, and its handler returned no list of rows`)
|
|
77
|
+
}
|
|
78
|
+
const count = rows.length
|
|
79
|
+
if (count > shape.maxRows) {
|
|
80
|
+
throw new ServerFault('attachment_rows', `'${endpoint}' returned ${count} rows, and its attachment holds at most ${shape.maxRows}`)
|
|
81
|
+
}
|
|
82
|
+
const encoder = new TextEncoder()
|
|
83
|
+
const chunks: Uint8Array[] = []
|
|
84
|
+
let size = 0
|
|
85
|
+
const append = (text: string): void => {
|
|
86
|
+
const bytes = encoder.encode(text)
|
|
87
|
+
size += bytes.byteLength
|
|
88
|
+
if (size > shape.maxBytes) {
|
|
89
|
+
throw new ServerFault('attachment_size', `'${endpoint}' encodes to more than its ${shape.maxBytes} bytes`)
|
|
90
|
+
}
|
|
91
|
+
chunks.push(bytes)
|
|
92
|
+
}
|
|
93
|
+
const names = shape.columns.map((column) => column.name)
|
|
94
|
+
const appendLine = (cellAt: (name: string) => unknown): void => {
|
|
95
|
+
for (const [index, name] of names.entries()) {
|
|
96
|
+
append(field(endpoint, cellAt(name)) + (index === names.length - 1 ? LINE_END : ','))
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
append(BYTE_ORDER_MARK)
|
|
100
|
+
appendLine((name) => name)
|
|
101
|
+
for (let index = 0; index < count; index++) {
|
|
102
|
+
const row = rowOf(rows[index])
|
|
103
|
+
appendLine((name) => cellOf(endpoint, row, name))
|
|
104
|
+
}
|
|
105
|
+
const file = new Uint8Array(size)
|
|
106
|
+
let offset = 0
|
|
107
|
+
for (const bytes of chunks) {
|
|
108
|
+
file.set(bytes, offset)
|
|
109
|
+
offset += bytes.byteLength
|
|
110
|
+
}
|
|
111
|
+
return file
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cellOf(endpoint: string, row: unknown, name: string): unknown {
|
|
115
|
+
if (typeof row !== 'object' || row === null || Array.isArray(row)) {
|
|
116
|
+
throw new ServerFault('contract_inconsistent', `'${endpoint}' is an attachment, and a row of its output is not an object`)
|
|
117
|
+
}
|
|
118
|
+
return Reflect.get(row, name)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* One field. Text is escaped before it is quoted, so the quote cannot hide the formula from the escape. A number is a
|
|
123
|
+
* finite JavaScript number, whose text is a numeric literal: its `-` is a sign, never a formula, so it stays a number.
|
|
124
|
+
*/
|
|
125
|
+
function field(endpoint: string, value: unknown): string {
|
|
126
|
+
if (value === null || value === undefined) {
|
|
127
|
+
return ''
|
|
128
|
+
}
|
|
129
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
130
|
+
return String(value)
|
|
131
|
+
}
|
|
132
|
+
if (typeof value === 'boolean') {
|
|
133
|
+
return value ? 'true' : 'false'
|
|
134
|
+
}
|
|
135
|
+
if (typeof value !== 'string') {
|
|
136
|
+
throw new ServerFault('contract_inconsistent', `'${endpoint}' is an attachment, and a cell of its output is not a scalar`)
|
|
137
|
+
}
|
|
138
|
+
if (value.length > ATTACHMENT_LIMITS.maxCellLength) {
|
|
139
|
+
throw new ServerFault('attachment_cell', `'${endpoint}' returned a cell of ${value.length} characters`)
|
|
140
|
+
}
|
|
141
|
+
const text = FORMULA_START.test(value) ? `'${value}` : value
|
|
142
|
+
return NEEDS_QUOTES.test(text) ? `"${text.replaceAll('"', '""')}"` : text
|
|
143
|
+
}
|