@fabrika/app 0.8.2 → 0.9.0-cloudflare.2

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 (201) hide show
  1. package/package.json +40 -24
  2. package/skill/SKILL.md +29 -3
  3. package/skill/references/app-setup.md +29 -11
  4. package/skill/references/dev.md +2 -1
  5. package/skill/references/endpoint.md +12 -3
  6. package/skill/references/errors.md +39 -35
  7. package/skill/references/files.md +4 -2
  8. package/skill/references/jobs.md +9 -0
  9. package/skill/references/table-and-migration.md +19 -2
  10. package/skill/references/tests.md +33 -1
  11. package/skill/references/webhooks-links-connectors.md +1 -1
  12. package/src/build-recipe.ts +198 -0
  13. package/src/build.ts +60 -4
  14. package/src/bun.ts +33 -8
  15. package/src/cloudflare/application.ts +352 -0
  16. package/src/cloudflare/bindings.ts +159 -0
  17. package/src/cloudflare/names.ts +16 -0
  18. package/src/cloudflare/ops.ts +246 -0
  19. package/src/cloudflare/r2-files.ts +172 -0
  20. package/src/cloudflare.ts +99 -146
  21. package/src/contract/app-id.ts +5 -0
  22. package/src/contract/artifact-schema.ts +8 -1
  23. package/src/contract/build.ts +68 -11
  24. package/src/contract/compile.ts +1 -0
  25. package/src/contract/context.ts +9 -0
  26. package/src/contract/declaration.ts +2 -0
  27. package/src/contract/define.ts +28 -2
  28. package/src/contract/group.ts +10 -1
  29. package/src/contract/kit.ts +6 -2
  30. package/src/contract/platform.ts +3 -2
  31. package/src/contract/vocabulary.ts +26 -8
  32. package/src/dev/changes.ts +35 -0
  33. package/src/dev/contract-digest.ts +17 -0
  34. package/src/dev/index.ts +11 -0
  35. package/src/dev/launcher.ts +713 -0
  36. package/src/dev/output.ts +72 -0
  37. package/src/dev/wire.ts +136 -0
  38. package/src/disk-assets.ts +8 -3
  39. package/src/files/archive.ts +2 -2
  40. package/src/files/job-names.ts +12 -0
  41. package/src/files/kinds.ts +19 -7
  42. package/src/files/scan.ts +3 -4
  43. package/src/files/streams.ts +3 -1
  44. package/src/implement/anonymous.ts +11 -1
  45. package/src/implement/boot.ts +70 -7
  46. package/src/implement/bounded.ts +21 -2
  47. package/src/implement/capability.ts +18 -3
  48. package/src/implement/dispatch.ts +251 -147
  49. package/src/implement/egress.ts +341 -8
  50. package/src/implement/failures.ts +17 -1
  51. package/src/implement/faults.ts +1 -0
  52. package/src/implement/index.ts +1 -0
  53. package/src/implement/invocation.ts +18 -9
  54. package/src/implement/principal.ts +25 -2
  55. package/src/implement/terminals.ts +93 -0
  56. package/src/implement/workload.ts +24 -13
  57. package/src/jobs/report.ts +5 -1
  58. package/src/jobs/runner.ts +40 -19
  59. package/src/jobs/schedule.ts +7 -1
  60. package/src/jobs/scrub.ts +14 -1
  61. package/src/spa-fallback.ts +12 -0
  62. package/src/testing/endpoint-access.ts +307 -152
  63. package/src/testing/isolation.ts +8 -1
  64. package/src/testing/kit.ts +20 -2
  65. package/src/testing/proxy.ts +5 -5
  66. package/types/src/agent-skill.d.ts +29 -0
  67. package/types/src/build-recipe.d.ts +70 -0
  68. package/types/src/build.d.ts +29 -0
  69. package/types/src/bun.d.ts +66 -0
  70. package/types/src/client/caller.d.ts +19 -0
  71. package/types/src/client/contract.d.ts +131 -0
  72. package/types/src/client/error-reporting.d.ts +66 -0
  73. package/types/src/client/index.d.ts +4 -0
  74. package/types/src/client/rpc.d.ts +14 -0
  75. package/types/src/client/share.d.ts +2 -0
  76. package/types/src/client/transport.d.ts +42 -0
  77. package/types/src/cloudflare/application.d.ts +42 -0
  78. package/types/src/cloudflare/bindings.d.ts +33 -0
  79. package/types/src/cloudflare/names.d.ts +11 -0
  80. package/types/src/cloudflare/ops.d.ts +46 -0
  81. package/types/src/cloudflare/r2-files.d.ts +9 -0
  82. package/types/src/cloudflare.d.ts +42 -0
  83. package/types/src/contract/app-id.d.ts +2 -0
  84. package/types/src/contract/artifact-schema.d.ts +8 -0
  85. package/types/src/contract/attachment.d.ts +41 -0
  86. package/types/src/contract/build.d.ts +12 -0
  87. package/types/src/contract/compile.d.ts +11 -0
  88. package/types/src/contract/connect.d.ts +51 -0
  89. package/types/src/contract/context.d.ts +510 -0
  90. package/types/src/contract/declaration.d.ts +307 -0
  91. package/types/src/contract/defaults.d.ts +208 -0
  92. package/types/src/contract/define.d.ts +376 -0
  93. package/types/src/contract/file.d.ts +44 -0
  94. package/types/src/contract/group.d.ts +282 -0
  95. package/types/src/contract/handled.d.ts +18 -0
  96. package/types/src/contract/index.d.ts +19 -0
  97. package/types/src/contract/kit.d.ts +211 -0
  98. package/types/src/contract/platform.d.ts +9 -0
  99. package/types/src/contract/problems.d.ts +14 -0
  100. package/types/src/contract/redirect.d.ts +25 -0
  101. package/types/src/contract/schema-from-shape.d.ts +10 -0
  102. package/types/src/contract/schema-shape.d.ts +5 -0
  103. package/types/src/contract/snapshot-source.d.ts +15 -0
  104. package/types/src/contract/token.d.ts +58 -0
  105. package/types/src/contract/vocabulary.d.ts +32 -0
  106. package/types/src/contract/webhook-binding.d.ts +24 -0
  107. package/types/src/contract/webhook-scheme.d.ts +23 -0
  108. package/types/src/dev/changes.d.ts +7 -0
  109. package/types/src/dev/contract-digest.d.ts +1 -0
  110. package/types/src/dev/index.d.ts +2 -0
  111. package/types/src/dev/launcher.d.ts +32 -0
  112. package/types/src/dev/output.d.ts +28 -0
  113. package/types/src/dev/wire.d.ts +126 -0
  114. package/types/src/disk-assets.d.ts +6 -0
  115. package/types/src/errors.d.ts +46 -0
  116. package/types/src/fetch.d.ts +7 -0
  117. package/types/src/files/archive.d.ts +26 -0
  118. package/types/src/files/clamscan.d.ts +32 -0
  119. package/types/src/files/handle.d.ts +106 -0
  120. package/types/src/files/job-names.d.ts +9 -0
  121. package/types/src/files/kinds.d.ts +101 -0
  122. package/types/src/files/scan.d.ts +78 -0
  123. package/types/src/files/sniff.d.ts +7 -0
  124. package/types/src/files/streams.d.ts +17 -0
  125. package/types/src/files/treatment.d.ts +18 -0
  126. package/types/src/from-row.d.ts +20 -0
  127. package/types/src/honeypot.d.ts +2 -0
  128. package/types/src/implement/anonymous.d.ts +28 -0
  129. package/types/src/implement/attachment.d.ts +14 -0
  130. package/types/src/implement/boot.d.ts +120 -0
  131. package/types/src/implement/bounded.d.ts +32 -0
  132. package/types/src/implement/browser-config.d.ts +19 -0
  133. package/types/src/implement/capability.d.ts +144 -0
  134. package/types/src/implement/challenge.d.ts +75 -0
  135. package/types/src/implement/client-address.d.ts +11 -0
  136. package/types/src/implement/connect.d.ts +67 -0
  137. package/types/src/implement/crashes.d.ts +31 -0
  138. package/types/src/implement/dispatch.d.ts +221 -0
  139. package/types/src/implement/egress.d.ts +49 -0
  140. package/types/src/implement/environment.d.ts +14 -0
  141. package/types/src/implement/failures.d.ts +39 -0
  142. package/types/src/implement/faults.d.ts +45 -0
  143. package/types/src/implement/headers.d.ts +7 -0
  144. package/types/src/implement/implement.d.ts +58 -0
  145. package/types/src/implement/index.d.ts +25 -0
  146. package/types/src/implement/invocation.d.ts +125 -0
  147. package/types/src/implement/membership.d.ts +201 -0
  148. package/types/src/implement/multipart.d.ts +36 -0
  149. package/types/src/implement/params.d.ts +7 -0
  150. package/types/src/implement/principal.d.ts +57 -0
  151. package/types/src/implement/project.d.ts +11 -0
  152. package/types/src/implement/public.d.ts +61 -0
  153. package/types/src/implement/serve.d.ts +25 -0
  154. package/types/src/implement/terminals.d.ts +33 -0
  155. package/types/src/implement/tokens.d.ts +49 -0
  156. package/types/src/implement/types.d.ts +243 -0
  157. package/types/src/implement/urls.d.ts +15 -0
  158. package/types/src/implement/verifiers.d.ts +19 -0
  159. package/types/src/implement/webhook.d.ts +55 -0
  160. package/types/src/implement/workload.d.ts +56 -0
  161. package/types/src/implement/xlsx.d.ts +6 -0
  162. package/types/src/index.d.ts +12 -0
  163. package/types/src/jobs/exception.d.ts +40 -0
  164. package/types/src/jobs/mail.d.ts +67 -0
  165. package/types/src/jobs/report.d.ts +56 -0
  166. package/types/src/jobs/runner.d.ts +154 -0
  167. package/types/src/jobs/schedule.d.ts +17 -0
  168. package/types/src/jobs/scrub.d.ts +12 -0
  169. package/types/src/jobs/types.d.ts +98 -0
  170. package/types/src/react/caller.d.ts +30 -0
  171. package/types/src/react/errors.d.ts +40 -0
  172. package/types/src/react/hooks.d.ts +110 -0
  173. package/types/src/react/index.d.ts +5 -0
  174. package/types/src/react/provider.d.ts +58 -0
  175. package/types/src/react/store.d.ts +68 -0
  176. package/types/src/react/testing.d.ts +28 -0
  177. package/types/src/react/types.d.ts +33 -0
  178. package/types/src/rpc/builder.d.ts +52 -0
  179. package/types/src/rpc/contract.d.ts +23 -0
  180. package/types/src/rpc/dispatcher.d.ts +11 -0
  181. package/types/src/rpc/types.d.ts +62 -0
  182. package/types/src/service/define.d.ts +42 -0
  183. package/types/src/service/index.d.ts +3 -0
  184. package/types/src/service/serve.d.ts +28 -0
  185. package/types/src/spa-fallback.d.ts +2 -0
  186. package/types/src/standard-schema.d.ts +55 -0
  187. package/types/src/testing/caller.d.ts +95 -0
  188. package/types/src/testing/challenge.d.ts +11 -0
  189. package/types/src/testing/dev.d.ts +97 -0
  190. package/types/src/testing/endpoint-access.d.ts +131 -0
  191. package/types/src/testing/index.d.ts +12 -0
  192. package/types/src/testing/isolation.d.ts +3 -0
  193. package/types/src/testing/kit.d.ts +187 -0
  194. package/types/src/testing/membership.d.ts +132 -0
  195. package/types/src/testing/policy-isolation.d.ts +113 -0
  196. package/types/src/testing/preload.d.ts +1 -0
  197. package/types/src/testing/proxy.d.ts +40 -0
  198. package/types/src/testing/runner.d.ts +8 -0
  199. package/types/src/testing/sample-file.d.ts +5 -0
  200. package/types/src/testing/status.d.ts +4 -0
  201. package/types/src/undumpable.d.ts +5 -0
