@skastr0/prism-sdk 0.3.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.
@@ -0,0 +1,270 @@
1
+ import { mkdir, writeFile, readFile, unlink, readdir, rename } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { createHash } from "node:crypto";
5
+ /**
6
+ * Error type for registry operation violations.
7
+ * Used to distinguish registry errors from other file system errors.
8
+ */
9
+ export class UDSRegistryError extends Error {
10
+ context;
11
+ kind = "uds-registry-error";
12
+ constructor(message, context) {
13
+ super(message);
14
+ this.context = context;
15
+ this.name = "UDSRegistryError";
16
+ }
17
+ }
18
+ /**
19
+ * Storage layout
20
+ * --------------
21
+ * Each plugin owns its own registry file:
22
+ *
23
+ * ~/.prism/runtime/mcp/<plugin>/<hash>.registry.json
24
+ *
25
+ * where `<hash>` is a short content-addressed digest of the plugin name.
26
+ * There is deliberately no single shared registry.json spanning every
27
+ * plugin: registering plugin A never touches any file plugin B reads or
28
+ * writes, so concurrent registrations across different plugins cannot race
29
+ * on the same path and cannot drop each other's entries. Within one
30
+ * plugin's file, writes are still atomic (write-temp + rename), so the
31
+ * per-plugin file itself is never observed half-written.
32
+ */
33
+ /** `~/.prism/runtime/mcp` — the root of all registry state. */
34
+ function registryRootDir() {
35
+ return join(homedir(), ".prism", "runtime", "mcp");
36
+ }
37
+ /**
38
+ * Filesystem-safe representation of a plugin name for use as a directory
39
+ * segment. Plugin names may contain characters (e.g. `/` in a scoped name
40
+ * like `@scope/plugin`) that would otherwise create unintended nested
41
+ * directories; the original, unsanitized name is preserved inside the
42
+ * stored record itself (see `StoredRecord`) so readers never need to
43
+ * reverse this transform.
44
+ */
45
+ function sanitizePluginSegment(plugin) {
46
+ const sanitized = plugin.replace(/[^a-zA-Z0-9._-]/g, "_");
47
+ return sanitized.length > 0 ? sanitized : "_";
48
+ }
49
+ function pluginDir(plugin) {
50
+ return join(registryRootDir(), sanitizePluginSegment(plugin));
51
+ }
52
+ /**
53
+ * Deterministic, content-addressed file name for a plugin's registration
54
+ * record: every reader and writer for a given plugin name resolves to
55
+ * exactly this one path, so there is never ambiguity about which file is
56
+ * "the" current record for that plugin.
57
+ */
58
+ function pluginRegistryFilePath(plugin) {
59
+ const hash = createHash("sha256").update(plugin).digest("hex").slice(0, 16);
60
+ return join(pluginDir(plugin), `${hash}.registry.json`);
61
+ }
62
+ function isValidStoredRecord(value) {
63
+ if (!value || typeof value !== "object" || Array.isArray(value))
64
+ return false;
65
+ const record = value;
66
+ return (typeof record.plugin === "string" &&
67
+ typeof record.pid === "number" &&
68
+ typeof record.sock === "string" &&
69
+ typeof record.bundleHash === "string" &&
70
+ typeof record.startedAt === "number" &&
71
+ typeof record.lastUsed === "number");
72
+ }
73
+ async function ensurePluginDir(plugin) {
74
+ try {
75
+ await mkdir(pluginDir(plugin), { recursive: true });
76
+ }
77
+ catch {
78
+ // Directory may already exist; proceed.
79
+ }
80
+ }
81
+ /**
82
+ * Atomically write a single plugin's registration record via write-temp +
83
+ * rename. Because each plugin owns a distinct file, this never reads or
84
+ * merges another plugin's (or another write's) data — there is no
85
+ * read-modify-write of shared state to race on.
86
+ *
87
+ * Throws UDSRegistryError on I/O failure (unrecoverable).
88
+ */
89
+ async function writePluginEntry(plugin, entry) {
90
+ try {
91
+ await ensurePluginDir(plugin);
92
+ }
93
+ catch (error) {
94
+ throw new UDSRegistryError(`Failed to ensure registry directory for plugin ${plugin}: ${error instanceof Error ? error.message : String(error)}`, error);
95
+ }
96
+ const finalPath = pluginRegistryFilePath(plugin);
97
+ const randomSuffix = Math.random().toString(36).substring(2, 10);
98
+ const tempPath = `${finalPath}.tmp.${process.pid}.${Date.now()}.${randomSuffix}`;
99
+ const stored = { ...entry, plugin };
100
+ try {
101
+ await writeFile(tempPath, JSON.stringify(stored, null, 2), "utf8");
102
+ await rename(tempPath, finalPath);
103
+ }
104
+ catch (error) {
105
+ try {
106
+ await unlink(tempPath);
107
+ }
108
+ catch {
109
+ // Ignore cleanup failures
110
+ }
111
+ throw new UDSRegistryError(`Failed to write registry entry for plugin ${plugin}: ${error instanceof Error ? error.message : String(error)}`, error);
112
+ }
113
+ }
114
+ /**
115
+ * Read a single plugin's registration record. Returns undefined if the file
116
+ * does not exist, is corrupted, or is missing required fields — never
117
+ * throws.
118
+ */
119
+ async function readPluginEntry(plugin) {
120
+ try {
121
+ const json = await readFile(pluginRegistryFilePath(plugin), "utf8");
122
+ const parsed = JSON.parse(json);
123
+ if (!isValidStoredRecord(parsed))
124
+ return undefined;
125
+ const { plugin: _plugin, ...entry } = parsed;
126
+ return entry;
127
+ }
128
+ catch {
129
+ return undefined;
130
+ }
131
+ }
132
+ /**
133
+ * Register a new daemon instance or update an existing one.
134
+ *
135
+ * Atomically writes the entry to this plugin's own registry file. Never
136
+ * reads or rewrites any other plugin's file.
137
+ *
138
+ * Throws UDSRegistryError on I/O failure (unrecoverable).
139
+ */
140
+ export async function registerDaemon(plugin, entry) {
141
+ await writePluginEntry(plugin, entry);
142
+ }
143
+ /**
144
+ * Unregister a daemon instance by plugin name.
145
+ *
146
+ * Removes this plugin's registry file. Does nothing (no error) if the
147
+ * entry does not exist.
148
+ *
149
+ * Throws UDSRegistryError on I/O failure (unrecoverable), file-not-found excepted.
150
+ */
151
+ export async function unregisterDaemon(plugin) {
152
+ try {
153
+ await unlink(pluginRegistryFilePath(plugin));
154
+ }
155
+ catch (error) {
156
+ if (error.code === "ENOENT")
157
+ return;
158
+ throw new UDSRegistryError(`Failed to unregister plugin ${plugin}: ${error instanceof Error ? error.message : String(error)}`, error);
159
+ }
160
+ }
161
+ /**
162
+ * Get a single daemon entry by plugin name.
163
+ *
164
+ * Returns `{ kind: "ok", value: entry }` on success.
165
+ * Returns `{ kind: "absent" }` if the plugin is not registered or
166
+ * the registry is corrupted.
167
+ *
168
+ * Never throws.
169
+ */
170
+ export async function getDaemon(plugin) {
171
+ const entry = await readPluginEntry(plugin);
172
+ return entry ? { kind: "ok", value: entry } : { kind: "absent" };
173
+ }
174
+ /**
175
+ * Get all registered daemon entries across every plugin.
176
+ *
177
+ * Returns `{ kind: "ok", value: data }` on success (may be empty).
178
+ * Returns `{ kind: "absent" }` if the registry root directory does not
179
+ * exist yet (nothing has ever registered).
180
+ *
181
+ * Never throws; unreadable or corrupted per-plugin files are skipped
182
+ * individually rather than failing the whole scan.
183
+ */
184
+ export async function getAllDaemons() {
185
+ const root = registryRootDir();
186
+ let pluginDirs;
187
+ try {
188
+ pluginDirs = await readdir(root);
189
+ }
190
+ catch {
191
+ return { kind: "absent" };
192
+ }
193
+ const data = {};
194
+ for (const dirName of pluginDirs) {
195
+ let files;
196
+ try {
197
+ files = await readdir(join(root, dirName));
198
+ }
199
+ catch {
200
+ continue;
201
+ }
202
+ for (const file of files) {
203
+ if (!file.endsWith(".registry.json"))
204
+ continue;
205
+ try {
206
+ const json = await readFile(join(root, dirName, file), "utf8");
207
+ const parsed = JSON.parse(json);
208
+ if (!isValidStoredRecord(parsed))
209
+ continue;
210
+ const { plugin, ...entry } = parsed;
211
+ data[plugin] = entry;
212
+ }
213
+ catch {
214
+ // Skip unreadable/corrupted per-plugin files; they do not affect
215
+ // any other plugin's data.
216
+ }
217
+ }
218
+ }
219
+ return { kind: "ok", value: data };
220
+ }
221
+ /**
222
+ * Update the lastUsed timestamp for a daemon entry.
223
+ *
224
+ * Returns `{ kind: "ok" }` on success.
225
+ * Returns `{ kind: "absent" }` if the plugin is not registered.
226
+ *
227
+ * Throws UDSRegistryError on I/O failure (unrecoverable).
228
+ */
229
+ export async function touchDaemon(plugin) {
230
+ const entry = await readPluginEntry(plugin);
231
+ if (!entry) {
232
+ return { kind: "absent" };
233
+ }
234
+ await writePluginEntry(plugin, { ...entry, lastUsed: Date.now() });
235
+ return { kind: "ok", value: undefined };
236
+ }
237
+ /**
238
+ * Ownership-gated teardown for a daemon's registration and socket file.
239
+ *
240
+ * Re-reads the current registry record for `plugin` and only acts — unlink
241
+ * `socketPath` and remove the registry entry — when that record's pid
242
+ * matches `pid`, i.e. when the caller is still the process the registry
243
+ * currently considers live for that plugin.
244
+ *
245
+ * This exists to protect a fast-respawning successor: if a predecessor is
246
+ * slow to drain and only calls this after a successor has already bound
247
+ * the same socket path and re-registered under the same plugin key, the
248
+ * registry record now names the successor's pid — a missing record or one
249
+ * naming a different pid means "not mine to clean up", so the successor's
250
+ * live socket file and registration are left completely untouched.
251
+ */
252
+ export async function cleanupDaemonIfOwner(plugin, pid, socketPath) {
253
+ const result = await getDaemon(plugin);
254
+ if (result.kind === "absent") {
255
+ return "absent";
256
+ }
257
+ if (result.value.pid !== pid) {
258
+ return "not-owner";
259
+ }
260
+ if (socketPath) {
261
+ try {
262
+ await unlink(socketPath);
263
+ }
264
+ catch {
265
+ // Socket may already be gone; non-fatal.
266
+ }
267
+ }
268
+ await unregisterDaemon(plugin);
269
+ return "cleaned";
270
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Error type for singleton/stale-socket violations.
3
+ */
4
+ export declare class UDSSingletonError extends Error {
5
+ readonly context?: unknown | undefined;
6
+ readonly kind: "uds-singleton-error";
7
+ constructor(message: string, context?: unknown | undefined);
8
+ }
9
+ /**
10
+ * Result of probing for an existing live daemon on a socket path.
11
+ *
12
+ * - "live": Another daemon is running on this socket; this process should exit.
13
+ * - "stale": Socket file exists but the daemon is not responding; safe to unlink and bind.
14
+ * - "available": Socket path is free; bind immediately.
15
+ */
16
+ export type ProbeResult = "live" | "stale" | "available";
17
+ /**
18
+ * Attempt to connect to an existing Unix domain socket to detect a live daemon.
19
+ *
20
+ * Returns "live" if a connection succeeds (live daemon).
21
+ * Returns "stale" if connection is refused/times out (socket file exists but no daemon).
22
+ * Returns "available" if the socket file does not exist.
23
+ *
24
+ * @param socketPath Absolute path to the UDS socket
25
+ * @param timeoutMs How long to wait before considering a connection attempt stale
26
+ */
27
+ export declare function probeSocketLiveness(socketPath: string, timeoutMs?: number): Promise<ProbeResult>;
28
+ /**
29
+ * Acquire a lock via exclusive file creation (fs `wx` open), with
30
+ * dead-holder reclamation and retry.
31
+ *
32
+ * Only one concurrent caller can ever hold the lock: the underlying
33
+ * `open(lockPath, "wx")` is an atomic O_CREAT|O_EXCL create that fails with
34
+ * EEXIST for every caller after the first, unlike a rename-based scheme
35
+ * (which silently replaces the destination and lets every racer "succeed").
36
+ *
37
+ * On EEXIST, the recorded holder's pid is liveness-checked via
38
+ * `process.kill(pid, 0)`: a dead holder's lock file is reclaimed (unlinked)
39
+ * and creation is retried once immediately; a live holder means the lock is
40
+ * genuinely held elsewhere, so this attempt waits out the retry interval
41
+ * and tries again until timeoutMs elapses.
42
+ *
43
+ * @param lockPath Path to the lock file
44
+ * @param timeoutMs How long to wait
45
+ * @param pid Process ID to write into the lock file
46
+ */
47
+ export declare function acquireLock(lockPath: string, timeoutMs: number, pid: number): Promise<boolean>;
48
+ /**
49
+ * Atomically probe and recover from a stale socket using a lock file for race safety.
50
+ *
51
+ * On the first call (or after lock acquisition), probes for a live daemon:
52
+ * - If live: returns "live" immediately (this process should exit)
53
+ * - If stale: unlinks the socket file and returns "stale-recovered"
54
+ * - If available: returns "available" immediately
55
+ *
56
+ * The lock file (at `<socketPath>.lock`) ensures only one process enters the
57
+ * probe-unlink-bind critical section at a time. Two simultaneous spawners will
58
+ * race to acquire the lock; the winner probes/unlinks/binds, the loser re-probes
59
+ * and discovers "live" (sees the winner's bound socket) and exits.
60
+ *
61
+ * @param socketPath Absolute path to the UDS socket
62
+ * @param lockTimeoutMs How long to wait acquiring the lock file
63
+ * @returns "live" (other daemon won the race), "stale-recovered" (unlinked stale socket), or "available" (free to bind)
64
+ * @throws UDSSingletonError if lock acquisition fails or other I/O errors
65
+ */
66
+ export declare function probeAndRecoverWithLock(socketPath: string, lockTimeoutMs?: number): Promise<"live" | "stale-recovered" | "available">;
67
+ /**
68
+ * Ensure the daemon can bind to the given socket path, handling stale sockets.
69
+ *
70
+ * This is the high-level entry point for singleton + stale-socket recovery.
71
+ * Call this before binding to a UDS socket in your server:
72
+ *
73
+ * ```
74
+ * const result = await ensureSocketBindability(socketPath);
75
+ * if (result === "live") {
76
+ * // Another daemon already owns this socket; exit gracefully
77
+ * process.exit(0);
78
+ * }
79
+ * // result is "stale-recovered" or "available"; proceed with binding
80
+ * ```
81
+ *
82
+ * @param socketPath Absolute path to the UDS socket
83
+ * @returns "live" if another daemon owns the socket (exit immediately),
84
+ * or "stale-recovered"/"available" (safe to bind)
85
+ * @throws UDSSingletonError on unrecoverable errors (lock acquisition, etc.)
86
+ */
87
+ export declare function ensureSocketBindability(socketPath: string): Promise<"live" | "stale-recovered" | "available">;
88
+ /**
89
+ * Outcome of `bindUnixSocketSingleton`.
90
+ *
91
+ * - "bound": this call's `bind` callback ran and its result is attached.
92
+ * - "already-served": a live daemon already owns the socket; the caller
93
+ * should exit cleanly (e.g. `process.exit(0)`) without ever binding.
94
+ */
95
+ export type UdsBindOutcome<T> = {
96
+ readonly kind: "bound";
97
+ readonly server: T;
98
+ } | {
99
+ readonly kind: "already-served";
100
+ };
101
+ /**
102
+ * Probe, recover from a stale socket, and invoke `bind()` — all inside the
103
+ * single critical section held by the `<socketPath>.lock` file.
104
+ *
105
+ * This closes the race the two-step "check bindability, then bind()
106
+ * separately" pattern leaves open: between releasing the probe/unlink lock
107
+ * and actually calling `bind()`, another process could probe the same
108
+ * now-empty path, also decide it is free, and race this one to `bind()`.
109
+ * Here `bind()` itself runs while the lock is still held, so at most one
110
+ * process per lock ever reaches it while the path is in the
111
+ * stale/available state.
112
+ *
113
+ * `bind()` is also wrapped in try/catch: if it still throws EADDRINUSE
114
+ * (e.g. a process outside this locking scheme bound the path, or a timing
115
+ * gap before the lock was acquired), the live owner is re-probed — if it
116
+ * responds, this process reports "already-served" instead of crashing
117
+ * uncaught; if it does not respond, the socket is stale, and `bind()` is
118
+ * retried exactly once after unlinking it.
119
+ *
120
+ * @param socketPath Absolute path to the UDS socket
121
+ * @param bind Callback that performs the actual bind (e.g. `() => Bun.serve(...)`)
122
+ * @param lockTimeoutMs How long to wait acquiring the lock file
123
+ */
124
+ export declare function bindUnixSocketSingleton<T>(socketPath: string, bind: () => T | Promise<T>, lockTimeoutMs?: number): Promise<UdsBindOutcome<T>>;