@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.
Files changed (116) hide show
  1. package/README.md +78 -90
  2. package/package.json +77 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +370 -0
  5. package/skill/references/dev.md +90 -0
  6. package/skill/references/endpoint.md +640 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +746 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +308 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +331 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1932 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +444 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +161 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +824 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +120 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1095 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +760 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +281 -0
  90. package/src/react/index.ts +34 -0
  91. package/src/react/provider.tsx +179 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/testing.ts +144 -0
  94. package/src/react/types.ts +40 -0
  95. package/src/rpc/dispatcher.ts +4 -19
  96. package/src/service/define.ts +121 -0
  97. package/src/service/index.ts +21 -0
  98. package/src/service/serve.ts +99 -0
  99. package/src/testing/caller.ts +123 -0
  100. package/src/testing/challenge.ts +10 -0
  101. package/src/testing/dev.ts +99 -0
  102. package/src/testing/endpoint-access.ts +809 -0
  103. package/src/testing/index.ts +36 -0
  104. package/src/testing/isolation.ts +25 -0
  105. package/src/testing/kit.ts +585 -0
  106. package/src/testing/membership.ts +797 -0
  107. package/src/testing/policy-isolation.ts +827 -0
  108. package/src/testing/preload.ts +6 -0
  109. package/src/testing/proxy.ts +87 -0
  110. package/src/testing/runner.ts +61 -0
  111. package/src/testing/status.ts +19 -0
  112. package/src/undumpable.ts +42 -0
  113. package/src/app.ts +0 -76
  114. package/src/middleware.ts +0 -38
  115. package/src/router.ts +0 -149
  116. 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('&', '&amp;')
89
+ .replaceAll('<', '&lt;')
90
+ .replaceAll('>', '&gt;')
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 for Fabrika applications:
2
- // Fetch-based HTTP routing, typed RPC, middleware, and object-level authorization.
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
- // ─── Routing ─────────────────────────────────────────────────────────
13
- export { matchRoutes, route } from './router.js'
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
- // ─── Middleware + auth contract ──────────────────────────────────────
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, RpcError, type RpcFetch } from './client.js'
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
+ }