@fabrika/daemon-kit 0.8.2 → 0.9.0-cloudflare.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/daemon-kit",
3
- "version": "0.8.2",
3
+ "version": "0.9.0-cloudflare.1",
4
4
  "type": "module",
5
5
  "description": "Bun daemon lifecycle, HTTP routing, WebSocket channels and CLI waiters.",
6
6
  "license": "MIT",
@@ -15,12 +15,13 @@
15
15
  "files": [
16
16
  "index.ts",
17
17
  "src",
18
- "!src/**/*.test.ts"
18
+ "!src/**/*.test.ts",
19
+ "types"
19
20
  ],
20
21
  "exports": {
21
22
  ".": {
22
23
  "bun": "./index.ts",
23
- "types": "./index.ts",
24
+ "types": "./types/index.d.ts",
24
25
  "default": "./index.ts"
25
26
  }
26
27
  },
@@ -0,0 +1,9 @@
1
+ export { browserFrame, createNotificationChannel } from './src/channel';
2
+ export type { BrowserFrameHandler, ChannelRole, ChannelSocket, NotificationChannel, NotificationChannelOptions, RelayOptions, TopicChannel, } from './src/channel';
3
+ export { corsHeaders, jsonResponse } from './src/http-utils';
4
+ export { createDaemonLifecycle, DaemonStartError, DaemonStopError } from './src/lifecycle';
5
+ export type { DaemonEvent, DaemonHealth, DaemonHealthDown, DaemonHealthUp, DaemonLifecycle, DaemonLifecycleConfig, DaemonPid, DaemonStopReason, DaemonStopResult, DaemonTimeouts, } from './src/lifecycle';
6
+ export { dispatch } from './src/router';
7
+ export type { Route, RouteHandler } from './src/router';
8
+ export { WaitError, waitForEvent } from './src/waiter';
9
+ export type { WaiterSocket, WaitFailure, WaitMessages, WaitOptions, WaitVerdict } from './src/waiter';
@@ -0,0 +1,74 @@
1
+ export type ChannelRole = 'browser' | 'wait';
2
+ /** The minimum a host socket must offer; `Bun.ServerWebSocket` satisfies it. */
3
+ export interface ChannelSocket {
4
+ send(message: string): unknown;
5
+ close(): void;
6
+ ping(): unknown;
7
+ }
8
+ export interface RelayOptions {
9
+ /**
10
+ * Close every waiter the message was handed to and drop the topic's waiter
11
+ * set. Defaults to the channel's `waiterPolicy`.
12
+ */
13
+ close?: boolean;
14
+ }
15
+ /** Channel operations bound to one topic. */
16
+ export interface TopicChannel {
17
+ readonly topic: string;
18
+ /** Send to every browser on the topic. */
19
+ broadcast(message: unknown): void;
20
+ /** Send to every waiter on the topic; `false` when there was none. */
21
+ relayToWaiters(message: unknown, options?: RelayOptions): boolean;
22
+ hasWaiter(): boolean;
23
+ /** Announce current waiter presence to the topic's browsers. */
24
+ broadcastPresence(): void;
25
+ }
26
+ export interface BrowserFrameHandler {
27
+ /** The bound work when the frame is this handler's, `undefined` otherwise. */
28
+ bind(frame: unknown, channel: TopicChannel): (() => void | Promise<void>) | undefined;
29
+ }
30
+ /**
31
+ * Register one frame of a host's vocabulary. Matching is synchronous so the
32
+ * socket's `message` handler stays synchronous; handling may be async.
33
+ */
34
+ export declare function browserFrame<T>(match: (frame: unknown) => frame is T, handle: (frame: T, channel: TopicChannel) => void | Promise<void>): BrowserFrameHandler;
35
+ export interface NotificationChannelOptions<S extends ChannelSocket> {
36
+ /** Which registry a socket belongs to. */
37
+ roleOf(socket: S): ChannelRole;
38
+ /** The socket's routing key — a canvas session id, a Figma file key, … */
39
+ topicOf(socket: S): string;
40
+ /** The host's browser-frame vocabulary, tried in order; first match wins. */
41
+ frames?: readonly BrowserFrameHandler[];
42
+ /** Builds the presence frame. Presence is not announced when omitted. */
43
+ presenceFrame?(hasWaiter: boolean, topic: string): unknown;
44
+ /** What a relay does to the waiters it reached. Default `"keep-open"`. */
45
+ waiterPolicy?: 'close-on-delivery' | 'keep-open';
46
+ /**
47
+ * Runs after a waiter is registered and before presence is announced, so a
48
+ * host that buffers events for an absent waiter can flush them here.
49
+ */
50
+ onWaiterOpen?(channel: TopicChannel): void;
51
+ /** Frame handler failures. Dropped when omitted. */
52
+ onError?(error: unknown): void;
53
+ /** Liveness sweep period; `false` leaves the sweep to the host. */
54
+ pingIntervalMs?: number | false;
55
+ }
56
+ export interface NotificationChannel<S extends ChannelSocket> {
57
+ handlers: {
58
+ open(socket: S): void;
59
+ message(socket: S, message: string | Buffer): void;
60
+ pong(socket: S): void;
61
+ close(socket: S): void;
62
+ };
63
+ broadcast(topic: string, message: unknown): void;
64
+ relayToWaiters(topic: string, message: unknown, options?: RelayOptions): boolean;
65
+ hasWaiter(topic: string): boolean;
66
+ broadcastPresence(topic: string): void;
67
+ /** The topic-bound view handed to frame handlers. */
68
+ topic(topic: string): TopicChannel;
69
+ /** One liveness sweep — ping live waiters, evict the ones that never ponged. */
70
+ pingWaiters(): void;
71
+ /** Stop the liveness timer. */
72
+ stop(): void;
73
+ }
74
+ export declare function createNotificationChannel<S extends ChannelSocket>(options: NotificationChannelOptions<S>): NotificationChannel<S>;
@@ -0,0 +1,2 @@
1
+ export declare function jsonResponse(data: unknown, status?: number): Response;
2
+ export declare function corsHeaders(port: number): Record<string, string>;
@@ -0,0 +1,114 @@
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
+ /** Every host health shape must carry the daemon's own pid. */
15
+ export interface DaemonPid {
16
+ pid: number;
17
+ }
18
+ export type DaemonHealthUp<H extends DaemonPid> = {
19
+ status: 'up';
20
+ ok: true;
21
+ url: string;
22
+ } & H;
23
+ export interface DaemonHealthDown {
24
+ status: 'down';
25
+ ok: false;
26
+ url: string;
27
+ error?: string;
28
+ }
29
+ export type DaemonHealth<H extends DaemonPid> = DaemonHealthUp<H> | DaemonHealthDown;
30
+ export type DaemonStopReason = 'stopped' | 'not-running'
31
+ /** Nothing was running; a pid file left behind was cleaned up. */
32
+ | 'stale-pid'
33
+ /** A daemon answered `/health`, but signalling its pid failed. */
34
+ | 'signal-failed' | 'refused-self';
35
+ export interface DaemonStopResult {
36
+ stopped: boolean;
37
+ /** The daemon's own pid, or the stale pid-file value that was cleaned up. */
38
+ pid?: number;
39
+ reason: DaemonStopReason;
40
+ message: string;
41
+ }
42
+ /** Progress worth telling a user about; the host writes the words. */
43
+ export type DaemonEvent = {
44
+ type: 'version-mismatch';
45
+ running: string;
46
+ expected: string;
47
+ } | {
48
+ type: 'starting';
49
+ command: readonly string[];
50
+ } | {
51
+ type: 'started';
52
+ pid: number;
53
+ } | {
54
+ type: 'stopped';
55
+ pid: number;
56
+ };
57
+ export interface DaemonTimeouts {
58
+ /** Per `/health` request. Default 1000. */
59
+ health?: number;
60
+ /** Wait for a daemon this process spawned. Default 3000. */
61
+ start?: number;
62
+ /** Wait for a daemon another process is already starting. Default 1000. */
63
+ concurrentStart?: number;
64
+ /** Wait for a signalled daemon to go down. Default 2000. */
65
+ stop?: number;
66
+ }
67
+ export interface DaemonLifecycleConfig<H extends DaemonPid> {
68
+ /** Daemon origin, e.g. `http://localhost:19400`. */
69
+ baseUrl: string;
70
+ /** Health endpoint path. Default `/health`. */
71
+ healthPath?: string;
72
+ /** Narrows the `/health` body; `undefined` means "not a daemon we can talk to". */
73
+ parseHealth(body: unknown): H | undefined;
74
+ /** Written at spawn, so a concurrent start can see a daemon that is still booting. */
75
+ pidFile: string;
76
+ /** argv of the daemon process, e.g. `[process.execPath, entry, "serve"]`. */
77
+ command: readonly string[];
78
+ /** Working directory of the spawned daemon. Defaults to the current one. */
79
+ cwd?: string;
80
+ /** File the daemon's stdout and stderr are appended to. */
81
+ logFile: string;
82
+ /** Restart the daemon when its version differs from this CLI's. Off when omitted. */
83
+ versionCheck?: {
84
+ expected: string;
85
+ of(health: H): string | undefined;
86
+ };
87
+ /** Removed with the pid file when the daemon stops (auth tokens, sockets, …). */
88
+ cleanupFiles?: readonly string[];
89
+ timeouts?: DaemonTimeouts;
90
+ onEvent?(event: DaemonEvent): void;
91
+ }
92
+ export declare class DaemonStartError extends Error {
93
+ readonly logFile: string;
94
+ readonly logTail: string;
95
+ constructor(message: string, logFile: string, logTail: string);
96
+ }
97
+ /** A daemon of the wrong version still answers after `stop()`, so no second one is spawned onto its port. */
98
+ export declare class DaemonStopError extends Error {
99
+ readonly pid: number;
100
+ constructor(message: string, pid: number);
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
+ /**
107
+ * A healthy daemon of the expected version, starting or restarting one if needed.
108
+ * Throws `DaemonStopError` when a daemon of another version cannot be stopped.
109
+ */
110
+ ensure(): Promise<DaemonHealthUp<H>>;
111
+ stop(): Promise<DaemonStopResult>;
112
+ restart(): Promise<DaemonHealthUp<H>>;
113
+ }
114
+ export declare function createDaemonLifecycle<H extends DaemonPid>(config: DaemonLifecycleConfig<H>): DaemonLifecycle<H>;
@@ -0,0 +1,7 @@
1
+ export type RouteHandler = (req: Request, url: URL, match: URLPatternResult) => Response | Promise<Response>;
2
+ export interface Route {
3
+ method: string;
4
+ pattern: URLPattern;
5
+ handler: RouteHandler;
6
+ }
7
+ export declare function dispatch(routes: Route[], req: Request, url: URL): Response | Promise<Response> | null;
@@ -0,0 +1,88 @@
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
+ /** The slice of `WebSocket` a waiter uses; a real `WebSocket` satisfies it. */
11
+ export interface WaiterSocket {
12
+ close(): void;
13
+ onopen: ((event: Event) => void) | null;
14
+ onmessage: ((event: MessageEvent) => void) | null;
15
+ onerror: ((event: Event) => void) | null;
16
+ onclose: ((event: CloseEvent) => void) | null;
17
+ }
18
+ /** What a frame means to the waiter; `undefined` means "not mine, keep waiting". */
19
+ export type WaitVerdict<T> = {
20
+ type: 'settle';
21
+ value: T;
22
+ } | {
23
+ type: 'fail';
24
+ error: Error;
25
+ }
26
+ /** Wake the probe — the frame announces state the waiter has to read back. */
27
+ | {
28
+ type: 'recheck';
29
+ };
30
+ export interface WaitMessages {
31
+ /** Deadline passed. Default: "Timed out waiting." */
32
+ timeout?: string;
33
+ /** Socket closed before the wait settled. Default: "Connection closed before the wait settled." */
34
+ closed?: string;
35
+ /** The socket errored. Default: "WebSocket connection failed." */
36
+ connectionFailed?: string;
37
+ /**
38
+ * `createSocket` threw, so no socket ever existed; the factory's own message
39
+ * is appended after a colon. Default: "Could not create the WebSocket".
40
+ */
41
+ socketCreateFailed?: string;
42
+ }
43
+ export interface WaitOptions<T> {
44
+ /** The daemon's wait socket, e.g. `ws://localhost:19400/ws/wait/<topic>`. */
45
+ url: string;
46
+ /** Reads a frame; returns how it settles the wait, if at all. */
47
+ onFrame(frame: unknown): WaitVerdict<T> | undefined;
48
+ /**
49
+ * Fast path against server state. Runs before connecting (unless the caller
50
+ * already did that read — see `alreadyProbed`), again once the socket opens —
51
+ * closing the race where the event lands between the two — and whenever a
52
+ * frame asks for a recheck. A probe that rejects is dropped: the socket, not
53
+ * the probe, is what the wait ultimately hangs on.
54
+ */
55
+ probe?(): Promise<{
56
+ value: T;
57
+ } | undefined>;
58
+ /**
59
+ * The caller has already made the probe's read itself and it came up empty,
60
+ * so the wait starts at the socket. Set it when that read is the caller's own
61
+ * error channel — a probe that rejects is dropped here, so a caller that must
62
+ * report *why* the read failed has to make it before calling. The on-open
63
+ * re-read still runs; it is what closes the subscribe race.
64
+ */
65
+ alreadyProbed?: boolean;
66
+ createSocket?(url: string): WaiterSocket;
67
+ /** Deadline for the whole wait, spanning reconnects. */
68
+ timeoutMs: number;
69
+ reconnect?: {
70
+ /** Reconnects after a dropped connection. Default 0. */
71
+ attempts?: number;
72
+ /** Delay before a reconnect, capped by the remaining deadline. Default 1000. */
73
+ delayMs?: number;
74
+ };
75
+ messages?: WaitMessages;
76
+ }
77
+ /** Why the wait ended without an answer; hosts map these onto their own errors. */
78
+ export type WaitFailure = 'timeout' | 'closed'
79
+ /** The socket was created, then errored. */
80
+ | 'connection-failed'
81
+ /** `createSocket` threw — nothing was ever connected to. */
82
+ | 'socket-create-failed';
83
+ export declare class WaitError extends Error {
84
+ readonly reason: WaitFailure;
85
+ readonly retryable: boolean;
86
+ constructor(message: string, reason: WaitFailure, retryable: boolean, options?: ErrorOptions);
87
+ }
88
+ export declare function waitForEvent<T>(options: WaitOptions<T>): Promise<T>;