@fabrika/canvas-kernel 0.1.45 → 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.
Files changed (66) hide show
  1. package/README.md +33 -67
  2. package/index.ts +19 -83
  3. package/package.json +68 -72
  4. package/src/client/AnnotationProvider.tsx +227 -268
  5. package/src/client/AnnotationSidebar.tsx +392 -338
  6. package/src/client/FileIcon.tsx +106 -65
  7. package/src/client/PlanRenderer.tsx +607 -551
  8. package/src/client/RenderErrorContext.ts +7 -7
  9. package/src/client/ResponsePreview.tsx +192 -173
  10. package/src/client/annotationDom.ts +43 -97
  11. package/src/client/annotationDraft.ts +98 -92
  12. package/src/client/appContext.tsx +40 -40
  13. package/src/client/components/Callout.tsx +20 -20
  14. package/src/client/components/Checklist.tsx +28 -28
  15. package/src/client/components/Choice.tsx +178 -159
  16. package/src/client/components/CodeBlock.tsx +26 -26
  17. package/src/client/components/Diff.tsx +17 -17
  18. package/src/client/components/FilePreview.tsx +99 -74
  19. package/src/client/components/Image.tsx +41 -39
  20. package/src/client/components/Markdown.tsx +33 -32
  21. package/src/client/components/Mermaid.tsx +592 -542
  22. package/src/client/components/Note.tsx +8 -8
  23. package/src/client/components/Priority.tsx +11 -11
  24. package/src/client/components/ResponseNote.tsx +17 -15
  25. package/src/client/components/Section.tsx +28 -26
  26. package/src/client/components/Table.tsx +34 -29
  27. package/src/client/components/Task.tsx +58 -60
  28. package/src/client/components/UserInput.tsx +169 -152
  29. package/src/client/components/index.ts +16 -16
  30. package/src/client/components/renderMarkdownChildren.tsx +86 -68
  31. package/src/client/diffLines.ts +51 -39
  32. package/src/client/generateMarkdown.ts +197 -191
  33. package/src/client/hostContext.tsx +34 -34
  34. package/src/client/index.ts +43 -125
  35. package/src/client/preact-all.ts +4 -4
  36. package/src/client/runtime.ts +58 -111
  37. package/src/client/styles.css +1 -74
  38. package/src/engine/compiler.ts +316 -313
  39. package/src/engine/paths.ts +44 -44
  40. package/src/engine/session.ts +623 -618
  41. package/src/engine/watcher.ts +63 -60
  42. package/src/engine/websocket.ts +123 -121
  43. package/annotate.ts +0 -94
  44. package/daemon.ts +0 -46
  45. package/src/client/AnnotationDraftFooter.tsx +0 -76
  46. package/src/client/AnnotationEditor.tsx +0 -320
  47. package/src/client/AnnotationList.tsx +0 -190
  48. package/src/client/Popover.tsx +0 -361
  49. package/src/client/SessionContext.tsx +0 -3
  50. package/src/client/annotationContext.ts +0 -130
  51. package/src/client/annotationMarkdown.ts +0 -102
  52. package/src/client/annotationTarget.ts +0 -73
  53. package/src/client/blockTarget.ts +0 -127
  54. package/src/client/highlightRange.ts +0 -262
  55. package/src/client/popoverPosition.ts +0 -46
  56. package/src/client/regionTarget.ts +0 -180
  57. package/src/client/testing/dom.ts +0 -74
  58. package/src/client/textTarget.ts +0 -31
  59. package/src/client/useRegionAnnotation.tsx +0 -188
  60. package/src/client/useTextAnnotation.tsx +0 -194
  61. package/src/client/utils.ts +0 -33
  62. package/src/engine/channel.ts +0 -278
  63. package/src/engine/http-utils.ts +0 -14
  64. package/src/engine/lifecycle.ts +0 -302
  65. package/src/engine/router.ts +0 -16
  66. package/src/engine/waiter.ts +0 -188
