@fabrika/daemon-kit 0.2.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Contember Limited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # @fabrika/daemon-kit
2
+
3
+ Bun daemon infrastructure without canvas or browser dependencies:
4
+
5
+ - `createNotificationChannel`, `browserFrame`: topic-based WebSocket routing.
6
+ - `waitForEvent`, `WaitError`: CLI waiting, reconnects and deadlines.
7
+ - `createDaemonLifecycle`, `DaemonStartError`: process startup, health and shutdown.
8
+ - `dispatch`, `Route`: HTTP routing.
9
+ - `jsonResponse`, `corsHeaders`: HTTP response helpers.
10
+
11
+ Import runtime functions and types from `@fabrika/daemon-kit`.
12
+ The host supplies its domain-specific frame vocabulary and persistence.
13
+ Canvas session/compile/upload paths remain in `@fabrika/canvas-kernel/server`.
package/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ export { browserFrame, createNotificationChannel } from './src/channel'
2
+ export type {
3
+ BrowserFrameHandler,
4
+ ChannelRole,
5
+ ChannelSocket,
6
+ NotificationChannel,
7
+ NotificationChannelOptions,
8
+ RelayOptions,
9
+ TopicChannel,
10
+ } from './src/channel'
11
+ export { corsHeaders, jsonResponse } from './src/http-utils'
12
+ export { createDaemonLifecycle, DaemonStartError } from './src/lifecycle'
13
+ export type {
14
+ DaemonEvent,
15
+ DaemonHealth,
16
+ DaemonHealthDown,
17
+ DaemonHealthUp,
18
+ DaemonLifecycle,
19
+ DaemonLifecycleConfig,
20
+ DaemonPid,
21
+ DaemonStopReason,
22
+ DaemonStopResult,
23
+ DaemonTimeouts,
24
+ } from './src/lifecycle'
25
+ export { dispatch } from './src/router'
26
+ export type { Route, RouteHandler } from './src/router'
27
+ export { WaitError, waitForEvent } from './src/waiter'
28
+ export type { WaiterSocket, WaitFailure, WaitMessages, WaitOptions, WaitVerdict } from './src/waiter'
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@fabrika/daemon-kit",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "Bun daemon lifecycle, HTTP routing, WebSocket channels and CLI waiters.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/contember/fabrika-platform.git",
10
+ "directory": "packages/daemon-kit"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "files": [
16
+ "index.ts",
17
+ "src",
18
+ "!src/**/*.test.ts"
19
+ ],
20
+ "exports": {
21
+ ".": {
22
+ "bun": "./index.ts",
23
+ "types": "./index.ts",
24
+ "default": "./index.ts"
25
+ }
26
+ },
27
+ "scripts": {
28
+ "typecheck": "tsc --noEmit",
29
+ "test": "bun test"
30
+ }
31
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,265 @@
1
+ // Hosts own frame vocabulary and delivery policy; topics are opaque to the channel.
2
+
3
+ export type ChannelRole = 'browser' | 'wait'
4
+
5
+ /** The minimum a host socket must offer; `Bun.ServerWebSocket` satisfies it. */
6
+ export interface ChannelSocket {
7
+ send(message: string): unknown
8
+ close(): void
9
+ ping(): unknown
10
+ }
11
+
12
+ export interface RelayOptions {
13
+ /**
14
+ * Close every waiter the message was handed to and drop the topic's waiter
15
+ * set. Defaults to the channel's `waiterPolicy`.
16
+ */
17
+ close?: boolean
18
+ }
19
+
20
+ /** Channel operations bound to one topic. */
21
+ export interface TopicChannel {
22
+ readonly topic: string
23
+ /** Send to every browser on the topic. */
24
+ broadcast(message: unknown): void
25
+ /** Send to every waiter on the topic; `false` when there was none. */
26
+ relayToWaiters(message: unknown, options?: RelayOptions): boolean
27
+ hasWaiter(): boolean
28
+ /** Announce current waiter presence to the topic's browsers. */
29
+ broadcastPresence(): void
30
+ }
31
+
32
+ export interface BrowserFrameHandler {
33
+ /** The bound work when the frame is this handler's, `undefined` otherwise. */
34
+ bind(frame: unknown, channel: TopicChannel): (() => void | Promise<void>) | undefined
35
+ }
36
+
37
+ /**
38
+ * Register one frame of a host's vocabulary. Matching is synchronous so the
39
+ * socket's `message` handler stays synchronous; handling may be async.
40
+ */
41
+ export function browserFrame<T>(
42
+ match: (frame: unknown) => frame is T,
43
+ handle: (frame: T, channel: TopicChannel) => void | Promise<void>,
44
+ ): BrowserFrameHandler {
45
+ return {
46
+ bind: (frame, channel) => (match(frame) ? () => handle(frame, channel) : undefined),
47
+ }
48
+ }
49
+
50
+ export interface NotificationChannelOptions<S extends ChannelSocket> {
51
+ /** Which registry a socket belongs to. */
52
+ roleOf(socket: S): ChannelRole
53
+ /** The socket's routing key — a canvas session id, a Figma file key, … */
54
+ topicOf(socket: S): string
55
+ /** The host's browser-frame vocabulary, tried in order; first match wins. */
56
+ frames?: readonly BrowserFrameHandler[]
57
+ /** Builds the presence frame. Presence is not announced when omitted. */
58
+ presenceFrame?(hasWaiter: boolean, topic: string): unknown
59
+ /** What a relay does to the waiters it reached. Default `"keep-open"`. */
60
+ waiterPolicy?: 'close-on-delivery' | 'keep-open'
61
+ /**
62
+ * Runs after a waiter is registered and before presence is announced, so a
63
+ * host that buffers events for an absent waiter can flush them here.
64
+ */
65
+ onWaiterOpen?(channel: TopicChannel): void
66
+ /** Frame handler failures. Dropped when omitted. */
67
+ onError?(error: unknown): void
68
+ /** Liveness sweep period; `false` leaves the sweep to the host. */
69
+ pingIntervalMs?: number | false
70
+ }
71
+
72
+ export interface NotificationChannel<S extends ChannelSocket> {
73
+ handlers: {
74
+ open(socket: S): void
75
+ message(socket: S, message: string | Buffer): void
76
+ pong(socket: S): void
77
+ close(socket: S): void
78
+ }
79
+ broadcast(topic: string, message: unknown): void
80
+ relayToWaiters(topic: string, message: unknown, options?: RelayOptions): boolean
81
+ hasWaiter(topic: string): boolean
82
+ broadcastPresence(topic: string): void
83
+ /** The topic-bound view handed to frame handlers. */
84
+ topic(topic: string): TopicChannel
85
+ /** One liveness sweep — ping live waiters, evict the ones that never ponged. */
86
+ pingWaiters(): void
87
+ /** Stop the liveness timer. */
88
+ stop(): void
89
+ }
90
+
91
+ const DEFAULT_PING_INTERVAL_MS = 5_000
92
+
93
+ function parseFrame(message: string | Buffer): unknown {
94
+ const text = typeof message === 'string' ? message : new TextDecoder().decode(message)
95
+ try {
96
+ return JSON.parse(text)
97
+ } catch {
98
+ return undefined
99
+ }
100
+ }
101
+
102
+ export function createNotificationChannel<S extends ChannelSocket>(
103
+ options: NotificationChannelOptions<S>,
104
+ ): NotificationChannel<S> {
105
+ const { roleOf, topicOf } = options
106
+ const frames = options.frames ?? []
107
+ const closeOnDelivery = (options.waiterPolicy ?? 'keep-open') === 'close-on-delivery'
108
+ const browserSockets = new Map<string, Set<S>>()
109
+ const waitSockets = new Map<string, Set<S>>()
110
+ const pongReceived = new WeakSet<S>()
111
+
112
+ function registry(role: ChannelRole): Map<string, Set<S>> {
113
+ return role === 'browser' ? browserSockets : waitSockets
114
+ }
115
+
116
+ function hasWaiter(topic: string): boolean {
117
+ return (waitSockets.get(topic)?.size ?? 0) > 0
118
+ }
119
+
120
+ function broadcast(topic: string, message: unknown): void {
121
+ const sockets = browserSockets.get(topic)
122
+ if (!sockets || sockets.size === 0) return
123
+ const payload = JSON.stringify(message)
124
+ for (const socket of sockets) socket.send(payload)
125
+ }
126
+
127
+ function presencePayload(topic: string): string | undefined {
128
+ if (!options.presenceFrame) return undefined
129
+ return JSON.stringify(options.presenceFrame(hasWaiter(topic), topic))
130
+ }
131
+
132
+ function broadcastPresence(topic: string): void {
133
+ const payload = presencePayload(topic)
134
+ if (payload === undefined) return
135
+ const sockets = browserSockets.get(topic)
136
+ if (!sockets) return
137
+ for (const socket of sockets) socket.send(payload)
138
+ }
139
+
140
+ function relayToWaiters(topic: string, message: unknown, relay: RelayOptions = {}): boolean {
141
+ const sockets = waitSockets.get(topic)
142
+ if (!sockets || sockets.size === 0) return false
143
+ const close = relay.close ?? closeOnDelivery
144
+ if (close) waitSockets.delete(topic)
145
+ const payload = JSON.stringify(message)
146
+ for (const socket of sockets) {
147
+ socket.send(payload)
148
+ if (close) socket.close()
149
+ }
150
+ return true
151
+ }
152
+
153
+ function topicChannel(topic: string): TopicChannel {
154
+ return {
155
+ topic,
156
+ broadcast: (message) => broadcast(topic, message),
157
+ relayToWaiters: (message, relay) => relayToWaiters(topic, message, relay),
158
+ hasWaiter: () => hasWaiter(topic),
159
+ broadcastPresence: () => broadcastPresence(topic),
160
+ }
161
+ }
162
+
163
+ function reportError(error: unknown): void {
164
+ options.onError?.(error)
165
+ }
166
+
167
+ function dropWaiter(topic: string, socket: S): void {
168
+ const sockets = waitSockets.get(topic)
169
+ if (sockets) {
170
+ sockets.delete(socket)
171
+ if (sockets.size === 0) waitSockets.delete(topic)
172
+ }
173
+ try {
174
+ socket.close()
175
+ } catch {}
176
+ broadcastPresence(topic)
177
+ }
178
+
179
+ // A dead socket never fires `close`, so presence would stay stuck on `true`
180
+ // until the daemon restarts — sweep for the missing pong instead.
181
+ function pingWaiters(): void {
182
+ for (const [topic, sockets] of waitSockets) {
183
+ for (const socket of sockets) {
184
+ if (!pongReceived.has(socket)) {
185
+ dropWaiter(topic, socket)
186
+ continue
187
+ }
188
+ pongReceived.delete(socket)
189
+ try {
190
+ socket.ping()
191
+ } catch {
192
+ dropWaiter(topic, socket)
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ const interval = options.pingIntervalMs === false
199
+ ? undefined
200
+ : setInterval(pingWaiters, options.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS)
201
+ interval?.unref()
202
+
203
+ const handlers = {
204
+ open(socket: S): void {
205
+ const topic = topicOf(socket)
206
+ const role = roleOf(socket)
207
+ const sockets = registry(role).get(topic) ?? new Set<S>()
208
+ sockets.add(socket)
209
+ registry(role).set(topic, sockets)
210
+
211
+ if (role === 'browser') {
212
+ const payload = presencePayload(topic)
213
+ if (payload !== undefined) socket.send(payload)
214
+ return
215
+ }
216
+ pongReceived.add(socket) // free pass on the first sweep
217
+ options.onWaiterOpen?.(topicChannel(topic))
218
+ broadcastPresence(topic)
219
+ },
220
+
221
+ message(socket: S, message: string | Buffer): void {
222
+ if (roleOf(socket) !== 'browser') return
223
+ const frame = parseFrame(message)
224
+ if (frame === undefined) return
225
+ const channel = topicChannel(topicOf(socket))
226
+ for (const handler of frames) {
227
+ const run = handler.bind(frame, channel)
228
+ if (!run) continue
229
+ try {
230
+ const result = run()
231
+ if (result instanceof Promise) result.catch(reportError)
232
+ } catch (error) {
233
+ reportError(error)
234
+ }
235
+ return
236
+ }
237
+ },
238
+
239
+ pong(socket: S): void {
240
+ if (roleOf(socket) === 'wait') pongReceived.add(socket)
241
+ },
242
+
243
+ close(socket: S): void {
244
+ const topic = topicOf(socket)
245
+ const role = roleOf(socket)
246
+ const sockets = registry(role).get(topic)
247
+ sockets?.delete(socket)
248
+ if (sockets && sockets.size === 0) registry(role).delete(topic)
249
+ if (role === 'wait') broadcastPresence(topic)
250
+ },
251
+ }
252
+
253
+ return {
254
+ handlers,
255
+ broadcast,
256
+ relayToWaiters,
257
+ hasWaiter,
258
+ broadcastPresence,
259
+ topic: topicChannel,
260
+ pingWaiters,
261
+ stop() {
262
+ if (interval) clearInterval(interval)
263
+ },
264
+ }
265
+ }
@@ -0,0 +1,14 @@
1
+ export function jsonResponse(data: unknown, status = 200): Response {
2
+ return new Response(JSON.stringify(data), {
3
+ status,
4
+ headers: { 'Content-Type': 'application/json' },
5
+ })
6
+ }
7
+
8
+ export function corsHeaders(port: number): Record<string, string> {
9
+ return {
10
+ 'Access-Control-Allow-Origin': 'http://localhost:' + port,
11
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
12
+ 'Access-Control-Allow-Headers': 'Content-Type',
13
+ }
14
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Daemon process lifecycle: is it up, start it, stop it, keep it in step with
3
+ * the CLI that talks to it.
4
+ *
5
+ * The kernel owns the algorithm — health probing, the concurrent-start race,
6
+ * which pid may be signalled — and the host owns every location: base URL, pid
7
+ * file, spawn argv, log file, health shape, timeouts.
8
+ *
9
+ * The daemon's own pid, reported through `/health`, is the only pid this module
10
+ * will signal. A pid file is written at spawn and read only to notice that
11
+ * *someone* is already booting a daemon; a stale file or a reused pid must
12
+ * never let the CLI kill an unrelated process.
13
+ */
14
+
15
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'fs'
16
+ import { dirname } from 'path'
17
+
18
+ /** Every host health shape must carry the daemon's own pid. */
19
+ export interface DaemonPid {
20
+ pid: number
21
+ }
22
+
23
+ export type DaemonHealthUp<H extends DaemonPid> = { status: 'up'; ok: true; url: string } & H
24
+
25
+ export interface DaemonHealthDown {
26
+ status: 'down'
27
+ ok: false
28
+ url: string
29
+ error?: string
30
+ }
31
+
32
+ export type DaemonHealth<H extends DaemonPid> = DaemonHealthUp<H> | DaemonHealthDown
33
+
34
+ export type DaemonStopReason =
35
+ | 'stopped'
36
+ | 'not-running'
37
+ /** Nothing was running; a pid file left behind was cleaned up. */
38
+ | 'stale-pid'
39
+ /** A daemon answered `/health`, but signalling its pid failed. */
40
+ | 'signal-failed'
41
+ | 'refused-self'
42
+
43
+ export interface DaemonStopResult {
44
+ stopped: boolean
45
+ /** The daemon's own pid, or the stale pid-file value that was cleaned up. */
46
+ pid?: number
47
+ reason: DaemonStopReason
48
+ message: string
49
+ }
50
+
51
+ /** Progress worth telling a user about; the host writes the words. */
52
+ export type DaemonEvent =
53
+ | { type: 'version-mismatch'; running: string; expected: string }
54
+ | { type: 'starting'; command: readonly string[] }
55
+ | { type: 'started'; pid: number }
56
+ | { type: 'stopped'; pid: number }
57
+
58
+ export interface DaemonTimeouts {
59
+ /** Per `/health` request. Default 1000. */
60
+ health?: number
61
+ /** Wait for a daemon this process spawned. Default 3000. */
62
+ start?: number
63
+ /** Wait for a daemon another process is already starting. Default 1000. */
64
+ concurrentStart?: number
65
+ /** Wait for a signalled daemon to go down. Default 2000. */
66
+ stop?: number
67
+ }
68
+
69
+ export interface DaemonLifecycleConfig<H extends DaemonPid> {
70
+ /** Daemon origin, e.g. `http://localhost:19400`. */
71
+ baseUrl: string
72
+ /** Health endpoint path. Default `/health`. */
73
+ healthPath?: string
74
+ /** Narrows the `/health` body; `undefined` means "not a daemon we can talk to". */
75
+ parseHealth(body: unknown): H | undefined
76
+ /** Written at spawn, so a concurrent start can see a daemon that is still booting. */
77
+ pidFile: string
78
+ /** argv of the daemon process, e.g. `[process.execPath, entry, "serve"]`. */
79
+ command: readonly string[]
80
+ /** Working directory of the spawned daemon. Defaults to the current one. */
81
+ cwd?: string
82
+ /** File the daemon's stdout and stderr are appended to. */
83
+ logFile: string
84
+ /** Restart the daemon when its version differs from this CLI's. Off when omitted. */
85
+ versionCheck?: {
86
+ expected: string
87
+ of(health: H): string | undefined
88
+ }
89
+ /** Removed with the pid file when the daemon stops (auth tokens, sockets, …). */
90
+ cleanupFiles?: readonly string[]
91
+ timeouts?: DaemonTimeouts
92
+ onEvent?(event: DaemonEvent): void
93
+ }
94
+
95
+ export class DaemonStartError extends Error {
96
+ constructor(message: string, readonly logFile: string, readonly logTail: string) {
97
+ super(message)
98
+ this.name = 'DaemonStartError'
99
+ }
100
+ }
101
+
102
+ export interface DaemonLifecycle<H extends DaemonPid> {
103
+ health(timeoutMs?: number): Promise<DaemonHealth<H>>
104
+ isRunning(): Promise<boolean>
105
+ start(): Promise<DaemonHealthUp<H>>
106
+ /** A healthy daemon of the expected version, starting or restarting one if needed. */
107
+ ensure(): Promise<DaemonHealthUp<H>>
108
+ stop(): Promise<DaemonStopResult>
109
+ restart(): Promise<DaemonHealthUp<H>>
110
+ }
111
+
112
+ const LOG_TAIL_BYTES = 4096
113
+
114
+ function sleep(ms: number): Promise<void> {
115
+ return new Promise((resolve) => setTimeout(resolve, ms))
116
+ }
117
+
118
+ function errorMessage(error: unknown): string {
119
+ return error instanceof Error ? error.message : String(error)
120
+ }
121
+
122
+ export function createDaemonLifecycle<H extends DaemonPid>(
123
+ config: DaemonLifecycleConfig<H>,
124
+ ): DaemonLifecycle<H> {
125
+ const healthUrl = `${config.baseUrl}${config.healthPath ?? '/health'}`
126
+ const timeouts = {
127
+ health: config.timeouts?.health ?? 1_000,
128
+ start: config.timeouts?.start ?? 3_000,
129
+ concurrentStart: config.timeouts?.concurrentStart ?? 1_000,
130
+ stop: config.timeouts?.stop ?? 2_000,
131
+ }
132
+
133
+ function emit(event: DaemonEvent): void {
134
+ config.onEvent?.(event)
135
+ }
136
+
137
+ async function health(timeoutMs: number = timeouts.health): Promise<DaemonHealth<H>> {
138
+ const url = config.baseUrl
139
+ try {
140
+ const response = await fetch(healthUrl, { signal: AbortSignal.timeout(timeoutMs) })
141
+ if (!response.ok) return { status: 'down', ok: false, url, error: `HTTP ${response.status}` }
142
+ const parsed = config.parseHealth(await response.json())
143
+ if (!parsed) return { status: 'down', ok: false, url, error: 'invalid health response' }
144
+ return { status: 'up', ok: true, url, ...parsed }
145
+ } catch (error) {
146
+ return { status: 'down', ok: false, url, error: errorMessage(error) }
147
+ }
148
+ }
149
+
150
+ async function pollHealthy(timeoutMs: number): Promise<DaemonHealth<H>> {
151
+ const deadline = Date.now() + timeoutMs
152
+ let last = await health()
153
+ while (last.status === 'down' && Date.now() < deadline) {
154
+ await sleep(100)
155
+ last = await health()
156
+ }
157
+ return last
158
+ }
159
+
160
+ function readPid(): number | undefined {
161
+ try {
162
+ const pid = Number(readFileSync(config.pidFile, 'utf-8').trim())
163
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined
164
+ } catch {
165
+ return undefined
166
+ }
167
+ }
168
+
169
+ function processIsAlive(pid: number): boolean {
170
+ try {
171
+ process.kill(pid, 0)
172
+ return true
173
+ } catch {
174
+ return false
175
+ }
176
+ }
177
+
178
+ function cleanupFiles(): void {
179
+ for (const file of [config.pidFile, ...(config.cleanupFiles ?? [])]) {
180
+ try {
181
+ rmSync(file, { force: true })
182
+ } catch {}
183
+ }
184
+ }
185
+
186
+ function readLogTail(): string {
187
+ if (!existsSync(config.logFile)) return ''
188
+ try {
189
+ const log = readFileSync(config.logFile, 'utf-8')
190
+ return log.slice(-LOG_TAIL_BYTES).trim()
191
+ } catch {
192
+ return ''
193
+ }
194
+ }
195
+
196
+ function spawnDaemon(): number {
197
+ mkdirSync(dirname(config.logFile), { recursive: true })
198
+ appendFileSync(config.logFile, `--- daemon start ${new Date().toISOString()}\n`)
199
+ const stdout = openSync(config.logFile, 'a')
200
+ const stderr = openSync(config.logFile, 'a')
201
+ try {
202
+ const child = Bun.spawn([...config.command], {
203
+ cwd: config.cwd ?? process.cwd(),
204
+ stdin: 'ignore',
205
+ stdout,
206
+ stderr,
207
+ detached: true,
208
+ })
209
+ child.unref()
210
+ mkdirSync(dirname(config.pidFile), { recursive: true })
211
+ writeFileSync(config.pidFile, `${child.pid}\n`)
212
+ return child.pid
213
+ } finally {
214
+ closeSync(stdout)
215
+ closeSync(stderr)
216
+ }
217
+ }
218
+
219
+ async function start(): Promise<DaemonHealthUp<H>> {
220
+ emit({ type: 'starting', command: config.command })
221
+ spawnDaemon()
222
+ const started = await pollHealthy(timeouts.start)
223
+ if (started.status === 'up') {
224
+ emit({ type: 'started', pid: started.pid })
225
+ return started
226
+ }
227
+ throw new DaemonStartError(
228
+ `Daemon did not become healthy within ${timeouts.start}ms.`,
229
+ config.logFile,
230
+ readLogTail(),
231
+ )
232
+ }
233
+
234
+ async function stop(): Promise<DaemonStopResult> {
235
+ const current = await health()
236
+
237
+ if (current.status === 'down') {
238
+ const stale = readPid()
239
+ if (stale === undefined) return { stopped: false, reason: 'not-running', message: 'Daemon is not running.' }
240
+ cleanupFiles()
241
+ return { stopped: false, pid: stale, reason: 'stale-pid', message: `Daemon is not running; removed stale pid ${stale}.` }
242
+ }
243
+
244
+ const pid = current.pid
245
+ if (pid === process.pid) {
246
+ return { stopped: false, pid, reason: 'refused-self', message: `Daemon pid ${pid} is this process; refusing to stop self.` }
247
+ }
248
+
249
+ try {
250
+ process.kill(pid, 'SIGTERM')
251
+ } catch {
252
+ cleanupFiles()
253
+ return { stopped: false, pid, reason: 'signal-failed', message: `Daemon pid ${pid} could not be signalled.` }
254
+ }
255
+
256
+ const deadline = Date.now() + timeouts.stop
257
+ while (Date.now() < deadline && (await health()).status === 'up') {
258
+ await sleep(100)
259
+ }
260
+ cleanupFiles()
261
+ emit({ type: 'stopped', pid })
262
+ return { stopped: true, pid, reason: 'stopped', message: `Stopped daemon pid ${pid}.` }
263
+ }
264
+
265
+ async function ensure(): Promise<DaemonHealthUp<H>> {
266
+ const current = await health()
267
+ if (current.status === 'up') {
268
+ const check = config.versionCheck
269
+ const running = check?.of(current)
270
+ if (check && running && running !== check.expected) {
271
+ emit({ type: 'version-mismatch', running, expected: check.expected })
272
+ await stop()
273
+ return await start()
274
+ }
275
+ return current
276
+ }
277
+
278
+ // Someone else may be mid-spawn: a live pid with no health yet is a daemon
279
+ // still booting, and racing it would put a second one on the same port.
280
+ const pid = readPid()
281
+ if (pid !== undefined && processIsAlive(pid)) {
282
+ const starting = await pollHealthy(timeouts.concurrentStart)
283
+ if (starting.status === 'up') return starting
284
+ }
285
+
286
+ return await start()
287
+ }
288
+
289
+ async function restart(): Promise<DaemonHealthUp<H>> {
290
+ await stop()
291
+ return await start()
292
+ }
293
+
294
+ return {
295
+ health,
296
+ async isRunning() {
297
+ return (await health()).status === 'up'
298
+ },
299
+ start,
300
+ ensure,
301
+ stop,
302
+ restart,
303
+ }
304
+ }
package/src/router.ts ADDED
@@ -0,0 +1,16 @@
1
+ export type RouteHandler = (req: Request, url: URL, match: URLPatternResult) => Response | Promise<Response>
2
+
3
+ export interface Route {
4
+ method: string
5
+ pattern: URLPattern
6
+ handler: RouteHandler
7
+ }
8
+
9
+ export function dispatch(routes: Route[], req: Request, url: URL): Response | Promise<Response> | null {
10
+ for (const route of routes) {
11
+ if (route.method !== req.method) continue
12
+ const match = route.pattern.exec(url)
13
+ if (match) return route.handler(req, url, match)
14
+ }
15
+ return null
16
+ }
package/src/waiter.ts ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The CLI half of the notification channel: connect to a daemon's wait socket
3
+ * and block until the host says the wait is over.
4
+ *
5
+ * What settles the wait is a host predicate, so one helper covers "feedback
6
+ * arrived", "screens confirmed" and "a run was requested". The socket factory
7
+ * is injectable, which is what makes the retry and settle logic testable
8
+ * without a live daemon.
9
+ */
10
+
11
+ /** The slice of `WebSocket` a waiter uses; a real `WebSocket` satisfies it. */
12
+ export interface WaiterSocket {
13
+ close(): void
14
+ onopen: ((event: Event) => void) | null
15
+ onmessage: ((event: MessageEvent) => void) | null
16
+ onerror: ((event: Event) => void) | null
17
+ onclose: ((event: CloseEvent) => void) | null
18
+ }
19
+
20
+ /** What a frame means to the waiter; `undefined` means "not mine, keep waiting". */
21
+ export type WaitVerdict<T> =
22
+ | { type: 'settle'; value: T }
23
+ | { type: 'fail'; error: Error }
24
+ /** Wake the probe — the frame announces state the waiter has to read back. */
25
+ | { type: 'recheck' }
26
+
27
+ export interface WaitMessages {
28
+ /** Deadline passed. Default: "Timed out waiting." */
29
+ timeout?: string
30
+ /** Socket closed before the wait settled. Default: "Connection closed before the wait settled." */
31
+ closed?: string
32
+ /** The socket errored. Default: "WebSocket connection failed." */
33
+ connectionFailed?: string
34
+ /**
35
+ * `createSocket` threw, so no socket ever existed; the factory's own message
36
+ * is appended after a colon. Default: "Could not create the WebSocket".
37
+ */
38
+ socketCreateFailed?: string
39
+ }
40
+
41
+ export interface WaitOptions<T> {
42
+ /** The daemon's wait socket, e.g. `ws://localhost:19400/ws/wait/<topic>`. */
43
+ url: string
44
+ /** Reads a frame; returns how it settles the wait, if at all. */
45
+ onFrame(frame: unknown): WaitVerdict<T> | undefined
46
+ /**
47
+ * Fast path against server state. Runs before connecting (unless the caller
48
+ * already did that read — see `alreadyProbed`), again once the socket opens —
49
+ * closing the race where the event lands between the two — and whenever a
50
+ * frame asks for a recheck. A probe that rejects is dropped: the socket, not
51
+ * the probe, is what the wait ultimately hangs on.
52
+ */
53
+ probe?(): Promise<{ value: T } | undefined>
54
+ /**
55
+ * The caller has already made the probe's read itself and it came up empty,
56
+ * so the wait starts at the socket. Set it when that read is the caller's own
57
+ * error channel — a probe that rejects is dropped here, so a caller that must
58
+ * report *why* the read failed has to make it before calling. The on-open
59
+ * re-read still runs; it is what closes the subscribe race.
60
+ */
61
+ alreadyProbed?: boolean
62
+ createSocket?(url: string): WaiterSocket
63
+ /** Deadline for the whole wait, spanning reconnects. */
64
+ timeoutMs: number
65
+ reconnect?: {
66
+ /** Reconnects after a dropped connection. Default 0. */
67
+ attempts?: number
68
+ /** Delay before a reconnect, capped by the remaining deadline. Default 1000. */
69
+ delayMs?: number
70
+ }
71
+ messages?: WaitMessages
72
+ }
73
+
74
+ /** Why the wait ended without an answer; hosts map these onto their own errors. */
75
+ export type WaitFailure =
76
+ | 'timeout'
77
+ | 'closed'
78
+ /** The socket was created, then errored. */
79
+ | 'connection-failed'
80
+ /** `createSocket` threw — nothing was ever connected to. */
81
+ | 'socket-create-failed'
82
+
83
+ export class WaitError extends Error {
84
+ constructor(
85
+ message: string,
86
+ readonly reason: WaitFailure,
87
+ readonly retryable: boolean,
88
+ options?: ErrorOptions,
89
+ ) {
90
+ super(message, options)
91
+ this.name = 'WaitError'
92
+ }
93
+ }
94
+
95
+ const DEFAULT_MESSAGES: Required<WaitMessages> = {
96
+ timeout: 'Timed out waiting.',
97
+ closed: 'Connection closed before the wait settled.',
98
+ connectionFailed: 'WebSocket connection failed.',
99
+ socketCreateFailed: 'Could not create the WebSocket',
100
+ }
101
+
102
+ function parseFrame(data: unknown): unknown {
103
+ if (typeof data !== 'string') return undefined
104
+ try {
105
+ return JSON.parse(data)
106
+ } catch {
107
+ return undefined
108
+ }
109
+ }
110
+
111
+ function connectAndWait<T>(
112
+ options: WaitOptions<T>,
113
+ createSocket: (url: string) => WaiterSocket,
114
+ messages: Required<WaitMessages>,
115
+ remainingMs: number,
116
+ ): Promise<T> {
117
+ return new Promise<T>((resolve, reject) => {
118
+ let socket: WaiterSocket
119
+ try {
120
+ socket = createSocket(options.url)
121
+ } catch (error) {
122
+ const detail = error instanceof Error ? error.message : String(error)
123
+ const message = `${messages.socketCreateFailed}: ${detail}`
124
+ reject(new WaitError(message, 'socket-create-failed', true, { cause: error }))
125
+ return
126
+ }
127
+
128
+ let settled = false
129
+ // The value is wrapped so a settling `T` of any shape stays distinguishable.
130
+ const finish = (outcome: { value: T } | Error) => {
131
+ if (settled) return
132
+ settled = true
133
+ clearTimeout(timer)
134
+ socket.close()
135
+ if (outcome instanceof Error) reject(outcome)
136
+ else resolve(outcome.value)
137
+ }
138
+
139
+ const timer = setTimeout(() => finish(new WaitError(messages.timeout, 'timeout', false)), remainingMs)
140
+
141
+ const runProbe = () => {
142
+ if (!options.probe) return
143
+ options.probe().then((result) => {
144
+ if (result) finish(result)
145
+ }).catch(() => {})
146
+ }
147
+
148
+ socket.onopen = () => runProbe()
149
+
150
+ socket.onmessage = (event) => {
151
+ const frame = parseFrame(event.data)
152
+ if (frame === undefined) return
153
+ const verdict = options.onFrame(frame)
154
+ if (!verdict) return
155
+ if (verdict.type === 'settle') finish({ value: verdict.value })
156
+ else if (verdict.type === 'fail') finish(verdict.error)
157
+ else runProbe()
158
+ }
159
+
160
+ socket.onerror = () => finish(new WaitError(messages.connectionFailed, 'connection-failed', true))
161
+ socket.onclose = () => finish(new WaitError(messages.closed, 'closed', true))
162
+ })
163
+ }
164
+
165
+ export async function waitForEvent<T>(options: WaitOptions<T>): Promise<T> {
166
+ const createSocket = options.createSocket ?? ((url: string) => new WebSocket(url))
167
+ const messages = { ...DEFAULT_MESSAGES, ...options.messages }
168
+ const attempts = options.reconnect?.attempts ?? 0
169
+ const delayMs = options.reconnect?.delayMs ?? 1_000
170
+ const deadline = Date.now() + options.timeoutMs
171
+
172
+ if (!options.alreadyProbed) {
173
+ const known = await options.probe?.().catch(() => undefined)
174
+ if (known) return known.value
175
+ }
176
+
177
+ for (let attempt = 0;; attempt++) {
178
+ const remainingMs = deadline - Date.now()
179
+ if (remainingMs <= 0) throw new WaitError(messages.timeout, 'timeout', false)
180
+
181
+ try {
182
+ return await connectAndWait(options, createSocket, messages, remainingMs)
183
+ } catch (error) {
184
+ if (!(error instanceof WaitError) || !error.retryable || attempt >= attempts) throw error
185
+ await Bun.sleep(Math.min(delayMs, Math.max(0, deadline - Date.now())))
186
+ }
187
+ }
188
+ }