@fabrika/app 0.8.1 → 0.9.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/app",
3
- "version": "0.8.1",
3
+ "version": "0.9.0-dev.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -82,6 +82,16 @@
82
82
  "types": "./src/testing/preload.ts",
83
83
  "default": "./src/testing/preload.ts"
84
84
  },
85
+ "./dev": {
86
+ "bun": "./src/dev/index.ts",
87
+ "types": "./src/dev/index.ts",
88
+ "default": "./src/dev/index.ts"
89
+ },
90
+ "./dev/wire": {
91
+ "bun": "./src/dev/wire.ts",
92
+ "types": "./src/dev/wire.ts",
93
+ "default": "./src/dev/wire.ts"
94
+ },
85
95
  "./agent-skill": {
86
96
  "bun": "./src/agent-skill.ts",
87
97
  "types": "./src/agent-skill.ts",
@@ -93,14 +103,14 @@
93
103
  "test": "bun test --path-ignore-patterns='src/react/**' && bun test --isolate src/react"
94
104
  },
95
105
  "dependencies": {
96
- "@fabrika/auth": "0.8.1",
97
- "@fabrika/auth-core": "0.8.1",
98
- "@fabrika/db": "0.8.1",
99
- "@fabrika/email": "0.8.1",
100
- "@fabrika/jwks": "0.8.1",
101
- "@fabrika/platform": "0.8.1",
102
- "@fabrika/platform-node": "0.8.1",
103
- "@fabrika/proxy-core": "0.8.1",
106
+ "@fabrika/auth": "0.9.0-dev.1",
107
+ "@fabrika/auth-core": "0.9.0-dev.1",
108
+ "@fabrika/db": "0.9.0-dev.1",
109
+ "@fabrika/email": "0.9.0-dev.1",
110
+ "@fabrika/jwks": "0.9.0-dev.1",
111
+ "@fabrika/platform": "0.9.0-dev.1",
112
+ "@fabrika/platform-node": "0.9.0-dev.1",
113
+ "@fabrika/proxy-core": "0.9.0-dev.1",
104
114
  "altcha-lib": "2.6.0",
105
115
  "jose": "^5.9.0"
106
116
  },
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fabrika-app
3
- version: 0.8.1
3
+ version: 0.9.0-dev.1
4
4
  description: >
5
5
  How to write and change a Fabrika application: src/vocabulary.ts, db/migrations/, src/db/tables.ts, the
6
6
  slices' endpoints.ts with their handlers, fabrika.contract.ts, jobs, files, webhooks, share links, connectors
@@ -19,20 +19,20 @@ the SPA is added with it.
19
19
  "test": "bun test --isolate"
20
20
  },
21
21
  "dependencies": {
22
- "@fabrika/app": "0.8.1",
23
- "@fabrika/db": "0.8.1",
24
- "@fabrika/provider-zerops": "0.8.1",
25
- "@fabrika/proxy-core": "0.8.1",
26
- "@fabrika/ui": "0.8.1",
22
+ "@fabrika/app": "0.9.0-dev.1",
23
+ "@fabrika/db": "0.9.0-dev.1",
24
+ "@fabrika/provider-zerops": "0.9.0-dev.1",
25
+ "@fabrika/proxy-core": "0.9.0-dev.1",
26
+ "@fabrika/ui": "0.9.0-dev.1",
27
27
  "kysely": "0.29.6",
28
28
  "react": "^19.0.0",
29
29
  "react-dom": "^19.0.0",
30
30
  "zod": "^3.25.76"
31
31
  },