@@ -1,278 +0,0 @@
1
- /**
2
- * The WebSocket notification channel: a two-role socket registry keyed by an
3
- * opaque topic string.
4
- *
5
- * The kernel owns the plumbing — who is registered under which topic, how a
6
- * message reaches browsers or waiters, when a waiter is announced as present,
7
- * and the ping/pong sweep that evicts dead waiters. It owns no vocabulary: the
8
- * host supplies the frames, decides what a frame does, and decides whether a
9
- * relayed frame closes the waiters that received it.
10
- *
11
- * Two hosts map onto it today:
12
- *
13
- * - agent-canvas (`engine/websocket.ts`) — topic = session id, waiter policy
14
- * `close-on-delivery` (the CLI's `wait` socket is satisfied by one frame),
15
- * frames `submit` / `render-error`, presence frame `watcher-status`.
16
- * - obrazovka — topic = Figma file key, waiter policy `keep-open` (a `--page`
17
- * waiter must survive a relay meant for a different page and filter frames
18
- * client-side), frames `confirm-screens` / `submit` registered by the host
19
- * plus server-initiated relays `run` / `feedback`, presence frame `presence`.
20
- */
21
-
22
- export type ChannelRole = "browser" | "wait";
23
-
24
- /** The minimum a host socket must offer; `Bun.ServerWebSocket` satisfies it. */
25
- export interface ChannelSocket {
26
- send(message: string): unknown;
27
- close(): void;
28
- ping(): unknown;
29
- }
30
-
31
- export interface RelayOptions {
32
- /**
33
- * Close every waiter the message was handed to and drop the topic's waiter
34
- * set. Defaults to the channel's `waiterPolicy`.
35
- */
36
- close?: boolean;
37
- }
38
-
39
- /** Channel operations bound to one topic. */
40
- export interface TopicChannel {
41
- readonly topic: string;
42
- /** Send to every browser on the topic. */
43
- broadcast(message: unknown): void;
44
- /** Send to every waiter on the topic; `false` when there was none. */
45
- relayToWaiters(message: unknown, options?: RelayOptions): boolean;
46
- hasWaiter(): boolean;
47
- /** Announce current waiter presence to the topic's browsers. */
48
- broadcastPresence(): void;
49
- }
50
-
51
- export interface BrowserFrameHandler {
52
- /** The bound work when the frame is this handler's, `undefined` otherwise. */
53
- bind(frame: unknown, channel: TopicChannel): (() => void | Promise<void>) | undefined;
54
- }
55
-
56
- /**
57
- * Register one frame of a host's vocabulary. Matching is synchronous so the
58
- * socket's `message` handler stays synchronous; handling may be async.
59
- */
60
- export function browserFrame<T>(
61
- match: (frame: unknown) => frame is T,
62
- handle: (frame: T, channel: TopicChannel) => void | Promise<void>,
63
- ): BrowserFrameHandler {
64
- return {
65
- bind: (frame, channel) => (match(frame) ? () => handle(frame, channel) : undefined),
66
- };
67
- }
68
-
69
- export interface NotificationChannelOptions<S extends ChannelSocket> {
70
- /** Which registry a socket belongs to. */
71
- roleOf(socket: S): ChannelRole;
72
- /** The socket's routing key — a canvas session id, a Figma file key, … */
73
- topicOf(socket: S): string;
74
- /** The host's browser-frame vocabulary, tried in order; first match wins. */
75
- frames?: readonly BrowserFrameHandler[];
76
- /** Builds the presence frame. Presence is not announced when omitted. */
77
- presenceFrame?(hasWaiter: boolean, topic: string): unknown;
78
- /** What a relay does to the waiters it reached. Default `"keep-open"`. */
79
- waiterPolicy?: "close-on-delivery" | "keep-open";
80
- /**
81
- * Runs after a waiter is registered and before presence is announced, so a
82
- * host that buffers events for an absent waiter can flush them here.
83
- */
84
- onWaiterOpen?(channel: TopicChannel): void;
85
- /** Frame handler failures. Dropped when omitted. */
86
- onError?(error: unknown): void;
87
- /** Liveness sweep period; `false` leaves the sweep to the host. */
88
- pingIntervalMs?: number | false;
89
- }
90
-
91
- export interface NotificationChannel<S extends ChannelSocket> {
92
- handlers: {
93
- open(socket: S): void;
94
- message(socket: S, message: string | Buffer): void;
95
- pong(socket: S): void;
96
- close(socket: S): void;
97
- };
98
- broadcast(topic: string, message: unknown): void;
99
- relayToWaiters(topic: string, message: unknown, options?: RelayOptions): boolean;
100
- hasWaiter(topic: string): boolean;
101
- broadcastPresence(topic: string): void;
102
- /** The topic-bound view handed to frame handlers. */
103
- topic(topic: string): TopicChannel;
104
- /** One liveness sweep — ping live waiters, evict the ones that never ponged. */
105
- pingWaiters(): void;
106
- /** Stop the liveness timer. */
107
- stop(): void;
108
- }
109
-
110
- const DEFAULT_PING_INTERVAL_MS = 5_000;
111
-
112
- function parseFrame(message: string | Buffer): unknown {
113
- const text = typeof message === "string" ? message : new TextDecoder().decode(message);
114
- try {
115
- return JSON.parse(text);
116
- } catch {
117
- return undefined;
118
- }
119
- }
120
-
121
- export function createNotificationChannel<S extends ChannelSocket>(
122
- options: NotificationChannelOptions<S>,
123
- ): NotificationChannel<S> {
124
- const { roleOf, topicOf } = options;
125
- const frames = options.frames ?? [];
126
- const closeOnDelivery = (options.waiterPolicy ?? "keep-open") === "close-on-delivery";
127
- const browserSockets = new Map<string, Set<S>>();
128
- const waitSockets = new Map<string, Set<S>>();
129
- const pongReceived = new WeakSet<S>();
130
-
131
- function registry(role: ChannelRole): Map<string, Set<S>> {
132
- return role === "browser" ? browserSockets : waitSockets;
133
- }
134
-
135
- function hasWaiter(topic: string): boolean {
136
- return (waitSockets.get(topic)?.size ?? 0) > 0;
137
- }
138
-
139
- function broadcast(topic: string, message: unknown): void {
140
- const sockets = browserSockets.get(topic);
141
- if (!sockets || sockets.size === 0) return;
142
- const payload = JSON.stringify(message);
143
- for (const socket of sockets) socket.send(payload);
144
- }
145
-
146
- function presencePayload(topic: string): string | undefined {
147
- if (!options.presenceFrame) return undefined;
148
- return JSON.stringify(options.presenceFrame(hasWaiter(topic), topic));
149
- }
150
-
151
- function broadcastPresence(topic: string): void {
152
- const payload = presencePayload(topic);
153
- if (payload === undefined) return;
154
- const sockets = browserSockets.get(topic);
155
- if (!sockets) return;
156
- for (const socket of sockets) socket.send(payload);
157
- }
158
-
159
- function relayToWaiters(topic: string, message: unknown, relay: RelayOptions = {}): boolean {
160
- const sockets = waitSockets.get(topic);
161
- if (!sockets || sockets.size === 0) return false;
162
- const close = relay.close ?? closeOnDelivery;
163
- if (close) waitSockets.delete(topic);
164
- const payload = JSON.stringify(message);
165
- for (const socket of sockets) {
166
- socket.send(payload);
167
- if (close) socket.close();
168
- }
169
- return true;
170
- }
171
-
172
- function topicChannel(topic: string): TopicChannel {
173
- return {
174
- topic,
175
- broadcast: (message) => broadcast(topic, message),
176
- relayToWaiters: (message, relay) => relayToWaiters(topic, message, relay),
177
- hasWaiter: () => hasWaiter(topic),
178
- broadcastPresence: () => broadcastPresence(topic),
179
- };
180
- }
181
-
182
- function reportError(error: unknown): void {
183
- options.onError?.(error);
184
- }
185
-
186
- function dropWaiter(topic: string, socket: S): void {
187
- const sockets = waitSockets.get(topic);
188
- if (sockets) {
189
- sockets.delete(socket);
190
- if (sockets.size === 0) waitSockets.delete(topic);
191
- }
192
- try { socket.close(); } catch {}
193
- broadcastPresence(topic);
194
- }
195
-
196
- // A dead socket never fires `close`, so presence would stay stuck on `true`
197
- // until the daemon restarts — sweep for the missing pong instead.
198
- function pingWaiters(): void {
199
- for (const [topic, sockets] of waitSockets) {
200
- for (const socket of sockets) {
201
- if (!pongReceived.has(socket)) {
202
- dropWaiter(topic, socket);
203
- continue;
204
- }
205
- pongReceived.delete(socket);
206
- try { socket.ping(); } catch { dropWaiter(topic, socket); }
207
- }
208
- }
209
- }
210
-
211
- const interval = options.pingIntervalMs === false
212
- ? undefined
213
- : setInterval(pingWaiters, options.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS);
214
- interval?.unref();
215
-
216
- const handlers = {
217
- open(socket: S): void {
218
- const topic = topicOf(socket);
219
- const role = roleOf(socket);
220
- const sockets = registry(role).get(topic) ?? new Set<S>();
221
- sockets.add(socket);
222
- registry(role).set(topic, sockets);
223
-
224
- if (role === "browser") {
225
- const payload = presencePayload(topic);
226
- if (payload !== undefined) socket.send(payload);
227
- return;
228
- }
229
- pongReceived.add(socket); // free pass on the first sweep
230
- options.onWaiterOpen?.(topicChannel(topic));
231
- broadcastPresence(topic);
232
- },
233
-
234
- message(socket: S, message: string | Buffer): void {
235
- if (roleOf(socket) !== "browser") return;
236
- const frame = parseFrame(message);
237
- if (frame === undefined) return;
238
- const channel = topicChannel(topicOf(socket));
239
- for (const handler of frames) {
240
- const run = handler.bind(frame, channel);
241
- if (!run) continue;
242
- try {
243
- const result = run();
244
- if (result instanceof Promise) result.catch(reportError);
245
- } catch (error) {
246
- reportError(error);
247
- }
248
- return;
249
- }
250
- },
251
-
252
- pong(socket: S): void {
253
- if (roleOf(socket) === "wait") pongReceived.add(socket);
254
- },
255
-
256
- close(socket: S): void {
257
- const topic = topicOf(socket);
258
- const role = roleOf(socket);
259
- const sockets = registry(role).get(topic);
260
- sockets?.delete(socket);
261
- if (sockets && sockets.size === 0) registry(role).delete(topic);
262
- if (role === "wait") broadcastPresence(topic);
263
- },
264
- };
265
-
266
- return {
267
- handlers,
268
- broadcast,
269
- relayToWaiters,
270
- hasWaiter,
271
- broadcastPresence,
272
- topic: topicChannel,
273
- pingWaiters,
274
- stop() {
275
- if (interval) clearInterval(interval);
276
- },
277
- };
278
- }
@@ -1,14 +0,0 @@
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
- }
@@ -1,302 +0,0 @@
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 { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, appendFileSync } 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 { rmSync(file, { force: true }); } catch {}
181
- }
182
- }
183
-
184
- function readLogTail(): string {
185
- if (!existsSync(config.logFile)) return "";
186
- try {
187
- const log = readFileSync(config.logFile, "utf-8");
188
- return log.slice(-LOG_TAIL_BYTES).trim();
189
- } catch {
190
- return "";
191
- }
192
- }
193
-
194
- function spawnDaemon(): number {
195
- mkdirSync(dirname(config.logFile), { recursive: true });
196
- appendFileSync(config.logFile, `--- daemon start ${new Date().toISOString()}\n`);
197
- const stdout = openSync(config.logFile, "a");
198
- const stderr = openSync(config.logFile, "a");
199
- try {
200
- const child = Bun.spawn([...config.command], {
201
- cwd: config.cwd ?? process.cwd(),
202
- stdin: "ignore",
203
- stdout,
204
- stderr,
205
- detached: true,
206
- });
207
- child.unref();
208
- mkdirSync(dirname(config.pidFile), { recursive: true });
209
- writeFileSync(config.pidFile, `${child.pid}\n`);
210
- return child.pid;
211
- } finally {
212
- closeSync(stdout);
213
- closeSync(stderr);
214
- }
215
- }
216
-
217
- async function start(): Promise<DaemonHealthUp<H>> {
218
- emit({ type: "starting", command: config.command });
219
- spawnDaemon();
220
- const started = await pollHealthy(timeouts.start);
221
- if (started.status === "up") {
222
- emit({ type: "started", pid: started.pid });
223
- return started;
224
- }
225
- throw new DaemonStartError(
226
- `Daemon did not become healthy within ${timeouts.start}ms.`,
227
- config.logFile,
228
- readLogTail(),
229
- );
230
- }
231
-
232
- async function stop(): Promise<DaemonStopResult> {
233
- const current = await health();
234
-
235
- if (current.status === "down") {
236
- const stale = readPid();
237
- if (stale === undefined) return { stopped: false, reason: "not-running", message: "Daemon is not running." };
238
- cleanupFiles();
239
- return { stopped: false, pid: stale, reason: "stale-pid", message: `Daemon is not running; removed stale pid ${stale}.` };
240
- }
241
-
242
- const pid = current.pid;
243
- if (pid === process.pid) {
244
- return { stopped: false, pid, reason: "refused-self", message: `Daemon pid ${pid} is this process; refusing to stop self.` };
245
- }
246
-
247
- try {
248
- process.kill(pid, "SIGTERM");
249
- } catch {
250
- cleanupFiles();
251
- return { stopped: false, pid, reason: "signal-failed", message: `Daemon pid ${pid} could not be signalled.` };
252
- }
253
-
254
- const deadline = Date.now() + timeouts.stop;
255
- while (Date.now() < deadline && (await health()).status === "up") {
256
- await sleep(100);
257
- }
258
- cleanupFiles();
259
- emit({ type: "stopped", pid });
260
- return { stopped: true, pid, reason: "stopped", message: `Stopped daemon pid ${pid}.` };
261
- }
262
-
263
- async function ensure(): Promise<DaemonHealthUp<H>> {
264
- const current = await health();
265
- if (current.status === "up") {
266
- const check = config.versionCheck;
267
- const running = check?.of(current);
268
- if (check && running && running !== check.expected) {
269
- emit({ type: "version-mismatch", running, expected: check.expected });
270
- await stop();
271
- return await start();
272
- }
273
- return current;
274
- }
275
-
276
- // Someone else may be mid-spawn: a live pid with no health yet is a daemon
277
- // still booting, and racing it would put a second one on the same port.
278
- const pid = readPid();
279
- if (pid !== undefined && processIsAlive(pid)) {
280
- const starting = await pollHealthy(timeouts.concurrentStart);
281
- if (starting.status === "up") return starting;
282
- }
283
-
284
- return await start();
285
- }
286
-
287
- async function restart(): Promise<DaemonHealthUp<H>> {
288
- await stop();
289
- return await start();
290
- }
291
-
292
- return {
293
- health,
294
- async isRunning() {
295
- return (await health()).status === "up";
296
- },
297
- start,
298
- ensure,
299
- stop,
300
- restart,
301
- };
302
- }
@@ -1,16 +0,0 @@
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
- }