@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.
- package/LICENSE +21 -0
- package/README.md +12 -0
- package/dist/compile-manifest.d.ts +293 -0
- package/dist/compile-manifest.js +232 -0
- package/dist/mcp/daemon-resolver.d.ts +100 -0
- package/dist/mcp/daemon-resolver.js +185 -0
- package/dist/mcp/shim.d.ts +155 -0
- package/dist/mcp/shim.js +434 -0
- package/dist/mcp/uds-path.d.ts +26 -0
- package/dist/mcp/uds-path.js +70 -0
- package/dist/mcp/uds-registry.d.ts +109 -0
- package/dist/mcp/uds-registry.js +270 -0
- package/dist/mcp/uds-singleton.d.ts +124 -0
- package/dist/mcp/uds-singleton.js +360 -0
- package/dist/refs.d.ts +15 -0
- package/dist/refs.js +26 -0
- package/dist/snapshot.d.ts +92 -0
- package/dist/snapshot.js +74 -0
- package/dist/stable-json.d.ts +8 -0
- package/dist/stable-json.js +27 -0
- package/package.json +73 -0
|
@@ -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>>;
|