@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,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
+ }