@fabrika/app 0.3.3 → 0.4.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/README.md +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -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 +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -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 +309 -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 +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -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 +808 -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 +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -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 +749 -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 +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -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/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -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,275 @@
|
|
|
1
|
+
// The XLSX an `attachment({ format: 'xlsx' })` answers: one sheet, the header row of the row's field names, then one
|
|
2
|
+
// row per returned row with a typed cell each. The framework writes it itself, without a dependency: a ZIP of stored
|
|
3
|
+
// (uncompressed) entries holding the six SpreadsheetML parts a reader needs, and every text as an inline string,
|
|
4
|
+
// which no reader evaluates as a formula. Like the CSV, the file is measured while it is built and sent only whole.
|
|
5
|
+
|
|
6
|
+
import type { AttachmentShape, SchemaShape } from '@fabrika/auth-core'
|
|
7
|
+
import { ATTACHMENT_LIMITS } from '../contract/defaults'
|
|
8
|
+
import { ServerFault } from './faults'
|
|
9
|
+
|
|
10
|
+
const MAIN = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
|
|
11
|
+
const RELATIONSHIPS = 'http://schemas.openxmlformats.org/package/2006/relationships'
|
|
12
|
+
const OFFICE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
|
|
13
|
+
const XML_DECLARATION = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
|
|
14
|
+
|
|
15
|
+
const SHEET_PART = 'xl/worksheets/sheet1.xml'
|
|
16
|
+
|
|
17
|
+
/** The parts beside the sheet, in the order they are stored. */
|
|
18
|
+
const FIXED_PARTS: readonly (readonly [string, string])[] = [
|
|
19
|
+
[
|
|
20
|
+
'[Content_Types].xml',
|
|
21
|
+
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`
|
|
22
|
+
+ `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>`
|
|
23
|
+
+ `<Default Extension="xml" ContentType="application/xml"/>`
|
|
24
|
+
+ `<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>`
|
|
25
|
+
+ `<Override PartName="/${SHEET_PART}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`
|
|
26
|
+
+ `<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>`
|
|
27
|
+
+ `</Types>`,
|
|
28
|
+
],
|
|
29
|
+
[
|
|
30
|
+
'_rels/.rels',
|
|
31
|
+
`<Relationships xmlns="${RELATIONSHIPS}"><Relationship Id="rId1" Type="${OFFICE}/officeDocument" Target="xl/workbook.xml"/></Relationships>`,
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
'xl/workbook.xml',
|
|
35
|
+
`<workbook xmlns="${MAIN}" xmlns:r="${OFFICE}"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>`,
|
|
36
|
+
],
|
|
37
|
+
[
|
|
38
|
+
'xl/_rels/workbook.xml.rels',
|
|
39
|
+
`<Relationships xmlns="${RELATIONSHIPS}">`
|
|
40
|
+
+ `<Relationship Id="rId1" Type="${OFFICE}/worksheet" Target="worksheets/sheet1.xml"/>`
|
|
41
|
+
+ `<Relationship Id="rId2" Type="${OFFICE}/styles" Target="styles.xml"/>`
|
|
42
|
+
+ `</Relationships>`,
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
'xl/styles.xml',
|
|
46
|
+
`<styleSheet xmlns="${MAIN}">`
|
|
47
|
+
+ `<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>`
|
|
48
|
+
+ `<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>`
|
|
49
|
+
+ `<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>`
|
|
50
|
+
+ `<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>`
|
|
51
|
+
+ `<cellXfs count="3"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>`
|
|
52
|
+
+ `<xf numFmtId="14" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>`
|
|
53
|
+
+ `<xf numFmtId="22" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/></cellXfs>`
|
|
54
|
+
+ `<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>`
|
|
55
|
+
+ `</styleSheet>`,
|
|
56
|
+
],
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
/** The cell style of a date and of a date-time: the built-in formats 14 and 22 of `styles.xml`. */
|
|
60
|
+
const DATE_STYLE = 1
|
|
61
|
+
const DATETIME_STYLE = 2
|
|
62
|
+
const MS_PER_DAY = 86_400_000
|
|
63
|
+
/** Day 0 of the 1900 date system as a reader counts it from 1900-03-01 on, past its phantom 1900-02-29. */
|
|
64
|
+
const EPOCH_MS = Date.UTC(1899, 11, 30)
|
|
65
|
+
const FIRST_SERIAL_DATE_MS = Date.UTC(1900, 2, 1)
|
|
66
|
+
|
|
67
|
+
/** How a column's values are written: a string column whose schema says it is a date is a date cell. */
|
|
68
|
+
type TextKind = 'text' | 'date' | 'datetime'
|
|
69
|
+
|
|
70
|
+
function textKind(shape: SchemaShape): TextKind {
|
|
71
|
+
switch (shape.type) {
|
|
72
|
+
case 'optional':
|
|
73
|
+
case 'nullable':
|
|
74
|
+
return textKind(shape.inner)
|
|
75
|
+
case 'string':
|
|
76
|
+
return shape.format === 'date' ? 'date' : shape.format === 'datetime' ? 'datetime' : 'text'
|
|
77
|
+
default:
|
|
78
|
+
return 'text'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Control characters but tab and line breaks, which XML 1.0 cannot carry, and a literal `_xHHHH_`, which a reader would decode. */
|
|
83
|
+
const UNSAFE = /(?![\t\n\r])\p{Cc}|[\uFFFE\uFFFF]|_(?=x[0-9A-Fa-f]{4}_)/gu
|
|
84
|
+
|
|
85
|
+
function escaped(text: string): string {
|
|
86
|
+
return text
|
|
87
|
+
.replace(UNSAFE, (char) => `_x${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}_`)
|
|
88
|
+
.replaceAll('&', '&')
|
|
89
|
+
.replaceAll('<', '<')
|
|
90
|
+
.replaceAll('>', '>')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `A`, …, `Z`, `AA`, …: the column of a cell reference. */
|
|
94
|
+
function columnName(index: number): string {
|
|
95
|
+
let name = ''
|
|
96
|
+
for (let rest = index + 1; rest > 0; rest = Math.floor((rest - 1) / 26)) {
|
|
97
|
+
name = String.fromCharCode(65 + ((rest - 1) % 26)) + name
|
|
98
|
+
}
|
|
99
|
+
return name
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The serial day of a reader's 1900 date system, or `null` for a value it cannot show as one. */
|
|
103
|
+
function serial(value: string, kind: 'date' | 'datetime'): number | null {
|
|
104
|
+
const time = kind === 'date' ? Date.parse(`${value}T00:00:00Z`) : Date.parse(value)
|
|
105
|
+
if (!Number.isFinite(time) || time < FIRST_SERIAL_DATE_MS) {
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
return (time - EPOCH_MS) / MS_PER_DAY
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function cell(endpoint: string, reference: string, value: unknown, kind: TextKind): string {
|
|
112
|
+
if (value === null || value === undefined) {
|
|
113
|
+
return ''
|
|
114
|
+
}
|
|
115
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
116
|
+
return `<c r="${reference}"><v>${value}</v></c>`
|
|
117
|
+
}
|
|
118
|
+
if (typeof value === 'boolean') {
|
|
119
|
+
return `<c r="${reference}" t="b"><v>${value ? 1 : 0}</v></c>`
|
|
120
|
+
}
|
|
121
|
+
if (typeof value !== 'string') {
|
|
122
|
+
throw new ServerFault('contract_inconsistent', `'${endpoint}' is an attachment, and a cell of its output is not a scalar`)
|
|
123
|
+
}
|
|
124
|
+
if (value.length > ATTACHMENT_LIMITS.maxCellLength) {
|
|
125
|
+
throw new ServerFault('attachment_cell', `'${endpoint}' returned a cell of ${value.length} characters`)
|
|
126
|
+
}
|
|
127
|
+
const day = kind === 'text' ? null : serial(value, kind)
|
|
128
|
+
if (day !== null) {
|
|
129
|
+
return `<c r="${reference}" s="${kind === 'date' ? DATE_STYLE : DATETIME_STYLE}"><v>${day}</v></c>`
|
|
130
|
+
}
|
|
131
|
+
return `<c r="${reference}" t="inlineStr"><is><t xml:space="preserve">${escaped(value)}</t></is></c>`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, index) => {
|
|
135
|
+
let crc = index
|
|
136
|
+
for (let bit = 0; bit < 8; bit++) {
|
|
137
|
+
crc = crc & 1 ? 0xEDB88320 ^ (crc >>> 1) : crc >>> 1
|
|
138
|
+
}
|
|
139
|
+
return crc >>> 0
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
function crc32(chunks: readonly Uint8Array[]): number {
|
|
143
|
+
let crc = 0xFFFFFFFF
|
|
144
|
+
for (const chunk of chunks) {
|
|
145
|
+
for (const byte of chunk) {
|
|
146
|
+
crc = (CRC_TABLE[(crc ^ byte) & 0xFF] ?? 0) ^ (crc >>> 8)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return (crc ^ 0xFFFFFFFF) >>> 0
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface Entry {
|
|
153
|
+
readonly name: Uint8Array
|
|
154
|
+
readonly chunks: readonly Uint8Array[]
|
|
155
|
+
readonly size: number
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const LOCAL_HEADER = 30
|
|
159
|
+
const CENTRAL_HEADER = 46
|
|
160
|
+
const END_RECORD = 22
|
|
161
|
+
/** 1980-01-01 00:00, the earliest DOS date: every entry has the same, so one set of rows is one file. */
|
|
162
|
+
const DOS_DATE = (1 << 5) | 1
|
|
163
|
+
|
|
164
|
+
/** Writes little-endian fields of a ZIP record from `offset` on. */
|
|
165
|
+
class Fields {
|
|
166
|
+
readonly #view: DataView
|
|
167
|
+
offset: number
|
|
168
|
+
|
|
169
|
+
constructor(view: DataView, offset: number) {
|
|
170
|
+
this.#view = view
|
|
171
|
+
this.offset = offset
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
u16(...values: readonly number[]): this {
|
|
175
|
+
for (const value of values) {
|
|
176
|
+
this.#view.setUint16(this.offset, value, true)
|
|
177
|
+
this.offset += 2
|
|
178
|
+
}
|
|
179
|
+
return this
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
u32(...values: readonly number[]): this {
|
|
183
|
+
for (const value of values) {
|
|
184
|
+
this.#view.setUint32(this.offset, value, true)
|
|
185
|
+
this.offset += 4
|
|
186
|
+
}
|
|
187
|
+
return this
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A ZIP of stored entries (APPNOTE 6.3: method 0, no data descriptor), `size` bytes long: the caller counted
|
|
193
|
+
* `LOCAL_HEADER`, `CENTRAL_HEADER` and the name twice per entry, the data and `END_RECORD`.
|
|
194
|
+
*/
|
|
195
|
+
function zip(entries: readonly Entry[], size: number): Uint8Array<ArrayBuffer> {
|
|
196
|
+
const file = new Uint8Array(size)
|
|
197
|
+
const view = new DataView(file.buffer)
|
|
198
|
+
const located = entries.map((entry) => ({ entry, crc: crc32(entry.chunks), offset: 0 }))
|
|
199
|
+
let offset = 0
|
|
200
|
+
for (const record of located) {
|
|
201
|
+
const { entry, crc } = record
|
|
202
|
+
record.offset = offset
|
|
203
|
+
// signature, version needed, flags, method, time, date, CRC-32, sizes, name and extra field lengths
|
|
204
|
+
offset = new Fields(view, offset).u32(0x04034B50).u16(20, 0, 0, 0, DOS_DATE).u32(crc, entry.size, entry.size).u16(entry.name.byteLength, 0)
|
|
205
|
+
.offset
|
|
206
|
+
file.set(entry.name, offset)
|
|
207
|
+
offset += entry.name.byteLength
|
|
208
|
+
for (const chunk of entry.chunks) {
|
|
209
|
+
file.set(chunk, offset)
|
|
210
|
+
offset += chunk.byteLength
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const directory = offset
|
|
214
|
+
for (const { entry, crc, offset: local } of located) {
|
|
215
|
+
// signature, versions made by and needed, flags, method, time, date, CRC-32, sizes, name, extra and comment
|
|
216
|
+
// lengths, disk, internal and external attributes, the local header's offset
|
|
217
|
+
offset = new Fields(view, offset).u32(0x02014B50).u16(20, 20, 0, 0, 0, DOS_DATE).u32(crc, entry.size, entry.size)
|
|
218
|
+
.u16(entry.name.byteLength, 0, 0, 0, 0).u32(0, local).offset
|
|
219
|
+
file.set(entry.name, offset)
|
|
220
|
+
offset += entry.name.byteLength
|
|
221
|
+
}
|
|
222
|
+
// signature, this disk, the directory's disk, its entries on this disk and in all, its size and offset, comment length
|
|
223
|
+
new Fields(view, offset).u32(0x06054B50).u16(0, 0, entries.length, entries.length).u32(offset - directory, directory).u16(0)
|
|
224
|
+
return file
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The XLSX of `rows`, each read through `rowOf` only when its turn comes, after the row count was checked. Each cell
|
|
229
|
+
* is counted against `maxBytes`, with every other part and header of the file, before the next one is read.
|
|
230
|
+
*/
|
|
231
|
+
export function encodeXlsx(endpoint: string, shape: AttachmentShape, rows: unknown, rowOf: (row: unknown) => unknown): Uint8Array<ArrayBuffer> {
|
|
232
|
+
if (!Array.isArray(rows)) {
|
|
233
|
+
throw new ServerFault('output_refused', `'${endpoint}' is an attachment, and its handler returned no list of rows`)
|
|
234
|
+
}
|
|
235
|
+
const count = rows.length
|
|
236
|
+
if (count > shape.maxRows) {
|
|
237
|
+
throw new ServerFault('attachment_rows', `'${endpoint}' returned ${count} rows, and its attachment holds at most ${shape.maxRows}`)
|
|
238
|
+
}
|
|
239
|
+
const encoder = new TextEncoder()
|
|
240
|
+
const fixed: Entry[] = FIXED_PARTS.map(([name, xml]) => {
|
|
241
|
+
const bytes = encoder.encode(XML_DECLARATION + xml)
|
|
242
|
+
return { name: encoder.encode(name), chunks: [bytes], size: bytes.byteLength }
|
|
243
|
+
})
|
|
244
|
+
const sheetName = encoder.encode(SHEET_PART)
|
|
245
|
+
const headers = [...fixed.map((entry) => entry.name), sheetName].reduce((sum, name) => sum + LOCAL_HEADER + CENTRAL_HEADER + 2 * name.byteLength, 0)
|
|
246
|
+
let size = headers + END_RECORD + fixed.reduce((sum, entry) => sum + entry.size, 0)
|
|
247
|
+
const sheet: Uint8Array[] = []
|
|
248
|
+
let sheetSize = 0
|
|
249
|
+
const append = (text: string): void => {
|
|
250
|
+
const bytes = encoder.encode(text)
|
|
251
|
+
size += bytes.byteLength
|
|
252
|
+
sheetSize += bytes.byteLength
|
|
253
|
+
if (size > shape.maxBytes) {
|
|
254
|
+
throw new ServerFault('attachment_size', `'${endpoint}' encodes to more than its ${shape.maxBytes} bytes`)
|
|
255
|
+
}
|
|
256
|
+
sheet.push(bytes)
|
|
257
|
+
}
|
|
258
|
+
const columns = shape.columns.map((column, index) => ({ name: column.name, letter: columnName(index), kind: textKind(column.shape) }))
|
|
259
|
+
append(`${XML_DECLARATION}<worksheet xmlns="${MAIN}"><sheetData>`)
|
|
260
|
+
append(`<row r="1">${columns.map((column) => cell(endpoint, `${column.letter}1`, column.name, 'text')).join('')}</row>`)
|
|
261
|
+
for (let index = 0; index < count; index++) {
|
|
262
|
+
const row = rowOf(rows[index])
|
|
263
|
+
if (typeof row !== 'object' || row === null || Array.isArray(row)) {
|
|
264
|
+
throw new ServerFault('contract_inconsistent', `'${endpoint}' is an attachment, and a row of its output is not an object`)
|
|
265
|
+
}
|
|
266
|
+
const line = index + 2
|
|
267
|
+
append(`<row r="${line}">`)
|
|
268
|
+
for (const column of columns) {
|
|
269
|
+
append(cell(endpoint, `${column.letter}${line}`, Reflect.get(row, column.name), column.kind))
|
|
270
|
+
}
|
|
271
|
+
append('</row>')
|
|
272
|
+
}
|
|
273
|
+
append('</sheetData></worksheet>')
|
|
274
|
+
return zip([...fixed, { name: sheetName, chunks: sheet, size: sheetSize }], size)
|
|
275
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// @fabrika/app — the first-party server framework
|
|
2
|
-
//
|
|
1
|
+
// @fabrika/app — the first-party server framework: typed RPC, its client, structural errors, and the `{ fetch }` the
|
|
2
|
+
// runtime adapters serve. A contract application uses `@fabrika/app/contract`; an installation service
|
|
3
|
+
// `@fabrika/app/service`.
|
|
3
4
|
|
|
4
5
|
// ─── RPC ─────────────────────────────────────────────────────────────
|
|
5
6
|
export { initRpc } from './rpc/builder.js'
|
|
@@ -9,13 +10,8 @@ export type { InferRouter, InferRouterClient } from './rpc/types.js'
|
|
|
9
10
|
// ─── Schema contract (validator-agnostic) ────────────────────────────
|
|
10
11
|
export type { StandardSchemaV1 } from './standard-schema.js'
|
|
11
12
|
|
|
12
|
-
// ───
|
|
13
|
-
export {
|
|
14
|
-
export type { HttpMethod, HttpRoute, Route, RouteOptions, RouteParams, RpcRoute } from './router.js'
|
|
15
|
-
|
|
16
|
-
// ─── Application ─────────────────────────────────────────────────────
|
|
17
|
-
export { defineApp } from './app.js'
|
|
18
|
-
export type { AppConfig, AssetsLike, FabrikaApp, RequestExecutionContext } from './app.js'
|
|
13
|
+
// ─── Request handling ────────────────────────────────────────────────
|
|
14
|
+
export type { FetchService, RequestExecutionContext } from './fetch.js'
|
|
19
15
|
|
|
20
16
|
// ─── Errors ──────────────────────────────────────────────────────────
|
|
21
17
|
export {
|
|
@@ -31,10 +27,13 @@ export {
|
|
|
31
27
|
UnauthorizedError,
|
|
32
28
|
} from './errors.js'
|
|
33
29
|
|
|
34
|
-
// ───
|
|
30
|
+
// ─── Rows ────────────────────────────────────────────────────────────
|
|
31
|
+
export { fromPage, fromRow, type RowInput } from './from-row.js'
|
|
32
|
+
|
|
33
|
+
// ─── Auth contract ───────────────────────────────────────────────────
|
|
35
34
|
export type { AuthContext, Scope } from '@fabrika/auth'
|
|
36
|
-
export type { Middleware } from './middleware.js'
|
|
37
35
|
|
|
38
36
|
// ─── Client ──────────────────────────────────────────────────────────
|
|
39
|
-
export { createRpcClient
|
|
40
|
-
export type { RpcClientOptions } from './client.js'
|
|
37
|
+
export { createRpcClient } from './client/rpc.js'
|
|
38
|
+
export type { RpcClientOptions } from './client/rpc.js'
|
|
39
|
+
export { RpcError, type RpcFetch } from './client/transport.js'
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// What the framework tells Operations, through the ingest every application already has (`FABRIKA_OPERATIONS_DSN`, the
|
|
2
|
+
// Sentry envelope): a failed or missed job run, and a request that answered 5xx. Operations groups by the
|
|
3
|
+
// fingerprint and raises its new-issue and regression alerts, so each kind of failure becomes one issue.
|
|
4
|
+
|
|
5
|
+
export interface OperationsReport {
|
|
6
|
+
/** Grouping in Operations: one issue per kind of problem and job or endpoint. */
|
|
7
|
+
readonly fingerprint: readonly string[]
|
|
8
|
+
/** The exception type Operations shows, e.g. `JobFailed`. */
|
|
9
|
+
readonly type: string
|
|
10
|
+
/** Framework-authored text only: fixed codes and declared names, never an application error's name or message. */
|
|
11
|
+
readonly message: string
|
|
12
|
+
readonly tags: Readonly<Record<string, string>>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface OperationsReporter {
|
|
16
|
+
/** Never throws: a report that cannot be delivered is one log line. */
|
|
17
|
+
report(report: OperationsReport): Promise<void>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OperationsTarget {
|
|
21
|
+
readonly envelopeUrl: string
|
|
22
|
+
readonly publicKey: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const PUBLIC_KEY = /^[0-9a-f]{32}$/
|
|
26
|
+
const PROJECT_ID = /^[1-9][0-9]{0,18}$/
|
|
27
|
+
const DELIVERY_TIMEOUT_MS = 5_000
|
|
28
|
+
|
|
29
|
+
/** The envelope endpoint and key of a DSN `https://<key>@<host>/<project>`, or `null` when it is not one. */
|
|
30
|
+
export function operationsTarget(dsn: string): OperationsTarget | null {
|
|
31
|
+
let url: URL
|
|
32
|
+
try {
|
|
33
|
+
url = new URL(dsn.trim())
|
|
34
|
+
} catch {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
const project = url.pathname.slice(1)
|
|
38
|
+
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || !PUBLIC_KEY.test(url.username) || url.password !== '' || !PROJECT_ID.test(project)) {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
// `@fabrika/operations-contract` builds the same URL, but it depends on this package.
|
|
42
|
+
return { envelopeUrl: `${url.origin}/api/${project}/envelope/`, publicKey: url.username }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The envelope's `logger`: which part of the framework reported. */
|
|
46
|
+
export type ReportSource = 'fabrika.jobs' | 'fabrika.requests'
|
|
47
|
+
|
|
48
|
+
export interface OperationsReporterOptions {
|
|
49
|
+
readonly target: OperationsTarget
|
|
50
|
+
readonly release: string | null
|
|
51
|
+
readonly environment: string | null
|
|
52
|
+
readonly source: ReportSource
|
|
53
|
+
readonly fetch?: (request: Request) => Promise<Response>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Where the reports of one application environment go: the ingest of its DSN, its release and its environment. */
|
|
57
|
+
export type OperationsSettings = Omit<OperationsReporterOptions, 'source' | 'fetch'>
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The settings of `FABRIKA_OPERATIONS_DSN`, `FABRIKA_RELEASE` and `FABRIKA_ENVIRONMENT`; `null` without a DSN. A DSN
|
|
61
|
+
* that is not an ingest DSN adds a problem, because every report would reach no one.
|
|
62
|
+
*/
|
|
63
|
+
export function operationsSettings(variables: Readonly<Record<string, string>>, problems: string[]): OperationsSettings | null {
|
|
64
|
+
const dsn = variables['FABRIKA_OPERATIONS_DSN']
|
|
65
|
+
if (dsn === undefined || dsn.trim() === '') {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
const target = operationsTarget(dsn)
|
|
69
|
+
if (target === null) {
|
|
70
|
+
problems.push('FABRIKA_OPERATIONS_DSN is not an ingest DSN (https://<key>@<host>/<project>), so a failure would reach no one')
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
return { target, release: variables['FABRIKA_RELEASE'] ?? null, environment: variables['FABRIKA_ENVIRONMENT'] ?? null }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const hexId = (): string => crypto.randomUUID().replaceAll('-', '')
|
|
77
|
+
|
|
78
|
+
/** The envelope of one error event: a header line, an item header line and the event. */
|
|
79
|
+
export function reportEnvelope(
|
|
80
|
+
report: OperationsReport,
|
|
81
|
+
options: Pick<OperationsReporterOptions, 'release' | 'environment' | 'source'>,
|
|
82
|
+
now: Date,
|
|
83
|
+
): string {
|
|
84
|
+
const { release, environment } = options
|
|
85
|
+
const eventId = hexId()
|
|
86
|
+
const event = {
|
|
87
|
+
event_id: eventId,
|
|
88
|
+
timestamp: now.toISOString(),
|
|
89
|
+
level: 'error',
|
|
90
|
+
platform: 'javascript',
|
|
91
|
+
logger: options.source,
|
|
92
|
+
fingerprint: [...report.fingerprint],
|
|
93
|
+
tags: { ...report.tags },
|
|
94
|
+
exception: { values: [{ type: report.type, value: report.message }] },
|
|
95
|
+
...(release === null ? {} : { release }),
|
|
96
|
+
...(environment === null ? {} : { environment }),
|
|
97
|
+
}
|
|
98
|
+
return [JSON.stringify({ event_id: eventId, sent_at: now.toISOString() }), JSON.stringify({ type: 'event' }), JSON.stringify(event), ''].join('\n')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function operationsReporter(options: OperationsReporterOptions): OperationsReporter {
|
|
102
|
+
const send = options.fetch ?? ((request: Request) => fetch(request))
|
|
103
|
+
return {
|
|
104
|
+
async report(report) {
|
|
105
|
+
const request = new Request(options.target.envelopeUrl, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: {
|
|
108
|
+
'content-type': 'application/x-sentry-envelope',
|
|
109
|
+
'x-sentry-auth': `Sentry sentry_version=7, sentry_key=${options.target.publicKey}`,
|
|
110
|
+
},
|
|
111
|
+
body: reportEnvelope(report, options, new Date()),
|
|
112
|
+
signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS),
|
|
113
|
+
})
|
|
114
|
+
try {
|
|
115
|
+
const response = await send(request)
|
|
116
|
+
await response.body?.cancel()
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
console.error(`fabrika: a report to Operations answered ${response.status}`)
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// The error may quote the ingest URL with its key.
|
|
122
|
+
console.error('fabrika: a report to Operations could not be delivered')
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Reports go to the server log only: the application has no Operations DSN. */
|
|
129
|
+
export const logReporter: OperationsReporter = {
|
|
130
|
+
report(report) {
|
|
131
|
+
console.error(`fabrika: ${report.type}: ${report.message}`)
|
|
132
|
+
return Promise.resolve()
|
|
133
|
+
},
|
|
134
|
+
}
|