@@ -0,0 +1,246 @@
1
+ // The object's side of Control's ops channel (ADR-0069 §1, §2): it authenticates the caller before it reads anything
2
+ // the request names, answers every refusal with `opsRefusal()`, and serves the view from its storage alone, so it
3
+ // works while the application cannot boot. Framework code only: an ops request never reaches the contract.
4
+
5
+ import {
6
+ type ApplicationObjects,
7
+ ENDPOINT_HEADER,
8
+ FABRIKA_APPLICATION_OBJECT,
9
+ FABRIKA_APPLICATION_OBJECTS,
10
+ FABRIKA_OPS_KEY_DIGESTS,
11
+ formatEndpointHeader,
12
+ OPS_METHOD,
13
+ OPS_ROW_COUNT_CAP,
14
+ type OpsBootFailureContent,
15
+ type OpsContentView,
16
+ opsKeyDigest,
17
+ type OpsOperation,
18
+ opsRefusal,
19
+ type OpsView,
20
+ parseApplicationObjects,
21
+ parseOpsContentView,
22
+ parseOpsInspectRequest,
23
+ parseOpsKeyDigests,
24
+ parseOpsMigrateRequest,
25
+ parseOpsOperation,
26
+ parseOpsStateRequest,
27
+ parseOpsView,
28
+ } from '@fabrika/auth-core'
29
+ import { FILES_TABLE, JOB_LEASES_TABLE, WEBHOOK_BINDINGS_TABLE, WEBHOOK_DELIVERIES_TABLE } from '@fabrika/db/migrations'
30
+ import type { SqliteCursor, SqliteValue } from '@fabrika/db/sqlite'
31
+ import { opsProxyRow } from '@fabrika/proxy-core/manifest'
32
+ import { timingSafeEqual } from 'node:crypto'
33
+ import appPackage from '../../package.json' with { type: 'json' }
34
+ import { jsonResponse } from '../errors.js'
35
+
36
+ /** What the view reads of the object's storage; workerd's `DurableObjectStorage` has both optional members. */
37
+ export interface OpsStorage {
38
+ readonly sql: {
39
+ exec(query: string, ...bindings: SqliteValue[]): SqliteCursor
40
+ /** The bun:sqlite test double has no size. */
41
+ readonly databaseSize?: number
42
+ }
43
+ /** The bun:sqlite test double has no alarm. */
44
+ getAlarm?(): Promise<number | null>
45
+ }
46
+
47
+ const OPS_ROW = opsProxyRow()
48
+
49
+ /** `X-Fabrika-Endpoint` of the composed ops row, the only value an ops request may carry (ADR-0022 rule 3). */
50
+ export const OPS_ENDPOINT = formatEndpointHeader(OPS_ROW.id, OPS_ROW.digest)
51
+
52
+ /** A view with no data but one table, through which `admittedTableName` asks the wire's own parser about a name. */
53
+ const ONE_TABLE_VIEW = {
54
+ release: '0.0.0',
55
+ maintenance: { mode: 'open', generation: null, published: false },
56
+ migrations: [],
57
+ scheduler: { alarm: null },
58
+ jobs: [],
59
+ files: [],
60
+ webhooks: [],
61
+ bootFailure: null,
62
+ }
63
+
64
+ /** The migration ledger `@fabrika/db/sqlite-migrate` keeps in the object's SQLite. */
65
+ const MIGRATION_LEDGER = 'fabrika_migrations'
66
+
67
+ /** What the platform wrote for the ops channel: the key digests and the objects a request may name. */
68
+ export interface OpsAccess {
69
+ readonly digests: readonly Uint8Array[]
70
+ readonly objects: ApplicationObjects
71
+ }
72
+
73
+ /** The environment's ops access, or `null` (an older deploy, or a malformed value), which refuses every ops request. */
74
+ export function opsAccess(env: Readonly<Record<string, unknown>>): OpsAccess | null {
75
+ const digests = parseOpsKeyDigests(stringOf(env[FABRIKA_OPS_KEY_DIGESTS]) ?? '')
76
+ const objects = parseApplicationObjects(stringOf(env[FABRIKA_APPLICATION_OBJECT]), stringOf(env[FABRIKA_APPLICATION_OBJECTS]))
77
+ if (digests === null || objects === null) {
78
+ return null
79
+ }
80
+ return { digests: digests.map((digest) => new TextEncoder().encode(digest)), objects }
81
+ }
82
+
83
+ /** What an ops request reads of the object it reached. */
84
+ export interface OpsTarget {
85
+ readonly storage: OpsStorage
86
+ /**
87
+ * The instance's last failed boot, the application's error text included. Asked only after authentication: it
88
+ * waits for a boot under way and never starts one.
89
+ */
90
+ bootFailure(): Promise<OpsBootFailureContent | null>
91
+ }
92
+
93
+ /**
94
+ * Answers an ops request the Worker forwarded with the proxy secret, which the object checked before. Authentication
95
+ * runs first; an unknown operation, a malformed body and an object name the list lacks are refused after it.
96
+ */
97
+ export async function serveOps(request: Request, access: OpsAccess | null, target: OpsTarget): Promise<Response> {
98
+ if (access === null || !opsAuthenticated(request, access)) {
99
+ return opsRefusal()
100
+ }
101
+ const operation = parseOpsOperation(new URL(request.url).pathname)
102
+ if (operation === null) {
103
+ return opsRefusal()
104
+ }
105
+ const body = await jsonBody(request)
106
+ if (operation === 'inspect') {
107
+ const inspect = parseOpsInspectRequest(body)
108
+ if (inspect === null || !access.objects.objects.includes(inspect.object)) {
109
+ return opsRefusal()
110
+ }
111
+ return jsonResponse(await inspectView(target, inspect.content), 200)
112
+ }
113
+ const object = objectNamed(operation, body)
114
+ if (object === null || !access.objects.objects.includes(object)) {
115
+ return opsRefusal()
116
+ }
117
+ // `state` and `migrate` belong to the maintenance barrier (work unit 3.3b).
118
+ return jsonResponse({ refused: 'not_implemented' }, 501)
119
+ }
120
+
121
+ /** The object an ops request names, read with its operation's strict parser; `null` for a body that does not parse. */
122
+ export function objectNamed(operation: OpsOperation, body: unknown): string | null {
123
+ switch (operation) {
124
+ case 'inspect':
125
+ return parseOpsInspectRequest(body)?.object ?? null
126
+ case 'state':
127
+ return parseOpsStateRequest(body)?.object ?? null
128
+ case 'migrate':
129
+ return parseOpsMigrateRequest(body)?.object ?? null
130
+ }
131
+ }
132
+
133
+ /** The request's body as JSON, or `undefined` when it is none: every parser refuses `undefined`. */
134
+ export async function jsonBody(request: { text(): Promise<string> }): Promise<unknown> {
135
+ try {
136
+ return JSON.parse(await request.text())
137
+ } catch {
138
+ return undefined
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Control's credentials on an ops request: the row's method, its endpoint header, and a key whose digest is one the
144
+ * platform wrote, compared in constant time. The Worker and the object both run it; it reads nothing the body names.
145
+ */
146
+ export function opsAuthenticated(request: Request, access: OpsAccess): boolean {
147
+ if (request.method !== OPS_METHOD || request.headers.get(ENDPOINT_HEADER) !== OPS_ENDPOINT) {
148
+ return false
149
+ }
150
+ const authorization = request.headers.get('Authorization') ?? ''
151
+ const key = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : ''
152
+ if (key === '') {
153
+ return false
154
+ }
155
+ const presented = new TextEncoder().encode(opsKeyDigest(key))
156
+ let matched = false
157
+ for (const digest of access.digests) {
158
+ // Every digest is compared, so the answer's timing does not say which one matched.
159
+ if (timingSafeEqual(presented, digest)) {
160
+ matched = true
161
+ }
162
+ }
163
+ return matched
164
+ }
165
+
166
+ /**
167
+ * The view of the object's storage, read and never written. A table the framework has not created yet (a boot that
168
+ * failed before its migrations) reads as empty. What the storage holds is parsed by the wire's own parser.
169
+ */
170
+ async function inspectView(target: OpsTarget, content: boolean): Promise<OpsView | OpsContentView> {
171
+ const { storage } = target
172
+ const bootFailure = await target.bootFailure()
173
+ const tables = tableNames(storage)
174
+ const has = (name: string) => tables.includes(name)
175
+ const alarm = storage.getAlarm === undefined ? missing('getAlarm') : await storage.getAlarm()
176
+ const view = {
177
+ release: appPackage.version,
178
+ maintenance: { mode: 'open', generation: null, published: false },
179
+ database: {
180
+ sizeBytes: storage.sql.databaseSize ?? missing('sql.databaseSize'),
181
+ tables: tables.filter(admittedTableName).map((name) => ({ name, rows: cappedCount(storage, name) })),
182
+ },
183
+ migrations: has(MIGRATION_LEDGER)
184
+ ? rows(storage, `select bundle, name, checksum, applied_at as appliedAt from ${MIGRATION_LEDGER} order by bundle, name`)
185
+ : [],
186
+ scheduler: { alarm: alarm === null ? null : new Date(alarm).toISOString() },
187
+ jobs: has(JOB_LEASES_TABLE)
188
+ ? rows(
189
+ storage,
190
+ `select job, last_status as lastStatus, last_started_at as lastStartedAt, last_finished_at as lastFinishedAt,
191
+ last_duration_ms as lastDurationMs from ${JOB_LEASES_TABLE} order by job`,
192
+ )
193
+ : [],
194
+ files: has(FILES_TABLE)
195
+ ? rows(storage, `select kind, status, count(*) as count from ${FILES_TABLE} group by kind, status order by kind, status`)
196
+ : [],
197
+ webhooks: has(WEBHOOK_BINDINGS_TABLE) && has(WEBHOOK_DELIVERIES_TABLE)
198
+ ? rows(
199
+ storage,
200
+ `select b.webhook as webhook, case when d.lease_until is null then 'completed' else 'claimed' end as state, count(*) as count
201
+ from ${WEBHOOK_DELIVERIES_TABLE} d join ${WEBHOOK_BINDINGS_TABLE} b on b.id = d.binding_id
202
+ group by 1, 2 order by 1, 2`,
203
+ )
204
+ : [],
205
+ bootFailure: content || bootFailure === null ? bootFailure : { at: bootFailure.at },
206
+ }
207
+ const parsed = content ? parseOpsContentView(view) : parseOpsView(view)
208
+ if (parsed === null) {
209
+ throw new Error("the object's ops view does not parse as the wire's view")
210
+ }
211
+ return parsed
212
+ }
213
+
214
+ /** Cloudflare's own `_cf_*` tables and SQLite's `sqlite_*` are no table of the application or the framework. */
215
+ function tableNames(storage: OpsStorage): string[] {
216
+ return storage.sql.exec(
217
+ `select name from sqlite_schema where type = 'table' and name not like '\\_cf\\_%' escape '\\' and name not like 'sqlite\\_%' escape '\\'
218
+ order by name`,
219
+ ).toArray().flatMap((row) => typeof row['name'] === 'string' ? [row['name']] : [])
220
+ }
221
+
222
+ /**
223
+ * Whether the view admits `name`. A table SQLite allows but the wire does not (`"MixedCase"`, from a raw migration) is
224
+ * left out, so one such table never takes the whole view down with it.
225
+ */
226
+ function admittedTableName(name: string): boolean {
227
+ return parseOpsView({ ...ONE_TABLE_VIEW, database: { sizeBytes: 0, tables: [{ name, rows: 0 }] } }) !== null
228
+ }
229
+
230
+ /** At most `OPS_ROW_COUNT_CAP`: a full count bills every row read. */
231
+ function cappedCount(storage: OpsStorage, table: string): unknown {
232
+ const quoted = `"${table.replaceAll('"', '""')}"`
233
+ return storage.sql.exec(`select count(*) as count from (select 1 from ${quoted} limit ${OPS_ROW_COUNT_CAP})`).toArray()[0]?.['count']
234
+ }
235
+
236
+ function rows(storage: OpsStorage, query: string): Record<string, SqliteValue>[] {
237
+ return storage.sql.exec(query).toArray()
238
+ }
239
+
240
+ function missing(member: string): never {
241
+ throw new Error(`the object's storage has no ${member}; a Durable Object's storage has it`)
242
+ }
243
+
244
+ function stringOf(value: unknown): string | undefined {
245
+ return typeof value === 'string' ? value : undefined
246
+ }
@@ -0,0 +1,172 @@
1
+ // The stored files of an application environment in its R2 bucket (`FABRIKA_FILES`, ADR-0065 §6): the
2
+ // `FileObjectStore` port, as the S3 store serves it on Zerops. A version is the object's etag.
3
+
4
+ import { type FileObjectStore, ObjectExistsError, type ObjectMetadata, ObjectTooLargeError, type StoredObject } from '@fabrika/platform'
5
+ import { method } from './bindings.js'
6
+
7
+ /**
8
+ * R2 stores a stream only when its length is known up front, which an upload's is not. A body up to this size is
9
+ * buffered and written in one conditional put; a longer one goes up in parts of this size, so an object holds at most
10
+ * one part in memory. R2 takes parts of at least 5 MiB, all but the last of one size.
11
+ */
12
+ export const R2_PART_BYTES = 8 * 1024 * 1024
13
+
14
+ interface R2Info {
15
+ readonly size: number
16
+ readonly etag: string
17
+ readonly contentType: string | null
18
+ readonly body: ReadableStream<Uint8Array> | null
19
+ }
20
+
21
+ /** An R2 answer's object, or `null` for anything else. */
22
+ function infoOf(value: unknown): R2Info | null {
23
+ if (typeof value !== 'object' || value === null) {
24
+ return null
25
+ }
26
+ const size: unknown = Reflect.get(value, 'size')
27
+ const etag: unknown = Reflect.get(value, 'etag')
28
+ if (typeof size !== 'number' || !Number.isSafeInteger(size) || typeof etag !== 'string' || etag === '') {
29
+ return null
30
+ }
31
+ const metadata: unknown = Reflect.get(value, 'httpMetadata')
32
+ const type: unknown = typeof metadata === 'object' && metadata !== null ? Reflect.get(metadata, 'contentType') : undefined
33
+ const body: unknown = 'body' in value ? Reflect.get(value, 'body') : null
34
+ return { size, etag, contentType: typeof type === 'string' && type !== '' ? type : null, body: body instanceof ReadableStream ? body : null }
35
+ }
36
+
37
+ function required(binding: unknown, name: string): (...args: unknown[]) => unknown {
38
+ const found = method(binding, name)
39
+ if (found === null) {
40
+ throw new Error(`the FABRIKA_FILES binding has no ${name}; it is an R2 bucket`)
41
+ }
42
+ return found
43
+ }
44
+
45
+ function joined(chunks: readonly Uint8Array[], length: number): Uint8Array {
46
+ const bytes = new Uint8Array(length)
47
+ let offset = 0
48
+ for (const chunk of chunks) {
49
+ bytes.set(chunk, offset)
50
+ offset += chunk.byteLength
51
+ }
52
+ return bytes
53
+ }
54
+
55
+ interface MultipartUpload {
56
+ uploadPart(bytes: Uint8Array): Promise<void>
57
+ complete(): Promise<void>
58
+ abort(): Promise<void>
59
+ }
60
+
61
+ async function multipartUpload(bucket: unknown, key: string, contentType: string): Promise<MultipartUpload> {
62
+ const upload: unknown = await required(bucket, 'createMultipartUpload')(key, { httpMetadata: { contentType } })
63
+ const parts: { readonly partNumber: number; readonly etag: string }[] = []
64
+ return {
65
+ async uploadPart(bytes) {
66
+ const partNumber = parts.length + 1
67
+ const uploaded: unknown = await required(upload, 'uploadPart')(partNumber, bytes)
68
+ const etag: unknown = typeof uploaded === 'object' && uploaded !== null ? Reflect.get(uploaded, 'etag') : undefined
69
+ if (typeof etag !== 'string') {
70
+ throw new Error('R2 answered a part upload without its etag')
71
+ }
72
+ parts.push({ partNumber, etag })
73
+ },
74
+ async complete() {
75
+ await required(upload, 'complete')(parts)
76
+ },
77
+ async abort() {
78
+ await required(upload, 'abort')()
79
+ },
80
+ }
81
+ }
82
+
83
+ /** `bucket` (an R2 binding) as the files' object store, or `undefined` when the environment binds none. */
84
+ export function r2FileObjectStore(bucket: unknown): FileObjectStore | undefined {
85
+ if (!['head', 'get', 'put', 'delete'].every((name) => method(bucket, name) !== null)) {
86
+ return undefined
87
+ }
88
+ const head = required(bucket, 'head')
89
+ const get = required(bucket, 'get')
90
+ const put = required(bucket, 'put')
91
+ const remove = required(bucket, 'delete')
92
+
93
+ const stored = (answer: unknown): StoredObject | null => {
94
+ const info = infoOf(answer)
95
+ // An object without a body is R2's answer to a failed condition: the object was replaced.
96
+ if (info === null || info.body === null) {
97
+ return null
98
+ }
99
+ return { size: info.size, contentType: info.contentType, version: info.etag, body: info.body }
100
+ }
101
+ const stopped = (signal: AbortSignal | undefined): void => {
102
+ if (signal?.aborted === true) {
103
+ throw new Error('the object read was stopped')
104
+ }
105
+ }
106
+
107
+ return {
108
+ async write(key, body, options): Promise<ObjectMetadata> {
109
+ // A file's key is a fresh UUID behind a unique row; the conditional put refuses a reused key atomically, and
110
+ // this check refuses one before a multipart upload, which takes no condition.
111
+ if ((await head(key)) !== null) {
112
+ await body.cancel()
113
+ throw new ObjectExistsError()
114
+ }
115
+ const httpMetadata = { contentType: options.contentType }
116
+ const reader = body.getReader()
117
+ const pending: Uint8Array[] = []
118
+ let buffered = 0
119
+ let size = 0
120
+ let upload: MultipartUpload | null = null
121
+ try {
122
+ for (;;) {
123
+ const { done, value } = await reader.read()
124
+ if (done) {
125
+ break
126
+ }
127
+ size += value.byteLength
128
+ if (size > options.maxBytes) {
129
+ await reader.cancel()
130
+ throw new ObjectTooLargeError(options.maxBytes)
131
+ }
132
+ pending.push(value)
133
+ buffered += value.byteLength
134
+ while (buffered >= R2_PART_BYTES) {
135
+ const all = joined(pending, buffered)
136
+ pending.length = 0
137
+ pending.push(all.subarray(R2_PART_BYTES))
138
+ buffered -= R2_PART_BYTES
139
+ upload ??= await multipartUpload(bucket, key, options.contentType)
140
+ await upload.uploadPart(all.subarray(0, R2_PART_BYTES))
141
+ }
142
+ }
143
+ const rest = joined(pending, buffered)
144
+ if (upload === null) {
145
+ if ((await put(key, rest, { httpMetadata, onlyIf: { etagDoesNotMatch: '*' } })) === null) {
146
+ throw new ObjectExistsError()
147
+ }
148
+ } else {
149
+ if (rest.byteLength > 0) {
150
+ await upload.uploadPart(rest)
151
+ }
152
+ await upload.complete()
153
+ }
154
+ } catch (error) {
155
+ await upload?.abort().catch(() => undefined)
156
+ throw error
157
+ }
158
+ return { size, contentType: options.contentType }
159
+ },
160
+ async read(key, signal) {
161
+ stopped(signal)
162
+ return stored(await get(key))
163
+ },
164
+ async readVersion(key, version, signal) {
165
+ stopped(signal)
166
+ return stored(await get(key, { onlyIf: { etagMatches: version } }))
167
+ },
168
+ async delete(key) {
169
+ await remove(key)
170
+ },
171
+ }
172
+ }