@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,352 @@
1
+ // The Durable Object an application environment runs in (ADR-0065): it migrates its SQLite, boots the application and
2
+ // serves every request and minute tick its Worker forwards. A plain class, so Bun and Lopata load it without
3
+ // `cloudflare:workers`.
4
+
5
+ import { FABRIKA_PROXY_SECRET, OPS_PREFIX, type OpsBootFailureContent, PROXY_SECRET_HEADER } from '@fabrika/auth-core'
6
+ import { isScopedDatabase } from '@fabrika/db/framework'
7
+ import { SQLITE_FRAMEWORK_MIGRATIONS } from '@fabrika/db/migrations-sqlite'
8
+ import { openSqlite, type SqliteStorage } from '@fabrika/db/sqlite'
9
+ import { migrateSqlite } from '@fabrika/db/sqlite-migrate'
10
+ import { AsyncLocalStorage } from 'node:async_hooks'
11
+ import { timingSafeEqual } from 'node:crypto'
12
+ import type { RequestExecutionContext } from '../fetch.js'
13
+ import { boot, type ContractApp } from '../implement/boot.js'
14
+ import { type CrashEvents, environmentCrashReporter, reportSurvivedCrashes } from '../implement/crashes.js'
15
+ import { proxySecretDigest } from '../implement/dispatch.js'
16
+ import { type CapturedEnvironment, captureEnvironment } from '../implement/environment.js'
17
+ import type { ContractImplementation } from '../implement/implement.js'
18
+ import type { CrashReporter } from '../jobs/report.js'
19
+ import { isDueAt } from '../jobs/schedule.js'
20
+ import { environmentScrubber, type Scrubber } from '../jobs/scrub.js'
21
+ import { bindingAssets, bindingKeySource, bindingMembership, bindingTerminals, bindingWorkloadTokens, connectBindings } from './bindings.js'
22
+ import { CLOUDFLARE_ASSETS_BINDING, CLOUDFLARE_FILES_BINDING } from './names.js'
23
+ import { type OpsAccess, opsAccess, type OpsStorage, serveOps } from './ops.js'
24
+ import { r2FileObjectStore } from './r2-files.js'
25
+
26
+ /** The application module, imported only after the environment was captured: the generated entrypoint's `import()`. */
27
+ export type ImplementationLoader = () => Promise<{ readonly default: ContractImplementation }>
28
+
29
+ /** One of the application's SQLite migrations, as `fabrika app build` bundles them from `db/migrations`. */
30
+ export interface ApplicationMigration {
31
+ readonly name: string
32
+ readonly sql: string
33
+ }
34
+
35
+ export interface ContractWorkerOptions {
36
+ /** The application's own SQLite migrations, applied as the bundle `app` after the framework's `fabrika`. */
37
+ readonly migrations?: readonly ApplicationMigration[]
38
+ /** Where the isolate hears of an unhandled rejection; the global `unhandledrejection` event by default. */
39
+ readonly rejections?: CrashEvents
40
+ /** What posts a report to Operations' ingest; `fetch` by default. */
41
+ readonly operationsFetch?: (request: Request) => Promise<Response>
42
+ }
43
+
44
+ /**
45
+ * Marks the Worker's minute tick, with its `scheduledTime`. The Worker's `fetch` removes it from every request it
46
+ * forwards, so only its own `scheduled` handler reaches the tick.
47
+ */
48
+ export const TICK_HEADER = 'X-Fabrika-Tick'
49
+
50
+ /** What the object needs of its `ctx.storage`; a Durable Object's `DurableObjectStorage` fits it. */
51
+ export type ApplicationStorage = SqliteStorage & OpsStorage
52
+
53
+ /** What the object needs of its `DurableObjectState`. */
54
+ export interface ApplicationState {
55
+ readonly storage: ApplicationStorage
56
+ blockConcurrencyWhile<T>(callback: () => Promise<T>): Promise<T>
57
+ waitUntil(promise: Promise<unknown>): void
58
+ }
59
+
60
+ /** The framework's bindings: IAM's application entrypoint and the Workers Assets of the SPA. No handler sees them. */
61
+ const FRAMEWORK_BINDINGS: ReadonlySet<string> = new Set(['IAM', CLOUDFLARE_ASSETS_BINDING])
62
+
63
+ /**
64
+ * The application this isolate booted, once: its module is loaded once, and its `scoped(tables)` layer binds once, to
65
+ * the storage of the object whose event runs (`objectStorage`), never to one database.
66
+ */
67
+ interface Booted {
68
+ readonly app: ContractApp
69
+ /** Whether the application has a data layer, which every object instance migrates in its own storage. */
70
+ readonly migrates: boolean
71
+ }
72
+
73
+ interface ApplicationSetup {
74
+ readonly load: ImplementationLoader
75
+ readonly options: ContractWorkerOptions
76
+ booted: Promise<Booted> | null
77
+ /** Whether the current streak of failed attachments was reported; an attachment that succeeds ends the streak. */
78
+ failureReported: boolean
79
+ watchingRejections: boolean
80
+ }
81
+
82
+ let registered: ApplicationSetup | null = null
83
+
84
+ /** The storage of the object whose event runs; the work an event hands to `waitUntil` keeps it. */
85
+ const objectStorage = new AsyncLocalStorage<ApplicationStorage>()
86
+
87
+ function eventStorage(): ApplicationStorage {
88
+ const storage = objectStorage.getStore()
89
+ if (storage === undefined) {
90
+ throw new Error('a statement ran outside any event of an application object: the framework runs every event in its object scope')
91
+ }
92
+ return storage
93
+ }
94
+
95
+ /** What the object classes of this isolate boot: the generated entrypoint's `createContractWorker` registers it. */
96
+ export function registerApplication(load: ImplementationLoader, options: ContractWorkerOptions): void {
97
+ registered = { load, options, booted: null, failureReported: false, watchingRejections: false }
98
+ }
99
+
100
+ /** The isolate's `unhandledrejection` event. Listening leaves the runtime's own handling, its log line, in place. */
101
+ function globalRejectionEvents(): CrashEvents {
102
+ return {
103
+ listen(listener) {
104
+ const rejection = (event: Event) => listener('unhandled_rejection', Reflect.get(event, 'reason'))
105
+ globalThis.addEventListener('unhandledrejection', rejection)
106
+ return () => globalThis.removeEventListener('unhandledrejection', rejection)
107
+ },
108
+ }
109
+ }
110
+
111
+ /** The framework's tables, then the application's, each migration once: a booted storage applies nothing. */
112
+ async function migrate(storage: ApplicationStorage, migrations: readonly ApplicationMigration[]): Promise<void> {
113
+ await migrateSqlite({ exec: (query, ...bindings) => storage.sql.exec(query, ...bindings), transactionSync: (fn) => storage.transactionSync(fn) }, [
114
+ { bundle: 'fabrika', migrations: SQLITE_FRAMEWORK_MIGRATIONS },
115
+ { bundle: 'app', migrations },
116
+ ])
117
+ }
118
+
119
+ async function bootApplication(
120
+ setup: ApplicationSetup,
121
+ state: ApplicationState,
122
+ env: Readonly<Record<string, unknown>>,
123
+ environment: CapturedEnvironment,
124
+ crashes: CrashReporter | null,
125
+ ): Promise<Booted> {
126
+ const { default: implementation } = await setup.load()
127
+ const migrates = isScopedDatabase(implementation.database)
128
+ if (migrates) {
129
+ await migrate(state.storage, setup.options.migrations ?? [])
130
+ }
131
+ const database = migrates
132
+ ? openSqlite({
133
+ sql: { exec: (query, ...bindings) => eventStorage().sql.exec(query, ...bindings) },
134
+ transaction: (closure) => eventStorage().transaction(closure),
135
+ transactionSync: (closure) => eventStorage().transactionSync(closure),
136
+ })
137
+ : undefined
138
+ const app = boot(implementation, {
139
+ environment,
140
+ iam: bindingKeySource(env['IAM']),
141
+ assets: bindingAssets(env[CLOUDFLARE_ASSETS_BINDING]),
142
+ membership: bindingMembership(env['IAM']),
143
+ terminals: bindingTerminals(env['IAM']),
144
+ workloadTokens: bindingWorkloadTokens(env['IAM']),
145
+ connectBindings: connectBindings(env),
146
+ database,
147
+ files: r2FileObjectStore(env[CLOUDFLARE_FILES_BINDING]),
148
+ // The Worker forwards only with the proxy secret, which the object checked; `boot` checks it again.
149
+ requireProxySecret: true,
150
+ // The proxy Worker sets `X-Fabrika-Client-Ip` from `CF-Connecting-IP`, the visitor Cloudflare saw.
151
+ clientAddress: 'vouched',
152
+ ...(setup.options.operationsFetch === undefined ? {} : { operationsFetch: setup.options.operationsFetch }),
153
+ })
154
+ const booted: Booted = { app, migrates }
155
+ if (crashes !== null && !setup.watchingRejections) {
156
+ setup.watchingRejections = true
157
+ reportSurvivedCrashes(setup.options.rejections ?? globalRejectionEvents(), crashes)
158
+ }
159
+ return booted
160
+ }
161
+
162
+ /**
163
+ * The isolate's booted application with this instance's storage migrated, or the first boot. Reports the first failure
164
+ * of a streak as `boot_failed`, whichever instance or step it comes from; an attachment that succeeds ends the streak.
165
+ */
166
+ async function attach(
167
+ setup: ApplicationSetup | null,
168
+ state: ApplicationState,
169
+ env: Readonly<Record<string, unknown>>,
170
+ failed: (failure: OpsBootFailureContent) => void,
171
+ ): Promise<Booted> {
172
+ if (setup === null) {
173
+ throw new Error('no contract application is registered: the generated entrypoint calls createContractWorker before an object starts')
174
+ }
175
+ const environment = captureEnvironment(Object.fromEntries(Object.entries(env).filter(([name]) => !FRAMEWORK_BINDINGS.has(name))))
176
+ const crashes = environmentCrashReporter(environment, setup.options.operationsFetch)
177
+ try {
178
+ const booted = await attachOnce(setup, state, env, environment, crashes)
179
+ setup.failureReported = false
180
+ return booted
181
+ } catch (error) {
182
+ failed(bootFailure(error, environmentScrubber(environment)))
183
+ if (crashes !== null && !setup.failureReported) {
184
+ setup.failureReported = true
185
+ state.waitUntil(crashes.report('boot_failed', error))
186
+ }
187
+ throw error
188
+ }
189
+ }
190
+
191
+ async function attachOnce(
192
+ setup: ApplicationSetup,
193
+ state: ApplicationState,
194
+ env: Readonly<Record<string, unknown>>,
195
+ environment: CapturedEnvironment,
196
+ crashes: CrashReporter | null,
197
+ ): Promise<Booted> {
198
+ if (setup.booted === null) {
199
+ const booting = bootApplication(setup, state, env, environment, crashes)
200
+ setup.booted = booting
201
+ // A failed boot is not kept: the next object instance boots again and fails with the same problems.
202
+ booting.catch(() => {
203
+ setup.booted = null
204
+ })
205
+ return await booting
206
+ }
207
+ const booted = await setup.booted
208
+ if (booted.migrates) {
209
+ await migrate(state.storage, setup.options.migrations ?? [])
210
+ }
211
+ return booted
212
+ }
213
+
214
+ /** A failed boot as the ops view shows it, its text scrubbed as a crash report's is. */
215
+ function bootFailure(error: unknown, scrubber: Scrubber): OpsBootFailureContent {
216
+ const at = new Date().toISOString()
217
+ if (error instanceof Error) {
218
+ return { at, message: scrubber.scrub(error.message), stack: typeof error.stack === 'string' ? scrubber.scrub(error.stack) : null }
219
+ }
220
+ const message = typeof error === 'string' ? scrubber.scrub(error) : `a thrown ${error === null ? 'null' : typeof error} that is not an Error`
221
+ return { at, message, stack: null }
222
+ }
223
+
224
+ type Outcome = { readonly booted: Booted } | { readonly failure: unknown }
225
+
226
+ /** The wait after an instance's first failed boot before an event boots again; it doubles with each failure, to the cap. */
227
+ const BOOT_RETRY_MS = 1_000
228
+ const BOOT_RETRY_CAP_MS = 60_000
229
+
230
+ /** A job run handed to `waitUntil`; the runner logs and reports its own failures. */
231
+ async function runQuietly(app: ContractApp, name: string): Promise<void> {
232
+ try {
233
+ await app.jobs.run(name)
234
+ } catch {
235
+ console.error(`fabrika: job '${name}' did not start: driver_failed`)
236
+ }
237
+ }
238
+
239
+ /** The application environment's one object. The Worker's generated entrypoint exports it as `FabrikaApplication`. */
240
+ export class FabrikaApplication {
241
+ readonly #state: ApplicationState
242
+ readonly #env: Readonly<Record<string, unknown>>
243
+ readonly #setup: ApplicationSetup | null
244
+ /** `proxySecretDigest` of the environment's proxy secret; `null` refuses every request. */
245
+ readonly #secret: Uint8Array | null
246
+ /** What the platform wrote for Control's ops channel; `null` refuses every ops request. */
247
+ readonly #ops: OpsAccess | null
248
+ /** The instance's last failed boot, kept in memory for the ops view. */
249
+ #bootFailure: OpsBootFailureContent | null = null
250
+ /** The instance's boot, `null` until the first event that needs the application: an ops request never starts it. */
251
+ #outcome: Promise<Outcome> | null = null
252
+ #failedBoots = 0
253
+ /** When an event may boot again after a failed boot; until then every request answers the failure. */
254
+ #retryAt = 0
255
+
256
+ constructor(state: ApplicationState, env: Readonly<Record<string, unknown>>) {
257
+ this.#state = state
258
+ this.#env = env
259
+ this.#setup = registered
260
+ const secret = env[FABRIKA_PROXY_SECRET]
261
+ this.#secret = typeof secret === 'string' && secret !== '' ? proxySecretDigest(secret) : null
262
+ this.#ops = opsAccess(env)
263
+ }
264
+
265
+ async fetch(request: Request): Promise<Response> {
266
+ // Another Worker on the account that binds this class reaches nothing, not even an answer that the object exists.
267
+ if (!this.#fromWorker(request)) {
268
+ return new Response(null, { status: 404 })
269
+ }
270
+ if (new URL(request.url).pathname.startsWith(OPS_PREFIX)) {
271
+ // Served from storage alone: it never starts a boot or a retry, and waits for one under way only once authenticated.
272
+ return await serveOps(request, this.#ops, {
273
+ storage: this.#state.storage,
274
+ bootFailure: async () => {
275
+ await this.#outcome
276
+ return this.#bootFailure
277
+ },
278
+ })
279
+ }
280
+ const outcome = await this.#current()
281
+ if ('failure' in outcome) {
282
+ throw outcome.failure
283
+ }
284
+ return await objectStorage.run(this.#state.storage, () => this.#serve(outcome.booted, request))
285
+ }
286
+
287
+ /** Settles either way: a callback that rejects would reset the object, and every request answers the failure instead. */
288
+ #boot(): Promise<Outcome> {
289
+ return this.#state.blockConcurrencyWhile(() =>
290
+ attach(this.#setup, this.#state, this.#env, (failure) => {
291
+ this.#bootFailure = failure
292
+ }).then((booted): Outcome => ({ booted }), (failure: unknown): Outcome => {
293
+ this.#failedBoots++
294
+ this.#retryAt = Date.now() + Math.min(BOOT_RETRY_MS * 2 ** (this.#failedBoots - 1), BOOT_RETRY_CAP_MS)
295
+ return { failure }
296
+ })
297
+ )
298
+ }
299
+
300
+ /**
301
+ * The instance's boot, started by the first event that needs it and booted again once a failed boot's backoff has
302
+ * passed; concurrent events share one boot and one retry.
303
+ */
304
+ async #current(): Promise<Outcome> {
305
+ if (this.#outcome === null) {
306
+ this.#outcome = this.#boot()
307
+ }
308
+ const pending = this.#outcome
309
+ const outcome = await pending
310
+ if (!('failure' in outcome) || Date.now() < this.#retryAt) {
311
+ return outcome
312
+ }
313
+ if (this.#outcome === pending) {
314
+ this.#outcome = this.#boot()
315
+ }
316
+ return await this.#outcome
317
+ }
318
+
319
+ #serve(booted: Booted, request: Request): Promise<Response> {
320
+ const tick = request.headers.get(TICK_HEADER)
321
+ if (tick !== null) {
322
+ return Promise.resolve(this.#tick(booted, tick))
323
+ }
324
+ const exec: RequestExecutionContext = { waitUntil: (promise) => this.#state.waitUntil(promise) }
325
+ return booted.app.fetch(request, exec)
326
+ }
327
+
328
+ /** The fixed-size digests are compared in constant time, so neither the length nor a prefix of the secret leaks. */
329
+ #fromWorker(request: Request): boolean {
330
+ const presented = request.headers.get(PROXY_SECRET_HEADER) ?? ''
331
+ return this.#secret !== null && presented !== '' && timingSafeEqual(proxySecretDigest(presented), this.#secret)
332
+ }
333
+
334
+ /** Runs every job due in the tick's UTC minute and the jobs' check, each past the answer. */
335
+ #tick(booted: Booted, scheduledTime: string): Response {
336
+ const time = Number(scheduledTime)
337
+ if (!Number.isSafeInteger(time)) {
338
+ return new Response(null, { status: 400 })
339
+ }
340
+ const at = new Date(time)
341
+ const jobs = booted.app.jobs.scheduled
342
+ for (const job of jobs) {
343
+ if (isDueAt(job.schedule, at)) {
344
+ this.#state.waitUntil(runQuietly(booted.app, job.name))
345
+ }
346
+ }
347
+ if (jobs.length > 0) {
348
+ this.#state.waitUntil(booted.app.jobs.check())
349
+ }
350
+ return new Response(null, { status: 204 })
351
+ }
352
+ }
@@ -0,0 +1,159 @@
1
+ // The Worker bindings a contract application's object boots with, each read structurally: IAM's application
2
+ // entrypoint, the SPA's Workers Assets and the owners' proxy Workers.
3
+
4
+ import { DecodedIamMembership, DecodedIamTerminals } from '@fabrika/auth'
5
+ import type { IamMembershipRpc, IamTerminalRpc, MintFromKeyResult } from '@fabrika/auth-core'
6
+ import { parseJwks } from '@fabrika/jwks'
7
+ import { z } from 'zod/v4'
8
+ import type { IamKeySource } from '../implement/boot.js'
9
+ import { CONNECT_BINDING_PREFIX, type ConnectFetch } from '../implement/connect.js'
10
+ import type { SpaAssets } from '../implement/dispatch.js'
11
+ import type { WorkloadTokenSource } from '../implement/workload.js'
12
+ import { isPageRoute } from '../spa-fallback.js'
13
+
14
+ /** The method `name` of a binding, bound to it, or `null` when it has none. */
15
+ export function method(target: unknown, name: string): ((...args: unknown[]) => unknown) | null {
16
+ if (typeof target !== 'object' || target === null || !(name in target)) {
17
+ return null
18
+ }
19
+ const found: unknown = Reflect.get(target, name)
20
+ return typeof found === 'function' ? (...args) => Reflect.apply(found, target, args) : null
21
+ }
22
+
23
+ export function bindingKeySource(binding: unknown): IamKeySource {
24
+ const getJwks = method(binding, 'getJwks')
25
+ if (getJwks === null) {
26
+ throw new Error('the IAM binding is missing (env.IAM); the platform binds it to every application Worker')
27
+ }
28
+ return {
29
+ async getJwks() {
30
+ const jwks = parseJwks(await getJwks())
31
+ if (jwks === null) {
32
+ throw new Error('IAM returned no usable key set')
33
+ }
34
+ return jwks
35
+ },
36
+ }
37
+ }
38
+
39
+ /**
40
+ * IAM's membership operations on the `IAM` binding (`PropustkaApplication`), every answer decoded like the HTTP
41
+ * transport's. A binding without them is no membership transport, and `boot` refuses a contract that needs one.
42
+ */
43
+ export function bindingMembership(binding: unknown): IamMembershipRpc | undefined {
44
+ const methods = ['requestDelegation', 'createScope', 'assignMember', 'revokeMember', 'listMembers', 'issueLink', 'revokeLink'] as const
45
+ if (!methods.every((name) => method(binding, name) !== null)) {
46
+ return undefined
47
+ }
48
+ return new DecodedIamMembership(async (name, input) => {
49
+ const call = method(binding, name)
50
+ if (call === null) {
51
+ throw new Error(`the IAM binding has no ${name}`)
52
+ }
53
+ return await call(input)
54
+ })
55
+ }
56
+
57
+ /** The identifications at a terminal over the IAM binding, when its entrypoint serves them (ADR-0067). */
58
+ export function bindingTerminals(binding: unknown): IamTerminalRpc | undefined {
59
+ const methods = ['inspectIdentification', 'assertIdentity', 'cancelIdentification'] as const
60
+ if (!methods.every((name) => method(binding, name) !== null)) {
61
+ return undefined
62
+ }
63
+ return new DecodedIamTerminals(async (name, input) => {
64
+ const call = method(binding, name)
65
+ if (call === null) {
66
+ throw new Error(`the IAM binding has no ${name}`)
67
+ }
68
+ return await call(input)
69
+ })
70
+ }
71
+
72
+ const mintFromKeyResultSchema = z.union([
73
+ z.strictObject({ ok: z.literal(true), token: z.string().min(1), expiresAt: z.number() }),
74
+ z.strictObject({ ok: z.literal(false), reason: z.enum(['invalid_key', 'unknown_principal', 'disabled', 'token_too_large']) }),
75
+ ])
76
+
77
+ /**
78
+ * IAM's token exchange on the `IAM` binding (`mintFromKey`), for the keys of the jobs, webhooks and public procedures.
79
+ * An answer that is not IAM's decided outcome throws: "IAM could not be asked".
80
+ */
81
+ export function bindingWorkloadTokens(binding: unknown): WorkloadTokenSource | undefined {
82
+ if (method(binding, 'mintFromKey') === null) {
83
+ return undefined
84
+ }
85
+ return {
86
+ async exchange(input): Promise<MintFromKeyResult> {
87
+ const mint = method(binding, 'mintFromKey')
88
+ if (mint === null) {
89
+ throw new Error('the IAM binding has no mintFromKey')
90
+ }
91
+ const parsed = mintFromKeyResultSchema.safeParse(await mint({ app: input.app, key: input.key, requestId: crypto.randomUUID() }))
92
+ if (!parsed.success) {
93
+ throw new Error('IAM answered the workload token exchange with something that is not its outcome')
94
+ }
95
+ return parsed.data
96
+ },
97
+ }
98
+ }
99
+
100
+ /**
101
+ * The SPA and link mounts' files from the Workers Assets binding, whose directory mirrors the request paths: the mount
102
+ * `/admin` reads `admin/…` of it, the root mount the rest (the Cloudflare deploy stages each mount's `dir` there). A page
103
+ * route that names no file (`isPageRoute`) gets the mount's `index.html`, as `diskAssets` serves it: Workers Assets
104
+ * answers `<prefix>/` with it. A missing asset stays 404.
105
+ */
106
+ export function bindingAssets(binding: unknown): SpaAssets | undefined {
107
+ const fetchAsset = method(binding, 'fetch')
108
+ if (fetchAsset === null) {
109
+ return undefined
110
+ }
111
+ const fetched = async (request: Request): Promise<Response> => {
112
+ const response: unknown = await fetchAsset(request)
113
+ if (!(response instanceof Response)) {
114
+ throw new Error('the ASSETS binding answered something that is not a Response')
115
+ }
116
+ return response
117
+ }
118
+ return {
119
+ async serve(mount, request) {
120
+ const response = await fetched(request)
121
+ if (response.status !== 404 || !isPageRoute(request)) {
122
+ return response
123
+ }
124
+ const index = new URL(request.url)
125
+ index.pathname = mount.prefix === '/' ? '/' : `${mount.prefix}/`
126
+ index.search = ''
127
+ if (index.pathname === new URL(request.url).pathname) {
128
+ return response
129
+ }
130
+ await response.body?.cancel()
131
+ return await fetched(new Request(index, { method: request.method, headers: request.headers }))
132
+ },
133
+ }
134
+ }
135
+
136
+ /**
137
+ * The service bindings to owners' proxy Workers (`FABRIKA_CONNECT_<IMPORT>`), which `captureEnvironment` keeps from
138
+ * the application because no `FABRIKA_*` value reaches it. A binding routes by itself: the URL it is called with
139
+ * carries the owner's host, which the proxy Worker selects the application by.
140
+ */
141
+ export function connectBindings(env: Readonly<Record<string, unknown>>): Record<string, ConnectFetch> {
142
+ const bindings: Record<string, ConnectFetch> = {}
143
+ for (const [name, binding] of Object.entries(env)) {
144
+ // Read, not tested with `in`: a binding stub may answer `fetch` without owning the property (Lopata's does not).
145
+ const fetchOwner: unknown = name.startsWith(CONNECT_BINDING_PREFIX) && typeof binding === 'object' && binding !== null
146
+ ? Reflect.get(binding, 'fetch')
147
+ : undefined
148
+ if (typeof fetchOwner === 'function') {
149
+ bindings[name] = async (request) => {
150
+ const response: unknown = await Reflect.apply(fetchOwner, binding, [request])
151
+ if (!(response instanceof Response)) {
152
+ throw new Error(`the ${name} binding answered something that is not a Response`)
153
+ }
154
+ return response
155
+ }
156
+ }
157
+ }
158
+ return bindings
159
+ }
@@ -0,0 +1,16 @@
1
+ // The names the Cloudflare provider and the application's Worker share (ADR-0065); `@fabrika/app/build` exports them.
2
+
3
+ /** The Durable Object class the Cloudflare entry exports: one object per application environment runs it (ADR-0065). */
4
+ export const CLOUDFLARE_APPLICATION_CLASS = 'FabrikaApplication'
5
+
6
+ /** The binding through which the application's Worker reaches its object. */
7
+ export const CLOUDFLARE_APPLICATION_BINDING = 'FABRIKA_APPLICATION'
8
+
9
+ /** The R2 bucket binding of the environment's stored files. */
10
+ export const CLOUDFLARE_FILES_BINDING = 'FABRIKA_FILES'
11
+
12
+ /**
13
+ * The Workers Assets binding the SPA and link mounts serve their files from. Its directory mirrors the request paths:
14
+ * the mount `/admin` serves `<directory>/admin/…`, the root mount `<directory>/…`.
15
+ */
16
+ export const CLOUDFLARE_ASSETS_BINDING = 'ASSETS'