@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.
- package/package.json +35 -24
- package/skill/SKILL.md +1 -1
- package/skill/references/app-setup.md +8 -8
- package/src/dev/changes.ts +35 -0
- package/src/dev/contract-digest.ts +17 -0
- package/src/dev/index.ts +11 -0
- package/src/dev/launcher.ts +604 -0
- package/src/dev/output.ts +62 -0
- package/src/dev/wire.ts +127 -0
- package/types/src/agent-skill.d.ts +29 -0
- package/types/src/build.d.ts +11 -0
- package/types/src/bun.d.ts +66 -0
- package/types/src/client/caller.d.ts +19 -0
- package/types/src/client/contract.d.ts +131 -0
- package/types/src/client/error-reporting.d.ts +66 -0
- package/types/src/client/index.d.ts +4 -0
- package/types/src/client/rpc.d.ts +14 -0
- package/types/src/client/share.d.ts +2 -0
- package/types/src/client/transport.d.ts +42 -0
- package/types/src/cloudflare.d.ts +49 -0
- package/types/src/contract/artifact-schema.d.ts +8 -0
- package/types/src/contract/attachment.d.ts +41 -0
- package/types/src/contract/build.d.ts +12 -0
- package/types/src/contract/compile.d.ts +11 -0
- package/types/src/contract/connect.d.ts +51 -0
- package/types/src/contract/context.d.ts +501 -0
- package/types/src/contract/declaration.d.ts +303 -0
- package/types/src/contract/defaults.d.ts +208 -0
- package/types/src/contract/define.d.ts +359 -0
- package/types/src/contract/file.d.ts +44 -0
- package/types/src/contract/group.d.ts +274 -0
- package/types/src/contract/handled.d.ts +18 -0
- package/types/src/contract/index.d.ts +19 -0
- package/types/src/contract/kit.d.ts +210 -0
- package/types/src/contract/platform.d.ts +9 -0
- package/types/src/contract/problems.d.ts +14 -0
- package/types/src/contract/redirect.d.ts +25 -0
- package/types/src/contract/schema-from-shape.d.ts +10 -0
- package/types/src/contract/schema-shape.d.ts +5 -0
- package/types/src/contract/snapshot-source.d.ts +15 -0
- package/types/src/contract/token.d.ts +58 -0
- package/types/src/contract/vocabulary.d.ts +30 -0
- package/types/src/contract/webhook-binding.d.ts +24 -0
- package/types/src/contract/webhook-scheme.d.ts +23 -0
- package/types/src/dev/changes.d.ts +7 -0
- package/types/src/dev/contract-digest.d.ts +1 -0
- package/types/src/dev/index.d.ts +2 -0
- package/types/src/dev/launcher.d.ts +32 -0
- package/types/src/dev/output.d.ts +22 -0
- package/types/src/dev/wire.d.ts +122 -0
- package/types/src/disk-assets.d.ts +3 -0
- package/types/src/errors.d.ts +46 -0
- package/types/src/fetch.d.ts +7 -0
- package/types/src/files/archive.d.ts +26 -0
- package/types/src/files/clamscan.d.ts +32 -0
- package/types/src/files/handle.d.ts +106 -0
- package/types/src/files/kinds.d.ts +105 -0
- package/types/src/files/scan.d.ts +80 -0
- package/types/src/files/sniff.d.ts +7 -0
- package/types/src/files/streams.d.ts +17 -0
- package/types/src/files/treatment.d.ts +18 -0
- package/types/src/from-row.d.ts +20 -0
- package/types/src/honeypot.d.ts +2 -0
- package/types/src/implement/anonymous.d.ts +26 -0
- package/types/src/implement/attachment.d.ts +14 -0
- package/types/src/implement/boot.d.ts +109 -0
- package/types/src/implement/bounded.d.ts +22 -0
- package/types/src/implement/browser-config.d.ts +19 -0
- package/types/src/implement/capability.d.ts +140 -0
- package/types/src/implement/challenge.d.ts +75 -0
- package/types/src/implement/client-address.d.ts +11 -0
- package/types/src/implement/connect.d.ts +67 -0
- package/types/src/implement/crashes.d.ts +31 -0
- package/types/src/implement/dispatch.d.ts +218 -0
- package/types/src/implement/egress.d.ts +14 -0
- package/types/src/implement/environment.d.ts +14 -0
- package/types/src/implement/failures.d.ts +34 -0
- package/types/src/implement/faults.d.ts +44 -0
- package/types/src/implement/headers.d.ts +7 -0
- package/types/src/implement/implement.d.ts +58 -0
- package/types/src/implement/index.d.ts +24 -0
- package/types/src/implement/invocation.d.ts +120 -0
- package/types/src/implement/membership.d.ts +201 -0
- package/types/src/implement/multipart.d.ts +36 -0
- package/types/src/implement/params.d.ts +7 -0
- package/types/src/implement/principal.d.ts +52 -0
- package/types/src/implement/project.d.ts +11 -0
- package/types/src/implement/public.d.ts +61 -0
- package/types/src/implement/serve.d.ts +25 -0
- package/types/src/implement/tokens.d.ts +49 -0
- package/types/src/implement/types.d.ts +243 -0
- package/types/src/implement/urls.d.ts +15 -0
- package/types/src/implement/verifiers.d.ts +19 -0
- package/types/src/implement/webhook.d.ts +55 -0
- package/types/src/implement/workload.d.ts +56 -0
- package/types/src/implement/xlsx.d.ts +6 -0
- package/types/src/index.d.ts +12 -0
- package/types/src/jobs/exception.d.ts +40 -0
- package/types/src/jobs/mail.d.ts +67 -0
- package/types/src/jobs/report.d.ts +56 -0
- package/types/src/jobs/runner.d.ts +145 -0
- package/types/src/jobs/schedule.d.ts +15 -0
- package/types/src/jobs/scrub.d.ts +12 -0
- package/types/src/jobs/types.d.ts +92 -0
- package/types/src/react/caller.d.ts +30 -0
- package/types/src/react/errors.d.ts +40 -0
- package/types/src/react/hooks.d.ts +110 -0
- package/types/src/react/index.d.ts +5 -0
- package/types/src/react/provider.d.ts +58 -0
- package/types/src/react/store.d.ts +68 -0
- package/types/src/react/testing.d.ts +28 -0
- package/types/src/react/types.d.ts +33 -0
- package/types/src/rpc/builder.d.ts +52 -0
- package/types/src/rpc/contract.d.ts +23 -0
- package/types/src/rpc/dispatcher.d.ts +11 -0
- package/types/src/rpc/types.d.ts +62 -0
- package/types/src/service/define.d.ts +42 -0
- package/types/src/service/index.d.ts +3 -0
- package/types/src/service/serve.d.ts +28 -0
- package/types/src/standard-schema.d.ts +55 -0
- package/types/src/testing/caller.d.ts +95 -0
- package/types/src/testing/challenge.d.ts +11 -0
- package/types/src/testing/dev.d.ts +97 -0
- package/types/src/testing/endpoint-access.d.ts +99 -0
- package/types/src/testing/index.d.ts +12 -0
- package/types/src/testing/isolation.d.ts +3 -0
- package/types/src/testing/kit.d.ts +182 -0
- package/types/src/testing/membership.d.ts +132 -0
- package/types/src/testing/policy-isolation.d.ts +113 -0
- package/types/src/testing/preload.d.ts +1 -0
- package/types/src/testing/proxy.d.ts +40 -0
- package/types/src/testing/runner.d.ts +8 -0
- package/types/src/testing/sample-file.d.ts +5 -0
- package/types/src/testing/status.d.ts +4 -0
- 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
|
+
}
|