32
32
  "devDependencies": {
33
- "@fabrika/check": "0.8.1",
34
- "@fabrika/cli": "0.8.1",
35
- "@fabrika/platform": "0.8.1",
33
+ "@fabrika/check": "0.9.0-dev.1",
34
+ "@fabrika/cli": "0.9.0-dev.1",
35
+ "@fabrika/platform": "0.9.0-dev.1",
36
36
  "@happy-dom/global-registrator": "^20.11.2",
37
37
  "@types/bun": "latest",
38
38
  "@types/react": "^19.0.0",
@@ -0,0 +1,73 @@
1
+ // What a sync changes, read off its paths: whether the build owns one (then the commit needs a bootstrap run), whether
2
+ // the migrations changed, and whether a web source did. Pure: the caller reads the files.
3
+
4
+ import { posix } from 'node:path'
5
+ import { CONTRACT_FILE } from '../build.js'
6
+
7
+ /** The application's migrations, as `fabrika db migrate` reads them. */
8
+ export const MIGRATIONS_DIRECTORY = 'db/migrations/'
9
+
10
+ /** Files whose change the build turns into something else: an install, the contract digest and proxy table, a generated file. */
11
+ const BUILD_OWNED_FILES: ReadonlySet<string> = new Set(['package.json', 'bun.lock', 'bun.lockb', 'bunfig.toml', 'zerops.yaml', 'zerops.yml'])
12
+ const BUILD_OWNED_DIRECTORIES = ['fabrika/', 'node_modules/'] as const
13
+
14
+ /** Server code and the database: a change only there leaves the SPA as it is. */
15
+ const SERVER_DIRECTORIES = ['src/', 'db/'] as const
16
+
17
+ /** Reads a file at the head the sync brings, or null when it does not exist there. */
18
+ export type SourceReader = (path: string) => string | null
19
+
20
+ const RELATIVE_IMPORT = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)['"](\.{1,2}\/[^'"]+)['"]/g
21
+
22
+ /** The repository paths a relative specifier of `from` may name, in the order Bun resolves a TypeScript import. */
23
+ function candidates(from: string, specifier: string): string[] {
24
+ const base = posix.normalize(posix.join(posix.dirname(from), specifier))
25
+ if (base.startsWith('../')) return []
26
+ const withoutJs = base.endsWith('.js') ? base.slice(0, -3) : base
27
+ return [base, `${withoutJs}.ts`, `${withoutJs}.tsx`, `${withoutJs}/index.ts`]
28
+ }
29
+
30
+ /** `fabrika.contract.ts` and every module it reaches by a relative import, read through `read`. */
31
+ export function contractModules(read: SourceReader): Set<string> {
32
+ const modules = new Set<string>()
33
+ const pending = [CONTRACT_FILE]
34
+ while (pending.length > 0) {
35
+ const path = pending.pop() ?? ''
36
+ if (modules.has(path)) continue
37
+ const text = read(path)
38
+ if (text === null) continue
39
+ modules.add(path)
40
+ for (const match of text.matchAll(RELATIVE_IMPORT)) {
41
+ const resolved = candidates(path, match[1] ?? '').find((candidate) => read(candidate) !== null)
42
+ if (resolved !== undefined) pending.push(resolved)
43
+ }
44
+ }
45
+ return modules
46
+ }
47
+
48
+ /**
49
+ * Why the change needs a bootstrap run, in one line, or null when the launcher may apply it. `before` reads the
50
+ * container's files, `after` the files at the target: a module the contract imports at either counts.
51
+ */
52
+ export function bootstrapReason(paths: readonly string[], before: SourceReader, after: SourceReader): string | null {
53
+ const contract = new Set([...contractModules(before), ...contractModules(after)])
54
+ for (const path of paths) {
55
+ const why = BUILD_OWNED_FILES.has(path)
56
+ ? 'the dependencies, the lockfile or the build descriptor'
57
+ : BUILD_OWNED_DIRECTORIES.some((directory) => path.startsWith(directory))
58
+ ? 'the generated files or the installed packages'
59
+ : contract.has(path)
60
+ ? 'the contract or a module it imports (its digest and the proxy table change)'
61
+ : path.startsWith('fabrika.') && !path.includes('/')
62
+ ? 'a fabrika.* file'
63
+ : null
64
+ if (why !== null) return `${path} changed, which is ${why}; that needs a bootstrap run: call bind_dev again`
65
+ }
66
+ return null
67
+ }
68
+
69
+ export const migrationsChanged = (paths: readonly string[]): boolean => paths.some((path) => path.startsWith(MIGRATIONS_DIRECTORY))
70
+
71
+ /** A change outside the server code and the database may reach the SPA, so its build runs again. */
72
+ export const webSourceChanged = (paths: readonly string[]): boolean =>
73
+ paths.some((path) => !SERVER_DIRECTORIES.some((directory) => path.startsWith(directory)))
@@ -0,0 +1,11 @@
1
+ // @fabrika/app/dev — the launcher of a `dev` environment, which the generated dev entrypoint runs (Bun only).
2
+
3
+ export { bootstrapReason, contractModules, migrationsChanged, webSourceChanged } from './changes.js'
4
+ export {
5
+ type DevLauncher,
6
+ type DevLauncherOptions,
7
+ type DevSourceTransport,
8
+ readDevLauncherConfig,
9
+ runDevLauncher,
10
+ startDevLauncher,
11
+ } from './launcher.js'
@@ -0,0 +1,557 @@
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 { DEV_LAUNCHER_CONFIG_FILE, DEV_LAUNCHER_STATE_FILE, type DevLauncherConfig, platformTransportVariables } from '@fabrika/auth-core'
8
+ import { applicationRuntimeUrl, dropApplicationDatabase } from '@fabrika/platform-node'
9
+ import { existsSync, readFileSync } from 'node:fs'
10
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
11
+ import { dirname, join } from 'node:path'
12
+ import { z } from 'zod/v4'
13
+ import { makeProcessUndumpable } from '../undumpable.js'
14
+ import { bootstrapReason, migrationsChanged, type SourceReader, webSourceChanged } from './changes.js'
15
+ import { OutputRing, type OutputSink, processOutput, readLines } from './output.js'
16
+ import {
17
+ DEV_SOURCE_REPORT_PATH,
18
+ DEV_SOURCE_WAIT_MS,
19
+ DEV_SOURCE_WAIT_PATH,
20
+ type DevCheckResult,
21
+ type DevReport,
22
+ type DevStatus,
23
+ type DevSync,
24
+ type DevWaitAnswer,
25
+ encodeDevReport,
26
+ encodeDevWaitRequest,
27
+ parseDevWaitAnswer,
28
+ } from './wire.js'
29
+
30
+ const PROVISIONING_DATABASE_URL = 'FABRIKA_APP_DATABASE_URL'
31
+ const RUNTIME_DATABASE_URL = 'FABRIKA_APP_RUNTIME_DATABASE_URL'
32
+ const SOURCE = platformTransportVariables('source')
33
+
34
+ /** Lines kept, and lines one report carries. */
35
+ const RING_LINES = 500
36
+ const REPORT_LINES = 100
37
+ /** A check's output reported: its last lines. */
38
+ const CHECK_OUTPUT_LINES = 60
39
+ const HEALTH_TIMEOUT_MS = 30_000
40
+ const STOP_GRACE_MS = 3_000
41
+ const CHECK_TIMEOUT_MS = 10 * 60_000
42
+ const CRASH_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000] as const
43
+ const FORWARDED_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const
44
+
45
+ const commitSha = z.string().regex(/^[0-9a-f]{40}$/)
46
+ /** A literal path of unreserved characters, as the provider's readiness probe requests it. */
47
+ const HEALTH_PATH = /^(?:\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+$/
48
+
49
+ const configSchema: z.ZodType<DevLauncherConfig> = z.object({
50
+ format: z.literal(1),
51
+ appId: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
52
+ head: commitSha,
53
+ port: z.number().int().min(1).max(65535),
54
+ healthPath: z.string().regex(HEALTH_PATH),
55
+ serve: z.array(z.string()).min(1),
56
+ migrate: z.array(z.string()).min(1),
57
+ spaBuild: z.array(z.string()),
58
+ checks: z.array(z.object({ name: z.string().min(1).max(64), command: z.string().min(1) }).strict()),
59
+ }).strict()
60
+
61
+ const stateSchema = z.object({
62
+ head: commitSha,
63
+ reset: z.number().int().min(0).nullable(),
64
+ checks: z.number().int().min(0).nullable(),
65
+ }).strict()
66
+
67
+ type LauncherState = z.infer<typeof stateSchema>
68
+
69
+ /** The launcher's configuration, which the build generated; a missing or malformed file stops the launch. */
70
+ export async function readDevLauncherConfig(root: string): Promise<DevLauncherConfig> {
71
+ const text = await readFile(join(root, DEV_LAUNCHER_CONFIG_FILE), 'utf8').catch(() => null)
72
+ if (text === null) throw new Error(`fabrika dev: ${DEV_LAUNCHER_CONFIG_FILE} is missing; only the build of a dev environment writes it`)
73
+ const parsed = configSchema.safeParse(JSON.parse(text))
74
+ if (!parsed.success) throw new Error(`fabrika dev: ${DEV_LAUNCHER_CONFIG_FILE} is not a launcher configuration this release reads`)
75
+ return parsed.data
76
+ }
77
+
78
+ /** What the state file holds, or the build's head for a container that has none (a new one). */
79
+ async function readState(root: string, config: DevLauncherConfig): Promise<LauncherState> {
80
+ const text = await readFile(join(root, DEV_LAUNCHER_STATE_FILE), 'utf8').catch(() => null)
81
+ const parsed = text === null ? null : stateSchema.safeParse(JSON.parse(text))
82
+ return parsed?.success === true ? parsed.data : { head: config.head, reset: null, checks: null }
83
+ }
84
+
85
+ export interface DevSourceTransport {
86
+ readonly url: string
87
+ readonly key: string
88
+ }
89
+
90
+ export interface DevLauncherOptions {
91
+ readonly root: string
92
+ readonly config: DevLauncherConfig
93
+ /** The launcher's own environment: the application's, with the provisioning credential and the `source` key. */
94
+ readonly environment: Readonly<Record<string, string>>
95
+ readonly source: DevSourceTransport
96
+ readonly fetch?: (request: Request) => Promise<Response>
97
+ readonly output?: OutputSink
98
+ /** How long a boot tries to reach Control before it serves the files it holds. */
99
+ readonly bootReconcileMs?: number
100
+ }
101
+
102
+ export interface DevLauncher {
103
+ /** Settles once the first boot served, or failed and reported it. */
104
+ readonly ready: Promise<void>
105
+ /** Stop the long poll and the application. */
106
+ stop(): Promise<void>
107
+ }
108
+
109
+ interface RunningApplication {
110
+ readonly process: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
111
+ readonly exited: Promise<number>
112
+ stopping: boolean
113
+ }
114
+
115
+ interface CommandResult {
116
+ readonly exitCode: number | null
117
+ readonly lines: readonly string[]
118
+ }
119
+
120
+ type SyncOutcome =
121
+ | { readonly kind: 'applied'; readonly migrations: boolean; readonly web: boolean }
122
+ | { readonly kind: 'refused'; readonly reason: string }
123
+
124
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
125
+
126
+ /** Every value of the environment a log line must never show: the database credentials and every key, secret or token. */
127
+ function secretValues(environment: Readonly<Record<string, string>>, runtimeUrl: string | null): string[] {
128
+ const values = new Set<string>()
129
+ const add = (value: string | undefined): void => {
130
+ if (value !== undefined && value.length >= 8) values.add(value)
131
+ }
132
+ for (const url of [environment[PROVISIONING_DATABASE_URL], runtimeUrl ?? undefined]) {
133
+ add(url)
134
+ if (url !== undefined && URL.canParse(url)) add(decodeURIComponent(new URL(url).password))
135
+ }
136
+ for (const [name, value] of Object.entries(environment)) {
137
+ if (/(KEY|SECRET|PASSWORD|TOKEN|DATABASE_URL)$/.test(name)) add(value)
138
+ }
139
+ return [...values].sort((left, right) => right.length - left.length)
140
+ }
141
+
142
+ const withoutFramework = (environment: Readonly<Record<string, string>>): Record<string, string> =>
143
+ Object.fromEntries(Object.entries(environment).filter(([name]) => !name.startsWith('FABRIKA_')))
144
+
145
+ /** Start the launcher: reconcile to the recorded head, serve, then follow Control's long poll. */
146
+ export function startDevLauncher(options: DevLauncherOptions): DevLauncher {
147
+ const launcher = new Launcher(options)
148
+ return { ready: launcher.start(), stop: () => launcher.stop() }
149
+ }
150
+
151
+ class Launcher {
152
+ private readonly root: string
153
+ private readonly config: DevLauncherConfig
154
+ private readonly fetch: (request: Request) => Promise<Response>
155
+ private readonly provisioningUrl: string | null
156
+ private readonly applicationEnvironment: Record<string, string>
157
+ private readonly commandEnvironment: Record<string, string>
158
+ private readonly ring: OutputRing
159
+ private readonly controller = new AbortController()
160
+ private state: LauncherState = { head: '', reset: null, checks: null }
161
+ private status: DevStatus = 'syncing'
162
+ private target: string | null = null
163
+ private message: string | null = null
164
+ private checkResults: DevReport['checks'] = null
165
+ private application: RunningApplication | null = null
166
+ private actions: Promise<void> = Promise.resolve()
167
+ private crashes = 0
168
+ private stopped = false
169
+ private reportFailing = false
170
+
171
+ constructor(private readonly options: DevLauncherOptions) {
172
+ this.root = options.root
173
+ this.config = options.config
174
+ this.fetch = options.fetch ?? ((request) => fetch(request))
175
+ const provisioning = options.environment[PROVISIONING_DATABASE_URL] ?? ''
176
+ this.provisioningUrl = provisioning === '' ? null : provisioning
177
+ const runtimeUrl = this.provisioningUrl === null ? null : applicationRuntimeUrl(this.provisioningUrl, this.config.appId)
178
+ const application: Record<string, string> = {}
179
+ for (const [name, value] of Object.entries(options.environment)) {
180
+ if (name !== PROVISIONING_DATABASE_URL && name !== SOURCE.url && name !== SOURCE.key) application[name] = value
181
+ }
182
+ if (runtimeUrl !== null) application[RUNTIME_DATABASE_URL] = runtimeUrl
183
+ this.applicationEnvironment = application
184
+ this.commandEnvironment = withoutFramework(options.environment)
185
+ this.ring = new OutputRing(RING_LINES, secretValues(options.environment, runtimeUrl), options.output ?? processOutput)
186
+ }
187
+
188
+ async start(): Promise<void> {
189
+ this.state = await readState(this.root, this.config)
190
+ const answer = await this.firstAnswer()
191
+ await this.enqueue(() => this.apply(answer ?? { sync: null, reset: this.state.reset ?? 0, checks: this.state.checks ?? 0 }, true))
192
+ void this.follow()
193
+ }
194
+
195
+ async stop(): Promise<void> {
196
+ this.stopped = true
197
+ this.controller.abort()
198
+ await this.actions
199
+ await this.stopApplication()
200
+ }
201
+
202
+ private say(line: string, stream: 'stdout' | 'stderr' = 'stdout'): void {
203
+ this.ring.push(stream, `fabrika dev: ${line}`)
204
+ }
205
+
206
+ /** Actions run one at a time: a sync, a reset, a check run and a respawn after a crash never interleave. */
207
+ private enqueue(action: () => Promise<void>): Promise<void> {
208
+ const next = this.actions.then(action, action)
209
+ this.actions = next.catch(() => undefined)
210
+ return next
211
+ }
212
+
213
+ /** At boot: the answer for the head this container holds, without holding, so the launcher reconciles before it serves. */
214
+ private async firstAnswer(): Promise<DevWaitAnswer | null> {
215
+ const deadline = Date.now() + (this.options.bootReconcileMs ?? 20_000)
216
+ while (!this.stopped) {
217
+ try {
218
+ return await this.wait(false)
219
+ } catch {
220
+ if (Date.now() >= deadline) {
221
+ this.say('Control did not answer at boot; serving the files this container holds', 'stderr')
222
+ return null
223
+ }
224
+ await sleep(1_000)
225
+ }
226
+ }
227
+ return null
228
+ }
229
+
230
+ private async follow(): Promise<void> {
231
+ let backoff = 1_000
232
+ while (!this.stopped) {
233
+ let answer: DevWaitAnswer
234
+ try {
235
+ answer = await this.wait(true)
236
+ backoff = 1_000
237
+ } catch {
238
+ if (this.stopped) return
239
+ this.say(`the long poll on Control failed; retrying in ${backoff / 1000} s`, 'stderr')
240
+ await sleep(backoff)
241
+ backoff = Math.min(backoff * 2, 15_000)
242
+ continue
243
+ }
244
+ await this.enqueue(() => this.apply(answer, false))
245
+ }
246
+ }
247
+
248
+ private async wait(hold: boolean): Promise<DevWaitAnswer> {
249
+ const body = encodeDevWaitRequest({ head: this.state.head, reset: this.state.reset, checks: this.state.checks, hold })
250
+ const signal = AbortSignal.any([this.controller.signal, AbortSignal.timeout(DEV_SOURCE_WAIT_MS + 15_000)])
251
+ const response = await this.fetch(this.request(DEV_SOURCE_WAIT_PATH, body, signal))
252
+ if (!response.ok) throw new Error(`the source receiver answered ${response.status}`)
253
+ const parsed = parseDevWaitAnswer(await response.json())
254
+ if (!parsed.ok) throw new Error('the source receiver answered a body this release does not read')
255
+ return parsed.value
256
+ }
257
+
258
+ private request(path: string, body: string, signal: AbortSignal): Request {
259
+ return new Request(new URL(path, this.options.source.url), {
260
+ method: 'POST',
261
+ headers: { authorization: `Bearer ${this.options.source.key}`, 'content-type': 'application/json' },
262
+ body,
263
+ signal,
264
+ })
265
+ }
266
+
267
+ private async report(): Promise<void> {
268
+ const body = encodeDevReport({
269
+ head: this.state.head,
270
+ status: this.status,
271
+ target: this.target,
272
+ message: this.message,
273
+ log: this.ring.tail(REPORT_LINES),
274
+ reset: this.state.reset,
275
+ checks: this.checkResults,
276
+ })
277
+ try {
278
+ const response = await this.fetch(this.request(DEV_SOURCE_REPORT_PATH, body, AbortSignal.timeout(10_000)))
279
+ await response.body?.cancel()
280
+ if (!response.ok) throw new Error(`the source receiver answered ${response.status}`)
281
+ this.reportFailing = false
282
+ } catch {
283
+ if (!this.reportFailing) this.say('reporting to Control failed; the next report retries', 'stderr')
284
+ this.reportFailing = true
285
+ }
286
+ }
287
+
288
+ private async apply(answer: DevWaitAnswer, boot: boolean): Promise<void> {
289
+ if (this.state.reset === null) this.state = { ...this.state, reset: answer.reset }
290
+ if (this.state.checks === null) this.state = { ...this.state, checks: answer.checks }
291
+ let changed = boot
292
+ let migrate = boot
293
+ let web = false
294
+ if (answer.sync !== null) {
295
+ const outcome = await this.writeSync(answer.sync)
296
+ if (outcome.kind === 'refused') {
297
+ this.status = 'needs_bootstrap'
298
+ this.target = answer.sync.to
299
+ this.message = outcome.reason
300
+ this.say(outcome.reason, 'stderr')
301
+ await this.report()
302
+ } else {
303
+ changed = true
304
+ migrate ||= outcome.migrations
305
+ web = outcome.web
306
+ }
307
+ }
308
+ const reset = answer.reset > (this.state.reset ?? answer.reset)
309
+ if (changed || reset) await this.converge({ migrate: migrate || reset, reset, web, resetTo: answer.reset })
310
+ if (answer.checks > (this.state.checks ?? answer.checks)) await this.check(answer.checks)
311
+ }
312
+
313
+ /** Bring the database, the SPA and the running application to the files on disk, then report. */
314
+ private async converge(steps: { migrate: boolean; reset: boolean; web: boolean; resetTo: number }): Promise<void> {
315
+ this.status = steps.reset ? 'resetting' : 'syncing'
316
+ void this.report()
317
+ let failure: string | null = null
318
+ // A migration's ALTER would queue behind the live version's locks, and a reset drops the role it connects as.
319
+ if (this.provisioningUrl !== null && steps.migrate) await this.stopApplication()
320
+ if (steps.reset) {
321
+ failure = await this.resetDatabase()
322
+ this.state = { ...this.state, reset: steps.resetTo }
323
+ }
324
+ if (failure === null && steps.migrate) failure = await this.migrate()
325
+ if (failure === null && steps.web) failure = await this.buildSpa()
326
+ const served = await this.restartApplication()
327
+ failure ??= served
328
+ this.status = failure === null ? 'serving' : 'failed'
329
+ this.target = null
330
+ this.message = failure
331
+ await this.persist()
332
+ await this.report()
333
+ }
334
+
335
+ private async writeSync(sync: DevSync): Promise<SyncOutcome> {
336
+ if (sync.from !== this.state.head) {
337
+ return { kind: 'refused', reason: `Control sent changes from ${sync.from}, and this container holds ${this.state.head}; call bind_dev again` }
338
+ }
339
+ const paths = sync.files.map((file) => file.path)
340
+ const incoming = new Map(sync.files.map((file) => [file.path, file.content]))
341
+ const before: SourceReader = (path) => this.readLocal(path)
342
+ const after: SourceReader = (path) => incoming.has(path) ? incoming.get(path) ?? null : this.readLocal(path)
343
+ const reason = bootstrapReason(paths, before, after)
344
+ if (reason !== null) return { kind: 'refused', reason }
345
+ for (const file of sync.files) {
346
+ const path = join(this.root, file.path)
347
+ if (file.content === null) {
348
+ await rm(path, { force: true })
349
+ continue
350
+ }
351
+ await mkdir(dirname(path), { recursive: true })
352
+ await writeFile(`${path}.fabrika-dev`, file.content)
353
+ await rename(`${path}.fabrika-dev`, path)
354
+ }
355
+ this.state = { ...this.state, head: sync.to }
356
+ this.say(`synced ${sync.files.length} files to ${sync.to}`)
357
+ return { kind: 'applied', migrations: migrationsChanged(paths), web: this.config.spaBuild.length > 0 && webSourceChanged(paths) }
358
+ }
359
+
360
+ private readLocal(path: string): string | null {
361
+ const absolute = join(this.root, path)
362
+ if (!existsSync(absolute)) return null
363
+ try {
364
+ return readFileSync(absolute, 'utf8')
365
+ } catch {
366
+ return null
367
+ }
368
+ }
369
+
370
+ private async persist(): Promise<void> {
371
+ const path = join(this.root, DEV_LAUNCHER_STATE_FILE)
372
+ await mkdir(dirname(path), { recursive: true })
373
+ await writeFile(`${path}.tmp`, JSON.stringify(this.state))
374
+ await rename(`${path}.tmp`, path)
375
+ }
376
+
377
+ private async migrate(): Promise<string | null> {
378
+ if (this.provisioningUrl === null) return null
379
+ const result = await this.run(this.config.migrate, {
380
+ ...this.commandEnvironment,
381
+ FABRIKA_APP_ID: this.config.appId,
382
+ [PROVISIONING_DATABASE_URL]: this.provisioningUrl,
383
+ })
384
+ if (result.exitCode === 0) return null
385
+ return `fabrika db migrate failed: ${lastLine(result)}; if an applied migration changed, call reset_dev`
386
+ }
387
+
388
+ private async resetDatabase(): Promise<string | null> {
389
+ if (this.provisioningUrl === null) return null
390
+ this.say(`dropping the schema and roles of ${this.config.appId}`)
391
+ try {
392
+ await dropApplicationDatabase(this.provisioningUrl, this.config.appId)
393
+ return null
394
+ } catch (error) {
395
+ // The driver's error may quote the URL, so only its class is named.
396
+ this.say(`dropping the schema failed (${error instanceof Error ? error.name : 'unknown error'})`, 'stderr')
397
+ return "dropping the environment's schema and roles failed; see the log"
398
+ }
399
+ }
400
+
401
+ private async buildSpa(): Promise<string | null> {
402
+ for (const command of this.config.spaBuild) {
403
+ const result = await this.run(['sh', '-c', command], this.commandEnvironment)
404
+ if (result.exitCode !== 0) return `the SPA command \`${command}\` failed: ${lastLine(result)}`
405
+ }
406
+ return null
407
+ }
408
+
409
+ private async check(request: number): Promise<void> {
410
+ const before = { status: this.status, message: this.message }
411
+ this.status = 'checking'
412
+ await this.report()
413
+ const results: DevCheckResult[] = []
414
+ for (const check of this.config.checks) {
415
+ this.say(`check ${check.name}: ${check.command}`)
416
+ const result = await this.run(['sh', '-c', check.command], this.commandEnvironment, CHECK_TIMEOUT_MS)
417
+ results.push({
418
+ name: check.name,
419
+ ok: result.exitCode === 0,
420
+ exitCode: result.exitCode,
421
+ output: result.lines.slice(-CHECK_OUTPUT_LINES).map((line) => line.slice(0, 2_000)),
422
+ })
423
+ }
424
+ this.checkResults = { request, results }
425
+ this.state = { ...this.state, checks: request }
426
+ this.status = before.status
427
+ this.message = before.message
428
+ await this.persist()
429
+ await this.report()
430
+ }
431
+
432
+ /** Run a command in the root; its output goes to the log, and its lines are returned. */
433
+ private async run(argv: readonly string[], env: Record<string, string>, timeoutMs = CHECK_TIMEOUT_MS): Promise<CommandResult> {
434
+ const child = Bun.spawn([...argv], { cwd: this.root, env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
435
+ const lines: string[] = []
436
+ const collect = (stream: 'stdout' | 'stderr') => (line: string) => {
437
+ lines.push(line)
438
+ this.ring.push(stream, line)
439
+ }
440
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
441
+ await Promise.all([readLines(child.stdout, collect('stdout')), readLines(child.stderr, collect('stderr')), child.exited])
442
+ clearTimeout(timer)
443
+ return { exitCode: child.exitCode, lines }
444
+ }
445
+
446
+ private async restartApplication(): Promise<string | null> {
447
+ await this.stopApplication()
448
+ const running = this.spawnApplication()
449
+ const failure = await this.awaitHealthy(running)
450
+ if (failure === null) this.crashes = 0
451
+ return failure
452
+ }
453
+
454
+ private spawnApplication(): RunningApplication {
455
+ const child = Bun.spawn([...this.config.serve], {
456
+ cwd: this.root,
457
+ env: this.applicationEnvironment,
458
+ stdin: 'ignore',
459
+ stdout: 'pipe',
460
+ stderr: 'pipe',
461
+ })
462
+ void readLines(child.stdout, (line) => this.ring.push('stdout', line))
463
+ void readLines(child.stderr, (line) => this.ring.push('stderr', line))
464
+ const running: RunningApplication = { process: child, exited: child.exited, stopping: false }
465
+ this.application = running
466
+ void running.exited.then((code) => {
467
+ if (running.stopping || this.stopped || this.application !== running) return
468
+ this.application = null
469
+ this.crashed(code)
470
+ })
471
+ return running
472
+ }
473
+
474
+ /** An application that exited on its own is reported and respawned after a growing pause. */
475
+ private crashed(code: number): void {
476
+ const pause = CRASH_BACKOFF_MS[Math.min(this.crashes, CRASH_BACKOFF_MS.length - 1)] ?? 10_000
477
+ this.crashes += 1
478
+ this.status = 'failed'
479
+ this.message = `the application exited with code ${code}; it restarts in ${pause / 1000} s`
480
+ this.say(this.message, 'stderr')
481
+ void this.report()
482
+ setTimeout(() => {
483
+ if (this.stopped) return
484
+ void this.enqueue(async () => {
485
+ if (this.application !== null || this.stopped) return
486
+ const failure = await this.restartApplication()
487
+ this.status = failure === null ? 'serving' : 'failed'
488
+ this.message = failure
489
+ await this.report()
490
+ })
491
+ }, pause)
492
+ }
493
+
494
+ private async awaitHealthy(running: RunningApplication): Promise<string | null> {
495
+ const url = `http://127.0.0.1:${this.config.port}${this.config.healthPath}`
496
+ const deadline = Date.now() + HEALTH_TIMEOUT_MS
497
+ let exited: number | null = null
498
+ void running.exited.then((code) => {
499
+ exited = code
500
+ })
501
+ while (Date.now() < deadline) {
502
+ if (exited !== null) return `the application exited with code ${exited} before it served; see the log`
503
+ try {
504
+ const response = await fetch(url, { signal: AbortSignal.timeout(1_000) })
505
+ await response.body?.cancel()
506
+ if (response.ok) return null
507
+ } catch {
508
+ // Not listening yet.
509
+ }
510
+ await sleep(25)
511
+ }
512
+ return `the application did not answer ${this.config.healthPath} within ${HEALTH_TIMEOUT_MS / 1000} s`
513
+ }
514
+
515
+ private async stopApplication(): Promise<void> {
516
+ const running = this.application
517
+ if (running === null) return
518
+ running.stopping = true
519
+ this.application = null
520
+ running.process.kill('SIGTERM')
521
+ const timer = setTimeout(() => running.process.kill('SIGKILL'), STOP_GRACE_MS)
522
+ await running.exited
523
+ clearTimeout(timer)
524
+ }
525
+ }
526
+
527
+ const lastLine = (result: CommandResult): string => [...result.lines].reverse().find((line) => line.trim() !== '')?.trim() ?? 'no output'
528
+
529
+ /** The process environment with its string values only. */
530
+ function stringEnvironment(env: Readonly<Record<string, string | undefined>>): Record<string, string> {
531
+ const strings: Record<string, string> = {}
532
+ for (const [name, value] of Object.entries(env)) {
533
+ if (value !== undefined) strings[name] = value
534
+ }
535
+ return strings
536
+ }
537
+
538
+ /**
539
+ * The generated dev entrypoint's only call. It reads the build's configuration and the `source` transport, makes
540
+ * itself non-dumpable (it holds the provisioning credential and the key), and runs until a signal stops it.
541
+ */
542
+ export async function runDevLauncher(env: Readonly<Record<string, string | undefined>>): Promise<void> {
543
+ const root = process.cwd()
544
+ const config = await readDevLauncherConfig(root)
545
+ const environment = stringEnvironment(env)
546
+ const url = environment[SOURCE.url] ?? ''
547
+ const key = environment[SOURCE.key] ?? ''
548
+ if (url === '' || key === '') {
549
+ throw new Error(`fabrika dev: ${SOURCE.url} and ${SOURCE.key} are not set; the platform writes them for a dev environment`)
550
+ }
551
+ makeProcessUndumpable()
552
+ const launcher = startDevLauncher({ root, config, environment, source: { url, key } })
553
+ for (const signal of FORWARDED_SIGNALS) {
554
+ process.once(signal, () => void launcher.stop().then(() => process.exit(0)))
555
+ }
556
+ await launcher.ready
557
+ }
@@ -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
+ }
@@ -0,0 +1,126 @@
1
+ // The wire of the platform transport's source receiver (ADR-0064): what the launcher of a `dev` environment asks
2
+ // Control and what it reports back. Both sides import it, so the shapes and the one parser of each message cannot drift
3
+ // apart. Runtime-neutral: zod and Web APIs only.
4
+
5
+ import { z } from 'zod/v4'
6
+
7
+ /** The long poll under the receiver's URL (`FABRIKA_PLATFORM_SOURCE_URL`). */
8
+ export const DEV_SOURCE_WAIT_PATH = '/platform/source/wait'
9
+ /** Where the launcher reports what it serves. */
10
+ export const DEV_SOURCE_REPORT_PATH = '/platform/source/report'
11
+
12
+ /** How long Control holds a wait before it answers `idle`: below every proxy's idle timeout on the way. */
13
+ export const DEV_SOURCE_WAIT_MS = 25_000
14
+
15
+ /** The most log lines one report carries, and the longest line. */
16
+ export const DEV_REPORT_MAX_LINES = 200
17
+ export const DEV_REPORT_MAX_LINE_CHARACTERS = 2_000
18
+ export const DEV_REPORT_MAX_MESSAGE_CHARACTERS = 500
19
+
20
+ const commitSha = z.string().regex(/^[0-9a-f]{40}$/)
21
+ const generation = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER)
22
+
23
+ /**
24
+ * Repository-relative with forward slashes: no leading slash, no backslash, no empty, `.` or `..` segment, no control
25
+ * character. The launcher writes these paths under its root, so nothing it receives may name a path outside it.
26
+ */
27
+ export function isSourcePath(path: string): boolean {
28
+ return path.length > 0 && path.length <= 512 && !path.startsWith('/') && !path.includes('\\')
29
+ && ![...path].some((character) => character.charCodeAt(0) <= 0x1f || character.charCodeAt(0) === 0x7f)
30
+ && path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
31
+ }
32
+
33
+ const sourcePath = z.string().refine(isSourcePath, 'not a repository-relative path')
34
+
35
+ export const devWaitRequestSchema = z.object({
36
+ /** The commit whose files the container holds now. */
37
+ head: commitSha,
38
+ /** The reset generation it applied last; null for a container with no record of one, which adopts the answer's. */
39
+ reset: generation.nullable(),
40
+ /** The check request it ran last; null as for `reset`. */
41
+ checks: generation.nullable(),
42
+ /** False at boot: answer at once, so the launcher reconciles before it serves. */
43
+ hold: z.boolean(),
44
+ }).strict()
45
+
46
+ export type DevWaitRequest = z.infer<typeof devWaitRequestSchema>
47
+
48
+ /** One changed file at the target commit; `content` null deletes it. */
49
+ export const devSourceFileSchema = z.object({ path: sourcePath, content: z.string().nullable() }).strict()
50
+
51
+ export type DevSourceFile = z.infer<typeof devSourceFileSchema>
52
+
53
+ /** The files that bring the container from `from` to `to`. */
54
+ export const devSyncSchema = z.object({ from: commitSha, to: commitSha, files: z.array(devSourceFileSchema) }).strict()
55
+
56
+ export type DevSync = z.infer<typeof devSyncSchema>
57
+
58
+ /**
59
+ * What to do. `sync` is null when the container holds the recorded head (or a head it refused). `reset` and `checks` are
60
+ * the environment's current generations: one past what the container applied asks it to reset its schema, or to run
61
+ * the check chain.
62
+ */
63
+ export const devWaitAnswerSchema = z.object({ sync: devSyncSchema.nullable(), reset: generation, checks: generation }).strict()
64
+
65
+ export type DevWaitAnswer = z.infer<typeof devWaitAnswerSchema>
66
+
67
+ export const DEV_STATUSES = ['syncing', 'serving', 'failed', 'needs_bootstrap', 'resetting', 'checking'] as const
68
+
69
+ export type DevStatus = (typeof DEV_STATUSES)[number]
70
+
71
+ export const devLogLineSchema = z.object({
72
+ /** The line's number in the launcher's output since it started; it only grows. */
73
+ n: z.number().int().min(0),
74
+ stream: z.enum(['stdout', 'stderr']),
75
+ text: z.string().max(DEV_REPORT_MAX_LINE_CHARACTERS),
76
+ }).strict()
77
+
78
+ export type DevLogLine = z.infer<typeof devLogLineSchema>
79
+
80
+ export const devCheckResultSchema = z.object({
81
+ name: z.string().min(1).max(64),
82
+ ok: z.boolean(),
83
+ exitCode: z.number().int().nullable(),
84
+ /** The last lines of its output. */
85
+ output: z.array(z.string().max(DEV_REPORT_MAX_LINE_CHARACTERS)).max(DEV_REPORT_MAX_LINES),
86
+ }).strict()
87
+
88
+ export type DevCheckResult = z.infer<typeof devCheckResultSchema>
89
+
90
+ export const devReportSchema = z.object({
91
+ /** The commit whose files the container holds now. */
92
+ head: commitSha,
93
+ status: z.enum(DEV_STATUSES),
94
+ /** The commit the launcher is syncing to or refused; null when it has none. */
95
+ target: commitSha.nullable(),
96
+ /** One line: why a sync was refused or failed. */
97
+ message: z.string().max(DEV_REPORT_MAX_MESSAGE_CHARACTERS).nullable(),
98
+ /** The newest lines of the application's and the launcher's output, oldest first. */
99
+ log: z.array(devLogLineSchema).max(DEV_REPORT_MAX_LINES),
100
+ reset: generation.nullable(),
101
+ checks: z.object({ request: generation, results: z.array(devCheckResultSchema).max(8) }).strict().nullable(),
102
+ }).strict()
103
+
104
+ export type DevReport = z.infer<typeof devReportSchema>
105
+
106
+ /** The body of a wait as JSON text. */
107
+ export function encodeDevWaitRequest(request: DevWaitRequest): string {
108
+ return JSON.stringify(devWaitRequestSchema.parse(request))
109
+ }
110
+
111
+ /** The body of a report as JSON text. */
112
+ export function encodeDevReport(report: DevReport): string {
113
+ return JSON.stringify(devReportSchema.parse(report))
114
+ }
115
+
116
+ export type DevParse<T> = { readonly ok: true; readonly value: T } | { readonly ok: false }
117
+
118
+ function parseWith<T>(schema: z.ZodType<T>, json: unknown): DevParse<T> {
119
+ const parsed = schema.safeParse(json)
120
+ return parsed.success ? { ok: true, value: parsed.data } : { ok: false }
121
+ }
122
+
123
+ /** The one parser of a wait request; a field this release does not know is refused, never dropped. */
124
+ export const parseDevWaitRequest = (json: unknown): DevParse<DevWaitRequest> => parseWith(devWaitRequestSchema, json)
125
+ export const parseDevWaitAnswer = (json: unknown): DevParse<DevWaitAnswer> => parseWith(devWaitAnswerSchema, json)
126
+ export const parseDevReport = (json: unknown): DevParse<DevReport> => parseWith(devReportSchema, json)