@fabrika/app 0.8.1 → 0.9.0-dev.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 (135) hide show
  1. package/package.json +35 -24
  2. package/skill/SKILL.md +1 -1
  3. package/skill/references/app-setup.md +8 -8
  4. package/src/dev/changes.ts +35 -0
  5. package/src/dev/contract-digest.ts +17 -0
  6. package/src/dev/index.ts +11 -0
  7. package/src/dev/launcher.ts +604 -0
  8. package/src/dev/output.ts +62 -0
  9. package/src/dev/wire.ts +127 -0
  10. package/types/src/agent-skill.d.ts +29 -0
  11. package/types/src/build.d.ts +11 -0
  12. package/types/src/bun.d.ts +66 -0
  13. package/types/src/client/caller.d.ts +19 -0
  14. package/types/src/client/contract.d.ts +131 -0
  15. package/types/src/client/error-reporting.d.ts +66 -0
  16. package/types/src/client/index.d.ts +4 -0
  17. package/types/src/client/rpc.d.ts +14 -0
  18. package/types/src/client/share.d.ts +2 -0
  19. package/types/src/client/transport.d.ts +42 -0
  20. package/types/src/cloudflare.d.ts +49 -0
  21. package/types/src/contract/artifact-schema.d.ts +8 -0
  22. package/types/src/contract/attachment.d.ts +41 -0
  23. package/types/src/contract/build.d.ts +12 -0
  24. package/types/src/contract/compile.d.ts +11 -0
  25. package/types/src/contract/connect.d.ts +51 -0
  26. package/types/src/contract/context.d.ts +501 -0
  27. package/types/src/contract/declaration.d.ts +303 -0
  28. package/types/src/contract/defaults.d.ts +208 -0
  29. package/types/src/contract/define.d.ts +359 -0
  30. package/types/src/contract/file.d.ts +44 -0
  31. package/types/src/contract/group.d.ts +274 -0
  32. package/types/src/contract/handled.d.ts +18 -0
  33. package/types/src/contract/index.d.ts +19 -0
  34. package/types/src/contract/kit.d.ts +210 -0
  35. package/types/src/contract/platform.d.ts +9 -0
  36. package/types/src/contract/problems.d.ts +14 -0
  37. package/types/src/contract/redirect.d.ts +25 -0
  38. package/types/src/contract/schema-from-shape.d.ts +10 -0
  39. package/types/src/contract/schema-shape.d.ts +5 -0
  40. package/types/src/contract/snapshot-source.d.ts +15 -0
  41. package/types/src/contract/token.d.ts +58 -0
  42. package/types/src/contract/vocabulary.d.ts +30 -0
  43. package/types/src/contract/webhook-binding.d.ts +24 -0
  44. package/types/src/contract/webhook-scheme.d.ts +23 -0
  45. package/types/src/dev/changes.d.ts +7 -0
  46. package/types/src/dev/contract-digest.d.ts +1 -0
  47. package/types/src/dev/index.d.ts +2 -0
  48. package/types/src/dev/launcher.d.ts +32 -0
  49. package/types/src/dev/output.d.ts +22 -0
  50. package/types/src/dev/wire.d.ts +122 -0
  51. package/types/src/disk-assets.d.ts +3 -0
  52. package/types/src/errors.d.ts +46 -0
  53. package/types/src/fetch.d.ts +7 -0
  54. package/types/src/files/archive.d.ts +26 -0
  55. package/types/src/files/clamscan.d.ts +32 -0
  56. package/types/src/files/handle.d.ts +106 -0
  57. package/types/src/files/kinds.d.ts +105 -0
  58. package/types/src/files/scan.d.ts +80 -0
  59. package/types/src/files/sniff.d.ts +7 -0
  60. package/types/src/files/streams.d.ts +17 -0
  61. package/types/src/files/treatment.d.ts +18 -0
  62. package/types/src/from-row.d.ts +20 -0
  63. package/types/src/honeypot.d.ts +2 -0
  64. package/types/src/implement/anonymous.d.ts +26 -0
  65. package/types/src/implement/attachment.d.ts +14 -0
  66. package/types/src/implement/boot.d.ts +109 -0
  67. package/types/src/implement/bounded.d.ts +22 -0
  68. package/types/src/implement/browser-config.d.ts +19 -0
  69. package/types/src/implement/capability.d.ts +140 -0
  70. package/types/src/implement/challenge.d.ts +75 -0
  71. package/types/src/implement/client-address.d.ts +11 -0
  72. package/types/src/implement/connect.d.ts +67 -0
  73. package/types/src/implement/crashes.d.ts +31 -0
  74. package/types/src/implement/dispatch.d.ts +218 -0
  75. package/types/src/implement/egress.d.ts +14 -0
  76. package/types/src/implement/environment.d.ts +14 -0
  77. package/types/src/implement/failures.d.ts +34 -0
  78. package/types/src/implement/faults.d.ts +44 -0
  79. package/types/src/implement/headers.d.ts +7 -0
  80. package/types/src/implement/implement.d.ts +58 -0
  81. package/types/src/implement/index.d.ts +24 -0
  82. package/types/src/implement/invocation.d.ts +120 -0
  83. package/types/src/implement/membership.d.ts +201 -0
  84. package/types/src/implement/multipart.d.ts +36 -0
  85. package/types/src/implement/params.d.ts +7 -0
  86. package/types/src/implement/principal.d.ts +52 -0
  87. package/types/src/implement/project.d.ts +11 -0
  88. package/types/src/implement/public.d.ts +61 -0
  89. package/types/src/implement/serve.d.ts +25 -0
  90. package/types/src/implement/tokens.d.ts +49 -0
  91. package/types/src/implement/types.d.ts +243 -0
  92. package/types/src/implement/urls.d.ts +15 -0
  93. package/types/src/implement/verifiers.d.ts +19 -0
  94. package/types/src/implement/webhook.d.ts +55 -0
  95. package/types/src/implement/workload.d.ts +56 -0
  96. package/types/src/implement/xlsx.d.ts +6 -0
  97. package/types/src/index.d.ts +12 -0
  98. package/types/src/jobs/exception.d.ts +40 -0
  99. package/types/src/jobs/mail.d.ts +67 -0
  100. package/types/src/jobs/report.d.ts +56 -0
  101. package/types/src/jobs/runner.d.ts +145 -0
  102. package/types/src/jobs/schedule.d.ts +15 -0
  103. package/types/src/jobs/scrub.d.ts +12 -0
  104. package/types/src/jobs/types.d.ts +92 -0
  105. package/types/src/react/caller.d.ts +30 -0
  106. package/types/src/react/errors.d.ts +40 -0
  107. package/types/src/react/hooks.d.ts +110 -0
  108. package/types/src/react/index.d.ts +5 -0
  109. package/types/src/react/provider.d.ts +58 -0
  110. package/types/src/react/store.d.ts +68 -0
  111. package/types/src/react/testing.d.ts +28 -0
  112. package/types/src/react/types.d.ts +33 -0
  113. package/types/src/rpc/builder.d.ts +52 -0
  114. package/types/src/rpc/contract.d.ts +23 -0
  115. package/types/src/rpc/dispatcher.d.ts +11 -0
  116. package/types/src/rpc/types.d.ts +62 -0
  117. package/types/src/service/define.d.ts +42 -0
  118. package/types/src/service/index.d.ts +3 -0
  119. package/types/src/service/serve.d.ts +28 -0
  120. package/types/src/standard-schema.d.ts +55 -0
  121. package/types/src/testing/caller.d.ts +95 -0
  122. package/types/src/testing/challenge.d.ts +11 -0
  123. package/types/src/testing/dev.d.ts +97 -0
  124. package/types/src/testing/endpoint-access.d.ts +99 -0
  125. package/types/src/testing/index.d.ts +12 -0
  126. package/types/src/testing/isolation.d.ts +3 -0
  127. package/types/src/testing/kit.d.ts +182 -0
  128. package/types/src/testing/membership.d.ts +132 -0
  129. package/types/src/testing/policy-isolation.d.ts +113 -0
  130. package/types/src/testing/preload.d.ts +1 -0
  131. package/types/src/testing/proxy.d.ts +40 -0
  132. package/types/src/testing/runner.d.ts +8 -0
  133. package/types/src/testing/sample-file.d.ts +5 -0
  134. package/types/src/testing/status.d.ts +4 -0
  135. package/types/src/undumpable.d.ts +5 -0
