@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 +19 -9
- package/skill/SKILL.md +1 -1
- package/skill/references/app-setup.md +8 -8
- package/src/dev/changes.ts +73 -0
- package/src/dev/index.ts +11 -0
- package/src/dev/launcher.ts +557 -0
- package/src/dev/output.ts +62 -0
- package/src/dev/wire.ts +126 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/app",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
97
|
-
"@fabrika/auth-core": "0.
|
|
98
|
-
"@fabrika/db": "0.
|
|
99
|
-
"@fabrika/email": "0.
|
|
100
|
-
"@fabrika/jwks": "0.
|
|
101
|
-
"@fabrika/platform": "0.
|
|
102
|
-
"@fabrika/platform-node": "0.
|
|
103
|
-
"@fabrika/proxy-core": "0.
|
|
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.
|
|
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.
|
|
23
|
-
"@fabrika/db": "0.
|
|
24
|
-
"@fabrika/provider-zerops": "0.
|
|
25
|
-
"@fabrika/proxy-core": "0.
|
|
26
|
-
"@fabrika/ui": "0.
|
|
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.
|
|
34
|
-
"@fabrika/cli": "0.
|
|
35
|
-
"@fabrika/platform": "0.
|
|
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)))
|
package/src/dev/index.ts
ADDED
|
@@ -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
|
+
}
|
package/src/dev/wire.ts
ADDED
|
@@ -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)
|