@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 +21 -0
- package/README.md +13 -0
- package/index.ts +28 -0
- package/package.json +31 -0
- package/src/channel.ts +265 -0
- package/src/http-utils.ts +14 -0
- package/src/lifecycle.ts +304 -0
- package/src/router.ts +16 -0
- package/src/waiter.ts +188 -0
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
|
+
}
|
package/src/lifecycle.ts
ADDED
|
@@ -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
|
+
}
|