@shaferllc/keel 0.36.0 → 0.58.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 +14 -2
- package/dist/core/application.d.ts +58 -2
- package/dist/core/application.js +99 -3
- package/dist/core/authorization.d.ts +52 -0
- package/dist/core/authorization.js +97 -0
- package/dist/core/broadcasting.d.ts +49 -0
- package/dist/core/broadcasting.js +84 -0
- package/dist/core/broker.d.ts +398 -0
- package/dist/core/broker.js +602 -0
- package/dist/core/crypto.d.ts +1 -1
- package/dist/core/crypto.js +12 -3
- package/dist/core/database.d.ts +26 -1
- package/dist/core/database.js +65 -2
- package/dist/core/decorators.d.ts +39 -0
- package/dist/core/decorators.js +72 -0
- package/dist/core/exceptions.d.ts +77 -6
- package/dist/core/exceptions.js +168 -10
- package/dist/core/helpers.d.ts +6 -0
- package/dist/core/helpers.js +12 -0
- package/dist/core/http/kernel.js +14 -2
- package/dist/core/http/router.d.ts +14 -0
- package/dist/core/http/router.js +30 -1
- package/dist/core/index.d.ts +28 -6
- package/dist/core/index.js +15 -3
- package/dist/core/logger.d.ts +5 -0
- package/dist/core/logger.js +24 -2
- package/dist/core/migrations.js +3 -3
- package/dist/core/model.d.ts +19 -1
- package/dist/core/model.js +72 -4
- package/dist/core/provider.d.ts +13 -4
- package/dist/core/provider.js +12 -2
- package/dist/core/redis.d.ts +78 -0
- package/dist/core/redis.js +176 -0
- package/dist/core/request-logger.d.ts +26 -0
- package/dist/core/request-logger.js +48 -0
- package/dist/core/request.d.ts +17 -1
- package/dist/core/request.js +27 -1
- package/dist/core/scheduler.d.ts +60 -0
- package/dist/core/scheduler.js +166 -0
- package/dist/core/session.js +17 -2
- package/dist/core/storage.d.ts +57 -0
- package/dist/core/storage.js +98 -0
- package/dist/core/template.d.ts +50 -0
- package/dist/core/template.js +753 -0
- package/dist/core/testing.d.ts +54 -0
- package/dist/core/testing.js +141 -0
- package/dist/core/transformer.d.ts +89 -0
- package/dist/core/transformer.js +152 -0
- package/dist/core/validation.d.ts +20 -0
- package/dist/core/validation.js +52 -1
- package/dist/core/vite.d.ts +117 -0
- package/dist/core/vite.js +258 -0
- package/dist/vite/index.d.ts +40 -0
- package/dist/vite/index.js +146 -0
- package/package.json +16 -1
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A service broker — a Moleculer-style backbone for service-oriented code.
|
|
3
|
+
* Register *services* (a name plus a bag of `actions` and `events`), then reach
|
|
4
|
+
* them by string name: `broker.call("users.get", { id })` runs an action and
|
|
5
|
+
* returns its result; `broker.emit("user.created", user)` fans an event out to
|
|
6
|
+
* every service that listens. Actions receive a `Context` and can call *other*
|
|
7
|
+
* actions or emit events through it, so a request threads its `meta` (auth,
|
|
8
|
+
* trace ids) all the way down.
|
|
9
|
+
*
|
|
10
|
+
* const broker = new Broker();
|
|
11
|
+
* broker.createService({
|
|
12
|
+
* name: "math",
|
|
13
|
+
* actions: {
|
|
14
|
+
* add: (ctx: Context<{ a: number; b: number }>) => ctx.params.a + ctx.params.b,
|
|
15
|
+
* },
|
|
16
|
+
* });
|
|
17
|
+
* await broker.start();
|
|
18
|
+
* await broker.call("math.add", { a: 2, b: 3 }); // 5
|
|
19
|
+
*
|
|
20
|
+
* Like the queue and Redis layers, clustering lives behind a pluggable seam: the
|
|
21
|
+
* default `LocalTransporter` is a single-node no-op, so the core imports no
|
|
22
|
+
* network client and runs on Node and the edge. Swap in a real `Transporter`
|
|
23
|
+
* (NATS, Redis, TCP) to span processes — the call/emit API doesn't change.
|
|
24
|
+
*/
|
|
25
|
+
import { Logger } from "./logger.js";
|
|
26
|
+
import { ValidationException } from "./exceptions.js";
|
|
27
|
+
/* ----------------------------- schema helpers ----------------------------- */
|
|
28
|
+
function toArray(x) {
|
|
29
|
+
return x == null ? [] : Array.isArray(x) ? x : [x];
|
|
30
|
+
}
|
|
31
|
+
/** Chain two lifecycle hooks into one that awaits both, in order. */
|
|
32
|
+
function chain(a, b) {
|
|
33
|
+
if (!a)
|
|
34
|
+
return b;
|
|
35
|
+
if (!b)
|
|
36
|
+
return a;
|
|
37
|
+
return async function (...args) {
|
|
38
|
+
await a.apply(this, args);
|
|
39
|
+
await b.apply(this, args);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const LIFECYCLE = new Set(["created", "started", "stopped", "merged"]);
|
|
43
|
+
/** Merge one schema over a base, following Moleculer's per-property rules. */
|
|
44
|
+
function mergeSchema(base, ext) {
|
|
45
|
+
const out = { ...base };
|
|
46
|
+
for (const key of Object.keys(ext)) {
|
|
47
|
+
const v = ext[key];
|
|
48
|
+
if (v === undefined || key === "mixins")
|
|
49
|
+
continue;
|
|
50
|
+
if (key === "name" || key === "version") {
|
|
51
|
+
out[key] = v;
|
|
52
|
+
}
|
|
53
|
+
else if (key === "settings" || key === "metadata" || key === "actions" || key === "methods" || key === "events") {
|
|
54
|
+
out[key] = { ...(base[key] ?? {}), ...v };
|
|
55
|
+
}
|
|
56
|
+
else if (key === "hooks") {
|
|
57
|
+
out[key] = {
|
|
58
|
+
before: { ...(base.hooks?.before ?? {}), ...(v.before ?? {}) },
|
|
59
|
+
after: { ...(base.hooks?.after ?? {}), ...(v.after ?? {}) },
|
|
60
|
+
error: { ...(base.hooks?.error ?? {}), ...(v.error ?? {}) },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
else if (key === "dependencies") {
|
|
64
|
+
out[key] = [...new Set([...toArray(base[key]), ...toArray(v)])];
|
|
65
|
+
}
|
|
66
|
+
else if (LIFECYCLE.has(key)) {
|
|
67
|
+
out[key] = chain(base[key], v);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
out[key] = v;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/** Flatten a schema's `mixins` into it — this schema always wins on conflict. */
|
|
76
|
+
function applyMixins(schema) {
|
|
77
|
+
if (!schema.mixins?.length)
|
|
78
|
+
return schema;
|
|
79
|
+
// Fold mixins last→first so the first mixin ends up highest-priority among
|
|
80
|
+
// them; the service's own schema is applied last, so it wins overall.
|
|
81
|
+
let merged = {};
|
|
82
|
+
for (const mixin of [...schema.mixins].reverse()) {
|
|
83
|
+
merged = mergeSchema(merged, applyMixins(mixin));
|
|
84
|
+
}
|
|
85
|
+
const { mixins: _drop, ...own } = schema;
|
|
86
|
+
return mergeSchema(merged, own);
|
|
87
|
+
}
|
|
88
|
+
/** A live service instance. Bound as `this` inside handlers, methods, and hooks. */
|
|
89
|
+
export class Service {
|
|
90
|
+
name;
|
|
91
|
+
version;
|
|
92
|
+
/** Versioned, dotted prefix — `"users"` or `"v2.users"`. */
|
|
93
|
+
fullName;
|
|
94
|
+
settings;
|
|
95
|
+
metadata;
|
|
96
|
+
/** Services that must exist before this one's `started` hook runs. */
|
|
97
|
+
dependencies;
|
|
98
|
+
broker;
|
|
99
|
+
logger;
|
|
100
|
+
/** Internal callers for this service's own actions — `this.actions.foo(params)`. */
|
|
101
|
+
actions = {};
|
|
102
|
+
/** @internal normalized actions, keyed by local (unprefixed) name. */
|
|
103
|
+
_actions = new Map();
|
|
104
|
+
/** @internal normalized event listeners, keyed by pattern. */
|
|
105
|
+
_events = new Map();
|
|
106
|
+
/** @internal normalized service-level hooks. */
|
|
107
|
+
_hooks;
|
|
108
|
+
constructor(broker, schema) {
|
|
109
|
+
this.name = schema.name;
|
|
110
|
+
this.version = schema.version;
|
|
111
|
+
this.fullName = schema.version != null ? `v${schema.version}.${schema.name}` : schema.name;
|
|
112
|
+
this.settings = schema.settings ?? {};
|
|
113
|
+
this.metadata = schema.metadata ?? {};
|
|
114
|
+
this.dependencies = toArray(schema.dependencies);
|
|
115
|
+
this.broker = broker;
|
|
116
|
+
this.logger = broker.logger.child({ service: this.fullName });
|
|
117
|
+
// Bind methods first so actions/hooks can call them via `this`.
|
|
118
|
+
for (const [key, fn] of Object.entries(schema.methods ?? {})) {
|
|
119
|
+
this[key] = fn.bind(this);
|
|
120
|
+
}
|
|
121
|
+
for (const [name, entry] of Object.entries(schema.actions ?? {})) {
|
|
122
|
+
const def = typeof entry === "function" ? { handler: entry } : entry;
|
|
123
|
+
this._actions.set(name, {
|
|
124
|
+
name,
|
|
125
|
+
handler: def.handler.bind(this),
|
|
126
|
+
visibility: def.visibility ?? "published",
|
|
127
|
+
timeout: def.timeout,
|
|
128
|
+
params: def.params,
|
|
129
|
+
cache: def.cache,
|
|
130
|
+
hooks: {
|
|
131
|
+
before: toArray(def.hooks?.before).map((f) => f.bind(this)),
|
|
132
|
+
after: toArray(def.hooks?.after).map((f) => f.bind(this)),
|
|
133
|
+
error: toArray(def.hooks?.error).map((f) => f.bind(this)),
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
for (const [pattern, entry] of Object.entries(schema.events ?? {})) {
|
|
138
|
+
const def = typeof entry === "function" ? { handler: entry } : entry;
|
|
139
|
+
this._events.set(pattern, { handler: def.handler.bind(this), group: def.group ?? this.name });
|
|
140
|
+
}
|
|
141
|
+
const bindHookMap = (m) => {
|
|
142
|
+
const out = {};
|
|
143
|
+
for (const [key, fns] of Object.entries(m ?? {}))
|
|
144
|
+
out[key] = toArray(fns).map((f) => f.bind(this));
|
|
145
|
+
return out;
|
|
146
|
+
};
|
|
147
|
+
this._hooks = {
|
|
148
|
+
before: bindHookMap(schema.hooks?.before),
|
|
149
|
+
after: bindHookMap(schema.hooks?.after),
|
|
150
|
+
error: bindHookMap(schema.hooks?.error),
|
|
151
|
+
};
|
|
152
|
+
this._schema = schema;
|
|
153
|
+
}
|
|
154
|
+
/** Wait until the named service(s) are registered on this broker. */
|
|
155
|
+
waitForServices(deps, timeout, interval) {
|
|
156
|
+
return this.broker.waitForServices(deps, timeout, interval);
|
|
157
|
+
}
|
|
158
|
+
/** @internal the merged schema, for lifecycle hooks. */
|
|
159
|
+
_schema;
|
|
160
|
+
}
|
|
161
|
+
/* --------------------------------- errors --------------------------------- */
|
|
162
|
+
/** Thrown by `call` when no action matches the requested name. */
|
|
163
|
+
export class ServiceNotFoundError extends Error {
|
|
164
|
+
action;
|
|
165
|
+
constructor(action) {
|
|
166
|
+
super(`No action named "${action}" is registered.`);
|
|
167
|
+
this.action = action;
|
|
168
|
+
this.name = "ServiceNotFoundError";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Thrown by `call` when an action exceeds its timeout. */
|
|
172
|
+
export class RequestTimeoutError extends Error {
|
|
173
|
+
action;
|
|
174
|
+
timeout;
|
|
175
|
+
constructor(action, timeout) {
|
|
176
|
+
super(`Action "${action}" timed out after ${timeout}ms.`);
|
|
177
|
+
this.action = action;
|
|
178
|
+
this.timeout = timeout;
|
|
179
|
+
this.name = "RequestTimeoutError";
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** The default transporter — a single node, nothing to connect. */
|
|
183
|
+
export class LocalTransporter {
|
|
184
|
+
async connect(_broker) { }
|
|
185
|
+
async disconnect() { }
|
|
186
|
+
}
|
|
187
|
+
/* --------------------------------- broker --------------------------------- */
|
|
188
|
+
/** Match a subscription pattern (with optional `*`/`**`/`?` globs) to an event name. */
|
|
189
|
+
function eventMatches(pattern, event) {
|
|
190
|
+
if (pattern === event)
|
|
191
|
+
return true;
|
|
192
|
+
if (!/[*?]/.test(pattern))
|
|
193
|
+
return false;
|
|
194
|
+
// Escape regex specials (but not `*`/`?`), then map globs: `**` spans dots, `*`
|
|
195
|
+
// a single segment, `?` one non-dot char. `user.*` → one level; `user.**` →
|
|
196
|
+
// any depth; `user.??eated` → a fixed-width name.
|
|
197
|
+
const rx = new RegExp("^" +
|
|
198
|
+
pattern
|
|
199
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
200
|
+
.replace(/\*\*/g, ".*")
|
|
201
|
+
.replace(/\*/g, "[^.]*")
|
|
202
|
+
.replace(/\?/g, "[^.]") +
|
|
203
|
+
"$");
|
|
204
|
+
return rx.test(event);
|
|
205
|
+
}
|
|
206
|
+
/** Build a cache key from an action name and its params (optionally a subset). */
|
|
207
|
+
function cacheKey(action, params, keys) {
|
|
208
|
+
let keyed = params;
|
|
209
|
+
if (keys && params && typeof params === "object") {
|
|
210
|
+
const picked = {};
|
|
211
|
+
for (const k of keys)
|
|
212
|
+
picked[k] = params[k];
|
|
213
|
+
keyed = picked;
|
|
214
|
+
}
|
|
215
|
+
return `${action}:${JSON.stringify(keyed ?? null)}`;
|
|
216
|
+
}
|
|
217
|
+
/** Match a hook key (`"*"`, exact name, `"a|b"` pipe list, or `*` glob) to an action. */
|
|
218
|
+
function hookMatches(pattern, action) {
|
|
219
|
+
if (pattern === "*")
|
|
220
|
+
return true;
|
|
221
|
+
if (pattern.includes("|"))
|
|
222
|
+
return pattern.split("|").some((p) => hookMatches(p.trim(), action));
|
|
223
|
+
if (pattern.includes("*")) {
|
|
224
|
+
const rx = new RegExp("^" + pattern.replace(/[.+^${}()[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");
|
|
225
|
+
return rx.test(action);
|
|
226
|
+
}
|
|
227
|
+
return pattern === action;
|
|
228
|
+
}
|
|
229
|
+
export class Broker {
|
|
230
|
+
nodeID;
|
|
231
|
+
logger;
|
|
232
|
+
transporter;
|
|
233
|
+
requestTimeout;
|
|
234
|
+
defaultRetries;
|
|
235
|
+
cacher;
|
|
236
|
+
middlewares;
|
|
237
|
+
services = [];
|
|
238
|
+
/** action fullName → endpoint. */
|
|
239
|
+
actions = new Map();
|
|
240
|
+
started = false;
|
|
241
|
+
uid = 0;
|
|
242
|
+
constructor(options = {}) {
|
|
243
|
+
this.nodeID = options.nodeID ?? `node-${Math.random().toString(36).slice(2, 8)}`;
|
|
244
|
+
this.logger = options.logger ?? new Logger({ bindings: { nodeID: options.nodeID } });
|
|
245
|
+
this.transporter = options.transporter ?? new LocalTransporter();
|
|
246
|
+
this.requestTimeout = options.requestTimeout ?? 0;
|
|
247
|
+
this.defaultRetries = options.retries ?? 0;
|
|
248
|
+
this.cacher = options.cacher;
|
|
249
|
+
this.middlewares = options.middlewares ?? [];
|
|
250
|
+
}
|
|
251
|
+
/* ------------------------------ registration ---------------------------- */
|
|
252
|
+
/** Register a service from a schema (flattening any `mixins`). Returns the instance. */
|
|
253
|
+
createService(rawSchema) {
|
|
254
|
+
const schema = applyMixins(rawSchema);
|
|
255
|
+
schema.merged?.(schema);
|
|
256
|
+
const service = new Service(this, schema);
|
|
257
|
+
for (const [name, action] of service._actions) {
|
|
258
|
+
const full = `${service.fullName}.${name}`;
|
|
259
|
+
this.actions.set(full, { service, action });
|
|
260
|
+
// Internal caller: runs the full pipeline (hooks, timeout) but skips the
|
|
261
|
+
// visibility gate, so a service can reach its own `private` actions.
|
|
262
|
+
service.actions[name] = (params, opts = {}) => this.invoke(full, { service, action }, params, opts);
|
|
263
|
+
}
|
|
264
|
+
this.services.push(service);
|
|
265
|
+
void service._schema.created?.call(service);
|
|
266
|
+
this.logger.debug("service created", { service: service.fullName });
|
|
267
|
+
void this.broadcast("$services.changed", { service: service.fullName });
|
|
268
|
+
return service;
|
|
269
|
+
}
|
|
270
|
+
/** Look up a local service by (versioned) name. */
|
|
271
|
+
getLocalService(name) {
|
|
272
|
+
return this.services.find((s) => s.fullName === name || s.name === name);
|
|
273
|
+
}
|
|
274
|
+
/** Remove a service, running its `stopped` hook and unregistering its actions. */
|
|
275
|
+
async destroyService(service) {
|
|
276
|
+
for (const name of service._actions.keys()) {
|
|
277
|
+
this.actions.delete(`${service.fullName}.${name}`);
|
|
278
|
+
}
|
|
279
|
+
const idx = this.services.indexOf(service);
|
|
280
|
+
if (idx !== -1)
|
|
281
|
+
this.services.splice(idx, 1);
|
|
282
|
+
if (this.started)
|
|
283
|
+
await service._schema.stopped?.call(service);
|
|
284
|
+
void this.broadcast("$services.changed", { service: service.fullName });
|
|
285
|
+
}
|
|
286
|
+
/** Resolve once every named service is registered; polls until `timeout` (ms). */
|
|
287
|
+
async waitForServices(deps, timeout = 0, interval = 100) {
|
|
288
|
+
const names = toArray(deps).filter(Boolean);
|
|
289
|
+
const missing = () => names.filter((n) => !this.getLocalService(n));
|
|
290
|
+
let pending = missing();
|
|
291
|
+
if (!pending.length)
|
|
292
|
+
return;
|
|
293
|
+
const startedAt = Date.now();
|
|
294
|
+
while (pending.length) {
|
|
295
|
+
if (timeout && Date.now() - startedAt >= timeout) {
|
|
296
|
+
throw new Error(`waitForServices timed out after ${timeout}ms; still missing: ${pending.join(", ")}`);
|
|
297
|
+
}
|
|
298
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
299
|
+
pending = missing();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/* ------------------------------- lifecycle ------------------------------ */
|
|
303
|
+
/** Connect the transporter and run every service's `started` hook. */
|
|
304
|
+
async start() {
|
|
305
|
+
if (this.started)
|
|
306
|
+
return;
|
|
307
|
+
await this.transporter.connect(this);
|
|
308
|
+
for (const service of this.services) {
|
|
309
|
+
if (service.dependencies.length) {
|
|
310
|
+
await this.waitForServices(service.dependencies, Number(service.settings.$dependencyTimeout) || 0);
|
|
311
|
+
}
|
|
312
|
+
await service._schema.started?.call(service);
|
|
313
|
+
}
|
|
314
|
+
this.started = true;
|
|
315
|
+
for (const mw of this.middlewares)
|
|
316
|
+
await mw.started?.(this);
|
|
317
|
+
this.logger.info("broker started", { nodeID: this.nodeID, services: this.services.length });
|
|
318
|
+
await this.broadcast("$broker.started");
|
|
319
|
+
}
|
|
320
|
+
/** Run every service's `stopped` hook (reverse order) and disconnect. */
|
|
321
|
+
async stop() {
|
|
322
|
+
if (!this.started)
|
|
323
|
+
return;
|
|
324
|
+
await this.broadcast("$broker.stopped");
|
|
325
|
+
for (const service of [...this.services].reverse()) {
|
|
326
|
+
await service._schema.stopped?.call(service);
|
|
327
|
+
}
|
|
328
|
+
for (const mw of [...this.middlewares].reverse())
|
|
329
|
+
await mw.stopped?.(this);
|
|
330
|
+
await this.transporter.disconnect();
|
|
331
|
+
this.started = false;
|
|
332
|
+
this.logger.info("broker stopped", { nodeID: this.nodeID });
|
|
333
|
+
}
|
|
334
|
+
/* --------------------------------- calls -------------------------------- */
|
|
335
|
+
/** A short unique id for tracing. */
|
|
336
|
+
generateUid() {
|
|
337
|
+
return `${this.nodeID}-${++this.uid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
338
|
+
}
|
|
339
|
+
/** Invoke an action by name and resolve with its result. */
|
|
340
|
+
async call(action, params, opts = {}) {
|
|
341
|
+
const endpoint = this.actions.get(action);
|
|
342
|
+
// `private` actions are unreachable from `call` — hidden as if unregistered.
|
|
343
|
+
if (!endpoint || endpoint.action.visibility === "private")
|
|
344
|
+
throw new ServiceNotFoundError(action);
|
|
345
|
+
return this.invoke(action, endpoint, params, opts);
|
|
346
|
+
}
|
|
347
|
+
/* ------------------------------ introspection --------------------------- */
|
|
348
|
+
/** Whether a callable (non-private) action is registered. */
|
|
349
|
+
hasAction(name) {
|
|
350
|
+
const endpoint = this.actions.get(name);
|
|
351
|
+
return !!endpoint && endpoint.action.visibility !== "private";
|
|
352
|
+
}
|
|
353
|
+
/** The names of every callable (non-private) action, sorted. */
|
|
354
|
+
listActions() {
|
|
355
|
+
return [...this.actions.entries()]
|
|
356
|
+
.filter(([, e]) => e.action.visibility !== "private")
|
|
357
|
+
.map(([name]) => name)
|
|
358
|
+
.sort();
|
|
359
|
+
}
|
|
360
|
+
/** The full names of every registered service. */
|
|
361
|
+
listServices() {
|
|
362
|
+
return this.services.map((s) => s.fullName);
|
|
363
|
+
}
|
|
364
|
+
/** A registered service by full name or bare name. */
|
|
365
|
+
getService(name) {
|
|
366
|
+
return this.services.find((s) => s.fullName === name || s.name === name);
|
|
367
|
+
}
|
|
368
|
+
/** Invoke several actions at once — pass an array or a keyed map; returns the same shape. */
|
|
369
|
+
async mcall(defs, opts = {}) {
|
|
370
|
+
const { settled, ...callOpts } = opts;
|
|
371
|
+
const run = (d) => this.call(d.action, d.params, { ...callOpts, ...d.opts });
|
|
372
|
+
const collect = (p) => settled
|
|
373
|
+
? p.then((value) => ({ status: "fulfilled", value }), (reason) => ({ status: "rejected", reason }))
|
|
374
|
+
: p;
|
|
375
|
+
if (Array.isArray(defs)) {
|
|
376
|
+
return Promise.all(defs.map((d) => collect(run(d))));
|
|
377
|
+
}
|
|
378
|
+
const entries = Object.entries(defs);
|
|
379
|
+
const keys = entries.map(([k]) => k);
|
|
380
|
+
const values = await Promise.all(entries.map(([, d]) => collect(run(d))));
|
|
381
|
+
return Object.fromEntries(keys.map((k, i) => [k, values[i]]));
|
|
382
|
+
}
|
|
383
|
+
/** The full call pipeline: build context → before hooks → handler → after hooks. */
|
|
384
|
+
async invoke(fullName, endpoint, params, opts) {
|
|
385
|
+
const { service, action } = endpoint;
|
|
386
|
+
const ctx = this.makeContext(service, fullName, params, opts);
|
|
387
|
+
const { before, after, error } = this.resolveHooks(service, action);
|
|
388
|
+
const timeout = opts.timeout ?? action.timeout ?? this.requestTimeout;
|
|
389
|
+
// Validate params against the action schema; the coerced value replaces them.
|
|
390
|
+
if (action.params) {
|
|
391
|
+
const result = action.params.safeParse(ctx.params);
|
|
392
|
+
if (!result.success) {
|
|
393
|
+
const errors = {};
|
|
394
|
+
for (const issue of result.error.issues) {
|
|
395
|
+
const key = issue.path.map((p) => String(p)).join(".") || "_";
|
|
396
|
+
(errors[key] ??= []).push(issue.message);
|
|
397
|
+
}
|
|
398
|
+
throw new ValidationException(errors);
|
|
399
|
+
}
|
|
400
|
+
ctx.params = result.data;
|
|
401
|
+
}
|
|
402
|
+
// Wrap the handler with `localAction` middlewares (first is outermost).
|
|
403
|
+
let handler = action.handler;
|
|
404
|
+
for (let i = this.middlewares.length - 1; i >= 0; i--) {
|
|
405
|
+
const wrap = this.middlewares[i].localAction;
|
|
406
|
+
if (wrap)
|
|
407
|
+
handler = wrap(handler, fullName);
|
|
408
|
+
}
|
|
409
|
+
const runOnce = () => {
|
|
410
|
+
const pipeline = (async () => {
|
|
411
|
+
for (const hook of before)
|
|
412
|
+
await hook(ctx);
|
|
413
|
+
let res = await handler(ctx);
|
|
414
|
+
for (const hook of after)
|
|
415
|
+
res = await hook(ctx, res);
|
|
416
|
+
return res;
|
|
417
|
+
})();
|
|
418
|
+
return timeout ? this.withTimeout(pipeline, timeout, fullName) : pipeline;
|
|
419
|
+
};
|
|
420
|
+
const execute = async () => {
|
|
421
|
+
// Retry the whole call on failure (total attempts = retries + 1).
|
|
422
|
+
const retries = opts.retries ?? this.defaultRetries;
|
|
423
|
+
let current;
|
|
424
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
425
|
+
try {
|
|
426
|
+
return await runOnce();
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
current = err;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// Every attempt failed: run error hooks, then fall back, else throw.
|
|
433
|
+
for (const hook of error) {
|
|
434
|
+
try {
|
|
435
|
+
return await hook(ctx, current);
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
current = e;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if ("fallback" in opts && opts.fallback !== undefined) {
|
|
442
|
+
return typeof opts.fallback === "function"
|
|
443
|
+
? opts.fallback(current, ctx)
|
|
444
|
+
: opts.fallback;
|
|
445
|
+
}
|
|
446
|
+
throw current;
|
|
447
|
+
};
|
|
448
|
+
// Cache the result when the action opts in and a cacher is configured.
|
|
449
|
+
if (action.cache && this.cacher) {
|
|
450
|
+
const cfg = typeof action.cache === "object" ? action.cache : {};
|
|
451
|
+
const key = cacheKey(fullName, ctx.params, cfg.keys);
|
|
452
|
+
return cfg.ttl != null
|
|
453
|
+
? this.cacher.remember(key, cfg.ttl, execute)
|
|
454
|
+
: this.cacher.rememberForever(key, execute);
|
|
455
|
+
}
|
|
456
|
+
return execute();
|
|
457
|
+
}
|
|
458
|
+
/** Race a promise against a timeout, rejecting with `RequestTimeoutError`. */
|
|
459
|
+
withTimeout(p, timeout, action) {
|
|
460
|
+
let timer;
|
|
461
|
+
const guard = new Promise((_, reject) => {
|
|
462
|
+
timer = setTimeout(() => reject(new RequestTimeoutError(action, timeout)), timeout);
|
|
463
|
+
});
|
|
464
|
+
return Promise.race([p, guard]).finally(() => clearTimeout(timer));
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Assemble the hook chains for an action. Before: service wildcard → service
|
|
468
|
+
* named → action. After/Error run in reverse: action → service named →
|
|
469
|
+
* service wildcard.
|
|
470
|
+
*/
|
|
471
|
+
resolveHooks(service, action) {
|
|
472
|
+
const gather = (map, wildcardFirst) => {
|
|
473
|
+
const wild = [];
|
|
474
|
+
const named = [];
|
|
475
|
+
for (const [pattern, fns] of Object.entries(map)) {
|
|
476
|
+
if (!hookMatches(pattern, action.name))
|
|
477
|
+
continue;
|
|
478
|
+
(pattern.includes("*") ? wild : named).push(...fns);
|
|
479
|
+
}
|
|
480
|
+
return wildcardFirst ? [...wild, ...named] : [...named, ...wild];
|
|
481
|
+
};
|
|
482
|
+
return {
|
|
483
|
+
before: [...gather(service._hooks.before, true), ...action.hooks.before],
|
|
484
|
+
after: [...action.hooks.after, ...gather(service._hooks.after, false)],
|
|
485
|
+
error: [...action.hooks.error, ...gather(service._hooks.error, false)],
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
/* -------------------------------- events -------------------------------- */
|
|
489
|
+
/**
|
|
490
|
+
* Emit a *balanced* event: each listening service receives it once. In a
|
|
491
|
+
* multi-node cluster only one instance per service group is chosen; locally,
|
|
492
|
+
* with one instance per service, that's every listener.
|
|
493
|
+
*/
|
|
494
|
+
emit(event, payload, opts = {}) {
|
|
495
|
+
return this.dispatch(event, payload, opts, false);
|
|
496
|
+
}
|
|
497
|
+
/** Broadcast an event to *every* listener (all instances, all groups). */
|
|
498
|
+
broadcast(event, payload, opts = {}) {
|
|
499
|
+
return this.dispatch(event, payload, opts, true);
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Broadcast to every *local* listener only. On a single node this is identical
|
|
503
|
+
* to `broadcast`; the distinction matters once a real transporter would
|
|
504
|
+
* otherwise relay the event to remote nodes.
|
|
505
|
+
*/
|
|
506
|
+
broadcastLocal(event, payload, opts = {}) {
|
|
507
|
+
return this.dispatch(event, payload, opts, true);
|
|
508
|
+
}
|
|
509
|
+
async dispatch(event, payload, opts, broadcast) {
|
|
510
|
+
// Locally there's a single instance per service, so balanced emit and
|
|
511
|
+
// broadcast reach the same handlers; the distinction matters once a real
|
|
512
|
+
// transporter registers remote instances.
|
|
513
|
+
const type = broadcast ? "broadcast" : "emit";
|
|
514
|
+
for (const service of this.services) {
|
|
515
|
+
for (const [pattern, listener] of service._events) {
|
|
516
|
+
if (!eventMatches(pattern, event))
|
|
517
|
+
continue;
|
|
518
|
+
if (opts.groups && !opts.groups.includes(listener.group))
|
|
519
|
+
continue;
|
|
520
|
+
const groups = opts.groups ?? [listener.group];
|
|
521
|
+
const ctx = this.makeContext(service, event, payload, { meta: opts.meta }, {
|
|
522
|
+
name: event,
|
|
523
|
+
type,
|
|
524
|
+
groups,
|
|
525
|
+
});
|
|
526
|
+
await listener.handler(ctx);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/** True if any registered service listens for the given event. */
|
|
531
|
+
hasEventListener(event) {
|
|
532
|
+
return this.services.some((s) => [...s._events.keys()].some((pattern) => eventMatches(pattern, event)));
|
|
533
|
+
}
|
|
534
|
+
/* --------------------------------- misc --------------------------------- */
|
|
535
|
+
/**
|
|
536
|
+
* Measure round-trip latency (and clock difference) to a node. With the local
|
|
537
|
+
* transporter there's no network, so both are ~0 — the hook is here for real
|
|
538
|
+
* transporters to implement.
|
|
539
|
+
*/
|
|
540
|
+
async ping(nodeID = this.nodeID) {
|
|
541
|
+
return { nodeID, elapsedTime: 0, timeDiff: 0 };
|
|
542
|
+
}
|
|
543
|
+
/** A snapshot of registered action names — handy for debugging. */
|
|
544
|
+
get registeredActions() {
|
|
545
|
+
return [...this.actions.keys()];
|
|
546
|
+
}
|
|
547
|
+
makeContext(service, name, params, opts, evt) {
|
|
548
|
+
const broker = this;
|
|
549
|
+
const parent = opts.parentCtx;
|
|
550
|
+
const meta = parent ? { ...parent.meta, ...opts.meta } : { ...(opts.meta ?? {}) };
|
|
551
|
+
const requestID = opts.requestID ?? parent?.requestID ?? broker.generateUid();
|
|
552
|
+
// The methods close over `ctx` so nested calls can chain from it (parentID,
|
|
553
|
+
// level, caller); they run only after the object exists, so the self-ref is safe.
|
|
554
|
+
const ctx = {
|
|
555
|
+
params,
|
|
556
|
+
meta,
|
|
557
|
+
locals: {},
|
|
558
|
+
headers: { ...(opts.headers ?? {}) },
|
|
559
|
+
broker,
|
|
560
|
+
service,
|
|
561
|
+
name,
|
|
562
|
+
action: evt ? undefined : { name },
|
|
563
|
+
event: evt,
|
|
564
|
+
eventName: evt?.name,
|
|
565
|
+
eventType: evt?.type,
|
|
566
|
+
eventGroups: evt?.groups,
|
|
567
|
+
nodeID: broker.nodeID,
|
|
568
|
+
id: broker.generateUid(),
|
|
569
|
+
parentID: parent?.id ?? null,
|
|
570
|
+
level: parent ? parent.level + 1 : 1,
|
|
571
|
+
caller: parent?.service?.fullName ?? null,
|
|
572
|
+
requestID,
|
|
573
|
+
call: (action, callParams, callOpts = {}) => broker.call(action, callParams, { ...callOpts, parentCtx: ctx }),
|
|
574
|
+
mcall: (defs, callOpts = {}) => broker.mcall(defs, { ...callOpts, parentCtx: ctx }),
|
|
575
|
+
toJSON: () => ({
|
|
576
|
+
id: ctx.id,
|
|
577
|
+
requestID: ctx.requestID,
|
|
578
|
+
parentID: ctx.parentID,
|
|
579
|
+
level: ctx.level,
|
|
580
|
+
nodeID: ctx.nodeID,
|
|
581
|
+
caller: ctx.caller,
|
|
582
|
+
name: ctx.name,
|
|
583
|
+
eventType: ctx.eventType,
|
|
584
|
+
meta: ctx.meta,
|
|
585
|
+
}),
|
|
586
|
+
emit: (ev, evPayload, evOpts = {}) => broker.emit(ev, evPayload, { ...evOpts, meta: { ...ctx.meta, ...evOpts.meta } }),
|
|
587
|
+
broadcast: (ev, evPayload, evOpts = {}) => broker.broadcast(ev, evPayload, { ...evOpts, meta: { ...ctx.meta, ...evOpts.meta } }),
|
|
588
|
+
};
|
|
589
|
+
return ctx;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/* --------------------------------- global --------------------------------- */
|
|
593
|
+
let instance = new Broker();
|
|
594
|
+
/** Register the default broker returned by `broker()`. */
|
|
595
|
+
export function setBroker(next) {
|
|
596
|
+
instance = next;
|
|
597
|
+
return instance;
|
|
598
|
+
}
|
|
599
|
+
/** The default broker instance. */
|
|
600
|
+
export function broker() {
|
|
601
|
+
return instance;
|
|
602
|
+
}
|
package/dist/core/crypto.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
export declare const hash: {
|
|
12
12
|
/** Hash a password (PBKDF2-SHA256 with a random salt). */
|
|
13
13
|
make(password: string, iterations?: number): Promise<string>;
|
|
14
|
-
/** Verify a password against a stored hash. */
|
|
14
|
+
/** Verify a password against a stored hash. Returns false for any malformed hash. */
|
|
15
15
|
verify(hashed: string, password: string): Promise<boolean>;
|
|
16
16
|
/** Whether a hash was made with fewer iterations than the current default. */
|
|
17
17
|
needsRehash(hashed: string, iterations?: number): boolean;
|
package/dist/core/crypto.js
CHANGED
|
@@ -51,13 +51,22 @@ export const hash = {
|
|
|
51
51
|
const derived = await pbkdf2(password, salt, iterations);
|
|
52
52
|
return `pbkdf2_sha256$${iterations}$${b64(salt)}$${b64(derived)}`;
|
|
53
53
|
},
|
|
54
|
-
/** Verify a password against a stored hash. */
|
|
54
|
+
/** Verify a password against a stored hash. Returns false for any malformed hash. */
|
|
55
55
|
async verify(hashed, password) {
|
|
56
56
|
const [algo, iter, salt64, hash64] = hashed.split("$");
|
|
57
57
|
if (algo !== "pbkdf2_sha256" || !iter || !salt64 || !hash64)
|
|
58
58
|
return false;
|
|
59
|
-
const
|
|
60
|
-
|
|
59
|
+
const iterations = Number(iter);
|
|
60
|
+
if (!Number.isInteger(iterations) || iterations < 1)
|
|
61
|
+
return false;
|
|
62
|
+
try {
|
|
63
|
+
const derived = await pbkdf2(password, fromB64(salt64), iterations);
|
|
64
|
+
return safeEqual(b64(derived), hash64);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Malformed base64 salt/hash, etc. — treat as a non-match, never throw.
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
61
70
|
},
|
|
62
71
|
/** Whether a hash was made with fewer iterations than the current default. */
|
|
63
72
|
needsRehash(hashed, iterations = DEFAULT_ITERATIONS) {
|
package/dist/core/database.d.ts
CHANGED
|
@@ -14,10 +14,18 @@ export interface WriteResult {
|
|
|
14
14
|
rowsAffected: number;
|
|
15
15
|
insertId?: number | string;
|
|
16
16
|
}
|
|
17
|
+
/** A page of results plus pagination metadata, returned by `paginate()`. */
|
|
18
|
+
export interface Paginated<T> {
|
|
19
|
+
data: T[];
|
|
20
|
+
total: number;
|
|
21
|
+
perPage: number;
|
|
22
|
+
currentPage: number;
|
|
23
|
+
lastPage: number;
|
|
24
|
+
}
|
|
17
25
|
/** The bridge to your database driver. */
|
|
18
26
|
export interface Connection {
|
|
19
27
|
/** Run a SELECT (or any row-returning query) and return the rows. */
|
|
20
|
-
select
|
|
28
|
+
select(sql: string, bindings: unknown[]): Promise<Row[]>;
|
|
21
29
|
/** Run an INSERT/UPDATE/DELETE and return write metadata. */
|
|
22
30
|
write(sql: string, bindings: unknown[]): Promise<WriteResult>;
|
|
23
31
|
}
|
|
@@ -41,7 +49,13 @@ export declare class QueryBuilder<T extends Row = Row> {
|
|
|
41
49
|
whereIn(column: string, values: unknown[]): this;
|
|
42
50
|
whereNull(column: string): this;
|
|
43
51
|
whereNotNull(column: string): this;
|
|
52
|
+
whereNotIn(column: string, values: unknown[]): this;
|
|
53
|
+
whereBetween(column: string, [min, max]: [unknown, unknown]): this;
|
|
54
|
+
whereLike(column: string, pattern: string): this;
|
|
44
55
|
orderBy(column: string, direction?: "asc" | "desc"): this;
|
|
56
|
+
/** Newest-first / oldest-first by a timestamp column (default `created_at`). */
|
|
57
|
+
latest(column?: string): this;
|
|
58
|
+
oldest(column?: string): this;
|
|
45
59
|
limit(n: number): this;
|
|
46
60
|
offset(n: number): this;
|
|
47
61
|
private whereClause;
|
|
@@ -49,6 +63,17 @@ export declare class QueryBuilder<T extends Row = Row> {
|
|
|
49
63
|
first(): Promise<T | null>;
|
|
50
64
|
count(): Promise<number>;
|
|
51
65
|
exists(): Promise<boolean>;
|
|
66
|
+
private aggregate;
|
|
67
|
+
sum(column: string): Promise<number>;
|
|
68
|
+
avg(column: string): Promise<number>;
|
|
69
|
+
min(column: string): Promise<number>;
|
|
70
|
+
max(column: string): Promise<number>;
|
|
71
|
+
/** The value of a single column from the first matching row (or null). */
|
|
72
|
+
value<V = unknown>(column: string): Promise<V | null>;
|
|
73
|
+
/** An array of a single column across all matching rows. */
|
|
74
|
+
pluck<V = unknown>(column: string): Promise<V[]>;
|
|
75
|
+
/** A page of results plus pagination metadata. */
|
|
76
|
+
paginate(page?: number, perPage?: number): Promise<Paginated<T>>;
|
|
52
77
|
insert(data: Row): Promise<WriteResult>;
|
|
53
78
|
insertGetId(data: Row): Promise<number | string | undefined>;
|
|
54
79
|
update(data: Row): Promise<WriteResult>;
|