@onetype/stack-api-kit 1.0.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/README.md +58 -0
- package/dist/api-BZrn0c9T.d.ts +654 -0
- package/dist/chunk-UCWOCNTI.js +1749 -0
- package/dist/chunk-UCWOCNTI.js.map +1 -0
- package/dist/index.d.ts +327 -0
- package/dist/index.js +381 -0
- package/dist/index.js.map +1 -0
- package/dist/testing.d.ts +165 -0
- package/dist/testing.js +450 -0
- package/dist/testing.js.map +1 -0
- package/package.json +75 -0
|
@@ -0,0 +1,1749 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
2
|
+
import { timingSafeEqual, createHash } from 'crypto';
|
|
3
|
+
import { mkdirSync, readdirSync, readFileSync } from 'fs';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
import { eq } from 'drizzle-orm';
|
|
6
|
+
import Database from 'better-sqlite3';
|
|
7
|
+
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|
8
|
+
|
|
9
|
+
// src/plugins/kernel/internal/faults.ts
|
|
10
|
+
var KernelFault = class extends Error {
|
|
11
|
+
code;
|
|
12
|
+
plugin;
|
|
13
|
+
detail;
|
|
14
|
+
constructor(code, message, made = {}) {
|
|
15
|
+
super(message, made.cause === void 0 ? void 0 : { cause: made.cause });
|
|
16
|
+
this.name = "KernelFault";
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.plugin = made.plugin;
|
|
19
|
+
this.detail = made.detail ?? {};
|
|
20
|
+
}
|
|
21
|
+
toString() {
|
|
22
|
+
return this.plugin === void 0 ? `${this.name} [${this.code}]: ${this.message}` : `${this.name} [${this.code}] in plugin "${this.plugin}": ${this.message}`;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// src/plugins/kernel/internal/answer.ts
|
|
27
|
+
var Answered = class _Answered {
|
|
28
|
+
status;
|
|
29
|
+
body;
|
|
30
|
+
headers;
|
|
31
|
+
constructor(status, body, headers = {}) {
|
|
32
|
+
this.status = status;
|
|
33
|
+
this.body = body;
|
|
34
|
+
this.headers = headers;
|
|
35
|
+
}
|
|
36
|
+
/** Sends the caller somewhere else. */
|
|
37
|
+
static redirect(to, permanent = false) {
|
|
38
|
+
return new _Answered(permanent ? 308 : 307, { to }, { location: to });
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var Refusal = class extends Error {
|
|
42
|
+
status;
|
|
43
|
+
code;
|
|
44
|
+
fields;
|
|
45
|
+
constructor(status, code, message, fields2) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "Refusal";
|
|
48
|
+
this.status = status;
|
|
49
|
+
this.code = code;
|
|
50
|
+
this.fields = fields2;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
var SPOKEN = {
|
|
54
|
+
UNAUTHENTICATED: { status: 401, message: "This request needs to be signed in." },
|
|
55
|
+
PERMISSION_DENIED: { status: 403, message: "This request is not permitted." },
|
|
56
|
+
RATE_LIMITED: { status: 429, message: "Too many requests. Try again shortly." },
|
|
57
|
+
INVALID_PAYLOAD: { status: 400, message: "The request body is not valid." }
|
|
58
|
+
};
|
|
59
|
+
function answer(cause) {
|
|
60
|
+
if (cause instanceof Refusal) {
|
|
61
|
+
return {
|
|
62
|
+
status: cause.status,
|
|
63
|
+
code: cause.code,
|
|
64
|
+
message: cause.message,
|
|
65
|
+
...cause.fields !== void 0 && { fields: cause.fields }
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (cause instanceof KernelFault) {
|
|
69
|
+
const known = SPOKEN[cause.code];
|
|
70
|
+
if (known !== void 0) {
|
|
71
|
+
return { status: known.status, code: cause.code, message: known.message };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { status: 500, code: "INTERNAL", message: "The request could not be completed." };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/plugins/kernel/internal/permissions.ts
|
|
78
|
+
function permissions(caller) {
|
|
79
|
+
const granted = () => {
|
|
80
|
+
return new Set(caller()?.permissions ?? []);
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
has: (permission) => {
|
|
84
|
+
return granted().has(permission);
|
|
85
|
+
},
|
|
86
|
+
all: (wanted) => {
|
|
87
|
+
const carries = granted();
|
|
88
|
+
return wanted.every((one) => carries.has(one));
|
|
89
|
+
},
|
|
90
|
+
/**
|
|
91
|
+
* What the project attached to this caller: a tenant, a role, a
|
|
92
|
+
* session. Read back as it was given, and never interpreted here.
|
|
93
|
+
*
|
|
94
|
+
* The kernel cannot know what a tenant means, so it carries the value
|
|
95
|
+
* and refuses to guess. A plugin scoping by tenant reads it and puts
|
|
96
|
+
* it in its own queries.
|
|
97
|
+
*/
|
|
98
|
+
claims: () => {
|
|
99
|
+
return caller()?.claims ?? {};
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/plugins/kernel/internal/context.ts
|
|
105
|
+
function absent(what, used, pass) {
|
|
106
|
+
throw new KernelFault(
|
|
107
|
+
"NOT_STARTED",
|
|
108
|
+
`A plugin used ctx.${used}, but no ${what} was given. Pass \`${pass}\` to createKernel, \`${pass}: true\` to start, or \`${pass}: true\` to booting in a test.`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
function origin(url) {
|
|
112
|
+
try {
|
|
113
|
+
const parsed = new URL(url);
|
|
114
|
+
return parsed.origin === "null" || parsed.origin === "" ? `${parsed.protocol}//${parsed.host}` : parsed.origin;
|
|
115
|
+
} catch {
|
|
116
|
+
return void 0;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function dialable(url) {
|
|
120
|
+
return url.toLowerCase().startsWith("https://");
|
|
121
|
+
}
|
|
122
|
+
function context(wiring, plugin2, caller, within, headers = {}, acting) {
|
|
123
|
+
const may = permissions(() => caller);
|
|
124
|
+
const seenBy = (plugin3, inside = within) => {
|
|
125
|
+
return context(wiring, plugin3, caller, inside, headers, acting);
|
|
126
|
+
};
|
|
127
|
+
const heard = (plugin3) => {
|
|
128
|
+
return context(wiring, plugin3, void 0, void 0, {});
|
|
129
|
+
};
|
|
130
|
+
const made = /* @__PURE__ */ new Map();
|
|
131
|
+
const of = (name) => {
|
|
132
|
+
if (made.has(name)) {
|
|
133
|
+
return made.get(name);
|
|
134
|
+
}
|
|
135
|
+
made.set(name, void 0);
|
|
136
|
+
const services = wiring.known.get(name)?.definition.services?.(
|
|
137
|
+
name === plugin2 ? ctx : seenBy(name)
|
|
138
|
+
);
|
|
139
|
+
made.set(name, services);
|
|
140
|
+
return services;
|
|
141
|
+
};
|
|
142
|
+
const scoping = (table) => {
|
|
143
|
+
const scope = wiring.known.get(plugin2)?.definition.scope;
|
|
144
|
+
if (scope === void 0) {
|
|
145
|
+
throw new KernelFault(
|
|
146
|
+
"UNDECLARED_SCOPE",
|
|
147
|
+
`"${plugin2}" asked to scope "${table}", but declares no scope. Add one, naming the claim and which column each table carries it in.`,
|
|
148
|
+
{ plugin: plugin2 }
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const column = scope.tables[table];
|
|
152
|
+
if (column === void 0) {
|
|
153
|
+
throw new KernelFault(
|
|
154
|
+
"UNDECLARED_SCOPE",
|
|
155
|
+
`"${plugin2}" asked to scope "${table}", which its scope does not name. Add it, or stop scoping a table nobody owns.`,
|
|
156
|
+
{ plugin: plugin2 }
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const held = caller === void 0 ? acting : caller.claims[scope.claim];
|
|
160
|
+
if (typeof held !== "string" || held.trim() === "") {
|
|
161
|
+
throw new Refusal(
|
|
162
|
+
403,
|
|
163
|
+
"OUT_OF_SCOPE",
|
|
164
|
+
"This request carries nothing to say whose rows it may reach."
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return { column, held };
|
|
168
|
+
};
|
|
169
|
+
const ctx = {
|
|
170
|
+
name: plugin2,
|
|
171
|
+
config: wiring.parsed.get(plugin2) ?? wiring.config[plugin2],
|
|
172
|
+
get services() {
|
|
173
|
+
return of(plugin2);
|
|
174
|
+
},
|
|
175
|
+
caller,
|
|
176
|
+
headers,
|
|
177
|
+
now: wiring.now,
|
|
178
|
+
log: {
|
|
179
|
+
debug: (line, about) => {
|
|
180
|
+
wiring.log("debug", plugin2, line, about);
|
|
181
|
+
},
|
|
182
|
+
info: (line, about) => {
|
|
183
|
+
wiring.log("info", plugin2, line, about);
|
|
184
|
+
},
|
|
185
|
+
warn: (line, about) => {
|
|
186
|
+
wiring.log("warn", plugin2, line, about);
|
|
187
|
+
},
|
|
188
|
+
error: (line, about) => {
|
|
189
|
+
wiring.log("error", plugin2, line, about);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
get db() {
|
|
193
|
+
if (within !== void 0) {
|
|
194
|
+
return within.db;
|
|
195
|
+
}
|
|
196
|
+
return wiring.db === void 0 ? absent("store", "db", "db") : wiring.db.of(plugin2);
|
|
197
|
+
},
|
|
198
|
+
write: (run) => {
|
|
199
|
+
if (within !== void 0 || wiring.db?.write === void 0) {
|
|
200
|
+
return run();
|
|
201
|
+
}
|
|
202
|
+
return wiring.db.write(run);
|
|
203
|
+
},
|
|
204
|
+
tx: async (run) => {
|
|
205
|
+
const store2 = wiring.db;
|
|
206
|
+
if (store2 === void 0) {
|
|
207
|
+
return absent("store", "db", "db");
|
|
208
|
+
}
|
|
209
|
+
const mark = {};
|
|
210
|
+
const outer = wiring.open.getStore();
|
|
211
|
+
const nested = within !== void 0;
|
|
212
|
+
wiring.pending.set(mark, []);
|
|
213
|
+
try {
|
|
214
|
+
const returned = await wiring.open.run(mark, () => store2.tx(plugin2, async (db) => {
|
|
215
|
+
const made2 = await run(seenBy(plugin2, { mark, db }));
|
|
216
|
+
const waiting = wiring.pending.get(mark) ?? [];
|
|
217
|
+
if (wiring.outbox !== void 0 && waiting.length > 0 && !(nested && outer !== void 0)) {
|
|
218
|
+
wiring.outbox.keep(db, waiting.map((one) => ({
|
|
219
|
+
id: one.id,
|
|
220
|
+
plugin: one.plugin,
|
|
221
|
+
name: one.name,
|
|
222
|
+
payload: one.payload
|
|
223
|
+
})));
|
|
224
|
+
}
|
|
225
|
+
return made2;
|
|
226
|
+
}));
|
|
227
|
+
const announced = wiring.pending.get(mark) ?? [];
|
|
228
|
+
if (nested && outer !== void 0) {
|
|
229
|
+
wiring.pending.get(outer)?.push(...announced);
|
|
230
|
+
return returned;
|
|
231
|
+
}
|
|
232
|
+
for (const announcement of announced) {
|
|
233
|
+
const delivered = wiring.bus.deliver(announcement.plugin, announcement.name, announcement.payload, (to) => heard(to));
|
|
234
|
+
void delivered.then(() => wiring.outbox?.sent(announcement.id));
|
|
235
|
+
}
|
|
236
|
+
return returned;
|
|
237
|
+
} finally {
|
|
238
|
+
wiring.pending.delete(mark);
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
// Async throughout, refusal included: a caller writing `.catch()`
|
|
242
|
+
// around a call would otherwise get an uncaught error for the one
|
|
243
|
+
// case it was guarding against.
|
|
244
|
+
fetch: async (call) => {
|
|
245
|
+
const allowed = wiring.known.get(plugin2)?.definition.outbound ?? [];
|
|
246
|
+
const host = origin(call.url);
|
|
247
|
+
if (host === void 0 || !allowed.includes(host)) {
|
|
248
|
+
throw new KernelFault(
|
|
249
|
+
"UNDECLARED_HOST",
|
|
250
|
+
`"${plugin2}" called ${host ?? `"${call.url}"`}, which it does not declare. Add it to outbound.`,
|
|
251
|
+
{ plugin: plugin2 }
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (!dialable(call.url)) {
|
|
255
|
+
throw new KernelFault(
|
|
256
|
+
"UNDECLARED_HOST",
|
|
257
|
+
`"${plugin2}" declares ${host}, but ctx.fetch speaks https and nothing else. Reach it with its own client, opened in setup and closed in teardown.`,
|
|
258
|
+
{ plugin: plugin2 }
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return wiring.dial === void 0 ? absent("dialer", "dial", "dial") : wiring.dial(call);
|
|
262
|
+
},
|
|
263
|
+
events: {
|
|
264
|
+
emit: (event, payload) => {
|
|
265
|
+
const checked = wiring.bus.checked(plugin2, event, payload);
|
|
266
|
+
const mark = within?.mark ?? wiring.open.getStore();
|
|
267
|
+
const waiting = mark === void 0 ? void 0 : wiring.pending.get(mark);
|
|
268
|
+
if (waiting !== void 0) {
|
|
269
|
+
waiting.push({ id: crypto.randomUUID(), plugin: plugin2, name: event, payload: checked });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
wiring.bus.deliver(plugin2, event, checked, (to) => heard(to));
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
hooks: {
|
|
276
|
+
run: (hook, payload) => {
|
|
277
|
+
return wiring.points.run(plugin2, hook, payload, (to) => seenBy(to));
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
permissions: may,
|
|
281
|
+
commands: {
|
|
282
|
+
run: (command, input) => {
|
|
283
|
+
return wiring.run(command, input, caller);
|
|
284
|
+
},
|
|
285
|
+
later: (command, input, inSeconds) => {
|
|
286
|
+
if (wiring.schedule === void 0) {
|
|
287
|
+
absent("schedule", "commands.later", "schedule");
|
|
288
|
+
}
|
|
289
|
+
const owns = wiring.known.get(plugin2)?.definition.commands ?? {};
|
|
290
|
+
if (!(command in owns)) {
|
|
291
|
+
throw new KernelFault(
|
|
292
|
+
"UNDECLARED_COMMAND",
|
|
293
|
+
`"${plugin2}" scheduled "${command}", which it does not declare. A plugin schedules only its own commands.`,
|
|
294
|
+
{ plugin: plugin2 }
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
wiring.schedule.keep(within?.db, {
|
|
298
|
+
id: crypto.randomUUID(),
|
|
299
|
+
plugin: plugin2,
|
|
300
|
+
command,
|
|
301
|
+
input,
|
|
302
|
+
at: wiring.now() + inSeconds * 1e3,
|
|
303
|
+
attempts: 0
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
owns: (owned) => {
|
|
308
|
+
wiring.owned.set(plugin2, owned);
|
|
309
|
+
return owned;
|
|
310
|
+
},
|
|
311
|
+
owned: () => {
|
|
312
|
+
return wiring.owned.get(plugin2);
|
|
313
|
+
},
|
|
314
|
+
forScope: (claim) => {
|
|
315
|
+
if (caller !== void 0) {
|
|
316
|
+
throw new KernelFault(
|
|
317
|
+
"OUT_OF_SCOPE",
|
|
318
|
+
`"${plugin2}" called ctx.forScope inside a request. The scope of a request is the caller's; forScope is for a listener or a scheduled command, where nobody is calling.`,
|
|
319
|
+
{ plugin: plugin2 }
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (claim.trim() === "") {
|
|
323
|
+
throw new KernelFault(
|
|
324
|
+
"OUT_OF_SCOPE",
|
|
325
|
+
`"${plugin2}" called ctx.forScope with nothing. A scope acted for is named, or it is every scope.`,
|
|
326
|
+
{ plugin: plugin2 }
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
return context(wiring, plugin2, void 0, within, headers, claim);
|
|
330
|
+
},
|
|
331
|
+
stamped: (table) => {
|
|
332
|
+
const { column, held } = scoping(table);
|
|
333
|
+
return { [column]: held };
|
|
334
|
+
},
|
|
335
|
+
scoped: (table) => {
|
|
336
|
+
const { column, held } = scoping(table);
|
|
337
|
+
return wiring.narrow === void 0 ? absent("narrowing", "scoped", "narrow") : wiring.narrow(table, column, held);
|
|
338
|
+
},
|
|
339
|
+
use: (name) => {
|
|
340
|
+
const declared2 = wiring.known.get(plugin2)?.definition.dependsOn ?? [];
|
|
341
|
+
if (name !== plugin2 && !declared2.includes(name)) {
|
|
342
|
+
throw new KernelFault(
|
|
343
|
+
"UNDECLARED_DEPENDENCY",
|
|
344
|
+
`"${plugin2}" reached "${name}", which it does not depend on. Add "${name}" to dependsOn.`,
|
|
345
|
+
{ plugin: plugin2 }
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
return of(name);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
return ctx;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// src/plugins/kernel/internal/events.ts
|
|
355
|
+
var KEPT = 100;
|
|
356
|
+
function events(now = Date.now, told2 = () => {
|
|
357
|
+
}) {
|
|
358
|
+
const published = /* @__PURE__ */ new Map();
|
|
359
|
+
const subscribers = /* @__PURE__ */ new Map();
|
|
360
|
+
const failures = [];
|
|
361
|
+
function record(event, plugin2, error) {
|
|
362
|
+
failures.push({ event, plugin: plugin2, error, at: now() });
|
|
363
|
+
if (failures.length > KEPT) {
|
|
364
|
+
failures.splice(0, failures.length - KEPT);
|
|
365
|
+
}
|
|
366
|
+
told2(plugin2, `listening to "${event}" failed`, {
|
|
367
|
+
event,
|
|
368
|
+
error: error instanceof Error ? error.message : String(error),
|
|
369
|
+
...error instanceof Error && error.stack !== void 0 && { stack: error.stack }
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
function checked(plugin2, name, payload) {
|
|
373
|
+
const publisher = published.get(name);
|
|
374
|
+
if (publisher === void 0) {
|
|
375
|
+
throw new KernelFault("UNDECLARED_EVENT", `"${plugin2}" emitted "${name}", which no plugin declares. Add it to emits.`, { plugin: plugin2 });
|
|
376
|
+
}
|
|
377
|
+
if (publisher.owner !== plugin2) {
|
|
378
|
+
throw new KernelFault("UNDECLARED_EVENT", `"${plugin2}" emitted "${name}", which belongs to "${publisher.owner}". A plugin emits only what it owns.`, { plugin: plugin2, detail: { owner: publisher.owner } });
|
|
379
|
+
}
|
|
380
|
+
const parsed = publisher.event.schema.safeParse(payload);
|
|
381
|
+
if (!parsed.success) {
|
|
382
|
+
throw new KernelFault("WRONG_PAYLOAD", `The payload for "${name}" does not match its schema: ${parsed.error.issues[0]?.message ?? "it was rejected"}.`, { plugin: plugin2 });
|
|
383
|
+
}
|
|
384
|
+
return parsed.data;
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
checked,
|
|
388
|
+
declare: (owner, name, event) => {
|
|
389
|
+
published.set(name, { owner, event });
|
|
390
|
+
},
|
|
391
|
+
listen: (plugin2, name, listener) => {
|
|
392
|
+
subscribers.set(name, [...subscribers.get(name) ?? [], { plugin: plugin2, listener }]);
|
|
393
|
+
},
|
|
394
|
+
// A listener that throws is recorded and reaches neither the emitter
|
|
395
|
+
// nor the ones behind it: one plugin's bug is not another's failure.
|
|
396
|
+
/**
|
|
397
|
+
* Calls every listener, and answers when they have all settled.
|
|
398
|
+
*
|
|
399
|
+
* The emitter never waits on this: it returns void from `emit`, and
|
|
400
|
+
* one plugin's slow listener is not another's slow request. What does
|
|
401
|
+
* wait is an outbox, which cannot forget an event until something has
|
|
402
|
+
* actually heard it.
|
|
403
|
+
*/
|
|
404
|
+
deliver: (plugin2, name, payload, ctx) => {
|
|
405
|
+
const heard = [];
|
|
406
|
+
for (const subscriber of subscribers.get(name) ?? []) {
|
|
407
|
+
if (subscriber.plugin === plugin2) {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
const handling = subscriber.listener.handle(payload, ctx(subscriber.plugin));
|
|
412
|
+
heard.push(Promise.resolve(handling).catch((error) => {
|
|
413
|
+
record(name, subscriber.plugin, error);
|
|
414
|
+
}));
|
|
415
|
+
} catch (error) {
|
|
416
|
+
record(name, subscriber.plugin, error);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return Promise.all(heard).then(() => void 0);
|
|
420
|
+
},
|
|
421
|
+
failures: () => {
|
|
422
|
+
return [...failures];
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/plugins/kernel/internal/hooks.ts
|
|
428
|
+
var LATE = /* @__PURE__ */ Symbol("late");
|
|
429
|
+
var PATIENCE = 5e3;
|
|
430
|
+
function hooks(patience = PATIENCE) {
|
|
431
|
+
const opened = /* @__PURE__ */ new Map();
|
|
432
|
+
const joined = /* @__PURE__ */ new Map();
|
|
433
|
+
return {
|
|
434
|
+
declare: (owner, name, hook) => {
|
|
435
|
+
opened.set(name, { owner, hook });
|
|
436
|
+
},
|
|
437
|
+
participate: (plugin2, name, participant) => {
|
|
438
|
+
joined.set(name, [...joined.get(name) ?? [], { plugin: plugin2, participant }]);
|
|
439
|
+
},
|
|
440
|
+
run: async (plugin2, name, payload, ctx) => {
|
|
441
|
+
const point = opened.get(name);
|
|
442
|
+
if (point === void 0) {
|
|
443
|
+
throw new KernelFault("UNDECLARED_HOOK", `"${plugin2}" ran "${name}", which no plugin declares. Add it to hooks.`, { plugin: plugin2 });
|
|
444
|
+
}
|
|
445
|
+
if (point.owner !== plugin2) {
|
|
446
|
+
throw new KernelFault("UNDECLARED_HOOK", `"${plugin2}" ran "${name}", which belongs to "${point.owner}". A plugin runs only the hooks it owns.`, { plugin: plugin2, detail: { owner: point.owner } });
|
|
447
|
+
}
|
|
448
|
+
const parsed = point.hook.schema.safeParse(payload);
|
|
449
|
+
if (!parsed.success) {
|
|
450
|
+
throw new KernelFault("WRONG_PAYLOAD", `The payload for "${name}" does not match its schema: ${parsed.error.issues[0]?.message ?? "it was rejected"}.`, { plugin: plugin2 });
|
|
451
|
+
}
|
|
452
|
+
for (const participant of joined.get(name) ?? []) {
|
|
453
|
+
let timer;
|
|
454
|
+
try {
|
|
455
|
+
const answered = participant.participant.handle(parsed.data, ctx(participant.plugin));
|
|
456
|
+
const refusal = await Promise.race([
|
|
457
|
+
answered,
|
|
458
|
+
new Promise((keep) => {
|
|
459
|
+
timer = setTimeout(() => keep(LATE), patience);
|
|
460
|
+
})
|
|
461
|
+
]);
|
|
462
|
+
if (refusal === LATE) {
|
|
463
|
+
return `"${participant.plugin}" did not answer in ${String(patience)}ms.`;
|
|
464
|
+
}
|
|
465
|
+
if (refusal !== void 0) {
|
|
466
|
+
return refusal;
|
|
467
|
+
}
|
|
468
|
+
} catch (cause) {
|
|
469
|
+
return `"${participant.plugin}" refused: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
470
|
+
} finally {
|
|
471
|
+
clearTimeout(timer);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return void 0;
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// src/plugins/kernel/internal/order.ts
|
|
480
|
+
function order(known) {
|
|
481
|
+
const sorted = [];
|
|
482
|
+
const state = /* @__PURE__ */ new Map();
|
|
483
|
+
function walk(name) {
|
|
484
|
+
if (state.get(name) !== void 0) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
state.set(name, "open");
|
|
488
|
+
const plugin2 = known.get(name);
|
|
489
|
+
for (const need of [...plugin2?.definition.dependsOn ?? []].sort()) {
|
|
490
|
+
if (known.has(need)) {
|
|
491
|
+
walk(need);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
state.set(name, "done");
|
|
495
|
+
if (plugin2 !== void 0) {
|
|
496
|
+
sorted.push(plugin2);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
for (const name of [...known.keys()].sort()) {
|
|
500
|
+
walk(name);
|
|
501
|
+
}
|
|
502
|
+
return sorted;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// src/plugins/kernel/internal/request.ts
|
|
506
|
+
var unknownRoute = {
|
|
507
|
+
status: 404,
|
|
508
|
+
body: { code: "NOT_FOUND", message: "No such route." }
|
|
509
|
+
};
|
|
510
|
+
var notServing = {
|
|
511
|
+
status: 503,
|
|
512
|
+
body: { code: "NOT_SERVING", message: "The service is shutting down." }
|
|
513
|
+
};
|
|
514
|
+
async function respond(mounted, incoming, context2, log, budget) {
|
|
515
|
+
const { plugin: plugin2, route } = mounted;
|
|
516
|
+
const caller = incoming.caller;
|
|
517
|
+
const may = permissions(() => caller);
|
|
518
|
+
const who = caller?.id !== void 0 && caller.id.trim() !== "" ? caller.id : void 0;
|
|
519
|
+
try {
|
|
520
|
+
if (route.public !== true && who === void 0) {
|
|
521
|
+
throw new KernelFault("UNAUTHENTICATED", `${route.method} ${route.path} needs a caller.`, { plugin: plugin2 });
|
|
522
|
+
}
|
|
523
|
+
if (route.limit !== void 0 && budget !== void 0) {
|
|
524
|
+
const verdict = budget.take(`${who ?? incoming.from ?? "anonymous"}:${route.method} ${route.path}`, route.limit);
|
|
525
|
+
if (!verdict.allowed) {
|
|
526
|
+
return {
|
|
527
|
+
status: 429,
|
|
528
|
+
body: { code: "RATE_LIMITED", message: "Too many requests. Try again shortly." },
|
|
529
|
+
headers: { "retry-after": String(verdict.resetsIn) }
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
const lacking = (route.requires ?? []).filter((permission) => !may.has(permission));
|
|
534
|
+
if (lacking.length > 0) {
|
|
535
|
+
throw new KernelFault(
|
|
536
|
+
"PERMISSION_DENIED",
|
|
537
|
+
`${route.method} ${route.path} needs ${lacking.map((permission) => `"${permission}"`).join(", ")}.`,
|
|
538
|
+
{ plugin: plugin2, detail: { lacking } }
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
const parsed = route.input.safeParse(incoming.input);
|
|
542
|
+
if (!parsed.success) {
|
|
543
|
+
throw new Refusal(400, "INVALID_INPUT", "The request is not valid.", fields(parsed.error));
|
|
544
|
+
}
|
|
545
|
+
const returned = await route.handle(parsed.data, context2(plugin2, caller, declared(route, incoming.headers)));
|
|
546
|
+
const carried = returned instanceof Answered ? returned : void 0;
|
|
547
|
+
const filtered = route.output.safeParse(carried === void 0 ? returned : carried.body);
|
|
548
|
+
if (!filtered.success) {
|
|
549
|
+
log("error", plugin2, `${route.method} ${route.path} returned what its output schema refuses`, {
|
|
550
|
+
issues: filtered.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
551
|
+
});
|
|
552
|
+
return { status: 500, body: { code: "INTERNAL", message: "The request could not be completed." } };
|
|
553
|
+
}
|
|
554
|
+
if (carried !== void 0) {
|
|
555
|
+
return { status: carried.status, body: filtered.data, headers: sendable(carried.headers, plugin2, route, log) };
|
|
556
|
+
}
|
|
557
|
+
return { status: route.method === "POST" ? 201 : 200, body: filtered.data };
|
|
558
|
+
} catch (cause) {
|
|
559
|
+
const refusal = answer(cause);
|
|
560
|
+
if (refusal.status >= 500) {
|
|
561
|
+
log("error", plugin2, `${route.method} ${route.path} threw`, told(cause));
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
status: refusal.status,
|
|
565
|
+
body: {
|
|
566
|
+
code: refusal.code,
|
|
567
|
+
message: refusal.message,
|
|
568
|
+
...refusal.fields !== void 0 && { fields: refusal.fields }
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
function told(cause) {
|
|
574
|
+
if (cause instanceof Error) {
|
|
575
|
+
return {
|
|
576
|
+
error: cause.message,
|
|
577
|
+
kind: cause.name,
|
|
578
|
+
...cause.stack !== void 0 && { stack: cause.stack },
|
|
579
|
+
...cause.cause !== void 0 && { cause: told(cause.cause) }
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
return { error: String(cause) };
|
|
583
|
+
}
|
|
584
|
+
var KEPT2 = /* @__PURE__ */ new Set([
|
|
585
|
+
"set-cookie",
|
|
586
|
+
"content-security-policy",
|
|
587
|
+
"x-content-type-options",
|
|
588
|
+
"x-frame-options",
|
|
589
|
+
"access-control-allow-origin",
|
|
590
|
+
"access-control-allow-credentials"
|
|
591
|
+
]);
|
|
592
|
+
function sendable(asked, plugin2, route, log) {
|
|
593
|
+
const sending = {};
|
|
594
|
+
for (const [name, value] of Object.entries(asked)) {
|
|
595
|
+
const lower = name.toLowerCase();
|
|
596
|
+
if (KEPT2.has(lower)) {
|
|
597
|
+
log("warn", plugin2, `${route.method} ${route.path} tried to set "${lower}", which the kit answers for`);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (/[\r\n]/.test(value)) {
|
|
601
|
+
log("warn", plugin2, `${route.method} ${route.path} tried to set "${lower}" to a value carrying a newline`);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
sending[lower] = value;
|
|
605
|
+
}
|
|
606
|
+
return sending;
|
|
607
|
+
}
|
|
608
|
+
function declared(route, sent = {}) {
|
|
609
|
+
const sending = {};
|
|
610
|
+
for (const name of route.reads ?? []) {
|
|
611
|
+
const value = sent[name];
|
|
612
|
+
if (value !== void 0) {
|
|
613
|
+
sending[name] = value;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return sending;
|
|
617
|
+
}
|
|
618
|
+
function fields(error) {
|
|
619
|
+
const named2 = {};
|
|
620
|
+
for (const issue of error.issues) {
|
|
621
|
+
const at = issue.path.map((segment) => String(segment)).join(".");
|
|
622
|
+
if (at !== "" && named2[at] === void 0) {
|
|
623
|
+
named2[at] = issue.message;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return named2;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// src/plugins/kernel/internal/names.ts
|
|
630
|
+
var PLUGIN = /^[a-z][a-z0-9-]{0,63}$/;
|
|
631
|
+
var NAMESPACED = /^[a-z][a-z0-9-]{0,63}(\.[a-z][a-z0-9-]{0,63})+$/;
|
|
632
|
+
function describe(value) {
|
|
633
|
+
const at = [...value].findIndex((character) => !/[a-z0-9.-]/.test(character));
|
|
634
|
+
return at === -1 ? `"${value}"` : `"${value}" (unsupported character at position ${at + 1}: "${value[at]}")`;
|
|
635
|
+
}
|
|
636
|
+
function plugin(value) {
|
|
637
|
+
if (!PLUGIN.test(value)) {
|
|
638
|
+
throw new KernelFault(
|
|
639
|
+
"INVALID_NAME",
|
|
640
|
+
`A plugin name is lowercase letters, digits and hyphens, starting with a letter, up to 64 characters. Received ${describe(value)}.`,
|
|
641
|
+
{ detail: { received: value } }
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
return value;
|
|
645
|
+
}
|
|
646
|
+
function namespaced(value, kind, owner) {
|
|
647
|
+
if (!NAMESPACED.test(value)) {
|
|
648
|
+
throw new KernelFault(
|
|
649
|
+
"INVALID_NAME",
|
|
650
|
+
`A ${kind} name is dot-separated lowercase segments, such as "${owner}.thing". Received ${describe(value)}.`,
|
|
651
|
+
{ plugin: owner, detail: { received: value, kind } }
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
if (!value.startsWith(`${owner}.`)) {
|
|
655
|
+
throw new KernelFault(
|
|
656
|
+
"INVALID_NAME",
|
|
657
|
+
`A ${kind} is named inside its own plugin: "${value}" belongs to "${value.split(".")[0] ?? ""}", not to "${owner}". Rename it to "${owner}.${value.split(".").slice(1).join(".")}".`,
|
|
658
|
+
{ plugin: owner, detail: { received: value, kind, owner } }
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
return value;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// src/plugins/kernel/internal/output.ts
|
|
665
|
+
function filters(schema) {
|
|
666
|
+
return whitelists(schema, 0, /* @__PURE__ */ new Set());
|
|
667
|
+
}
|
|
668
|
+
var OPEN = /* @__PURE__ */ new Set(["any", "unknown", "record", "map", "custom", "never", "void", "transform", "pipe", "promise", "function", "file", "symbol"]);
|
|
669
|
+
function kindOf(schema) {
|
|
670
|
+
if (schema === null || typeof schema !== "object") {
|
|
671
|
+
return "";
|
|
672
|
+
}
|
|
673
|
+
return String(schema._zod?.def?.type ?? "");
|
|
674
|
+
}
|
|
675
|
+
function whitelists(schema, depth, seen) {
|
|
676
|
+
if (depth > 24 || schema === null || typeof schema !== "object") {
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
if (seen.has(schema)) {
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
seen.add(schema);
|
|
683
|
+
const zod = schema;
|
|
684
|
+
const def = zod._zod?.def;
|
|
685
|
+
if (def === void 0) {
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
const kind = String(def["type"] ?? "");
|
|
689
|
+
if (OPEN.has(kind)) {
|
|
690
|
+
return false;
|
|
691
|
+
}
|
|
692
|
+
switch (kind) {
|
|
693
|
+
case "object": {
|
|
694
|
+
const catchall = def["catchall"];
|
|
695
|
+
if (catchall !== void 0 && kindOf(catchall) !== "never") {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
const shape = def["shape"];
|
|
699
|
+
if (shape === null || typeof shape !== "object") {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
return Object.values(shape).every((field) => whitelists(field, depth + 1, seen));
|
|
703
|
+
}
|
|
704
|
+
case "array":
|
|
705
|
+
case "set": {
|
|
706
|
+
return whitelists(def["element"], depth + 1, seen);
|
|
707
|
+
}
|
|
708
|
+
case "tuple": {
|
|
709
|
+
const items = Array.isArray(def["items"]) ? def["items"] : [];
|
|
710
|
+
return items.every((item) => whitelists(item, depth + 1, seen)) && def["rest"] === void 0;
|
|
711
|
+
}
|
|
712
|
+
case "union": {
|
|
713
|
+
const options = Array.isArray(def["options"]) ? def["options"] : [];
|
|
714
|
+
return options.length > 0 && options.every((option) => whitelists(option, depth + 1, seen));
|
|
715
|
+
}
|
|
716
|
+
case "intersection": {
|
|
717
|
+
return whitelists(def["left"], depth + 1, seen) && whitelists(def["right"], depth + 1, seen);
|
|
718
|
+
}
|
|
719
|
+
case "optional":
|
|
720
|
+
case "nullable":
|
|
721
|
+
case "default":
|
|
722
|
+
case "prefault":
|
|
723
|
+
case "readonly":
|
|
724
|
+
case "nonoptional":
|
|
725
|
+
case "catch": {
|
|
726
|
+
return whitelists(def["innerType"], depth + 1, seen);
|
|
727
|
+
}
|
|
728
|
+
// A recursive shape: the schema is behind a getter, so it has to be
|
|
729
|
+
// called. Depth stops the walk before the recursion does, and a tree
|
|
730
|
+
// that deep is one nobody reviews anyway.
|
|
731
|
+
case "lazy": {
|
|
732
|
+
const getter = def["getter"];
|
|
733
|
+
if (typeof getter !== "function") {
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
try {
|
|
737
|
+
return whitelists(getter(), depth + 1, seen);
|
|
738
|
+
} catch {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
// A leaf: it carries its own value and nothing of the caller's shape.
|
|
743
|
+
default: {
|
|
744
|
+
return true;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// src/plugins/kernel/internal/validate.ts
|
|
750
|
+
function validate(plugins, config) {
|
|
751
|
+
const wrong = [];
|
|
752
|
+
const say = (code, plugin2, message) => {
|
|
753
|
+
wrong.push({ code, plugin: plugin2, message });
|
|
754
|
+
};
|
|
755
|
+
const by = /* @__PURE__ */ new Map();
|
|
756
|
+
for (const plugin2 of plugins) {
|
|
757
|
+
if (by.has(plugin2.name)) {
|
|
758
|
+
say("DUPLICATE_PLUGIN", plugin2.name, `Two plugins are named "${plugin2.name}". A name is what everything else refers to, so it must be unique.`);
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
by.set(plugin2.name, plugin2);
|
|
762
|
+
}
|
|
763
|
+
const owned = {
|
|
764
|
+
routes: /* @__PURE__ */ new Map(),
|
|
765
|
+
events: /* @__PURE__ */ new Map(),
|
|
766
|
+
hooks: /* @__PURE__ */ new Map(),
|
|
767
|
+
commands: /* @__PURE__ */ new Map(),
|
|
768
|
+
permissions: /* @__PURE__ */ new Map(),
|
|
769
|
+
tables: /* @__PURE__ */ new Map()
|
|
770
|
+
};
|
|
771
|
+
for (const [name, plugin2] of by) {
|
|
772
|
+
declares(name, plugin2, owned, say);
|
|
773
|
+
}
|
|
774
|
+
for (const [name, plugin2] of by) {
|
|
775
|
+
refers(name, plugin2, by, owned, say);
|
|
776
|
+
settings(name, plugin2, config, say);
|
|
777
|
+
}
|
|
778
|
+
cycles(by, say);
|
|
779
|
+
return wrong;
|
|
780
|
+
}
|
|
781
|
+
function declares(name, plugin2, owned, say) {
|
|
782
|
+
const claim = (kind, key, code, label) => {
|
|
783
|
+
const first = owned[kind].get(key);
|
|
784
|
+
if (first !== void 0) {
|
|
785
|
+
say(code, name, `${label} "${key}" is already declared by "${first}". Two plugins cannot own one name.`);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
owned[kind].set(key, name);
|
|
789
|
+
};
|
|
790
|
+
for (const key of Object.keys(plugin2.definition.permissions ?? {})) {
|
|
791
|
+
named(name, key, "permission", say) && claim("permissions", key, "DUPLICATE_PERMISSION", "Permission");
|
|
792
|
+
}
|
|
793
|
+
for (const key of Object.keys(plugin2.definition.emits ?? {})) {
|
|
794
|
+
named(name, key, "event", say) && claim("events", key, "DUPLICATE_EVENT", "Event");
|
|
795
|
+
}
|
|
796
|
+
for (const key of Object.keys(plugin2.definition.hooks ?? {})) {
|
|
797
|
+
named(name, key, "hook", say) && claim("hooks", key, "DUPLICATE_HOOK", "Hook");
|
|
798
|
+
}
|
|
799
|
+
for (const key of Object.keys(plugin2.definition.commands ?? {})) {
|
|
800
|
+
named(name, key, "command", say) && claim("commands", key, "DUPLICATE_COMMAND", "Command");
|
|
801
|
+
}
|
|
802
|
+
for (const key of Object.keys(plugin2.definition.tables ?? {})) {
|
|
803
|
+
claim("tables", key, "DUPLICATE_TABLE", "Table");
|
|
804
|
+
}
|
|
805
|
+
for (const route of plugin2.definition.routes ?? []) {
|
|
806
|
+
path(name, route.method, route.path, owned, say);
|
|
807
|
+
if (route.describe.trim() === "") {
|
|
808
|
+
say("INVALID_ROUTE", name, `Route ${route.method} "${route.path}" has no description. A route nobody described is one nobody can review.`);
|
|
809
|
+
}
|
|
810
|
+
if (!filters(route.output)) {
|
|
811
|
+
say("INVALID_OUTPUT", name, `Route ${route.method} "${route.path}" has an output schema that cannot filter what leaves. Use z.object naming every field that may be sent: it strips the rest. z.any, z.unknown, z.record, z.looseObject, a catchall and a transform all forward whatever the handler returned.`);
|
|
812
|
+
}
|
|
813
|
+
limits(name, route, say);
|
|
814
|
+
reads(name, route, say);
|
|
815
|
+
}
|
|
816
|
+
const scope = plugin2.definition.scope;
|
|
817
|
+
if (scope !== void 0) {
|
|
818
|
+
const owns = plugin2.definition.tables ?? {};
|
|
819
|
+
if (scope.claim.trim() === "") {
|
|
820
|
+
say("UNDECLARED_SCOPE", name, "A scope names the claim it reads. An empty one reads nothing.");
|
|
821
|
+
}
|
|
822
|
+
for (const table of Object.keys(scope.tables)) {
|
|
823
|
+
if (!(table in owns)) {
|
|
824
|
+
say("UNDECLARED_SCOPE", name, `Scope names "${table}", which is not one of this plugin's tables. A plugin scopes only what it owns.`);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (Object.keys(scope.tables).length === 0) {
|
|
828
|
+
say("UNDECLARED_SCOPE", name, "A scope names no table, so nothing is scoped. Name the tables that carry the claim, or remove it.");
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
for (const host of plugin2.definition.outbound ?? []) {
|
|
832
|
+
const wrong = unreachable(host);
|
|
833
|
+
if (wrong !== void 0) {
|
|
834
|
+
say("UNDECLARED_HOST", name, wrong);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
if (!/^\d+\.\d+\.\d+/.test(plugin2.definition.version)) {
|
|
838
|
+
say("INVALID_NAME", name, `Version "${plugin2.definition.version}" is not a version. Use major.minor.patch.`);
|
|
839
|
+
}
|
|
840
|
+
if (plugin2.definition.describe.trim() === "") {
|
|
841
|
+
say("INVALID_NAME", name, "A plugin describes itself in one sentence. An empty description tells the next reader nothing.");
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
var SECRET = /* @__PURE__ */ new Set(["cookie", "authorization", "proxy-authorization", "set-cookie"]);
|
|
845
|
+
function reads(name, route, say) {
|
|
846
|
+
for (const header of route.reads ?? []) {
|
|
847
|
+
if (header !== header.toLowerCase()) {
|
|
848
|
+
say("INVALID_ROUTE", name, `Route ${route.method} "${route.path}" reads "${header}". Header names are matched lowercase.`);
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
if (SECRET.has(header)) {
|
|
852
|
+
say("INVALID_ROUTE", name, `Route ${route.method} "${route.path}" reads "${header}", which carries a credential. Whoever identifies the caller reads it; a handler holding it would log it.`);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
var SCHEMES = /* @__PURE__ */ new Set([
|
|
857
|
+
"https",
|
|
858
|
+
// an api, and what ctx.fetch dials
|
|
859
|
+
"wss",
|
|
860
|
+
// a socket, encrypted
|
|
861
|
+
"redis",
|
|
862
|
+
"rediss",
|
|
863
|
+
"postgres",
|
|
864
|
+
"postgresql",
|
|
865
|
+
"mysql",
|
|
866
|
+
"mongodb",
|
|
867
|
+
"mongodb+srv",
|
|
868
|
+
"amqp",
|
|
869
|
+
"amqps",
|
|
870
|
+
"grpc",
|
|
871
|
+
"grpcs"
|
|
872
|
+
]);
|
|
873
|
+
var PLAIN = /* @__PURE__ */ new Set(["http", "ws", "ftp"]);
|
|
874
|
+
function unreachable(host) {
|
|
875
|
+
const at = host.indexOf("://");
|
|
876
|
+
if (at === -1) {
|
|
877
|
+
return `Outbound host "${host}" names no scheme. Write it as an origin, such as "https://api.stripe.com" or "redis://cache.internal:6379".`;
|
|
878
|
+
}
|
|
879
|
+
const scheme = host.slice(0, at).toLowerCase();
|
|
880
|
+
const rest = host.slice(at + 3);
|
|
881
|
+
if (PLAIN.has(scheme)) {
|
|
882
|
+
return `Outbound host "${host}" is not encrypted. Use "${scheme}s://" instead: what travels over ${scheme} travels in the clear, credentials included.`;
|
|
883
|
+
}
|
|
884
|
+
if (!SCHEMES.has(scheme)) {
|
|
885
|
+
return `Outbound host "${host}" uses a scheme this kit does not know. Declared hosts are one of: ${[...SCHEMES].join(", ")}.`;
|
|
886
|
+
}
|
|
887
|
+
if (!/^[a-z0-9._-]+(:\d+)?$/i.test(rest)) {
|
|
888
|
+
return `Outbound host "${host}" is not an origin. Declare the host it reaches, such as "${scheme}://cache.internal:6379", and no path.`;
|
|
889
|
+
}
|
|
890
|
+
return void 0;
|
|
891
|
+
}
|
|
892
|
+
function limits(name, route, say) {
|
|
893
|
+
const limit = route.limit;
|
|
894
|
+
if (limit === void 0) {
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (!Number.isInteger(limit.requests) || limit.requests < 1) {
|
|
898
|
+
say("INVALID_ROUTE", name, `Route ${route.method} "${route.path}" declares ${limit.requests} requests per window. A budget under one refuses everything, including the caller who set it.`);
|
|
899
|
+
}
|
|
900
|
+
if (!Number.isInteger(limit.seconds) || limit.seconds < 1) {
|
|
901
|
+
say("INVALID_ROUTE", name, `Route ${route.method} "${route.path}" declares a window of ${limit.seconds} seconds. A window without length is one that never resets.`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
function path(name, method, given, owned, say) {
|
|
905
|
+
if (!given.startsWith("/")) {
|
|
906
|
+
say("INVALID_ROUTE", name, `Route path "${given}" must start with "/".`);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (/\s/.test(given)) {
|
|
910
|
+
say("INVALID_ROUTE", name, `Route path "${given}" contains whitespace.`);
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
if (given.includes("//") || given.length > 1 && given.endsWith("/")) {
|
|
914
|
+
say("INVALID_ROUTE", name, `Route path "${given}" has an empty segment. Two paths differing only by a slash are one route to a caller and two to a router.`);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
for (const segment of given.split("/").slice(1)) {
|
|
918
|
+
const named2 = segment.startsWith(":") ? /^:[a-zA-Z][a-zA-Z0-9]*$/.test(segment) : /^[a-z0-9][a-z0-9-]*$/.test(segment);
|
|
919
|
+
if (!named2) {
|
|
920
|
+
say("INVALID_ROUTE", name, segment.startsWith(":") ? `Route path "${given}" names a parameter "${segment}", which is not letters and digits starting with a letter.` : `Route path "${given}" has a segment "${segment}" outside lowercase letters, digits and hyphens.`);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const shape = `${method} ${given.replace(/:[a-zA-Z0-9]+/g, ":*")}`;
|
|
925
|
+
const first = owned.routes.get(shape);
|
|
926
|
+
if (first !== void 0) {
|
|
927
|
+
say("DUPLICATE_ROUTE", name, `Route ${method} "${given}" is already declared by "${first}". Which one answers would depend on order.`);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
owned.routes.set(shape, name);
|
|
931
|
+
}
|
|
932
|
+
function named(owner, key, kind, say) {
|
|
933
|
+
try {
|
|
934
|
+
namespaced(key, kind, owner);
|
|
935
|
+
return true;
|
|
936
|
+
} catch (cause) {
|
|
937
|
+
say("INVALID_NAME", owner, cause instanceof Error ? cause.message : String(cause));
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
function refers(name, plugin2, by, owned, say) {
|
|
942
|
+
const declared2 = new Set(plugin2.definition.dependsOn ?? []);
|
|
943
|
+
const exists = (kind, key, code, label) => {
|
|
944
|
+
if (owned[kind].get(key) === void 0) {
|
|
945
|
+
const owner = key.split(".")[0] ?? "";
|
|
946
|
+
const missing = owner !== "" && owner !== name && !by.has(owner) ? ` "${owner}" would declare it and was not given to createKernel: pass it too, which a test of a listener has to do.` : "";
|
|
947
|
+
say(code, name, `${label} "${key}" is not declared by any plugin. Declare it, or correct the name.${missing}`);
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
for (const need of declared2) {
|
|
951
|
+
if (!by.has(need)) {
|
|
952
|
+
say("UNKNOWN_DEPENDENCY", name, `"${name}" depends on "${need}", which no plugin provides. Pass it to createKernel, or remove it from dependsOn.`);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
const reach = (kind, key, code, label) => {
|
|
956
|
+
const from = owned[kind].get(key);
|
|
957
|
+
if (from === void 0) {
|
|
958
|
+
say(code, name, `${label} "${key}" is not declared by any plugin. Declare it, or correct the name.`);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (from !== name && !declared2.has(from)) {
|
|
962
|
+
say("UNDECLARED_DEPENDENCY", name, `${label} "${key}" belongs to "${from}", which "${name}" does not depend on. Add "${from}" to dependsOn.`);
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
for (const key of Object.keys(plugin2.definition.listens ?? {})) {
|
|
966
|
+
exists("events", key, "UNDECLARED_EVENT", "Event");
|
|
967
|
+
}
|
|
968
|
+
for (const key of Object.keys(plugin2.definition.participates ?? {})) {
|
|
969
|
+
exists("hooks", key, "UNDECLARED_HOOK", "Hook");
|
|
970
|
+
}
|
|
971
|
+
for (const route of plugin2.definition.routes ?? []) {
|
|
972
|
+
for (const permission of route.requires ?? []) {
|
|
973
|
+
reach("permissions", permission, "UNDECLARED_PERMISSION", "Permission");
|
|
974
|
+
}
|
|
975
|
+
if (route.public === true && (route.requires ?? []).length > 0) {
|
|
976
|
+
say("INVALID_ROUTE", name, `Route ${route.method} "${route.path}" is public and also requires ${(route.requires ?? []).map((one) => `"${one}"`).join(", ")}. It is one or the other.`);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
for (const one of Object.values(plugin2.definition.commands ?? {})) {
|
|
980
|
+
for (const permission of one.requires ?? []) {
|
|
981
|
+
reach("permissions", permission, "UNDECLARED_PERMISSION", "Permission");
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
function settings(name, plugin2, config, say) {
|
|
986
|
+
const schema = plugin2.definition.config;
|
|
987
|
+
if (schema === void 0) {
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
const answered = schema.safeParse(config[name] ?? {});
|
|
991
|
+
if (!answered.success) {
|
|
992
|
+
const first = answered.error.issues[0];
|
|
993
|
+
const at = first === void 0 || first.path.length === 0 ? "" : ` at "${first.path.join(".")}"`;
|
|
994
|
+
say("INVALID_CONFIG", name, `Config for "${name}" is invalid${at}: ${first?.message ?? "it does not match the schema"}.`);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
function cycles(by, say) {
|
|
998
|
+
const state = /* @__PURE__ */ new Map();
|
|
999
|
+
const walking = [];
|
|
1000
|
+
const reported = /* @__PURE__ */ new Set();
|
|
1001
|
+
function walk(name) {
|
|
1002
|
+
if (state.get(name) === "done") {
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (state.get(name) === "open") {
|
|
1006
|
+
const at = walking.indexOf(name);
|
|
1007
|
+
const loop = [...walking.slice(at === -1 ? 0 : at), name];
|
|
1008
|
+
const key = [...loop].sort().join(",");
|
|
1009
|
+
if (!reported.has(key)) {
|
|
1010
|
+
reported.add(key);
|
|
1011
|
+
say("DEPENDENCY_CYCLE", name, `Plugins depend on each other in a loop: ${loop.join(" -> ")}. One of them has to stop.`);
|
|
1012
|
+
}
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
state.set(name, "open");
|
|
1016
|
+
walking.push(name);
|
|
1017
|
+
for (const need of [...by.get(name)?.definition.dependsOn ?? []].sort()) {
|
|
1018
|
+
if (by.has(need)) {
|
|
1019
|
+
walk(need);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
walking.pop();
|
|
1023
|
+
state.set(name, "done");
|
|
1024
|
+
}
|
|
1025
|
+
for (const name of [...by.keys()].sort()) {
|
|
1026
|
+
walk(name);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// src/plugins/kernel/internal/kernel.ts
|
|
1031
|
+
var quiet = () => {
|
|
1032
|
+
};
|
|
1033
|
+
function readable(given) {
|
|
1034
|
+
try {
|
|
1035
|
+
return decodeURIComponent(given);
|
|
1036
|
+
} catch {
|
|
1037
|
+
return void 0;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
function matched(routes, method, path2) {
|
|
1041
|
+
const exact = routes.get(`${method} ${path2}`);
|
|
1042
|
+
if (exact !== void 0) {
|
|
1043
|
+
return { mounted: exact, params: {} };
|
|
1044
|
+
}
|
|
1045
|
+
const asked = path2.split("/");
|
|
1046
|
+
for (const [key, mounted] of routes) {
|
|
1047
|
+
const [verb, declared2] = key.split(" ");
|
|
1048
|
+
if (verb !== method || declared2 === void 0) {
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
const parts = declared2.split("/");
|
|
1052
|
+
if (parts.length !== asked.length) {
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
const params = {};
|
|
1056
|
+
const fits = parts.every((part, at) => {
|
|
1057
|
+
const given = asked[at] ?? "";
|
|
1058
|
+
if (!part.startsWith(":")) {
|
|
1059
|
+
return part === given;
|
|
1060
|
+
}
|
|
1061
|
+
const value = readable(given);
|
|
1062
|
+
if (value === void 0) {
|
|
1063
|
+
return false;
|
|
1064
|
+
}
|
|
1065
|
+
params[part.slice(1)] = value;
|
|
1066
|
+
return given !== "";
|
|
1067
|
+
});
|
|
1068
|
+
if (fits) {
|
|
1069
|
+
return { mounted, params };
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return void 0;
|
|
1073
|
+
}
|
|
1074
|
+
function taken(input, params) {
|
|
1075
|
+
if (Object.keys(params).length === 0) {
|
|
1076
|
+
return input;
|
|
1077
|
+
}
|
|
1078
|
+
const given = input !== null && typeof input === "object" && !Array.isArray(input) ? { ...input } : {};
|
|
1079
|
+
return { ...given, ...params };
|
|
1080
|
+
}
|
|
1081
|
+
function createKernel(options) {
|
|
1082
|
+
const config = options.config ?? {};
|
|
1083
|
+
const log = options.log ?? quiet;
|
|
1084
|
+
const known = new Map(options.plugins.map((one) => [one.name, one]));
|
|
1085
|
+
const bus = events(Date.now, (plugin2, line, about) => {
|
|
1086
|
+
log("error", plugin2, line, about);
|
|
1087
|
+
});
|
|
1088
|
+
const points = hooks(options.patience);
|
|
1089
|
+
const parsed = /* @__PURE__ */ new Map();
|
|
1090
|
+
const pending = /* @__PURE__ */ new Map();
|
|
1091
|
+
const routes = /* @__PURE__ */ new Map();
|
|
1092
|
+
const commands = /* @__PURE__ */ new Map();
|
|
1093
|
+
let running = false;
|
|
1094
|
+
let beating;
|
|
1095
|
+
async function due() {
|
|
1096
|
+
if (options.schedule === void 0 || !running) {
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
const clock = options.now ?? Date.now;
|
|
1100
|
+
const taken2 = await options.schedule.take(clock(), 20);
|
|
1101
|
+
for (const job of taken2) {
|
|
1102
|
+
try {
|
|
1103
|
+
await run(job.command, job.input);
|
|
1104
|
+
await options.schedule.done(job.id);
|
|
1105
|
+
} catch (cause) {
|
|
1106
|
+
log("error", job.plugin, "a scheduled command failed", {
|
|
1107
|
+
command: job.command,
|
|
1108
|
+
attempts: job.attempts + 1,
|
|
1109
|
+
error: cause instanceof Error ? cause.message : String(cause)
|
|
1110
|
+
});
|
|
1111
|
+
if (job.attempts + 1 >= (options.attempts ?? 8)) {
|
|
1112
|
+
log("error", job.plugin, "a scheduled command gave up", {
|
|
1113
|
+
command: job.command,
|
|
1114
|
+
attempts: job.attempts + 1
|
|
1115
|
+
});
|
|
1116
|
+
await options.schedule.gaveUp(job.id);
|
|
1117
|
+
} else {
|
|
1118
|
+
await options.schedule.failed(job.id, clock() + Math.min(2 ** job.attempts, 60) * 1e3);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
1124
|
+
let started = [];
|
|
1125
|
+
const wiring = {
|
|
1126
|
+
known,
|
|
1127
|
+
parsed,
|
|
1128
|
+
open: new AsyncLocalStorage(),
|
|
1129
|
+
config,
|
|
1130
|
+
bus,
|
|
1131
|
+
points,
|
|
1132
|
+
pending,
|
|
1133
|
+
outbox: options.outbox,
|
|
1134
|
+
now: options.now ?? Date.now,
|
|
1135
|
+
schedule: options.schedule,
|
|
1136
|
+
narrow: options.narrow,
|
|
1137
|
+
owned: /* @__PURE__ */ new Map(),
|
|
1138
|
+
db: options.db,
|
|
1139
|
+
dial: options.dial,
|
|
1140
|
+
log,
|
|
1141
|
+
run: (command, input, caller) => run(command, input, caller)
|
|
1142
|
+
};
|
|
1143
|
+
const seenBy = (plugin2, caller, headers) => {
|
|
1144
|
+
return context(wiring, plugin2, caller, void 0, headers);
|
|
1145
|
+
};
|
|
1146
|
+
async function run(command, input, caller) {
|
|
1147
|
+
if (!running) {
|
|
1148
|
+
throw new KernelFault(
|
|
1149
|
+
"NOT_STARTED",
|
|
1150
|
+
`Command "${command}" was run before the kernel started. Every plugin's setup runs first, so a command called from one is too early: reach the service directly instead.`
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
const declared2 = commands.get(command);
|
|
1154
|
+
if (declared2 === void 0) {
|
|
1155
|
+
throw new KernelFault("UNDECLARED_COMMAND", `Command "${command}" is not declared by any plugin.`);
|
|
1156
|
+
}
|
|
1157
|
+
const may = permissions(() => caller);
|
|
1158
|
+
const lacking = declared2.requires.filter((permission) => !may.has(permission));
|
|
1159
|
+
if (lacking.length > 0) {
|
|
1160
|
+
const scheduled = caller === void 0 ? " A scheduled run has no caller, so a command asked for by commands.later declares no requires." : "";
|
|
1161
|
+
throw new KernelFault(
|
|
1162
|
+
"PERMISSION_DENIED",
|
|
1163
|
+
`Command "${command}" needs ${lacking.map((permission) => `"${permission}"`).join(", ")}, which the caller does not have.${scheduled}`,
|
|
1164
|
+
{ plugin: declared2.plugin, detail: { lacking } }
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
const parsed2 = declared2.schema.safeParse(input);
|
|
1168
|
+
if (!parsed2.success) {
|
|
1169
|
+
throw new KernelFault(
|
|
1170
|
+
"INVALID_PAYLOAD",
|
|
1171
|
+
`The input for "${command}" does not match its schema: ${parsed2.error?.issues[0]?.message ?? "it was rejected"}.`,
|
|
1172
|
+
{ plugin: declared2.plugin }
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
await declared2.run(parsed2.data, seenBy(declared2.plugin, caller));
|
|
1176
|
+
}
|
|
1177
|
+
return {
|
|
1178
|
+
started: () => {
|
|
1179
|
+
return running;
|
|
1180
|
+
},
|
|
1181
|
+
async start() {
|
|
1182
|
+
if (running) {
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
const wrong = validate(options.plugins, config);
|
|
1186
|
+
if (wrong.length > 0) {
|
|
1187
|
+
const lines = wrong.map((problem) => ` - [${problem.code}] ${problem.plugin}: ${problem.message}`);
|
|
1188
|
+
throw new KernelFault(
|
|
1189
|
+
wrong[0]?.code ?? "INVALID_CONFIG",
|
|
1190
|
+
`${wrong.length} ${wrong.length === 1 ? "problem" : "problems"} stopped the kernel from starting:
|
|
1191
|
+
${lines.join("\n")}`,
|
|
1192
|
+
{ plugin: wrong[0]?.plugin ?? "", detail: { wrong } }
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
started = order(known);
|
|
1196
|
+
for (const plugin2 of started) {
|
|
1197
|
+
const schema = plugin2.definition.config;
|
|
1198
|
+
if (schema !== void 0) {
|
|
1199
|
+
parsed.set(plugin2.name, schema.parse(config[plugin2.name] ?? {}));
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
for (const plugin2 of started) {
|
|
1203
|
+
for (const [name, event] of Object.entries(plugin2.definition.emits ?? {})) {
|
|
1204
|
+
bus.declare(plugin2.name, name, event);
|
|
1205
|
+
}
|
|
1206
|
+
for (const [name, hook] of Object.entries(plugin2.definition.hooks ?? {})) {
|
|
1207
|
+
points.declare(plugin2.name, name, hook);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
for (const plugin2 of started) {
|
|
1211
|
+
for (const [name, listener] of Object.entries(plugin2.definition.listens ?? {})) {
|
|
1212
|
+
bus.listen(plugin2.name, name, listener);
|
|
1213
|
+
}
|
|
1214
|
+
for (const [name, participant] of Object.entries(plugin2.definition.participates ?? {})) {
|
|
1215
|
+
points.participate(plugin2.name, name, participant);
|
|
1216
|
+
}
|
|
1217
|
+
for (const [name, command] of Object.entries(plugin2.definition.commands ?? {})) {
|
|
1218
|
+
commands.set(name, {
|
|
1219
|
+
plugin: plugin2.name,
|
|
1220
|
+
requires: command.requires ?? [],
|
|
1221
|
+
schema: command.schema,
|
|
1222
|
+
run: command.run
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
for (const route of plugin2.definition.routes ?? []) {
|
|
1226
|
+
routes.set(`${route.method} ${route.path}`, { plugin: plugin2.name, route });
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
if (options.budget === void 0) {
|
|
1230
|
+
const declared2 = [...routes.values()].filter(({ route }) => route.limit !== void 0);
|
|
1231
|
+
if (declared2.length > 0) {
|
|
1232
|
+
const named2 = declared2.map(({ plugin: plugin2, route }) => `${plugin2}: ${route.method} ${route.path}`);
|
|
1233
|
+
throw new KernelFault(
|
|
1234
|
+
"INVALID_ROUTE",
|
|
1235
|
+
`${declared2.length} ${declared2.length === 1 ? "route declares a limit" : "routes declare limits"} and no budget was given to createKernel, so nothing would enforce them:
|
|
1236
|
+
${named2.map((one) => ` - ${one}`).join("\n")}
|
|
1237
|
+
Pass \`budget\`, or remove the limits.`,
|
|
1238
|
+
{ plugin: declared2[0]?.plugin ?? "" }
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
for (const plugin2 of started) {
|
|
1243
|
+
await plugin2.definition.setup?.(seenBy(plugin2.name));
|
|
1244
|
+
}
|
|
1245
|
+
running = true;
|
|
1246
|
+
if (options.schedule !== void 0) {
|
|
1247
|
+
beating = setInterval(() => void due(), options.beat ?? 1e3);
|
|
1248
|
+
beating.unref?.();
|
|
1249
|
+
}
|
|
1250
|
+
const interrupted = await options.outbox?.waiting() ?? [];
|
|
1251
|
+
for (const announcement of interrupted) {
|
|
1252
|
+
log("info", announcement.plugin, "delivering an event that outlived its process", {
|
|
1253
|
+
event: announcement.name
|
|
1254
|
+
});
|
|
1255
|
+
await bus.deliver(announcement.plugin, announcement.name, announcement.payload, (to) => seenBy(to));
|
|
1256
|
+
await options.outbox?.sent(announcement.id);
|
|
1257
|
+
}
|
|
1258
|
+
},
|
|
1259
|
+
async stop() {
|
|
1260
|
+
running = false;
|
|
1261
|
+
if (beating !== void 0) {
|
|
1262
|
+
clearInterval(beating);
|
|
1263
|
+
beating = void 0;
|
|
1264
|
+
}
|
|
1265
|
+
if (inFlight.size > 0) {
|
|
1266
|
+
log("info", "kernel", "waiting for requests in flight", { count: inFlight.size });
|
|
1267
|
+
await Promise.allSettled([...inFlight]);
|
|
1268
|
+
}
|
|
1269
|
+
for (const plugin2 of [...started].reverse()) {
|
|
1270
|
+
try {
|
|
1271
|
+
await plugin2.definition.teardown?.(seenBy(plugin2.name));
|
|
1272
|
+
} catch (cause) {
|
|
1273
|
+
log("error", plugin2.name, "teardown threw", { cause });
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
},
|
|
1277
|
+
routes: () => [...routes.values()].map(({ plugin: plugin2, route }) => ({
|
|
1278
|
+
plugin: plugin2,
|
|
1279
|
+
method: route.method,
|
|
1280
|
+
path: route.path,
|
|
1281
|
+
describe: route.describe,
|
|
1282
|
+
requires: route.requires ?? [],
|
|
1283
|
+
public: route.public === true,
|
|
1284
|
+
limit: route.limit,
|
|
1285
|
+
reads: route.reads ?? []
|
|
1286
|
+
})),
|
|
1287
|
+
handle: (incoming) => {
|
|
1288
|
+
if (!running) {
|
|
1289
|
+
return Promise.resolve(notServing);
|
|
1290
|
+
}
|
|
1291
|
+
const found = matched(routes, incoming.method, incoming.path);
|
|
1292
|
+
if (found === void 0) {
|
|
1293
|
+
return Promise.resolve(unknownRoute);
|
|
1294
|
+
}
|
|
1295
|
+
const answering = respond(
|
|
1296
|
+
found.mounted,
|
|
1297
|
+
{ ...incoming, input: taken(incoming.input, found.params) },
|
|
1298
|
+
seenBy,
|
|
1299
|
+
log,
|
|
1300
|
+
options.budget
|
|
1301
|
+
);
|
|
1302
|
+
inFlight.add(answering);
|
|
1303
|
+
return answering.finally(() => {
|
|
1304
|
+
inFlight.delete(answering);
|
|
1305
|
+
});
|
|
1306
|
+
},
|
|
1307
|
+
context: seenBy,
|
|
1308
|
+
events: { failures: bus.failures },
|
|
1309
|
+
due,
|
|
1310
|
+
run
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// src/plugins/kernel/internal/define.ts
|
|
1315
|
+
function definePlugin(name, definition) {
|
|
1316
|
+
plugin(name);
|
|
1317
|
+
return { name, definition };
|
|
1318
|
+
}
|
|
1319
|
+
definePlugin.over = () => (name, definition) => {
|
|
1320
|
+
plugin(name);
|
|
1321
|
+
return { name, definition };
|
|
1322
|
+
};
|
|
1323
|
+
function defineRoute() {
|
|
1324
|
+
return (route) => {
|
|
1325
|
+
return route;
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
function defineListener() {
|
|
1329
|
+
return (_schema, listener) => {
|
|
1330
|
+
return listener;
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
function defineParticipant() {
|
|
1334
|
+
return (_schema, participant) => {
|
|
1335
|
+
return participant;
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
function defineCommand() {
|
|
1339
|
+
return (command) => {
|
|
1340
|
+
return command;
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
var MigrationFault = class extends Error {
|
|
1344
|
+
plugin;
|
|
1345
|
+
step;
|
|
1346
|
+
constructor(message, plugin2, step) {
|
|
1347
|
+
super(message);
|
|
1348
|
+
this.name = "MigrationFault";
|
|
1349
|
+
this.plugin = plugin2;
|
|
1350
|
+
this.step = step;
|
|
1351
|
+
}
|
|
1352
|
+
};
|
|
1353
|
+
var NAMED = /^(\d{4})-[a-z0-9][a-z0-9-]*\.sql$/;
|
|
1354
|
+
var LEDGER = `
|
|
1355
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
1356
|
+
plugin TEXT NOT NULL,
|
|
1357
|
+
name TEXT NOT NULL,
|
|
1358
|
+
hash TEXT NOT NULL,
|
|
1359
|
+
ran_at TEXT NOT NULL,
|
|
1360
|
+
PRIMARY KEY (plugin, name)
|
|
1361
|
+
)
|
|
1362
|
+
`;
|
|
1363
|
+
function read(plugin2, from, name) {
|
|
1364
|
+
const sql = readFileSync(join(from, name), "utf8");
|
|
1365
|
+
return { plugin: plugin2, name, sql, hash: createHash("sha256").update(sql).digest("hex") };
|
|
1366
|
+
}
|
|
1367
|
+
function steps(source) {
|
|
1368
|
+
let found;
|
|
1369
|
+
try {
|
|
1370
|
+
found = readdirSync(source.from);
|
|
1371
|
+
} catch {
|
|
1372
|
+
throw new MigrationFault(`"${source.plugin}" declares migrations at "${source.from}", which cannot be read.`, source.plugin);
|
|
1373
|
+
}
|
|
1374
|
+
const sql = found.filter((name) => name.endsWith(".sql"));
|
|
1375
|
+
for (const name of sql) {
|
|
1376
|
+
if (!NAMED.test(name)) {
|
|
1377
|
+
throw new MigrationFault(`"${name}" is not named NNNN-name.sql, so its place in the order is ambiguous.`, source.plugin, name);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
const numbers = /* @__PURE__ */ new Map();
|
|
1381
|
+
for (const name of sql) {
|
|
1382
|
+
const at = NAMED.exec(name)?.[1] ?? "";
|
|
1383
|
+
const first = numbers.get(at);
|
|
1384
|
+
if (first !== void 0) {
|
|
1385
|
+
throw new MigrationFault(`"${name}" and "${first}" share the number ${at}, so which runs first is undefined.`, source.plugin, name);
|
|
1386
|
+
}
|
|
1387
|
+
numbers.set(at, name);
|
|
1388
|
+
}
|
|
1389
|
+
return [...sql].sort().map((name) => read(source.plugin, source.from, name));
|
|
1390
|
+
}
|
|
1391
|
+
function migrate(connection, sources) {
|
|
1392
|
+
connection.exec(LEDGER);
|
|
1393
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1394
|
+
for (const row of connection.prepare("SELECT plugin, name, hash FROM _migrations").all()) {
|
|
1395
|
+
seen.set(`${row.plugin}/${row.name}`, row.hash);
|
|
1396
|
+
}
|
|
1397
|
+
const ran = [];
|
|
1398
|
+
for (const source of sources) {
|
|
1399
|
+
for (const step of steps(source)) {
|
|
1400
|
+
const before = seen.get(`${step.plugin}/${step.name}`);
|
|
1401
|
+
if (before === step.hash) {
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
if (before !== void 0) {
|
|
1405
|
+
throw new MigrationFault(
|
|
1406
|
+
`"${step.name}" has changed since it ran. A migration is history: add a new one rather than editing what other databases already applied.`,
|
|
1407
|
+
step.plugin,
|
|
1408
|
+
step.name
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
const apply = connection.transaction(() => {
|
|
1412
|
+
connection.exec(step.sql);
|
|
1413
|
+
connection.prepare("INSERT INTO _migrations (plugin, name, hash, ran_at) VALUES (?, ?, ?, ?)").run(step.plugin, step.name, step.hash, (/* @__PURE__ */ new Date()).toISOString());
|
|
1414
|
+
});
|
|
1415
|
+
try {
|
|
1416
|
+
apply();
|
|
1417
|
+
} catch (cause) {
|
|
1418
|
+
throw new MigrationFault(
|
|
1419
|
+
`"${step.name}" failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
1420
|
+
step.plugin,
|
|
1421
|
+
step.name
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
ran.push(step);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
return ran;
|
|
1428
|
+
}
|
|
1429
|
+
function narrowing(owned) {
|
|
1430
|
+
return (table, column, value) => {
|
|
1431
|
+
const found = Object.values(owned).map((tables) => tables[table]).find((one) => one !== void 0);
|
|
1432
|
+
if (found === void 0) {
|
|
1433
|
+
throw new Error(`Cannot scope "${table}": no table of that name was given to the store.`);
|
|
1434
|
+
}
|
|
1435
|
+
const at = found[column];
|
|
1436
|
+
if (at === void 0) {
|
|
1437
|
+
throw new Error(`Cannot scope "${table}" by "${column}": the table declares no such column.`);
|
|
1438
|
+
}
|
|
1439
|
+
return eq(at, value);
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// src/plugins/database/internal/outbox.ts
|
|
1444
|
+
function outbox(connection) {
|
|
1445
|
+
connection.exec(`
|
|
1446
|
+
CREATE TABLE IF NOT EXISTS kit_outbox (
|
|
1447
|
+
id TEXT PRIMARY KEY,
|
|
1448
|
+
plugin TEXT NOT NULL,
|
|
1449
|
+
name TEXT NOT NULL,
|
|
1450
|
+
payload TEXT NOT NULL,
|
|
1451
|
+
keptAt TEXT NOT NULL
|
|
1452
|
+
)
|
|
1453
|
+
`);
|
|
1454
|
+
const insert = connection.prepare(
|
|
1455
|
+
"INSERT INTO kit_outbox (id, plugin, name, payload, keptAt) VALUES (?, ?, ?, ?, ?)"
|
|
1456
|
+
);
|
|
1457
|
+
const remove = connection.prepare("DELETE FROM kit_outbox WHERE id = ?");
|
|
1458
|
+
const unsent = connection.prepare("SELECT id, plugin, name, payload FROM kit_outbox ORDER BY keptAt");
|
|
1459
|
+
return {
|
|
1460
|
+
// Synchronous, and it must stay that way: this runs inside an open
|
|
1461
|
+
// transaction, and an await here would let another one interleave.
|
|
1462
|
+
keep: (_db, announcements) => {
|
|
1463
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
1464
|
+
for (const one of announcements) {
|
|
1465
|
+
insert.run(one.id, one.plugin, one.name, JSON.stringify(one.payload), at);
|
|
1466
|
+
}
|
|
1467
|
+
},
|
|
1468
|
+
sent: (id) => {
|
|
1469
|
+
try {
|
|
1470
|
+
remove.run(id);
|
|
1471
|
+
} catch (cause) {
|
|
1472
|
+
if (!(cause instanceof TypeError)) {
|
|
1473
|
+
throw cause;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
return Promise.resolve();
|
|
1477
|
+
},
|
|
1478
|
+
waiting: () => {
|
|
1479
|
+
const rows = unsent.all();
|
|
1480
|
+
return Promise.resolve(rows.map((row) => ({
|
|
1481
|
+
id: row.id,
|
|
1482
|
+
plugin: row.plugin,
|
|
1483
|
+
name: row.name,
|
|
1484
|
+
payload: JSON.parse(row.payload)
|
|
1485
|
+
})));
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// src/plugins/database/internal/schedule.ts
|
|
1491
|
+
function schedule(connection) {
|
|
1492
|
+
connection.exec(`
|
|
1493
|
+
CREATE TABLE IF NOT EXISTS kit_schedule (
|
|
1494
|
+
id TEXT PRIMARY KEY,
|
|
1495
|
+
plugin TEXT NOT NULL,
|
|
1496
|
+
command TEXT NOT NULL,
|
|
1497
|
+
input TEXT NOT NULL,
|
|
1498
|
+
runAt INTEGER NOT NULL,
|
|
1499
|
+
takenAt INTEGER,
|
|
1500
|
+
attempts INTEGER NOT NULL DEFAULT 0
|
|
1501
|
+
);
|
|
1502
|
+
|
|
1503
|
+
CREATE INDEX IF NOT EXISTS kit_schedule_due ON kit_schedule (runAt) WHERE takenAt IS NULL;
|
|
1504
|
+
`);
|
|
1505
|
+
const insert = connection.prepare(
|
|
1506
|
+
"INSERT INTO kit_schedule (id, plugin, command, input, runAt, attempts) VALUES (?, ?, ?, ?, ?, ?)"
|
|
1507
|
+
);
|
|
1508
|
+
const claim = connection.prepare(`
|
|
1509
|
+
UPDATE kit_schedule SET takenAt = ?
|
|
1510
|
+
WHERE id IN (
|
|
1511
|
+
SELECT id FROM kit_schedule
|
|
1512
|
+
WHERE takenAt IS NULL AND runAt <= ?
|
|
1513
|
+
ORDER BY runAt
|
|
1514
|
+
LIMIT ?
|
|
1515
|
+
)
|
|
1516
|
+
RETURNING id, plugin, command, input, runAt, attempts
|
|
1517
|
+
`);
|
|
1518
|
+
const remove = connection.prepare("DELETE FROM kit_schedule WHERE id = ?");
|
|
1519
|
+
const again = connection.prepare(
|
|
1520
|
+
"UPDATE kit_schedule SET takenAt = NULL, runAt = ?, attempts = attempts + 1 WHERE id = ?"
|
|
1521
|
+
);
|
|
1522
|
+
return {
|
|
1523
|
+
keep: (_db, job) => {
|
|
1524
|
+
insert.run(job.id, job.plugin, job.command, JSON.stringify(job.input), job.at, job.attempts);
|
|
1525
|
+
},
|
|
1526
|
+
take: (now, limit) => {
|
|
1527
|
+
const rows = claim.all(now, now, limit);
|
|
1528
|
+
return Promise.resolve(rows.map((row) => ({
|
|
1529
|
+
id: row.id,
|
|
1530
|
+
plugin: row.plugin,
|
|
1531
|
+
command: row.command,
|
|
1532
|
+
input: JSON.parse(row.input),
|
|
1533
|
+
at: row.runAt,
|
|
1534
|
+
attempts: row.attempts
|
|
1535
|
+
})));
|
|
1536
|
+
},
|
|
1537
|
+
done: (id) => {
|
|
1538
|
+
remove.run(id);
|
|
1539
|
+
return Promise.resolve();
|
|
1540
|
+
},
|
|
1541
|
+
failed: (id, at) => {
|
|
1542
|
+
again.run(at, id);
|
|
1543
|
+
return Promise.resolve();
|
|
1544
|
+
},
|
|
1545
|
+
gaveUp: (id) => {
|
|
1546
|
+
remove.run(id);
|
|
1547
|
+
return Promise.resolve();
|
|
1548
|
+
}
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
function connect(opening) {
|
|
1552
|
+
const memory = opening.file === ":memory:";
|
|
1553
|
+
if (!memory) {
|
|
1554
|
+
mkdirSync(dirname(opening.file), { recursive: true });
|
|
1555
|
+
}
|
|
1556
|
+
const connection = new Database(opening.file);
|
|
1557
|
+
if (opening.wal ?? !memory) {
|
|
1558
|
+
connection.pragma("journal_mode = WAL");
|
|
1559
|
+
connection.pragma("synchronous = NORMAL");
|
|
1560
|
+
}
|
|
1561
|
+
connection.pragma("foreign_keys = ON");
|
|
1562
|
+
connection.pragma(`busy_timeout = ${opening.busyMs ?? 5e3}`);
|
|
1563
|
+
return connection;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// src/plugins/database/internal/queue.ts
|
|
1567
|
+
function queue() {
|
|
1568
|
+
let last = Promise.resolve();
|
|
1569
|
+
return {
|
|
1570
|
+
run: (work) => {
|
|
1571
|
+
const mine = last.then(work, work);
|
|
1572
|
+
last = mine.catch(() => void 0);
|
|
1573
|
+
return mine;
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// src/plugins/database/internal/store.ts
|
|
1579
|
+
function store(holding) {
|
|
1580
|
+
const handles = /* @__PURE__ */ new Map();
|
|
1581
|
+
const waiting = queue();
|
|
1582
|
+
const inside = new AsyncLocalStorage();
|
|
1583
|
+
let open = true;
|
|
1584
|
+
let counter = 0;
|
|
1585
|
+
function of(plugin2) {
|
|
1586
|
+
if (!open) {
|
|
1587
|
+
throw new Error(`"${plugin2}" reached the database after it was closed.`);
|
|
1588
|
+
}
|
|
1589
|
+
const already = handles.get(plugin2);
|
|
1590
|
+
if (already !== void 0) {
|
|
1591
|
+
return already;
|
|
1592
|
+
}
|
|
1593
|
+
const owns = holding.tables[plugin2];
|
|
1594
|
+
if (owns === void 0) {
|
|
1595
|
+
throw new Error(`"${plugin2}" asked for a database handle but declares no tables. Add them to its contract, or stop reaching for ctx.db.`);
|
|
1596
|
+
}
|
|
1597
|
+
const made = drizzle(holding.connection, { schema: owns });
|
|
1598
|
+
handles.set(plugin2, made);
|
|
1599
|
+
return made;
|
|
1600
|
+
}
|
|
1601
|
+
async function within(plugin2, run) {
|
|
1602
|
+
const db = of(plugin2);
|
|
1603
|
+
const nested = inside.getStore() !== void 0;
|
|
1604
|
+
counter += 1;
|
|
1605
|
+
const at = counter;
|
|
1606
|
+
const name = `sp_${String(at)}`;
|
|
1607
|
+
holding.connection.exec(nested ? `SAVEPOINT ${name}` : "BEGIN IMMEDIATE");
|
|
1608
|
+
try {
|
|
1609
|
+
const made = await inside.run(at, () => run(db));
|
|
1610
|
+
holding.connection.exec(nested ? `RELEASE ${name}` : "COMMIT");
|
|
1611
|
+
return made;
|
|
1612
|
+
} catch (cause) {
|
|
1613
|
+
try {
|
|
1614
|
+
holding.connection.exec(nested ? `ROLLBACK TO ${name}; RELEASE ${name}` : "ROLLBACK");
|
|
1615
|
+
} catch {
|
|
1616
|
+
}
|
|
1617
|
+
throw cause;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
return {
|
|
1621
|
+
of,
|
|
1622
|
+
/**
|
|
1623
|
+
* Runs work in one transaction, rolled back if it throws.
|
|
1624
|
+
*
|
|
1625
|
+
* Serialised against every other transaction and every write: this is
|
|
1626
|
+
* async but better-sqlite3 is not, so an await inside would otherwise
|
|
1627
|
+
* leave the connection in a transaction while other work ran through
|
|
1628
|
+
* it. That work would then belong to this transaction, and vanish
|
|
1629
|
+
* with its rollback.
|
|
1630
|
+
*
|
|
1631
|
+
* A transaction already open on this call stack becomes a savepoint
|
|
1632
|
+
* rather than queueing behind itself, which would deadlock.
|
|
1633
|
+
*/
|
|
1634
|
+
tx: (plugin2, run) => {
|
|
1635
|
+
return inside.getStore() === void 0 ? waiting.run(() => within(plugin2, run)) : within(plugin2, run);
|
|
1636
|
+
},
|
|
1637
|
+
/**
|
|
1638
|
+
* Runs work outside a transaction, but never during someone else's.
|
|
1639
|
+
*
|
|
1640
|
+
* better-sqlite3 is synchronous, so one statement cannot interleave
|
|
1641
|
+
* with another. What can interleave is a statement issued while an
|
|
1642
|
+
* async transaction is parked on an await: it joins that transaction
|
|
1643
|
+
* and is undone by its rollback, having told its caller it succeeded.
|
|
1644
|
+
*
|
|
1645
|
+
* The kernel routes every non-transactional query through here, so
|
|
1646
|
+
* that window does not exist. It always queues: `depth` says a
|
|
1647
|
+
* transaction is open somewhere, never that this caller is the one
|
|
1648
|
+
* inside it, and work that is genuinely inside one reaches the
|
|
1649
|
+
* database through the transaction's own handle instead.
|
|
1650
|
+
*/
|
|
1651
|
+
write: (run) => {
|
|
1652
|
+
return inside.getStore() === void 0 ? waiting.run(run) : run();
|
|
1653
|
+
},
|
|
1654
|
+
/** Whether the running code is inside a transaction. For diagnosis. */
|
|
1655
|
+
inTransaction: () => {
|
|
1656
|
+
return inside.getStore() !== void 0;
|
|
1657
|
+
},
|
|
1658
|
+
close: () => {
|
|
1659
|
+
open = false;
|
|
1660
|
+
handles.clear();
|
|
1661
|
+
holding.connection.close();
|
|
1662
|
+
}
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// src/plugins/database/api.ts
|
|
1667
|
+
function database(settings2) {
|
|
1668
|
+
const connection = connect(settings2);
|
|
1669
|
+
const made = store({ connection, tables: settings2.tables });
|
|
1670
|
+
return {
|
|
1671
|
+
of: made.of,
|
|
1672
|
+
tx: made.tx,
|
|
1673
|
+
write: made.write,
|
|
1674
|
+
inTransaction: made.inTransaction,
|
|
1675
|
+
close: made.close,
|
|
1676
|
+
/** Runs every migration that has not run, in the order given. */
|
|
1677
|
+
migrate: (sources) => {
|
|
1678
|
+
return migrate(connection, sources);
|
|
1679
|
+
},
|
|
1680
|
+
/**
|
|
1681
|
+
* Where events wait, in this same database.
|
|
1682
|
+
*
|
|
1683
|
+
* Built here rather than from a connection handed out, because an
|
|
1684
|
+
* outbox that wrote somewhere else would be exactly the thing it
|
|
1685
|
+
* exists to prevent: two places that can disagree about whether the
|
|
1686
|
+
* work happened.
|
|
1687
|
+
*/
|
|
1688
|
+
outbox: () => {
|
|
1689
|
+
return outbox(connection);
|
|
1690
|
+
},
|
|
1691
|
+
/** Where later work waits, in this same database. */
|
|
1692
|
+
schedule: () => {
|
|
1693
|
+
return schedule(connection);
|
|
1694
|
+
},
|
|
1695
|
+
/** How a scope narrows a query, over the tables one plugin declared. */
|
|
1696
|
+
narrowing: () => {
|
|
1697
|
+
return narrowing(settings2.tables);
|
|
1698
|
+
}
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
function same(left, right) {
|
|
1702
|
+
const first = Buffer.from(left, "utf8");
|
|
1703
|
+
const second = Buffer.from(right, "utf8");
|
|
1704
|
+
if (first.length !== second.length) {
|
|
1705
|
+
return false;
|
|
1706
|
+
}
|
|
1707
|
+
return timingSafeEqual(first, second);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// src/plugins/guard/internal/limit.ts
|
|
1711
|
+
function limiter(now = Date.now) {
|
|
1712
|
+
const counted = /* @__PURE__ */ new Map();
|
|
1713
|
+
return {
|
|
1714
|
+
take: (key, window) => {
|
|
1715
|
+
const at = now();
|
|
1716
|
+
const seen = counted.get(key);
|
|
1717
|
+
if (seen === void 0 || seen.until <= at) {
|
|
1718
|
+
counted.set(key, { hits: 1, until: at + window.seconds * 1e3 });
|
|
1719
|
+
return { allowed: true, remaining: window.requests - 1, resetsIn: window.seconds };
|
|
1720
|
+
}
|
|
1721
|
+
seen.hits += 1;
|
|
1722
|
+
return {
|
|
1723
|
+
allowed: seen.hits <= window.requests,
|
|
1724
|
+
remaining: Math.max(0, window.requests - seen.hits),
|
|
1725
|
+
resetsIn: Math.ceil((seen.until - at) / 1e3)
|
|
1726
|
+
};
|
|
1727
|
+
},
|
|
1728
|
+
// Called on a timer by whoever holds the limiter: a map that only
|
|
1729
|
+
// grows is a slow leak on a public route.
|
|
1730
|
+
sweep: () => {
|
|
1731
|
+
const at = now();
|
|
1732
|
+
let dropped = 0;
|
|
1733
|
+
for (const [key, seen] of counted) {
|
|
1734
|
+
if (seen.until <= at) {
|
|
1735
|
+
counted.delete(key);
|
|
1736
|
+
dropped += 1;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
return dropped;
|
|
1740
|
+
},
|
|
1741
|
+
size: () => {
|
|
1742
|
+
return counted.size;
|
|
1743
|
+
}
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
export { Answered, KernelFault, MigrationFault, Refusal, answer, createKernel, database, defineCommand, defineListener, defineParticipant, definePlugin, defineRoute, limiter, narrowing, outbox, same, schedule };
|
|
1748
|
+
//# sourceMappingURL=chunk-UCWOCNTI.js.map
|
|
1749
|
+
//# sourceMappingURL=chunk-UCWOCNTI.js.map
|