memwarden 0.0.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/LICENSE +202 -0
- package/README.md +402 -0
- package/dist/bundle/bundle.d.ts +28 -0
- package/dist/bundle/bundle.js +85 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +593 -0
- package/dist/cli/connect.d.ts +63 -0
- package/dist/cli/connect.js +121 -0
- package/dist/cli/hook.d.ts +24 -0
- package/dist/cli/hook.js +186 -0
- package/dist/cli/tools.d.ts +47 -0
- package/dist/cli/tools.js +246 -0
- package/dist/daemon/ensure.d.ts +12 -0
- package/dist/daemon/ensure.js +54 -0
- package/dist/daemon/service.d.ts +15 -0
- package/dist/daemon/service.js +210 -0
- package/dist/embedding/index.d.ts +10 -0
- package/dist/embedding/index.js +33 -0
- package/dist/embedding/local-embedding.d.ts +14 -0
- package/dist/embedding/local-embedding.js +80 -0
- package/dist/functions/access-tracker.d.ts +13 -0
- package/dist/functions/access-tracker.js +92 -0
- package/dist/functions/audit.d.ts +46 -0
- package/dist/functions/audit.js +0 -0
- package/dist/functions/cjk-segmenter.d.ts +6 -0
- package/dist/functions/cjk-segmenter.js +120 -0
- package/dist/functions/compress-synthetic.d.ts +2 -0
- package/dist/functions/compress-synthetic.js +104 -0
- package/dist/functions/config.d.ts +68 -0
- package/dist/functions/config.js +231 -0
- package/dist/functions/conflicts.d.ts +19 -0
- package/dist/functions/conflicts.js +328 -0
- package/dist/functions/context.d.ts +3 -0
- package/dist/functions/context.js +155 -0
- package/dist/functions/dedup.d.ts +11 -0
- package/dist/functions/dedup.js +51 -0
- package/dist/functions/dejafix.d.ts +96 -0
- package/dist/functions/dejafix.js +356 -0
- package/dist/functions/doctor.d.ts +29 -0
- package/dist/functions/doctor.js +137 -0
- package/dist/functions/forget.d.ts +3 -0
- package/dist/functions/forget.js +87 -0
- package/dist/functions/hybrid-search.d.ts +17 -0
- package/dist/functions/hybrid-search.js +205 -0
- package/dist/functions/index.d.ts +32 -0
- package/dist/functions/index.js +44 -0
- package/dist/functions/keyed-mutex.d.ts +1 -0
- package/dist/functions/keyed-mutex.js +21 -0
- package/dist/functions/logger.d.ts +6 -0
- package/dist/functions/logger.js +37 -0
- package/dist/functions/memory-utils.d.ts +2 -0
- package/dist/functions/memory-utils.js +29 -0
- package/dist/functions/observe.d.ts +5 -0
- package/dist/functions/observe.js +326 -0
- package/dist/functions/paths.d.ts +1 -0
- package/dist/functions/paths.js +38 -0
- package/dist/functions/privacy.d.ts +1 -0
- package/dist/functions/privacy.js +30 -0
- package/dist/functions/provenance.d.ts +9 -0
- package/dist/functions/provenance.js +57 -0
- package/dist/functions/quantized-vector-index.d.ts +60 -0
- package/dist/functions/quantized-vector-index.js +275 -0
- package/dist/functions/receipt.d.ts +31 -0
- package/dist/functions/receipt.js +95 -0
- package/dist/functions/search-index.d.ts +27 -0
- package/dist/functions/search-index.js +217 -0
- package/dist/functions/search.d.ts +25 -0
- package/dist/functions/search.js +523 -0
- package/dist/functions/stemmer.d.ts +1 -0
- package/dist/functions/stemmer.js +110 -0
- package/dist/functions/synonyms.d.ts +1 -0
- package/dist/functions/synonyms.js +69 -0
- package/dist/functions/turboquant.d.ts +53 -0
- package/dist/functions/turboquant.js +278 -0
- package/dist/functions/types.d.ts +217 -0
- package/dist/functions/types.js +8 -0
- package/dist/functions/vector-index.d.ts +25 -0
- package/dist/functions/vector-index.js +125 -0
- package/dist/functions/vector-persistence.d.ts +14 -0
- package/dist/functions/vector-persistence.js +75 -0
- package/dist/functions/verify.d.ts +13 -0
- package/dist/functions/verify.js +104 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +219 -0
- package/dist/kernel/http.d.ts +24 -0
- package/dist/kernel/http.js +261 -0
- package/dist/kernel/index.d.ts +19 -0
- package/dist/kernel/index.js +21 -0
- package/dist/kernel/kernel.d.ts +80 -0
- package/dist/kernel/kernel.js +297 -0
- package/dist/kernel/pubsub.d.ts +21 -0
- package/dist/kernel/pubsub.js +38 -0
- package/dist/kernel/types.d.ts +139 -0
- package/dist/kernel/types.js +20 -0
- package/dist/mcp/bin.d.ts +2 -0
- package/dist/mcp/bin.js +27 -0
- package/dist/mcp/server.d.ts +34 -0
- package/dist/mcp/server.js +377 -0
- package/dist/observability/metrics.d.ts +26 -0
- package/dist/observability/metrics.js +104 -0
- package/dist/proxy/server.d.ts +30 -0
- package/dist/proxy/server.js +331 -0
- package/dist/state/kv.d.ts +41 -0
- package/dist/state/kv.js +50 -0
- package/dist/state/oplog.d.ts +25 -0
- package/dist/state/oplog.js +57 -0
- package/dist/state/schema.d.ts +60 -0
- package/dist/state/schema.js +88 -0
- package/dist/state/store-libsql.d.ts +46 -0
- package/dist/state/store-libsql.js +263 -0
- package/dist/state/store-memory.d.ts +23 -0
- package/dist/state/store-memory.js +121 -0
- package/dist/state/store.d.ts +87 -0
- package/dist/state/store.js +58 -0
- package/dist/triggers/api.d.ts +14 -0
- package/dist/triggers/api.js +510 -0
- package/dist/triggers/auth.d.ts +1 -0
- package/dist/triggers/auth.js +13 -0
- package/package.json +58 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
//
|
|
2
|
+
// The kernel's HTTP front door. A node:http server that matches
|
|
3
|
+
// registered `type:"http"` routes by method + path, parses the JSON
|
|
4
|
+
// body into `req.body`, runs the middleware chain, invokes the bound
|
|
5
|
+
// function, and serializes its `{status_code, headers?, body}` return.
|
|
6
|
+
// Answers CORS preflight per the configured origins.
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { URL } from "node:url";
|
|
9
|
+
const DEFAULT_ORIGINS = [
|
|
10
|
+
"http://localhost:3111",
|
|
11
|
+
"http://localhost:3113",
|
|
12
|
+
"http://127.0.0.1:3111",
|
|
13
|
+
"http://127.0.0.1:3113",
|
|
14
|
+
];
|
|
15
|
+
const ALLOWED_METHODS = "GET,POST,PUT,DELETE,OPTIONS";
|
|
16
|
+
const ALLOWED_HEADERS = "Content-Type,Authorization";
|
|
17
|
+
const DEFAULT_MAX_BODY = 16 * 1024 * 1024;
|
|
18
|
+
export function startHttpServer(kernel, opts) {
|
|
19
|
+
const host = opts.host ?? "127.0.0.1";
|
|
20
|
+
const allowedOrigins = opts.allowedOrigins ?? DEFAULT_ORIGINS;
|
|
21
|
+
const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY;
|
|
22
|
+
// Index routes by `METHOD path` for O(1) exact-match lookup.
|
|
23
|
+
const routeIndex = new Map();
|
|
24
|
+
for (const route of kernel.getHttpRoutes()) {
|
|
25
|
+
routeIndex.set(routeKey(route.method, route.path), route);
|
|
26
|
+
}
|
|
27
|
+
const server = createServer((req, res) => {
|
|
28
|
+
handleRequest(req, res, kernel, routeIndex, {
|
|
29
|
+
allowedOrigins,
|
|
30
|
+
maxBodyBytes,
|
|
31
|
+
}).catch((err) => {
|
|
32
|
+
// Last-resort guard: never let a handler throw take down the
|
|
33
|
+
// connection without a response.
|
|
34
|
+
if (!res.headersSent) {
|
|
35
|
+
sendJson(res, 500, undefined, {
|
|
36
|
+
error: "internal",
|
|
37
|
+
message: err instanceof Error ? err.message : String(err),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
server.listen(opts.port, host);
|
|
43
|
+
return {
|
|
44
|
+
server,
|
|
45
|
+
port: opts.port,
|
|
46
|
+
close: () => new Promise((resolve) => {
|
|
47
|
+
server.close(() => resolve());
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async function handleRequest(req, res, kernel, routeIndex, cfg) {
|
|
52
|
+
const origin = req.headers.origin;
|
|
53
|
+
applyCors(res, origin, cfg.allowedOrigins);
|
|
54
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
55
|
+
// DNS-rebinding firewall: only serve requests whose Host header is a
|
|
56
|
+
// loopback host bound to our actual listening port. A malicious webpage can
|
|
57
|
+
// rebind its hostname's DNS to 127.0.0.1, but the browser still sends the
|
|
58
|
+
// page's own hostname in Host — so a non-loopback Host means the request did
|
|
59
|
+
// not originate from a local client. We compare the Host port against the
|
|
60
|
+
// socket's local port (handles ephemeral `port: 0` correctly). This runs
|
|
61
|
+
// BEFORE CORS-exempt routes and before any body is read, so it also blocks
|
|
62
|
+
// whole-brain exfil via GET /memwarden/export. Applies to every method incl.
|
|
63
|
+
// OPTIONS.
|
|
64
|
+
const localPort = req.socket.localPort;
|
|
65
|
+
if (!isLoopbackHost(req.headers.host, localPort)) {
|
|
66
|
+
sendJson(res, 403, undefined, { error: "forbidden_host" });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// CORS preflight.
|
|
70
|
+
if (method === "OPTIONS") {
|
|
71
|
+
res.statusCode = 204;
|
|
72
|
+
res.end();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
76
|
+
const pathname = url.pathname;
|
|
77
|
+
const route = routeIndex.get(routeKey(method, pathname));
|
|
78
|
+
if (!route) {
|
|
79
|
+
sendJson(res, 404, undefined, { error: "not_found", path: pathname });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// Require JSON for body-bearing methods BEFORE parsing. A cross-origin
|
|
83
|
+
// text/plain POST is a "simple request" that skips the CORS preflight, so
|
|
84
|
+
// without this a webpage could POST to /observe or /import (memory
|
|
85
|
+
// poisoning). Demanding application/json forces a preflight the browser
|
|
86
|
+
// will block. Bodyless POST/PUT (e.g. action triggers) still pass.
|
|
87
|
+
if (method === "POST" || method === "PUT") {
|
|
88
|
+
if (hasRequestBody(req) && !isJsonContentType(req.headers["content-type"])) {
|
|
89
|
+
sendJson(res, 415, undefined, {
|
|
90
|
+
error: "unsupported_media_type",
|
|
91
|
+
message: "Content-Type must be application/json",
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const headers = normalizeHeaders(req.headers);
|
|
97
|
+
const queryParams = queryToRecord(url);
|
|
98
|
+
// Middleware chain (auth). Short-circuits on `respond`.
|
|
99
|
+
const short = await kernel.runMiddleware(route.middlewareFunctionIds, headers);
|
|
100
|
+
if (short) {
|
|
101
|
+
sendJson(res, short.status_code, undefined, short.body);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Parse the JSON body for methods that carry one.
|
|
105
|
+
let body;
|
|
106
|
+
if (method === "POST" || method === "PUT" || method === "DELETE") {
|
|
107
|
+
const parsed = await readJsonBody(req, cfg.maxBodyBytes);
|
|
108
|
+
if (parsed.error) {
|
|
109
|
+
sendJson(res, parsed.status, undefined, { error: parsed.error });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
body = parsed.value;
|
|
113
|
+
}
|
|
114
|
+
const apiRequest = { headers, query_params: queryParams };
|
|
115
|
+
if (body !== undefined)
|
|
116
|
+
apiRequest.body = body;
|
|
117
|
+
const response = await kernel.invokeHttp(route.functionId, apiRequest);
|
|
118
|
+
sendJson(res, response.status_code, response.headers, response.body);
|
|
119
|
+
}
|
|
120
|
+
// --- helpers --------------------------------------------------------
|
|
121
|
+
function routeKey(method, path) {
|
|
122
|
+
return `${method} ${path}`;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Accept only a loopback Host header bound to our port (or with no port). The
|
|
126
|
+
* Host header is case-insensitive and may carry `:port` or be a bracketed IPv6
|
|
127
|
+
* literal; we split host/port robustly and reject anything non-loopback. This
|
|
128
|
+
* is the DNS-rebinding guard: the value reflects the hostname the client
|
|
129
|
+
* actually targeted, which a rebinding attacker cannot forge to "localhost".
|
|
130
|
+
*/
|
|
131
|
+
export function isLoopbackHost(hostHeader, port) {
|
|
132
|
+
if (typeof hostHeader !== "string" || hostHeader.trim() === "")
|
|
133
|
+
return false;
|
|
134
|
+
const raw = hostHeader.trim();
|
|
135
|
+
let hostname;
|
|
136
|
+
let portPart;
|
|
137
|
+
if (raw.startsWith("[")) {
|
|
138
|
+
// Bracketed IPv6: [::1] or [::1]:3111
|
|
139
|
+
const close = raw.indexOf("]");
|
|
140
|
+
if (close === -1)
|
|
141
|
+
return false;
|
|
142
|
+
hostname = raw.slice(1, close);
|
|
143
|
+
const after = raw.slice(close + 1);
|
|
144
|
+
portPart = after.startsWith(":") ? after.slice(1) : after || undefined;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const idx = raw.lastIndexOf(":");
|
|
148
|
+
if (idx === -1) {
|
|
149
|
+
hostname = raw;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
hostname = raw.slice(0, idx);
|
|
153
|
+
portPart = raw.slice(idx + 1);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const h = hostname.toLowerCase();
|
|
157
|
+
const loopback = h === "localhost" || h === "::1" || h === "127.0.0.1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
|
|
158
|
+
if (!loopback)
|
|
159
|
+
return false;
|
|
160
|
+
// A present port must match ours; absent port is allowed. If we can't
|
|
161
|
+
// determine our own port (port undefined), accept any numeric port on a
|
|
162
|
+
// loopback hostname — the hostname check already carries the guarantee.
|
|
163
|
+
if (portPart !== undefined && portPart !== "") {
|
|
164
|
+
if (!/^\d+$/.test(portPart))
|
|
165
|
+
return false;
|
|
166
|
+
if (port !== undefined && Number(portPart) !== port)
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
function isJsonContentType(contentType) {
|
|
172
|
+
if (typeof contentType !== "string")
|
|
173
|
+
return false;
|
|
174
|
+
// Strip any parameters (charset, boundary) and lowercase the media type.
|
|
175
|
+
const media = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
176
|
+
return media === "application/json";
|
|
177
|
+
}
|
|
178
|
+
// A request carries a body if it advertises one. Bodyless POST/PUT (no
|
|
179
|
+
// content-length, no chunked transfer-encoding) are exempt from the
|
|
180
|
+
// content-type requirement.
|
|
181
|
+
function hasRequestBody(req) {
|
|
182
|
+
const len = req.headers["content-length"];
|
|
183
|
+
if (typeof len === "string" && /^\d+$/.test(len) && Number(len) > 0)
|
|
184
|
+
return true;
|
|
185
|
+
const te = req.headers["transfer-encoding"];
|
|
186
|
+
if (typeof te === "string" && te.toLowerCase().includes("chunked"))
|
|
187
|
+
return true;
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
function applyCors(res, origin, allowedOrigins) {
|
|
191
|
+
if (origin && allowedOrigins.includes(origin)) {
|
|
192
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
193
|
+
}
|
|
194
|
+
res.setHeader("Access-Control-Allow-Methods", ALLOWED_METHODS);
|
|
195
|
+
res.setHeader("Access-Control-Allow-Headers", ALLOWED_HEADERS);
|
|
196
|
+
res.setHeader("Vary", "Origin");
|
|
197
|
+
}
|
|
198
|
+
function normalizeHeaders(raw) {
|
|
199
|
+
const out = {};
|
|
200
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
201
|
+
out[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
function queryToRecord(url) {
|
|
206
|
+
const out = {};
|
|
207
|
+
for (const [k, v] of url.searchParams)
|
|
208
|
+
out[k] = v;
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
function readJsonBody(req, maxBytes) {
|
|
212
|
+
return new Promise((resolve) => {
|
|
213
|
+
const chunks = [];
|
|
214
|
+
let total = 0;
|
|
215
|
+
let aborted = false;
|
|
216
|
+
req.on("data", (chunk) => {
|
|
217
|
+
if (aborted)
|
|
218
|
+
return;
|
|
219
|
+
total += chunk.length;
|
|
220
|
+
if (total > maxBytes) {
|
|
221
|
+
aborted = true;
|
|
222
|
+
resolve({ error: "payload_too_large", status: 413 });
|
|
223
|
+
req.destroy();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
chunks.push(chunk);
|
|
227
|
+
});
|
|
228
|
+
req.on("end", () => {
|
|
229
|
+
if (aborted)
|
|
230
|
+
return;
|
|
231
|
+
if (chunks.length === 0) {
|
|
232
|
+
resolve({ value: {}, status: 200 });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const text = Buffer.concat(chunks).toString("utf-8").trim();
|
|
236
|
+
if (!text) {
|
|
237
|
+
resolve({ value: {}, status: 200 });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
resolve({ value: JSON.parse(text), status: 200 });
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
resolve({ error: "invalid_json", status: 400 });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
req.on("error", () => {
|
|
248
|
+
if (!aborted)
|
|
249
|
+
resolve({ error: "request_error", status: 400 });
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
function sendJson(res, status, headers, body) {
|
|
254
|
+
if (headers) {
|
|
255
|
+
for (const [k, v] of Object.entries(headers))
|
|
256
|
+
res.setHeader(k, v);
|
|
257
|
+
}
|
|
258
|
+
res.setHeader("Content-Type", "application/json");
|
|
259
|
+
res.statusCode = status;
|
|
260
|
+
res.end(JSON.stringify(body ?? null));
|
|
261
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { VoidAction } from "./types.js";
|
|
2
|
+
export { registerWorker, Kernel, __resetKernelSingleton } from "./kernel.js";
|
|
3
|
+
export type { HttpRoute, KernelOptions } from "./kernel.js";
|
|
4
|
+
export { startHttpServer } from "./http.js";
|
|
5
|
+
export type { HttpServerOptions, RunningHttpServer } from "./http.js";
|
|
6
|
+
export { PubSub } from "./pubsub.js";
|
|
7
|
+
export type { StreamItem } from "./pubsub.js";
|
|
8
|
+
export { TriggerError } from "./types.js";
|
|
9
|
+
export type { ApiRequest, ApiResponse, ConnectionStateListener, Counter, FunctionHandler, Histogram, HttpMethod, ISdk, Meter, MiddlewareResult, RegisterWorkerOptions, StateChangeEvent, TriggerConfig, TriggerOptions, VoidAction, } from "./types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Fire-and-forget trigger sentinel. App code does
|
|
12
|
+
* `import { TriggerAction } from "<kernel>"` and calls
|
|
13
|
+
* `TriggerAction.Void()` to mark a trigger whose result is not awaited.
|
|
14
|
+
* `trigger` recognizes the `{ __void: true }` sentinel and swallows any
|
|
15
|
+
* rejection so a fire-and-forget failure never crashes the process.
|
|
16
|
+
*/
|
|
17
|
+
export declare const TriggerAction: {
|
|
18
|
+
readonly Void: () => VoidAction;
|
|
19
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Public barrel for the memwarden kernel. This is the module that
|
|
3
|
+
// is the in-process runtime app code builds against: it exports the
|
|
4
|
+
// `registerWorker` factory, the `TriggerAction` value, and the
|
|
5
|
+
// `ISdk` / `ApiRequest` types the call sites import.
|
|
6
|
+
export { registerWorker, Kernel, __resetKernelSingleton } from "./kernel.js";
|
|
7
|
+
export { startHttpServer } from "./http.js";
|
|
8
|
+
export { PubSub } from "./pubsub.js";
|
|
9
|
+
export { TriggerError } from "./types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Fire-and-forget trigger sentinel. App code does
|
|
12
|
+
* `import { TriggerAction } from "<kernel>"` and calls
|
|
13
|
+
* `TriggerAction.Void()` to mark a trigger whose result is not awaited.
|
|
14
|
+
* `trigger` recognizes the `{ __void: true }` sentinel and swallows any
|
|
15
|
+
* rejection so a fire-and-forget failure never crashes the process.
|
|
16
|
+
*/
|
|
17
|
+
export const TriggerAction = {
|
|
18
|
+
Void() {
|
|
19
|
+
return { __void: true };
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type ApiResponse, type ConnectionStateListener, type FunctionHandler, type HttpMethod, type ISdk, type Meter, type RegisterWorkerOptions, type TriggerConfig, type TriggerOptions } from "./types.js";
|
|
2
|
+
import { PubSub } from "./pubsub.js";
|
|
3
|
+
import type { StateStore } from "../state/store.js";
|
|
4
|
+
/** A resolved HTTP route the server can dispatch against. */
|
|
5
|
+
export interface HttpRoute {
|
|
6
|
+
method: HttpMethod;
|
|
7
|
+
path: string;
|
|
8
|
+
functionId: string;
|
|
9
|
+
middlewareFunctionIds: string[];
|
|
10
|
+
}
|
|
11
|
+
export interface KernelOptions {
|
|
12
|
+
/**
|
|
13
|
+
* The persistence store the five `state::*` ids route to. Defaults to
|
|
14
|
+
* an in-memory StoreMemory; the boot path injects a durable
|
|
15
|
+
* StoreLibsql.
|
|
16
|
+
*/
|
|
17
|
+
store?: StateStore;
|
|
18
|
+
}
|
|
19
|
+
export declare class Kernel implements ISdk {
|
|
20
|
+
readonly workerName: string;
|
|
21
|
+
private readonly functions;
|
|
22
|
+
private readonly httpRoutes;
|
|
23
|
+
private readonly stateTriggers;
|
|
24
|
+
private readonly connectionStateListeners;
|
|
25
|
+
private readonly store;
|
|
26
|
+
private readonly unsubscribeMutations;
|
|
27
|
+
private readonly pubsub;
|
|
28
|
+
private shuttingDown;
|
|
29
|
+
private lastSwallowedLogAt;
|
|
30
|
+
constructor(opts: RegisterWorkerOptions, kernelOpts?: KernelOptions);
|
|
31
|
+
registerFunction(id: string, handler: FunctionHandler): void;
|
|
32
|
+
registerTrigger(cfg: TriggerConfig): void;
|
|
33
|
+
on(event: "connection_state", cb: ConnectionStateListener): void;
|
|
34
|
+
getMeter(_name: string): Meter;
|
|
35
|
+
trigger<P = any, R = any>(opts: TriggerOptions<P>): Promise<R>;
|
|
36
|
+
/**
|
|
37
|
+
* Resolve a function_id to a result. Built-in ids are routed here
|
|
38
|
+
* before consulting the app registry. Unregistered ids reject with a
|
|
39
|
+
* TriggerError carrying { code, function_id, message }.
|
|
40
|
+
*/
|
|
41
|
+
private invoke;
|
|
42
|
+
/**
|
|
43
|
+
* Route engine-provided built-ins: the five `state::*` ops, the
|
|
44
|
+
* `stream::*` surface, and `engine::workers::list`. Returns the
|
|
45
|
+
* sentinel NOT_BUILTIN for everything else. State-change events are
|
|
46
|
+
* emitted by the store (via onMutation), not here, so set/update/delete
|
|
47
|
+
* stay a single store call.
|
|
48
|
+
*/
|
|
49
|
+
private routeBuiltin;
|
|
50
|
+
/**
|
|
51
|
+
* Fan a store mutation event out to any `type:"state"` trigger bound
|
|
52
|
+
* to that scope. The original only ever subscribed `KV.sessions`, but
|
|
53
|
+
* this generically dispatches for any subscribed scope.
|
|
54
|
+
*/
|
|
55
|
+
private dispatchStateChange;
|
|
56
|
+
/** Snapshot of registered HTTP routes. */
|
|
57
|
+
getHttpRoutes(): readonly HttpRoute[];
|
|
58
|
+
/**
|
|
59
|
+
* Run an ordered middleware chain. Returns the short-circuit response
|
|
60
|
+
* if any middleware responds, else null to proceed.
|
|
61
|
+
*/
|
|
62
|
+
runMiddleware(middlewareFunctionIds: string[], headers: Record<string, string | undefined>): Promise<{
|
|
63
|
+
status_code: number;
|
|
64
|
+
body: unknown;
|
|
65
|
+
} | null>;
|
|
66
|
+
/** Invoke an HTTP-bound function and return its ApiResponse. */
|
|
67
|
+
invokeHttp(functionId: string, request: {
|
|
68
|
+
body?: unknown;
|
|
69
|
+
headers?: Record<string, string | undefined>;
|
|
70
|
+
query_params?: Record<string, string>;
|
|
71
|
+
}): Promise<ApiResponse>;
|
|
72
|
+
get streams(): PubSub;
|
|
73
|
+
/** The underlying state store (exposed for StateKV construction etc.). */
|
|
74
|
+
get stateStore(): StateStore;
|
|
75
|
+
shutdown(): Promise<void>;
|
|
76
|
+
private logSwallowed;
|
|
77
|
+
}
|
|
78
|
+
export declare function registerWorker(_engineUrl: string, opts: RegisterWorkerOptions, kernelOpts?: KernelOptions): Kernel;
|
|
79
|
+
/** Test helper: drop the singleton so a fresh kernel can be built. */
|
|
80
|
+
export declare function __resetKernelSingleton(): void;
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
//
|
|
2
|
+
// The memwarden kernel: an in-process worker runtime
|
|
3
|
+
// worker runtime. It owns
|
|
4
|
+
// - the function registry (Map<id, handler>),
|
|
5
|
+
// - the single `trigger` dispatch chokepoint (including the built-in
|
|
6
|
+
// `state::*`, `stream::*`, and `engine::workers::list` ids that the
|
|
7
|
+
// engine, not app code, used to provide),
|
|
8
|
+
// - `registerTrigger` wiring to HTTP / durable-subscriber / state
|
|
9
|
+
// surfaces,
|
|
10
|
+
// - the optional `on("connection_state")` and `getMeter` shims,
|
|
11
|
+
// - the `shutdown` lifecycle.
|
|
12
|
+
//
|
|
13
|
+
// Persistence lives behind the STATE layer's `StateStore` abstraction
|
|
14
|
+
// (../state/store.ts). The kernel routes the five `state::*`
|
|
15
|
+
// function_ids to that store and drives any registered `type:"state"`
|
|
16
|
+
// trigger from the store's mutation events. The kernel does NOT carry
|
|
17
|
+
// its own KV implementation.
|
|
18
|
+
//
|
|
19
|
+
// HTTP serving is delegated to the router in ./http.ts; the kernel just
|
|
20
|
+
// collects route registrations and exposes them.
|
|
21
|
+
import { TriggerError, } from "./types.js";
|
|
22
|
+
import { PubSub } from "./pubsub.js";
|
|
23
|
+
import { StoreMemory } from "../state/store-memory.js";
|
|
24
|
+
const NOOP_METER = {
|
|
25
|
+
createCounter: () => ({ add: () => { } }),
|
|
26
|
+
createHistogram: () => ({ record: () => { } }),
|
|
27
|
+
};
|
|
28
|
+
export class Kernel {
|
|
29
|
+
workerName;
|
|
30
|
+
functions = new Map();
|
|
31
|
+
httpRoutes = [];
|
|
32
|
+
stateTriggers = new Map(); // scope -> functionIds
|
|
33
|
+
connectionStateListeners = [];
|
|
34
|
+
store;
|
|
35
|
+
unsubscribeMutations;
|
|
36
|
+
pubsub = new PubSub();
|
|
37
|
+
shuttingDown = false;
|
|
38
|
+
lastSwallowedLogAt = 0;
|
|
39
|
+
constructor(opts, kernelOpts = {}) {
|
|
40
|
+
this.workerName = opts.workerName;
|
|
41
|
+
this.store = kernelOpts.store ?? new StoreMemory();
|
|
42
|
+
// Drive registered type:"state" triggers from the store's mutation
|
|
43
|
+
// events. The store emits {scope, key, event_type, old/new_value};
|
|
44
|
+
// we fan it out to any function bound to that scope.
|
|
45
|
+
this.unsubscribeMutations = this.store.onMutation((event) => this.dispatchStateChange(event));
|
|
46
|
+
}
|
|
47
|
+
// --- registration -------------------------------------------------
|
|
48
|
+
registerFunction(id, handler) {
|
|
49
|
+
// Last-write-wins; ids are unique in practice.
|
|
50
|
+
this.functions.set(id, handler);
|
|
51
|
+
}
|
|
52
|
+
registerTrigger(cfg) {
|
|
53
|
+
switch (cfg.type) {
|
|
54
|
+
case "http": {
|
|
55
|
+
this.httpRoutes.push({
|
|
56
|
+
method: cfg.config.http_method,
|
|
57
|
+
path: cfg.config.api_path,
|
|
58
|
+
functionId: cfg.function_id,
|
|
59
|
+
middlewareFunctionIds: cfg.config.middleware_function_ids ?? [],
|
|
60
|
+
});
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case "durable:subscriber": {
|
|
64
|
+
const fnId = cfg.function_id;
|
|
65
|
+
this.pubsub.subscribe(cfg.config.topic, (payload) => {
|
|
66
|
+
// Subscriber invocation is fire-and-forget; never crash.
|
|
67
|
+
void this.invoke(fnId, payload).catch((err) => this.logSwallowed("durable:subscriber", fnId, err));
|
|
68
|
+
});
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case "state": {
|
|
72
|
+
const list = this.stateTriggers.get(cfg.config.scope) ?? [];
|
|
73
|
+
list.push(cfg.function_id);
|
|
74
|
+
this.stateTriggers.set(cfg.config.scope, list);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
on(event, cb) {
|
|
80
|
+
if (event === "connection_state") {
|
|
81
|
+
this.connectionStateListeners.push(cb);
|
|
82
|
+
// In-process kernel is "connected" the moment it exists. Fire on
|
|
83
|
+
// the next tick so listeners registered synchronously after
|
|
84
|
+
// construction still observe it.
|
|
85
|
+
queueMicrotask(() => cb("connected"));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
getMeter(_name) {
|
|
89
|
+
// No real OTel transport in-process; hand back no-op instruments.
|
|
90
|
+
// The boot path feature-detects this and falls back to NOOP anyway,
|
|
91
|
+
// but providing it keeps the call site identical.
|
|
92
|
+
return NOOP_METER;
|
|
93
|
+
}
|
|
94
|
+
// --- dispatch -----------------------------------------------------
|
|
95
|
+
async trigger(opts) {
|
|
96
|
+
const isVoid = !!opts.action?.__void;
|
|
97
|
+
if (isVoid) {
|
|
98
|
+
// Fire-and-forget: never reject toward the caller (many call
|
|
99
|
+
// sites invoke without await, as a bare statement). Run the
|
|
100
|
+
// handler and swallow/log any rejection.
|
|
101
|
+
void this.invoke(opts.function_id, opts.payload).catch((err) => this.logSwallowed("trigger:void", opts.function_id, err));
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
return this.invoke(opts.function_id, opts.payload);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Resolve a function_id to a result. Built-in ids are routed here
|
|
108
|
+
* before consulting the app registry. Unregistered ids reject with a
|
|
109
|
+
* TriggerError carrying { code, function_id, message }.
|
|
110
|
+
*/
|
|
111
|
+
async invoke(functionId, payload) {
|
|
112
|
+
const builtin = await this.routeBuiltin(functionId, payload);
|
|
113
|
+
if (builtin !== NOT_BUILTIN)
|
|
114
|
+
return builtin;
|
|
115
|
+
const handler = this.functions.get(functionId);
|
|
116
|
+
if (!handler) {
|
|
117
|
+
throw new TriggerError(`No function registered for "${functionId}"`, "FUNCTION_NOT_FOUND", functionId);
|
|
118
|
+
}
|
|
119
|
+
return (await handler(payload));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Route engine-provided built-ins: the five `state::*` ops, the
|
|
123
|
+
* `stream::*` surface, and `engine::workers::list`. Returns the
|
|
124
|
+
* sentinel NOT_BUILTIN for everything else. State-change events are
|
|
125
|
+
* emitted by the store (via onMutation), not here, so set/update/delete
|
|
126
|
+
* stay a single store call.
|
|
127
|
+
*/
|
|
128
|
+
async routeBuiltin(functionId, payload) {
|
|
129
|
+
switch (functionId) {
|
|
130
|
+
case "state::get": {
|
|
131
|
+
const p = payload;
|
|
132
|
+
return (await this.store.get(p.scope, p.key));
|
|
133
|
+
}
|
|
134
|
+
case "state::set": {
|
|
135
|
+
const p = payload;
|
|
136
|
+
return (await this.store.set(p.scope, p.key, p.value));
|
|
137
|
+
}
|
|
138
|
+
case "state::update": {
|
|
139
|
+
const p = payload;
|
|
140
|
+
return (await this.store.update(p.scope, p.key, p.ops));
|
|
141
|
+
}
|
|
142
|
+
case "state::delete": {
|
|
143
|
+
const p = payload;
|
|
144
|
+
await this.store.delete(p.scope, p.key);
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
case "state::list": {
|
|
148
|
+
const p = payload;
|
|
149
|
+
return (await this.store.list(p.scope));
|
|
150
|
+
}
|
|
151
|
+
case "state::verify": {
|
|
152
|
+
// Tamper-evidence: verify the whole oplog hash chain. Read-only.
|
|
153
|
+
return (await this.store.verifyOplog());
|
|
154
|
+
}
|
|
155
|
+
case "state::oplog-count": {
|
|
156
|
+
const entries = await this.store.readOplog();
|
|
157
|
+
return { count: entries.length };
|
|
158
|
+
}
|
|
159
|
+
case "state::oplog-find": {
|
|
160
|
+
// Chain evidence for one key (delete receipts). Payloads are
|
|
161
|
+
// STRIPPED — a receipt proves an entry existed in the chain without
|
|
162
|
+
// re-disclosing the content that was just deleted.
|
|
163
|
+
const p = payload;
|
|
164
|
+
const entries = await this.store.readOplog();
|
|
165
|
+
const matches = entries
|
|
166
|
+
.filter((e) => e.key === p.key)
|
|
167
|
+
.map((e) => ({
|
|
168
|
+
id: e.id,
|
|
169
|
+
ts: e.ts,
|
|
170
|
+
op: e.op,
|
|
171
|
+
scope: e.scope,
|
|
172
|
+
key: e.key,
|
|
173
|
+
hash: e.hash,
|
|
174
|
+
prev_hash: e.prev_hash,
|
|
175
|
+
}));
|
|
176
|
+
return { entries: matches };
|
|
177
|
+
}
|
|
178
|
+
case "stream::set":
|
|
179
|
+
case "stream::send": {
|
|
180
|
+
// Live-viewer surface. Best-effort fan-out to in-process
|
|
181
|
+
// listeners; no durable backing.
|
|
182
|
+
this.pubsub.emitStream(payload ?? {});
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
case "engine::workers::list": {
|
|
186
|
+
// Engine-internal in the original; the kernel is the only
|
|
187
|
+
// worker. Health monitor reads `.workers`.
|
|
188
|
+
return { workers: [] };
|
|
189
|
+
}
|
|
190
|
+
default:
|
|
191
|
+
return NOT_BUILTIN;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Fan a store mutation event out to any `type:"state"` trigger bound
|
|
196
|
+
* to that scope. The original only ever subscribed `KV.sessions`, but
|
|
197
|
+
* this generically dispatches for any subscribed scope.
|
|
198
|
+
*/
|
|
199
|
+
dispatchStateChange(event) {
|
|
200
|
+
const fnIds = this.stateTriggers.get(event.scope);
|
|
201
|
+
if (!fnIds || fnIds.length === 0)
|
|
202
|
+
return;
|
|
203
|
+
const payload = {
|
|
204
|
+
key: event.key,
|
|
205
|
+
event_type: event.event_type,
|
|
206
|
+
...(event.old_value !== undefined ? { old_value: event.old_value } : {}),
|
|
207
|
+
...(event.new_value !== undefined ? { new_value: event.new_value } : {}),
|
|
208
|
+
};
|
|
209
|
+
for (const fnId of fnIds) {
|
|
210
|
+
// State-change handlers are fire-and-forget side effects.
|
|
211
|
+
void this.invoke(fnId, payload).catch((err) => this.logSwallowed("state-change", fnId, err));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// --- HTTP surface (consumed by ./http.ts) -------------------------
|
|
215
|
+
/** Snapshot of registered HTTP routes. */
|
|
216
|
+
getHttpRoutes() {
|
|
217
|
+
return this.httpRoutes;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Run an ordered middleware chain. Returns the short-circuit response
|
|
221
|
+
* if any middleware responds, else null to proceed.
|
|
222
|
+
*/
|
|
223
|
+
async runMiddleware(middlewareFunctionIds, headers) {
|
|
224
|
+
for (const id of middlewareFunctionIds) {
|
|
225
|
+
const handler = this.functions.get(id);
|
|
226
|
+
if (!handler)
|
|
227
|
+
continue; // Missing middleware = open (no-op).
|
|
228
|
+
const result = (await handler({ request: { headers } }));
|
|
229
|
+
if (result && result.action === "respond") {
|
|
230
|
+
return result.response;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
/** Invoke an HTTP-bound function and return its ApiResponse. */
|
|
236
|
+
async invokeHttp(functionId, request) {
|
|
237
|
+
const handler = this.functions.get(functionId);
|
|
238
|
+
if (!handler) {
|
|
239
|
+
return {
|
|
240
|
+
status_code: 500,
|
|
241
|
+
body: { error: `No handler for ${functionId}` },
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return (await handler(request));
|
|
245
|
+
}
|
|
246
|
+
// --- pub/sub passthrough (for the viewer / external wiring) -------
|
|
247
|
+
get streams() {
|
|
248
|
+
return this.pubsub;
|
|
249
|
+
}
|
|
250
|
+
/** The underlying state store (exposed for StateKV construction etc.). */
|
|
251
|
+
get stateStore() {
|
|
252
|
+
return this.store;
|
|
253
|
+
}
|
|
254
|
+
// --- lifecycle ----------------------------------------------------
|
|
255
|
+
async shutdown() {
|
|
256
|
+
if (this.shuttingDown)
|
|
257
|
+
return;
|
|
258
|
+
this.shuttingDown = true;
|
|
259
|
+
this.unsubscribeMutations();
|
|
260
|
+
for (const cb of this.connectionStateListeners) {
|
|
261
|
+
try {
|
|
262
|
+
cb("disconnected");
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
/* ignore */
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
await this.store.close().catch(() => undefined);
|
|
269
|
+
}
|
|
270
|
+
// --- internals ----------------------------------------------------
|
|
271
|
+
logSwallowed(context, functionId, err) {
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
// Throttle to avoid spamming on bursts of fire-and-forget failures.
|
|
274
|
+
if (now - this.lastSwallowedLogAt < 60_000)
|
|
275
|
+
return;
|
|
276
|
+
this.lastSwallowedLogAt = now;
|
|
277
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
278
|
+
console.warn(`[memwarden] swallowed ${context} rejection (${functionId}): ${message}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const NOT_BUILTIN = Symbol("not-builtin");
|
|
282
|
+
/**
|
|
283
|
+
* Factory matching the daemon factory entrypoint. The kernel is a
|
|
284
|
+
* process singleton: repeated calls return the same instance so every
|
|
285
|
+
* module that imports `registerWorker` shares one registry + store.
|
|
286
|
+
*/
|
|
287
|
+
let singleton = null;
|
|
288
|
+
export function registerWorker(_engineUrl, opts, kernelOpts) {
|
|
289
|
+
if (!singleton) {
|
|
290
|
+
singleton = new Kernel(opts, kernelOpts ?? {});
|
|
291
|
+
}
|
|
292
|
+
return singleton;
|
|
293
|
+
}
|
|
294
|
+
/** Test helper: drop the singleton so a fresh kernel can be built. */
|
|
295
|
+
export function __resetKernelSingleton() {
|
|
296
|
+
singleton = null;
|
|
297
|
+
}
|