@@ -0,0 +1,604 @@
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
+
7
+ import {
8
+ DEV_LAUNCHER_CONFIG_FILE,
9
+ DEV_LAUNCHER_STATE_FILE,
10
+ type DevLauncherConfig,
11
+ FABRIKA_CONTRACT_DIGEST,
12
+ platformTransportVariables,
13
+ } from '@fabrika/auth-core'
14
+ import { applicationRuntimeUrl, dropApplicationDatabase } from '@fabrika/platform-node'
15
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
16
+ import { dirname, join } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+ import { z } from 'zod/v4'
19
+ import { makeProcessUndumpable } from '../undumpable.js'
20
+ import { bootstrapReason, migrationsChanged, webSourceChanged } from './changes.js'
21
+ import { OutputRing, type OutputSink, processOutput, readLines } from './output.js'
22
+ import {
23
+ DEV_SOURCE_REPORT_PATH,
24
+ DEV_SOURCE_WAIT_MS,
25
+ DEV_SOURCE_WAIT_PATH,
26
+ type DevCheckResult,
27
+ type DevReport,
28
+ type DevStatus,
29
+ type DevSync,
30
+ type DevWaitAnswer,
31
+ encodeDevReport,
32
+ encodeDevWaitRequest,
33
+ parseDevWaitAnswer,
34
+ } from './wire.js'
35
+
36
+ const PROVISIONING_DATABASE_URL = 'FABRIKA_APP_DATABASE_URL'
37
+ const RUNTIME_DATABASE_URL = 'FABRIKA_APP_RUNTIME_DATABASE_URL'
38
+ const SOURCE = platformTransportVariables('source')
39
+
40
+ /** Lines kept, and lines one report carries. */
41
+ const RING_LINES = 500
42
+ const REPORT_LINES = 100
43
+ /** A check's output reported: its last lines. */
44
+ const CHECK_OUTPUT_LINES = 60
45
+ const HEALTH_TIMEOUT_MS = 30_000
46
+ const STOP_GRACE_MS = 3_000
47
+ const CHECK_TIMEOUT_MS = 10 * 60_000
48
+ const CRASH_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000] as const
49
+ const FORWARDED_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const
50
+ const DIGEST_TIMEOUT_MS = 60_000
51
+ const CONTRACT_DIGEST = /^sha256:[0-9a-f]{64}$/
52
+ const CONTRACT_DIGEST_CHILD = fileURLToPath(new URL('./contract-digest.ts', import.meta.url))
53
+
54
+ const commitSha = z.string().regex(/^[0-9a-f]{40}$/)
55
+ /** A literal path of unreserved characters, as the provider's readiness probe requests it. */
56
+ const HEALTH_PATH = /^(?:\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+$/
57
+
58
+ const configSchema: z.ZodType<DevLauncherConfig> = z.object({
59
+ format: z.literal(1),
60
+ appId: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
61
+ head: commitSha,
62
+ port: z.number().int().min(1).max(65535),
63
+ healthPath: z.string().regex(HEALTH_PATH),
64
+ serve: z.array(z.string()).min(1),
65
+ migrate: z.array(z.string()).min(1),
66
+ spaBuild: z.array(z.string()),
67
+ checks: z.array(z.object({ name: z.string().min(1).max(64), command: z.string().min(1) }).strict()),
68
+ }).strict()
69
+
70
+ const stateSchema = z.object({
71
+ head: commitSha,
72
+ reset: z.number().int().min(0).nullable(),
73
+ checks: z.number().int().min(0).nullable(),
74
+ }).strict()
75
+
76
+ type LauncherState = z.infer<typeof stateSchema>
77
+
78
+ /** The launcher's configuration, which the build generated; a missing or malformed file stops the launch. */
79
+ export async function readDevLauncherConfig(root: string): Promise<DevLauncherConfig> {
80
+ const text = await readFile(join(root, DEV_LAUNCHER_CONFIG_FILE), 'utf8').catch(() => null)
81
+ if (text === null) throw new Error(`fabrika dev: ${DEV_LAUNCHER_CONFIG_FILE} is missing; only the build of a dev environment writes it`)
82
+ const parsed = configSchema.safeParse(JSON.parse(text))
83
+ if (!parsed.success) throw new Error(`fabrika dev: ${DEV_LAUNCHER_CONFIG_FILE} is not a launcher configuration this release reads`)
84
+ return parsed.data
85
+ }
86
+
87
+ /** What the state file holds, or the build's head for a container that has none (a new one). */
88
+ async function readState(root: string, config: DevLauncherConfig): Promise<LauncherState> {
89
+ const text = await readFile(join(root, DEV_LAUNCHER_STATE_FILE), 'utf8').catch(() => null)
90
+ const parsed = text === null ? null : stateSchema.safeParse(JSON.parse(text))
91
+ return parsed?.success === true ? parsed.data : { head: config.head, reset: null, checks: null }
92
+ }
93
+
94
+ export interface DevSourceTransport {
95
+ readonly url: string
96
+ readonly key: string
97
+ }
98
+
99
+ export interface DevLauncherOptions {
100
+ readonly root: string
101
+ readonly config: DevLauncherConfig
102
+ /** The launcher's own environment: the application's, with the provisioning credential and the `source` key. */
103
+ readonly environment: Readonly<Record<string, string>>
104
+ readonly source: DevSourceTransport
105
+ readonly fetch?: (request: Request) => Promise<Response>
106
+ readonly output?: OutputSink
107
+ /** How long a boot tries to reach Control before it serves the files it holds. */
108
+ readonly bootReconcileMs?: number
109
+ }
110
+
111
+ export interface DevLauncher {
112
+ /** Settles once the first boot served, or failed and reported it. */
113
+ readonly ready: Promise<void>
114
+ /** Stop the long poll and the application. */
115
+ stop(): Promise<void>
116
+ }
117
+
118
+ interface RunningApplication {
119
+ readonly process: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
120
+ readonly exited: Promise<number>
121
+ stopping: boolean
122
+ }
123
+
124
+ interface CommandResult {
125
+ readonly exitCode: number | null
126
+ readonly lines: readonly string[]
127
+ }
128
+
129
+ type SyncOutcome =
130
+ | { readonly kind: 'applied'; readonly migrations: boolean; readonly web: boolean }
131
+ | { readonly kind: 'refused'; readonly reason: string }
132
+
133
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
134
+
135
+ /** Every value of the environment a log line must never show: the database credentials and every key, secret or token. */
136
+ function secretValues(environment: Readonly<Record<string, string>>, runtimeUrl: string | null): string[] {
137
+ const values = new Set<string>()
138
+ const add = (value: string | undefined): void => {
139
+ if (value !== undefined && value.length >= 8) values.add(value)
140
+ }
141
+ for (const url of [environment[PROVISIONING_DATABASE_URL], runtimeUrl ?? undefined]) {
142
+ add(url)
143
+ if (url !== undefined && URL.canParse(url)) add(decodeURIComponent(new URL(url).password))
144
+ }
145
+ for (const [name, value] of Object.entries(environment)) {
146
+ if (/(KEY|SECRET|PASSWORD|TOKEN|DATABASE_URL)$/.test(name)) add(value)
147
+ }
148
+ return [...values].sort((left, right) => right.length - left.length)
149
+ }
150
+
151
+ const withoutFramework = (environment: Readonly<Record<string, string>>): Record<string, string> =>
152
+ Object.fromEntries(Object.entries(environment).filter(([name]) => !name.startsWith('FABRIKA_')))
153
+
154
+ /** Start the launcher: reconcile to the recorded head, serve, then follow Control's long poll. */
155
+ export function startDevLauncher(options: DevLauncherOptions): DevLauncher {
156
+ const launcher = new Launcher(options)
157
+ return { ready: launcher.start(), stop: () => launcher.stop() }
158
+ }
159
+
160
+ class Launcher {
161
+ private readonly root: string
162
+ private readonly config: DevLauncherConfig
163
+ private readonly fetch: (request: Request) => Promise<Response>
164
+ private readonly provisioningUrl: string | null
165
+ private readonly applicationEnvironment: Record<string, string>
166
+ private readonly commandEnvironment: Record<string, string>
167
+ private readonly ring: OutputRing
168
+ private readonly controller = new AbortController()
169
+ private state: LauncherState = { head: '', reset: null, checks: null }
170
+ private status: DevStatus = 'syncing'
171
+ private target: string | null = null
172
+ private message: string | null = null
173
+ private checkResults: DevReport['checks'] = null
174
+ private application: RunningApplication | null = null
175
+ private actions: Promise<void> = Promise.resolve()
176
+ private crashes = 0
177
+ private stopped = false
178
+ private reportFailing = false
179
+
180
+ constructor(private readonly options: DevLauncherOptions) {
181
+ this.root = options.root
182
+ this.config = options.config
183
+ this.fetch = options.fetch ?? ((request) => fetch(request))
184
+ const provisioning = options.environment[PROVISIONING_DATABASE_URL] ?? ''
185
+ this.provisioningUrl = provisioning === '' ? null : provisioning
186
+ const runtimeUrl = this.provisioningUrl === null ? null : applicationRuntimeUrl(this.provisioningUrl, this.config.appId)
187
+ const application: Record<string, string> = {}
188
+ for (const [name, value] of Object.entries(options.environment)) {
189
+ if (name !== PROVISIONING_DATABASE_URL && name !== SOURCE.url && name !== SOURCE.key) application[name] = value
190
+ }
191
+ if (runtimeUrl !== null) application[RUNTIME_DATABASE_URL] = runtimeUrl
192
+ this.applicationEnvironment = application
193
+ this.commandEnvironment = withoutFramework(options.environment)
194
+ this.ring = new OutputRing(RING_LINES, secretValues(options.environment, runtimeUrl), options.output ?? processOutput)
195
+ }
196
+
197
+ async start(): Promise<void> {
198
+ this.state = await readState(this.root, this.config)
199
+ const answer = await this.firstAnswer()
200
+ await this.enqueue(() => this.apply(answer ?? { sync: null, reset: this.state.reset ?? 0, checks: this.state.checks ?? 0 }, true))
201
+ void this.follow()
202
+ }
203
+
204
+ async stop(): Promise<void> {
205
+ this.stopped = true
206
+ this.controller.abort()
207
+ await this.actions
208
+ await this.stopApplication()
209
+ }
210
+
211
+ private say(line: string, stream: 'stdout' | 'stderr' = 'stdout'): void {
212
+ this.ring.push(stream, `fabrika dev: ${line}`)
213
+ }
214
+
215
+ /** Actions run one at a time: a sync, a reset, a check run and a respawn after a crash never interleave. */
216
+ private enqueue(action: () => Promise<void>): Promise<void> {
217
+ const next = this.actions.then(action, action)
218
+ this.actions = next.catch(() => undefined)
219
+ return next
220
+ }
221
+
222
+ /** At boot: the answer for the head this container holds, without holding, so the launcher reconciles before it serves. */
223
+ private async firstAnswer(): Promise<DevWaitAnswer | null> {
224
+ const deadline = Date.now() + (this.options.bootReconcileMs ?? 20_000)
225
+ while (!this.stopped) {
226
+ try {
227
+ return await this.wait(false)
228
+ } catch {
229
+ if (Date.now() >= deadline) {
230
+ this.say('Control did not answer at boot; serving the files this container holds', 'stderr')
231
+ return null
232
+ }
233
+ await sleep(1_000)
234
+ }
235
+ }
236
+ return null
237
+ }
238
+
239
+ private async follow(): Promise<void> {
240
+ let backoff = 1_000
241
+ while (!this.stopped) {
242
+ let answer: DevWaitAnswer
243
+ try {
244
+ answer = await this.wait(true)
245
+ backoff = 1_000
246
+ } catch {
247
+ if (this.stopped) return
248
+ this.say(`the long poll on Control failed; retrying in ${backoff / 1000} s`, 'stderr')
249
+ await sleep(backoff)
250
+ backoff = Math.min(backoff * 2, 15_000)
251
+ continue
252
+ }
253
+ await this.enqueue(() => this.apply(answer, false))
254
+ }
255
+ }
256
+
257
+ private async wait(hold: boolean): Promise<DevWaitAnswer> {
258
+ const body = encodeDevWaitRequest({ head: this.state.head, reset: this.state.reset, checks: this.state.checks, hold })
259
+ const signal = AbortSignal.any([this.controller.signal, AbortSignal.timeout(DEV_SOURCE_WAIT_MS + 15_000)])
260
+ const response = await this.fetch(this.request(DEV_SOURCE_WAIT_PATH, body, signal))
261
+ if (!response.ok) throw new Error(`the source receiver answered ${response.status}`)
262
+ const parsed = parseDevWaitAnswer(await response.json())
263
+ if (!parsed.ok) throw new Error('the source receiver answered a body this release does not read')
264
+ return parsed.value
265
+ }
266
+
267
+ private request(path: string, body: string, signal: AbortSignal): Request {
268
+ return new Request(new URL(path, this.options.source.url), {
269
+ method: 'POST',
270
+ headers: { authorization: `Bearer ${this.options.source.key}`, 'content-type': 'application/json' },
271
+ body,
272
+ signal,
273
+ })
274
+ }
275
+
276
+ private async report(): Promise<void> {
277
+ const body = encodeDevReport({
278
+ head: this.state.head,
279
+ status: this.status,
280
+ target: this.target,
281
+ message: this.message,
282
+ log: this.ring.tail(REPORT_LINES),
283
+ reset: this.state.reset,
284
+ checks: this.checkResults,
285
+ })
286
+ try {
287
+ const response = await this.fetch(this.request(DEV_SOURCE_REPORT_PATH, body, AbortSignal.timeout(10_000)))
288
+ await response.body?.cancel()
289
+ if (!response.ok) throw new Error(`the source receiver answered ${response.status}`)
290
+ this.reportFailing = false
291
+ } catch {
292
+ if (!this.reportFailing) this.say('reporting to Control failed; the next report retries', 'stderr')
293
+ this.reportFailing = true
294
+ }
295
+ }
296
+
297
+ private async apply(answer: DevWaitAnswer, boot: boolean): Promise<void> {
298
+ if (this.state.reset === null) this.state = { ...this.state, reset: answer.reset }
299
+ if (this.state.checks === null) this.state = { ...this.state, checks: answer.checks }
300
+ let changed = boot
301
+ let migrate = boot
302
+ let web = false
303
+ if (answer.sync !== null) {
304
+ const outcome = await this.writeSync(answer.sync)
305
+ if (outcome.kind === 'refused') {
306
+ this.status = 'needs_bootstrap'
307
+ this.target = answer.sync.to
308
+ this.message = outcome.reason
309
+ this.say(outcome.reason, 'stderr')
310
+ await this.report()
311
+ } else {
312
+ changed = true
313
+ migrate ||= outcome.migrations
314
+ web = outcome.web
315
+ }
316
+ }
317
+ const reset = answer.reset > (this.state.reset ?? answer.reset)
318
+ if (changed || reset) await this.converge({ migrate: migrate || reset, reset, web, resetTo: answer.reset })
319
+ if (answer.checks > (this.state.checks ?? answer.checks)) await this.check(answer.checks)
320
+ }
321
+
322
+ /** Bring the database, the SPA and the running application to the files on disk, then report. */
323
+ private async converge(steps: { migrate: boolean; reset: boolean; web: boolean; resetTo: number }): Promise<void> {
324
+ this.status = steps.reset ? 'resetting' : 'syncing'
325
+ void this.report()
326
+ let failure: string | null = null
327
+ // A migration's ALTER would queue behind the live version's locks, and a reset drops the role it connects as.
328
+ if (this.provisioningUrl !== null && steps.migrate) await this.stopApplication()
329
+ if (steps.reset) {
330
+ failure = await this.timed('the reset', () => this.resetDatabase())
331
+ this.state = { ...this.state, reset: steps.resetTo }
332
+ }
333
+ if (failure === null && steps.migrate) failure = await this.timed('the migration', () => this.migrate())
334
+ if (failure === null && steps.web) failure = await this.timed('the SPA build', () => this.buildSpa())
335
+ const served = await this.timed('the restart until healthy', () => this.restartApplication())
336
+ failure ??= served
337
+ this.status = failure === null ? 'serving' : 'failed'
338
+ this.target = null
339
+ this.message = failure
340
+ await this.persist()
341
+ await this.report()
342
+ }
343
+
344
+ /** Run one step of a sync and log how long it took: what a slow `apply_changes` is read from. */
345
+ private async timed(step: string, run: () => Promise<string | null>): Promise<string | null> {
346
+ const started = performance.now()
347
+ const failure = await run()
348
+ this.say(`${step} ${failure === null ? 'took' : 'failed after'} ${Math.round(performance.now() - started)} ms`)
349
+ return failure
350
+ }
351
+
352
+ private async writeSync(sync: DevSync): Promise<SyncOutcome> {
353
+ if (sync.from !== this.state.head) {
354
+ return { kind: 'refused', reason: `Control sent changes from ${sync.from}, and this container holds ${this.state.head}; call bind_dev again` }
355
+ }
356
+ const paths = sync.files.map((file) => file.path)
357
+ const reason = bootstrapReason(paths)
358
+ if (reason !== null) return { kind: 'refused', reason }
359
+ const previous = await Promise.all(
360
+ sync.files.map(async (file) => ({ path: file.path, content: await readFile(join(this.root, file.path)).catch(() => null) })),
361
+ )
362
+ await this.writeFiles(sync.files)
363
+ const changedContract = await this.contractChange()
364
+ if (changedContract !== null) {
365
+ await this.writeFiles(previous)
366
+ return { kind: 'refused', reason: changedContract }
367
+ }
368
+ this.state = { ...this.state, head: sync.to }
369
+ this.say(`synced ${sync.files.length} files to ${sync.to}`)
370
+ return { kind: 'applied', migrations: migrationsChanged(paths), web: this.config.spaBuild.length > 0 && webSourceChanged(paths) }
371
+ }
372
+
373
+ private async writeFiles(files: readonly { readonly path: string; readonly content: string | Uint8Array | null }[]): Promise<void> {
374
+ for (const file of files) {
375
+ const path = join(this.root, file.path)
376
+ if (file.content === null) {
377
+ await rm(path, { force: true })
378
+ continue
379
+ }
380
+ await mkdir(dirname(path), { recursive: true })
381
+ await writeFile(`${path}.fabrika-dev`, file.content)
382
+ await rename(`${path}.fabrika-dev`, path)
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Why the files on disk need a bootstrap run because their contract digest is not the one the bootstrap run deployed,
388
+ * or null. A child computes it: it loads the application module, which the launcher never does, with no credential.
389
+ * A digest it cannot compute is no reason: the respawned application then fails to boot and reports why.
390
+ */
391
+ private async contractChange(): Promise<string | null> {
392
+ const deployed = this.options.environment[FABRIKA_CONTRACT_DIGEST] ?? ''
393
+ if (deployed === '') return null
394
+ const started = performance.now()
395
+ const child = Bun.spawn(['bun', CONTRACT_DIGEST_CHILD], {
396
+ cwd: this.root,
397
+ env: { PATH: this.options.environment['PATH'] ?? '', HOME: this.options.environment['HOME'] ?? '' },
398
+ stdin: 'ignore',
399
+ stdout: 'pipe',
400
+ stderr: 'pipe',
401
+ })
402
+ const timer = setTimeout(() => child.kill('SIGKILL'), DIGEST_TIMEOUT_MS)
403
+ const [stdout, stderr] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
404
+ clearTimeout(timer)
405
+ const digest = stdout.trim()
406
+ if (child.exitCode !== 0 || !CONTRACT_DIGEST.test(digest)) {
407
+ for (const line of stderr.split('\n').filter((text) => text.trim() !== '').slice(-5)) this.ring.push('stderr', line)
408
+ this.say('the contract digest could not be computed; the application is respawned and reports why', 'stderr')
409
+ return null
410
+ }
411
+ this.say(`contract digest computed in ${Math.round(performance.now() - started)} ms`)
412
+ if (digest === deployed) return null
413
+ return `the change alters the contract (its digest is ${digest}, and the bootstrap run deployed ${deployed}): the proxy table and `
414
+ + 'the approval follow the contract, so that needs a bootstrap run: call bind_dev again'
415
+ }
416
+
417
+ private async persist(): Promise<void> {
418
+ const path = join(this.root, DEV_LAUNCHER_STATE_FILE)
419
+ await mkdir(dirname(path), { recursive: true })
420
+ await writeFile(`${path}.tmp`, JSON.stringify(this.state))
421
+ await rename(`${path}.tmp`, path)
422
+ }
423
+
424
+ private async migrate(): Promise<string | null> {
425
+ if (this.provisioningUrl === null) return null
426
+ const result = await this.run(this.config.migrate, {
427
+ ...this.commandEnvironment,
428
+ FABRIKA_APP_ID: this.config.appId,
429
+ [PROVISIONING_DATABASE_URL]: this.provisioningUrl,
430
+ })
431
+ if (result.exitCode === 0) return null
432
+ return `fabrika db migrate failed: ${lastLine(result)}; if an applied migration changed, call reset_dev`
433
+ }
434
+
435
+ private async resetDatabase(): Promise<string | null> {
436
+ if (this.provisioningUrl === null) return null
437
+ this.say(`dropping the schema and roles of ${this.config.appId}`)
438
+ try {
439
+ await dropApplicationDatabase(this.provisioningUrl, this.config.appId)
440
+ return null
441
+ } catch (error) {
442
+ // The driver's error may quote the URL, so only its class is named.
443
+ this.say(`dropping the schema failed (${error instanceof Error ? error.name : 'unknown error'})`, 'stderr')
444
+ return "dropping the environment's schema and roles failed; see the log"
445
+ }
446
+ }
447
+
448
+ private async buildSpa(): Promise<string | null> {
449
+ for (const command of this.config.spaBuild) {
450
+ const result = await this.run(['sh', '-c', command], this.commandEnvironment)
451
+ if (result.exitCode !== 0) return `the SPA command \`${command}\` failed: ${lastLine(result)}`
452
+ }
453
+ return null
454
+ }
455
+
456
+ private async check(request: number): Promise<void> {
457
+ const before = { status: this.status, message: this.message }
458
+ this.status = 'checking'
459
+ await this.report()
460
+ const results: DevCheckResult[] = []
461
+ for (const check of this.config.checks) {
462
+ this.say(`check ${check.name}: ${check.command}`)
463
+ const result = await this.run(['sh', '-c', check.command], this.commandEnvironment, CHECK_TIMEOUT_MS)
464
+ results.push({
465
+ name: check.name,
466
+ ok: result.exitCode === 0,
467
+ exitCode: result.exitCode,
468
+ output: result.lines.slice(-CHECK_OUTPUT_LINES).map((line) => line.slice(0, 2_000)),
469
+ })
470
+ }
471
+ this.checkResults = { request, head: this.state.head, results }
472
+ this.state = { ...this.state, checks: request }
473
+ this.status = before.status
474
+ this.message = before.message
475
+ await this.persist()
476
+ await this.report()
477
+ }
478
+
479
+ /** Run a command in the root; its output goes to the log, and its lines are returned. */
480
+ private async run(argv: readonly string[], env: Record<string, string>, timeoutMs = CHECK_TIMEOUT_MS): Promise<CommandResult> {
481
+ const child = Bun.spawn([...argv], { cwd: this.root, env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
482
+ const lines: string[] = []
483
+ const collect = (stream: 'stdout' | 'stderr') => (line: string) => {
484
+ lines.push(line)
485
+ this.ring.push(stream, line)
486
+ }
487
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
488
+ await Promise.all([readLines(child.stdout, collect('stdout')), readLines(child.stderr, collect('stderr')), child.exited])
489
+ clearTimeout(timer)
490
+ return { exitCode: child.exitCode, lines }
491
+ }
492
+
493
+ private async restartApplication(): Promise<string | null> {
494
+ await this.stopApplication()
495
+ const running = this.spawnApplication()
496
+ const failure = await this.awaitHealthy(running)
497
+ if (failure === null) this.crashes = 0
498
+ return failure
499
+ }
500
+
501
+ private spawnApplication(): RunningApplication {
502
+ const child = Bun.spawn([...this.config.serve], {
503
+ cwd: this.root,
504
+ env: this.applicationEnvironment,
505
+ stdin: 'ignore',
506
+ stdout: 'pipe',
507
+ stderr: 'pipe',
508
+ })
509
+ void readLines(child.stdout, (line) => this.ring.push('stdout', line))
510
+ void readLines(child.stderr, (line) => this.ring.push('stderr', line))
511
+ const running: RunningApplication = { process: child, exited: child.exited, stopping: false }
512
+ this.application = running
513
+ void running.exited.then((code) => {
514
+ if (running.stopping || this.stopped || this.application !== running) return
515
+ this.application = null
516
+ this.crashed(code)
517
+ })
518
+ return running
519
+ }
520
+
521
+ /** An application that exited on its own is reported and respawned after a growing pause. */
522
+ private crashed(code: number): void {
523
+ const pause = CRASH_BACKOFF_MS[Math.min(this.crashes, CRASH_BACKOFF_MS.length - 1)] ?? 10_000
524
+ this.crashes += 1
525
+ this.status = 'failed'
526
+ this.message = `the application exited with code ${code}; it restarts in ${pause / 1000} s`
527
+ this.say(this.message, 'stderr')
528
+ void this.report()
529
+ setTimeout(() => {
530
+ if (this.stopped) return
531
+ void this.enqueue(async () => {
532
+ if (this.application !== null || this.stopped) return
533
+ const failure = await this.restartApplication()
534
+ this.status = failure === null ? 'serving' : 'failed'
535
+ this.message = failure
536
+ await this.report()
537
+ })
538
+ }, pause)
539
+ }
540
+
541
+ private async awaitHealthy(running: RunningApplication): Promise<string | null> {
542
+ const url = `http://127.0.0.1:${this.config.port}${this.config.healthPath}`
543
+ const deadline = Date.now() + HEALTH_TIMEOUT_MS
544
+ let exited: number | null = null
545
+ void running.exited.then((code) => {
546
+ exited = code
547
+ })
548
+ while (Date.now() < deadline) {
549
+ if (exited !== null) return `the application exited with code ${exited} before it served; see the log`
550
+ try {
551
+ const response = await fetch(url, { signal: AbortSignal.timeout(1_000) })
552
+ await response.body?.cancel()
553
+ if (response.ok) return null
554
+ } catch {
555
+ // Not listening yet.
556
+ }
557
+ await sleep(25)
558
+ }
559
+ return `the application did not answer ${this.config.healthPath} within ${HEALTH_TIMEOUT_MS / 1000} s`
560
+ }
561
+
562
+ private async stopApplication(): Promise<void> {
563
+ const running = this.application
564
+ if (running === null) return
565
+ running.stopping = true
566
+ this.application = null
567
+ running.process.kill('SIGTERM')
568
+ const timer = setTimeout(() => running.process.kill('SIGKILL'), STOP_GRACE_MS)
569
+ await running.exited
570
+ clearTimeout(timer)
571
+ }
572
+ }
573
+
574
+ const lastLine = (result: CommandResult): string => [...result.lines].reverse().find((line) => line.trim() !== '')?.trim() ?? 'no output'
575
+
576
+ /** The process environment with its string values only. */
577
+ function stringEnvironment(env: Readonly<Record<string, string | undefined>>): Record<string, string> {
578
+ const strings: Record<string, string> = {}
579
+ for (const [name, value] of Object.entries(env)) {
580
+ if (value !== undefined) strings[name] = value
581
+ }
582
+ return strings
583
+ }
584
+
585
+ /**
586
+ * The generated dev entrypoint's only call. It reads the build's configuration and the `source` transport, makes
587
+ * itself non-dumpable (it holds the provisioning credential and the key), and runs until a signal stops it.
588
+ */
589
+ export async function runDevLauncher(env: Readonly<Record<string, string | undefined>>): Promise<void> {
590
+ const root = process.cwd()
591
+ const config = await readDevLauncherConfig(root)
592
+ const environment = stringEnvironment(env)
593
+ const url = environment[SOURCE.url] ?? ''
594
+ const key = environment[SOURCE.key] ?? ''
595
+ if (url === '' || key === '') {
596
+ throw new Error(`fabrika dev: ${SOURCE.url} and ${SOURCE.key} are not set; the platform writes them for a dev environment`)
597
+ }
598
+ makeProcessUndumpable()
599
+ const launcher = startDevLauncher({ root, config, environment, source: { url, key } })
600
+ for (const signal of FORWARDED_SIGNALS) {
601
+ process.once(signal, () => void launcher.stop().then(() => process.exit(0)))
602
+ }
603
+ await launcher.ready
604
+ }
@@ -0,0 +1,62 @@
1
+ // The launcher's view of output: every line of the application and of the commands it runs is forwarded to its own
2
+ // stream, as Zerops' runtime log reads it, and the newest lines are kept to report. Secret values never reach either.
3
+
4
+ import { DEV_REPORT_MAX_LINE_CHARACTERS, type DevLogLine } from './wire.js'
5
+
6
+ export type OutputStream = DevLogLine['stream']
7
+
8
+ export interface OutputSink {
9
+ write(stream: OutputStream, line: string): void
10
+ }
11
+
12
+ /** The launcher process's own stdout and stderr. */
13
+ export const processOutput: OutputSink = {
14
+ write(stream, line) {
15
+ ;(stream === 'stdout' ? process.stdout : process.stderr).write(`${line}\n`)
16
+ },
17
+ }
18
+
19
+ /** The newest `capacity` lines, numbered from 0 since the launcher started, with every known secret value replaced. */
20
+ export class OutputRing {
21
+ private readonly lines: DevLogLine[] = []
22
+ private next = 0
23
+
24
+ constructor(private readonly capacity: number, private readonly secrets: readonly string[], private readonly sink: OutputSink) {}
25
+
26
+ push(stream: OutputStream, text: string): void {
27
+ const line = this.redact(text)
28
+ this.sink.write(stream, line)
29
+ this.lines.push({ n: this.next, stream, text: line.slice(0, DEV_REPORT_MAX_LINE_CHARACTERS) })
30
+ this.next += 1
31
+ if (this.lines.length > this.capacity) this.lines.shift()
32
+ }
33
+
34
+ /** The newest `count` lines, oldest first. */
35
+ tail(count: number): DevLogLine[] {
36
+ return this.lines.slice(Math.max(0, this.lines.length - count))
37
+ }
38
+
39
+ private redact(text: string): string {
40
+ let line = text
41
+ for (const secret of this.secrets) line = line.replaceAll(secret, '[redacted]')
42
+ return line
43
+ }
44
+ }
45
+
46
+ /** Feed `stream` to `onLine` one line at a time; a last line without a newline is fed when the stream ends. */
47
+ export async function readLines(stream: ReadableStream<Uint8Array>, onLine: (line: string) => void): Promise<void> {
48
+ const decoder = new TextDecoder()
49
+ const reader = stream.getReader()
50
+ let pending = ''
51
+ for (let read = await reader.read(); !read.done; read = await reader.read()) {
52
+ pending += decoder.decode(read.value, { stream: true })
53
+ let end = pending.indexOf('\n')
54
+ while (end !== -1) {
55
+ onLine(pending.slice(0, end).replace(/\r$/, ''))
56
+ pending = pending.slice(end + 1)
57
+ end = pending.indexOf('\n')
58
+ }
59
+ }
60
+ pending += decoder.decode()
61
+ if (pending !== '') onLine(pending)
62
+ }