@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,713 @@
1
+ // The launcher of a `dev` environment: ADR-0017's launcher, kept as the parent for the life of the container. It holds
2
+ // the provisioning credential and the `source` key, loads no application module, and runs the serving entry with the
3
+ // runtime credential only. It holds a long poll on Control's source receiver (ADR-0064); a new head brings the changed
4
+ // files, which it writes, then it migrates when `db/migrations/` changed, runs the SPA commands when a web source
5
+ // changed, and respawns the application. A change the build owns is refused with one line: it needs a bootstrap run.
6
+ // A new container migrates and verifies its schema here, not in an init command: only after Control said whether a reset
7
+ // is pending, since the database still holds what the previous container left.
8
+
9
+ import {
10
+ DEV_LAUNCHER_BOOTED_FILE,
11
+ DEV_LAUNCHER_CONFIG_FILE,
12
+ DEV_LAUNCHER_STATE_FILE,
13
+ type DevLauncherConfig,
14
+ FABRIKA_CONTRACT_DIGEST,
15
+ platformTransportVariables,
16
+ } from '@fabrika/auth-core'
17
+ import { applicationRuntimeUrl, dropApplicationDatabase } from '@fabrika/platform-node'
18
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
19
+ import { dirname, join } from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+ import { z } from 'zod/v4'
22
+ import { makeProcessUndumpable } from '../undumpable.js'
23
+ import { bootstrapReason, migrationsChanged, webSourceChanged } from './changes.js'
24
+ import { OutputRing, type OutputSink, type OutputStream, processOutput, readLines } from './output.js'
25
+ import {
26
+ DEV_SOURCE_REPORT_PATH,
27
+ DEV_SOURCE_WAIT_MS,
28
+ DEV_SOURCE_WAIT_PATH,
29
+ type DevCheckResult,
30
+ type DevReport,
31
+ type DevStatus,
32
+ type DevSync,
33
+ type DevWaitAnswer,
34
+ encodeDevReport,
35
+ encodeDevWaitRequest,
36
+ parseDevWaitAnswer,
37
+ } from './wire.js'
38
+
39
+ const PROVISIONING_DATABASE_URL = 'FABRIKA_APP_DATABASE_URL'
40
+ const RUNTIME_DATABASE_URL = 'FABRIKA_APP_RUNTIME_DATABASE_URL'
41
+ const SOURCE = platformTransportVariables('source')
42
+
43
+ /** Lines kept, and lines one report carries. */
44
+ const RING_LINES = 500
45
+ const REPORT_LINES = 100
46
+ /** A check's output reported: its last lines. */
47
+ const CHECK_OUTPUT_LINES = 60
48
+ const HEALTH_TIMEOUT_MS = 30_000
49
+ const STOP_GRACE_MS = 3_000
50
+ const CHECK_TIMEOUT_MS = 10 * 60_000
51
+ const CRASH_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000] as const
52
+ const FORWARDED_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const
53
+ const DIGEST_TIMEOUT_MS = 60_000
54
+ const CONTRACT_DIGEST = /^sha256:[0-9a-f]{64}$/
55
+ const CONTRACT_DIGEST_CHILD = fileURLToPath(new URL('./contract-digest.ts', import.meta.url))
56
+
57
+ const commitSha = z.string().regex(/^[0-9a-f]{40}$/)
58
+ /** A literal path of unreserved characters, as the provider's readiness probe requests it. */
59
+ const HEALTH_PATH = /^(?:\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+$/
60
+
61
+ const configSchema: z.ZodType<DevLauncherConfig> = z.object({
62
+ format: z.literal(1),
63
+ appId: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
64
+ head: commitSha,
65
+ port: z.number().int().min(1).max(65535),
66
+ healthPath: z.string().regex(HEALTH_PATH),
67
+ serve: z.array(z.string()).min(1),
68
+ migrate: z.array(z.string()).min(1),
69
+ spaBuild: z.array(z.string()),
70
+ checks: z.array(z.object({ name: z.string().min(1).max(64), command: z.string().min(1) }).strict()),
71
+ }).strict()
72
+
73
+ const stateSchema = z.object({
74
+ head: commitSha,
75
+ reset: z.number().int().min(0).nullable(),
76
+ checks: z.number().int().min(0).nullable(),
77
+ }).strict()
78
+
79
+ type LauncherState = z.infer<typeof stateSchema>
80
+
81
+ /** The launcher's configuration, which the build generated; a missing or malformed file stops the launch. */
82
+ export async function readDevLauncherConfig(root: string): Promise<DevLauncherConfig> {
83
+ const text = await readFile(join(root, DEV_LAUNCHER_CONFIG_FILE), 'utf8').catch(() => null)
84
+ if (text === null) throw new Error(`fabrika dev: ${DEV_LAUNCHER_CONFIG_FILE} is missing; only the build of a dev environment writes it`)
85
+ const parsed = configSchema.safeParse(JSON.parse(text))
86
+ if (!parsed.success) throw new Error(`fabrika dev: ${DEV_LAUNCHER_CONFIG_FILE} is not a launcher configuration this release reads`)
87
+ return parsed.data
88
+ }
89
+
90
+ /** What the state file holds, or the build's head for a container that has none (a new one). */
91
+ async function readState(root: string, config: DevLauncherConfig): Promise<LauncherState> {
92
+ const text = await readFile(join(root, DEV_LAUNCHER_STATE_FILE), 'utf8').catch(() => null)
93
+ const parsed = text === null ? null : stateSchema.safeParse(JSON.parse(text))
94
+ return parsed?.success === true ? parsed.data : { head: config.head, reset: null, checks: null }
95
+ }
96
+
97
+ /**
98
+ * `fabrika db codegen --verify` of the CLI that runs the configured `fabrika db migrate`. The configuration names no
99
+ * command for it, and a key of its own would fail the strict reader of every earlier 0.9 launcher.
100
+ */
101
+ const verifyCommand = (migrate: readonly string[]): readonly string[] | null =>
102
+ migrate.at(-2) === 'db' && migrate.at(-1) === 'migrate' ? [...migrate.slice(0, -1), 'codegen', '--verify'] : null
103
+
104
+ export interface DevSourceTransport {
105
+ readonly url: string
106
+ readonly key: string
107
+ }
108
+
109
+ export interface DevLauncherOptions {
110
+ readonly root: string
111
+ readonly config: DevLauncherConfig
112
+ /** The launcher's own environment: the application's, with the provisioning credential and the `source` key. */
113
+ readonly environment: Readonly<Record<string, string>>
114
+ readonly source: DevSourceTransport
115
+ readonly fetch?: (request: Request) => Promise<Response>
116
+ readonly output?: OutputSink
117
+ /** How long a boot tries to reach Control before it serves the files it holds. */
118
+ readonly bootReconcileMs?: number
119
+ }
120
+
121
+ export interface DevLauncher {
122
+ /** Settles once the first boot served, or failed and reported it. */
123
+ readonly ready: Promise<void>
124
+ /** Stop the long poll and the application. */
125
+ stop(): Promise<void>
126
+ }
127
+
128
+ interface RunningApplication {
129
+ readonly process: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
130
+ readonly exited: Promise<number>
131
+ stopping: boolean
132
+ }
133
+
134
+ interface CommandResult {
135
+ readonly exitCode: number | null
136
+ /** The signal that ended the command, when one did: it never exited on its own. */
137
+ readonly signal: string | null
138
+ readonly lines: readonly string[]
139
+ }
140
+
141
+ /** Bun echoes the script it runs (`$ bun build-web.ts`) on stderr; it names the command, not a failure. */
142
+ const BUN_SCRIPT_ECHO = /^\$ \S/
143
+
144
+ type SyncOutcome =
145
+ | { readonly kind: 'applied'; readonly migrations: boolean; readonly spaFailure: string | null }
146
+ | { readonly kind: 'refused'; readonly reason: string }
147
+
148
+ /** Control answers 409 to the container of a bootstrap run that a newer one replaced. */
149
+ class SupersededError extends Error {}
150
+
151
+ const SUPERSEDED = 'a newer bootstrap run replaced this container; it stops following Control and serves until it is stopped'
152
+
153
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
154
+
155
+ /** Every value of the environment a log line must never show: the database credentials and every key, secret or token. */
156
+ function secretValues(environment: Readonly<Record<string, string>>, runtimeUrl: string | null): string[] {
157
+ const values = new Set<string>()
158
+ const add = (value: string | undefined): void => {
159
+ if (value !== undefined && value.length >= 8) values.add(value)
160
+ }
161
+ for (const url of [environment[PROVISIONING_DATABASE_URL], runtimeUrl ?? undefined]) {
162
+ add(url)
163
+ if (url !== undefined && URL.canParse(url)) add(decodeURIComponent(new URL(url).password))
164
+ }
165
+ for (const [name, value] of Object.entries(environment)) {
166
+ if (/(KEY|SECRET|PASSWORD|TOKEN|DATABASE_URL)$/.test(name)) add(value)
167
+ }
168
+ return [...values].sort((left, right) => right.length - left.length)
169
+ }
170
+
171
+ const withoutFramework = (environment: Readonly<Record<string, string>>): Record<string, string> =>
172
+ Object.fromEntries(Object.entries(environment).filter(([name]) => !name.startsWith('FABRIKA_')))
173
+
174
+ /** Start the launcher: reconcile to the recorded head, serve, then follow Control's long poll. */
175
+ export function startDevLauncher(options: DevLauncherOptions): DevLauncher {
176
+ const launcher = new Launcher(options)
177
+ return { ready: launcher.start(), stop: () => launcher.stop() }
178
+ }
179
+
180
+ class Launcher {
181
+ private readonly root: string
182
+ private readonly config: DevLauncherConfig
183
+ private readonly fetch: (request: Request) => Promise<Response>
184
+ private readonly provisioningUrl: string | null
185
+ private readonly applicationEnvironment: Record<string, string>
186
+ private readonly commandEnvironment: Record<string, string>
187
+ private readonly ring: OutputRing
188
+ private readonly controller = new AbortController()
189
+ private state: LauncherState = { head: '', reset: null, checks: null }
190
+ private status: DevStatus = 'syncing'
191
+ private target: string | null = null
192
+ private message: string | null = null
193
+ private checkResults: DevReport['checks'] = null
194
+ private application: RunningApplication | null = null
195
+ private actions: Promise<void> = Promise.resolve()
196
+ private crashes = 0
197
+ private stopped = false
198
+ private reportFailing = false
199
+ /** A newer bootstrap run replaced this container: it keeps serving until the platform stops it, and asks nothing. */
200
+ private superseded = false
201
+ /** A new container checks the schema types at boot, as a bootstrap does; a restarted one holds synced files instead. */
202
+ private verifyAtBoot = false
203
+
204
+ constructor(private readonly options: DevLauncherOptions) {
205
+ this.root = options.root
206
+ this.config = options.config
207
+ this.fetch = options.fetch ?? ((request) => fetch(request))
208
+ const provisioning = options.environment[PROVISIONING_DATABASE_URL] ?? ''
209
+ this.provisioningUrl = provisioning === '' ? null : provisioning
210
+ const runtimeUrl = this.provisioningUrl === null ? null : applicationRuntimeUrl(this.provisioningUrl, this.config.appId)
211
+ const application: Record<string, string> = {}
212
+ for (const [name, value] of Object.entries(options.environment)) {
213
+ if (name !== PROVISIONING_DATABASE_URL && name !== SOURCE.url && name !== SOURCE.key) application[name] = value
214
+ }
215
+ if (runtimeUrl !== null) application[RUNTIME_DATABASE_URL] = runtimeUrl
216
+ this.applicationEnvironment = application
217
+ this.commandEnvironment = withoutFramework(options.environment)
218
+ this.ring = new OutputRing(RING_LINES, secretValues(options.environment, runtimeUrl), options.output ?? processOutput)
219
+ }
220
+
221
+ async start(): Promise<void> {
222
+ const booted = join(this.root, DEV_LAUNCHER_BOOTED_FILE)
223
+ await rm(booted, { force: true })
224
+ this.state = await readState(this.root, this.config)
225
+ const fresh = this.state.reset === null
226
+ this.verifyAtBoot = fresh
227
+ let answer = await this.firstAnswer()
228
+ // A new container's first answer carries the reset generation the previous container applied; the second, any
229
+ // reset still pending, which the boot then runs before it migrates over the previous container's schema.
230
+ if (fresh && answer !== null && !this.superseded) {
231
+ this.state = { ...this.state, reset: answer.reset, checks: answer.checks }
232
+ answer = (await this.firstAnswer()) ?? answer
233
+ }
234
+ const boot = answer ?? { sync: null, reset: this.state.reset ?? 0, checks: this.state.checks ?? 0 }
235
+ try {
236
+ await this.enqueue(() => this.apply(boot, true))
237
+ } finally {
238
+ await mkdir(dirname(booted), { recursive: true })
239
+ await writeFile(booted, '')
240
+ }
241
+ void this.follow()
242
+ }
243
+
244
+ async stop(): Promise<void> {
245
+ this.stopped = true
246
+ this.controller.abort()
247
+ await this.actions
248
+ await this.stopApplication()
249
+ }
250
+
251
+ private say(line: string, stream: 'stdout' | 'stderr' = 'stdout'): void {
252
+ this.ring.push(stream, `fabrika dev: ${line}`)
253
+ }
254
+
255
+ /** Actions run one at a time: a sync, a reset, a check run and a respawn after a crash never interleave. */
256
+ private enqueue(action: () => Promise<void>): Promise<void> {
257
+ const next = this.actions.then(action, action)
258
+ this.actions = next.catch(() => undefined)
259
+ return next
260
+ }
261
+
262
+ /** At boot: the answer for the head this container holds, without holding, so the launcher reconciles before it serves. */
263
+ private async firstAnswer(): Promise<DevWaitAnswer | null> {
264
+ const deadline = Date.now() + (this.options.bootReconcileMs ?? 20_000)
265
+ while (!this.stopped) {
266
+ try {
267
+ return await this.wait(false)
268
+ } catch (error) {
269
+ if (error instanceof SupersededError) {
270
+ this.say(SUPERSEDED, 'stderr')
271
+ this.superseded = true
272
+ return null
273
+ }
274
+ if (Date.now() >= deadline) {
275
+ this.say('Control did not answer at boot; serving the files this container holds', 'stderr')
276
+ return null
277
+ }
278
+ await sleep(1_000)
279
+ }
280
+ }
281
+ return null
282
+ }
283
+
284
+ private async follow(): Promise<void> {
285
+ let backoff = 1_000
286
+ while (!this.stopped && !this.superseded) {
287
+ let answer: DevWaitAnswer
288
+ try {
289
+ answer = await this.wait(true)
290
+ backoff = 1_000
291
+ } catch (error) {
292
+ if (this.stopped) return
293
+ if (error instanceof SupersededError) {
294
+ this.say(SUPERSEDED, 'stderr')
295
+ this.superseded = true
296
+ return
297
+ }
298
+ this.say(`the long poll on Control failed; retrying in ${backoff / 1000} s`, 'stderr')
299
+ await sleep(backoff)
300
+ backoff = Math.min(backoff * 2, 15_000)
301
+ continue
302
+ }
303
+ await this.enqueue(() => this.apply(answer, false))
304
+ }
305
+ }
306
+
307
+ private async wait(hold: boolean): Promise<DevWaitAnswer> {
308
+ const body = encodeDevWaitRequest({
309
+ bootstrap: this.config.head,
310
+ head: this.state.head,
311
+ reset: this.state.reset,
312
+ checks: this.state.checks,
313
+ hold,
314
+ })
315
+ const signal = AbortSignal.any([this.controller.signal, AbortSignal.timeout(DEV_SOURCE_WAIT_MS + 15_000)])
316
+ const response = await this.fetch(this.request(DEV_SOURCE_WAIT_PATH, body, signal))
317
+ if (response.status === 409) {
318
+ await response.body?.cancel()
319
+ throw new SupersededError()
320
+ }
321
+ if (!response.ok) throw new Error(`the source receiver answered ${response.status}`)
322
+ const parsed = parseDevWaitAnswer(await response.json())
323
+ if (!parsed.ok) throw new Error('the source receiver answered a body this release does not read')
324
+ return parsed.value
325
+ }
326
+
327
+ private request(path: string, body: string, signal: AbortSignal): Request {
328
+ return new Request(new URL(path, this.options.source.url), {
329
+ method: 'POST',
330
+ headers: { authorization: `Bearer ${this.options.source.key}`, 'content-type': 'application/json' },
331
+ body,
332
+ signal,
333
+ })
334
+ }
335
+
336
+ private async report(): Promise<void> {
337
+ if (this.superseded) return
338
+ const body = encodeDevReport({
339
+ bootstrap: this.config.head,
340
+ head: this.state.head,
341
+ status: this.status,
342
+ target: this.target,
343
+ message: this.message,
344
+ log: this.ring.tail(REPORT_LINES),
345
+ reset: this.state.reset,
346
+ checks: this.checkResults,
347
+ })
348
+ try {
349
+ const response = await this.fetch(this.request(DEV_SOURCE_REPORT_PATH, body, AbortSignal.timeout(10_000)))
350
+ await response.body?.cancel()
351
+ if (!response.ok) throw new Error(`the source receiver answered ${response.status}`)
352
+ this.reportFailing = false
353
+ } catch {
354
+ if (!this.reportFailing) this.say('reporting to Control failed; the next report retries', 'stderr')
355
+ this.reportFailing = true
356
+ }
357
+ }
358
+
359
+ private async apply(answer: DevWaitAnswer, boot: boolean): Promise<void> {
360
+ if (this.state.reset === null) this.state = { ...this.state, reset: answer.reset }
361
+ if (this.state.checks === null) this.state = { ...this.state, checks: answer.checks }
362
+ let changed = boot
363
+ let migrate = boot
364
+ let spaFailure: string | null = null
365
+ const reset = answer.reset > (this.state.reset ?? answer.reset)
366
+ if (answer.sync !== null || reset) this.ring.mark()
367
+ if (answer.sync !== null) {
368
+ const outcome = await this.writeSync(answer.sync)
369
+ if (outcome.kind === 'refused') {
370
+ this.status = 'needs_bootstrap'
371
+ this.target = answer.sync.to
372
+ this.message = outcome.reason
373
+ this.say(outcome.reason, 'stderr')
374
+ await this.report()
375
+ } else {
376
+ changed = true
377
+ migrate ||= outcome.migrations
378
+ spaFailure = outcome.spaFailure
379
+ }
380
+ }
381
+ if (changed || reset) {
382
+ await this.converge({ migrate: migrate || reset, verify: (boot && this.verifyAtBoot) || reset, reset, spaFailure, resetTo: answer.reset })
383
+ }
384
+ if (answer.checks > (this.state.checks ?? answer.checks)) await this.check(answer.checks)
385
+ }
386
+
387
+ /** Bring the database, the SPA and the running application to the files on disk, then report. */
388
+ private async converge(
389
+ steps: { migrate: boolean; verify: boolean; reset: boolean; spaFailure: string | null; resetTo: number },
390
+ ): Promise<void> {
391
+ this.status = steps.reset ? 'resetting' : 'syncing'
392
+ void this.report()
393
+ let failure: string | null = null
394
+ // A migration's ALTER would queue behind the live version's locks, and a reset drops the role it connects as.
395
+ if (this.provisioningUrl !== null && steps.migrate) await this.stopApplication()
396
+ if (steps.reset) {
397
+ failure = await this.timed('the reset', () => this.resetDatabase())
398
+ this.state = { ...this.state, reset: steps.resetTo }
399
+ }
400
+ if (failure === null && steps.migrate) failure = await this.timed('the migration', () => this.migrate())
401
+ if (failure === null && steps.verify) failure = await this.timed('the schema check', () => this.verifySchema())
402
+ if (failure === null) failure = steps.spaFailure
403
+ const served = await this.timed('the restart until healthy', () => this.restartApplication())
404
+ failure ??= served
405
+ this.status = failure === null ? 'serving' : 'failed'
406
+ this.target = null
407
+ this.message = failure
408
+ await this.persist()
409
+ await this.report()
410
+ }
411
+
412
+ /** Run one step of a sync and log how long it took: what a slow `apply_changes` is read from. */
413
+ private async timed(step: string, run: () => Promise<string | null>): Promise<string | null> {
414
+ const started = performance.now()
415
+ const failure = await run()
416
+ this.say(`${step} ${failure === null ? 'took' : 'failed after'} ${Math.round(performance.now() - started)} ms`)
417
+ return failure
418
+ }
419
+
420
+ private async writeSync(sync: DevSync): Promise<SyncOutcome> {
421
+ if (sync.from !== this.state.head) {
422
+ return { kind: 'refused', reason: `Control sent changes from ${sync.from}, and this container holds ${this.state.head}; call bind_dev again` }
423
+ }
424
+ const paths = sync.files.map((file) => file.path)
425
+ const reason = bootstrapReason(paths)
426
+ if (reason !== null) return { kind: 'refused', reason }
427
+ const previous = await Promise.all(
428
+ sync.files.map(async (file) => ({ path: file.path, content: await readFile(join(this.root, file.path)).catch(() => null) })),
429
+ )
430
+ await this.writeFiles(sync.files)
431
+ const web = this.config.spaBuild.length > 0 && webSourceChanged(paths)
432
+ // The SPA build runs beside the digest: a refusal is rare, and then the build runs again over the restored files.
433
+ const [changedContract, spaFailure] = await Promise.all([this.contractChange(), web ? this.spaBuild() : null])
434
+ if (changedContract !== null) {
435
+ await this.writeFiles(previous)
436
+ if (web) await this.spaBuild()
437
+ return { kind: 'refused', reason: changedContract }
438
+ }
439
+ this.state = { ...this.state, head: sync.to }
440
+ this.say(`synced ${sync.files.length} files to ${sync.to}`)
441
+ return { kind: 'applied', migrations: migrationsChanged(paths), spaFailure }
442
+ }
443
+
444
+ private spaBuild(): Promise<string | null> {
445
+ return this.timed('the SPA build', () => this.buildSpa())
446
+ }
447
+
448
+ private async writeFiles(files: readonly { readonly path: string; readonly content: string | Uint8Array | null }[]): Promise<void> {
449
+ for (const file of files) {
450
+ const path = join(this.root, file.path)
451
+ if (file.content === null) {
452
+ await rm(path, { force: true })
453
+ continue
454
+ }
455
+ await mkdir(dirname(path), { recursive: true })
456
+ await writeFile(`${path}.fabrika-dev`, file.content)
457
+ await rename(`${path}.fabrika-dev`, path)
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Why the files on disk need a bootstrap run because their contract digest is not the one the bootstrap run deployed,
463
+ * or null. A child computes it: it loads the application module, which the launcher never does, with no credential.
464
+ * A digest it cannot compute is no reason: the respawned application then fails to boot and reports why.
465
+ */
466
+ private async contractChange(): Promise<string | null> {
467
+ const deployed = this.options.environment[FABRIKA_CONTRACT_DIGEST] ?? ''
468
+ if (deployed === '') return null
469
+ const started = performance.now()
470
+ const child = Bun.spawn(['bun', CONTRACT_DIGEST_CHILD], {
471
+ cwd: this.root,
472
+ env: { PATH: this.options.environment['PATH'] ?? '', HOME: this.options.environment['HOME'] ?? '' },
473
+ stdin: 'ignore',
474
+ stdout: 'pipe',
475
+ stderr: 'pipe',
476
+ })
477
+ const timer = setTimeout(() => child.kill('SIGKILL'), DIGEST_TIMEOUT_MS)
478
+ const [stdout, stderr] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
479
+ clearTimeout(timer)
480
+ const digest = stdout.trim()
481
+ if (child.exitCode !== 0 || !CONTRACT_DIGEST.test(digest)) {
482
+ for (const line of stderr.split('\n').filter((text) => text.trim() !== '').slice(-5)) this.ring.push('stderr', line)
483
+ this.say('the contract digest could not be computed; the application is respawned and reports why', 'stderr')
484
+ return null
485
+ }
486
+ this.say(`contract digest computed in ${Math.round(performance.now() - started)} ms`)
487
+ if (digest === deployed) return null
488
+ return `the change alters the contract (its digest is ${digest}, and the bootstrap run deployed ${deployed}): the proxy table and `
489
+ + 'the approval follow the contract, so that needs a bootstrap run: call bind_dev again'
490
+ }
491
+
492
+ private async persist(): Promise<void> {
493
+ const path = join(this.root, DEV_LAUNCHER_STATE_FILE)
494
+ await mkdir(dirname(path), { recursive: true })
495
+ await writeFile(`${path}.tmp`, JSON.stringify(this.state))
496
+ await rename(`${path}.tmp`, path)
497
+ }
498
+
499
+ private async migrate(): Promise<string | null> {
500
+ if (this.provisioningUrl === null) return null
501
+ const result = await this.run(this.config.migrate, {
502
+ ...this.commandEnvironment,
503
+ FABRIKA_APP_ID: this.config.appId,
504
+ [PROVISIONING_DATABASE_URL]: this.provisioningUrl,
505
+ })
506
+ if (result.exitCode === 0) return null
507
+ return `fabrika db migrate failed: ${lastLine(result)}; if an applied migration changed, call reset_dev`
508
+ }
509
+
510
+ /** The committed schema types against the migrated schema, as a bootstrap verifies them: at a new container's boot and after a reset. */
511
+ private async verifySchema(): Promise<string | null> {
512
+ const command = verifyCommand(this.config.migrate)
513
+ if (this.provisioningUrl === null || command === null) return null
514
+ const result = await this.run(command, {
515
+ ...this.commandEnvironment,
516
+ FABRIKA_APP_ID: this.config.appId,
517
+ [PROVISIONING_DATABASE_URL]: this.provisioningUrl,
518
+ })
519
+ if (result.exitCode === 0) return null
520
+ return `fabrika db codegen --verify failed: ${lastLine(result)}; if the database holds another commit's schema, call reset_dev`
521
+ }
522
+
523
+ private async resetDatabase(): Promise<string | null> {
524
+ if (this.provisioningUrl === null) return null
525
+ this.say(`dropping the schema and roles of ${this.config.appId}`)
526
+ try {
527
+ await dropApplicationDatabase(this.provisioningUrl, this.config.appId)
528
+ return null
529
+ } catch (error) {
530
+ // The driver's error may quote the URL, so only its class is named.
531
+ this.say(`dropping the schema failed (${error instanceof Error ? error.name : 'unknown error'})`, 'stderr')
532
+ return "dropping the environment's schema and roles failed; see the log"
533
+ }
534
+ }
535
+
536
+ private async buildSpa(): Promise<string | null> {
537
+ for (const command of this.config.spaBuild) {
538
+ const result = await this.run(
539
+ ['sh', '-c', command],
540
+ this.commandEnvironment,
541
+ CHECK_TIMEOUT_MS,
542
+ (stream, line) => stream === 'stderr' && BUN_SCRIPT_ECHO.test(line) ? 'stdout' : stream,
543
+ )
544
+ if (result.exitCode !== 0) return `the SPA command \`${command}\` failed: ${lastLine(result)}`
545
+ }
546
+ return null
547
+ }
548
+
549
+ private async check(request: number): Promise<void> {
550
+ const before = { status: this.status, message: this.message }
551
+ this.status = 'checking'
552
+ await this.report()
553
+ const results: DevCheckResult[] = []
554
+ for (const check of this.config.checks) {
555
+ this.say(`check ${check.name}: ${check.command}`)
556
+ const started = performance.now()
557
+ const result = await this.run(['sh', '-c', check.command], this.commandEnvironment, CHECK_TIMEOUT_MS)
558
+ const durationMs = Math.round(performance.now() - started)
559
+ const killed = result.signal === null ? [] : [killedLine(check.name, result.signal, durationMs >= CHECK_TIMEOUT_MS)]
560
+ for (const line of killed) this.say(line, 'stderr')
561
+ results.push({
562
+ name: check.name,
563
+ ok: result.exitCode === 0,
564
+ exitCode: result.exitCode,
565
+ durationMs,
566
+ output: [...result.lines.slice(-(CHECK_OUTPUT_LINES - killed.length)), ...killed].map((line) => line.slice(0, 2_000)),
567
+ })
568
+ }
569
+ this.checkResults = { request, head: this.state.head, results }
570
+ this.state = { ...this.state, checks: request }
571
+ this.status = before.status
572
+ this.message = before.message
573
+ await this.persist()
574
+ await this.report()
575
+ }
576
+
577
+ /** Run a command in the root; its output goes to the log, and its lines are returned. */
578
+ private async run(
579
+ argv: readonly string[],
580
+ env: Record<string, string>,
581
+ timeoutMs = CHECK_TIMEOUT_MS,
582
+ classify: (stream: OutputStream, line: string) => OutputStream = (stream) => stream,
583
+ ): Promise<CommandResult> {
584
+ const child = Bun.spawn([...argv], { cwd: this.root, env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
585
+ const lines: string[] = []
586
+ const collect = (stream: OutputStream) => (line: string) => {
587
+ lines.push(line)
588
+ this.ring.push(classify(stream, line), line)
589
+ }
590
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
591
+ await Promise.all([readLines(child.stdout, collect('stdout')), readLines(child.stderr, collect('stderr')), child.exited])
592
+ clearTimeout(timer)
593
+ return { exitCode: child.exitCode, signal: child.signalCode === null ? null : String(child.signalCode), lines }
594
+ }
595
+
596
+ private async restartApplication(): Promise<string | null> {
597
+ await this.stopApplication()
598
+ const running = this.spawnApplication()
599
+ const failure = await this.awaitHealthy(running)
600
+ if (failure === null) this.crashes = 0
601
+ return failure
602
+ }
603
+
604
+ private spawnApplication(): RunningApplication {
605
+ const child = Bun.spawn([...this.config.serve], {
606
+ cwd: this.root,
607
+ env: this.applicationEnvironment,
608
+ stdin: 'ignore',
609
+ stdout: 'pipe',
610
+ stderr: 'pipe',
611
+ })
612
+ void readLines(child.stdout, (line) => this.ring.push('stdout', line))
613
+ void readLines(child.stderr, (line) => this.ring.push('stderr', line))
614
+ const running: RunningApplication = { process: child, exited: child.exited, stopping: false }
615
+ this.application = running
616
+ void running.exited.then((code) => {
617
+ if (running.stopping || this.stopped || this.application !== running) return
618
+ this.application = null
619
+ this.crashed(code)
620
+ })
621
+ return running
622
+ }
623
+
624
+ /** An application that exited on its own is reported and respawned after a growing pause. */
625
+ private crashed(code: number): void {
626
+ const pause = CRASH_BACKOFF_MS[Math.min(this.crashes, CRASH_BACKOFF_MS.length - 1)] ?? 10_000
627
+ this.crashes += 1
628
+ this.status = 'failed'
629
+ this.message = `the application exited with code ${code}; it restarts in ${pause / 1000} s`
630
+ this.say(this.message, 'stderr')
631
+ void this.report()
632
+ setTimeout(() => {
633
+ if (this.stopped) return
634
+ void this.enqueue(async () => {
635
+ if (this.application !== null || this.stopped) return
636
+ const failure = await this.restartApplication()
637
+ this.status = failure === null ? 'serving' : 'failed'
638
+ this.message = failure
639
+ await this.report()
640
+ })
641
+ }, pause)
642
+ }
643
+
644
+ private async awaitHealthy(running: RunningApplication): Promise<string | null> {
645
+ const url = `http://127.0.0.1:${this.config.port}${this.config.healthPath}`
646
+ const deadline = Date.now() + HEALTH_TIMEOUT_MS
647
+ let exited: number | null = null
648
+ void running.exited.then((code) => {
649
+ exited = code
650
+ })
651
+ while (Date.now() < deadline) {
652
+ if (exited !== null) return `the application exited with code ${exited} before it served; see the log`
653
+ try {
654
+ const response = await fetch(url, { signal: AbortSignal.timeout(1_000) })
655
+ await response.body?.cancel()
656
+ if (response.ok) return null
657
+ } catch {
658
+ // Not listening yet.
659
+ }
660
+ await sleep(25)
661
+ }
662
+ return `the application did not answer ${this.config.healthPath} within ${HEALTH_TIMEOUT_MS / 1000} s`
663
+ }
664
+
665
+ private async stopApplication(): Promise<void> {
666
+ const running = this.application
667
+ if (running === null) return
668
+ running.stopping = true
669
+ this.application = null
670
+ running.process.kill('SIGTERM')
671
+ const timer = setTimeout(() => running.process.kill('SIGKILL'), STOP_GRACE_MS)
672
+ await running.exited
673
+ clearTimeout(timer)
674
+ }
675
+ }
676
+
677
+ /** Why a check has no exit code: the launcher's timeout, or a signal from outside, most often the out-of-memory killer. */
678
+ const killedLine = (name: string, signal: string, timedOut: boolean): string =>
679
+ timedOut
680
+ ? `check ${name} was killed (${signal}) after the ${CHECK_TIMEOUT_MS / 60_000}-minute timeout; it failed`
681
+ : `check ${name} was killed (${signal}) before it finished, most likely because the container ran out of memory; it failed`
682
+
683
+ const lastLine = (result: CommandResult): string => [...result.lines].reverse().find((line) => line.trim() !== '')?.trim() ?? 'no output'
684
+
685
+ /** The process environment with its string values only. */
686
+ function stringEnvironment(env: Readonly<Record<string, string | undefined>>): Record<string, string> {
687
+ const strings: Record<string, string> = {}
688
+ for (const [name, value] of Object.entries(env)) {
689
+ if (value !== undefined) strings[name] = value
690
+ }
691
+ return strings
692
+ }
693
+
694
+ /**
695
+ * The generated dev entrypoint's only call. It reads the build's configuration and the `source` transport, makes
696
+ * itself non-dumpable (it holds the provisioning credential and the key), and runs until a signal stops it.
697
+ */
698
+ export async function runDevLauncher(env: Readonly<Record<string, string | undefined>>): Promise<void> {
699
+ const root = process.cwd()
700
+ const config = await readDevLauncherConfig(root)
701
+ const environment = stringEnvironment(env)
702
+ const url = environment[SOURCE.url] ?? ''
703
+ const key = environment[SOURCE.key] ?? ''
704
+ if (url === '' || key === '') {
705
+ throw new Error(`fabrika dev: ${SOURCE.url} and ${SOURCE.key} are not set; the platform writes them for a dev environment`)
706
+ }
707
+ makeProcessUndumpable()
708
+ const launcher = startDevLauncher({ root, config, environment, source: { url, key } })
709
+ for (const signal of FORWARDED_SIGNALS) {
710
+ process.once(signal, () => void launcher.stop().then(() => process.exit(0)))
711
+ }
712
+ await launcher.ready
713
+ }