@shaferllc/keel 0.36.0 → 0.59.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.
Files changed (57) hide show
  1. package/README.md +14 -2
  2. package/dist/core/application.d.ts +58 -2
  3. package/dist/core/application.js +99 -3
  4. package/dist/core/auth.d.ts +21 -2
  5. package/dist/core/auth.js +38 -3
  6. package/dist/core/authorization.d.ts +52 -0
  7. package/dist/core/authorization.js +97 -0
  8. package/dist/core/broadcasting.d.ts +49 -0
  9. package/dist/core/broadcasting.js +84 -0
  10. package/dist/core/broker.d.ts +398 -0
  11. package/dist/core/broker.js +602 -0
  12. package/dist/core/crypto.d.ts +51 -1
  13. package/dist/core/crypto.js +96 -3
  14. package/dist/core/database.d.ts +26 -1
  15. package/dist/core/database.js +65 -2
  16. package/dist/core/decorators.d.ts +39 -0
  17. package/dist/core/decorators.js +72 -0
  18. package/dist/core/exceptions.d.ts +77 -6
  19. package/dist/core/exceptions.js +168 -10
  20. package/dist/core/helpers.d.ts +6 -0
  21. package/dist/core/helpers.js +12 -0
  22. package/dist/core/http/kernel.js +14 -2
  23. package/dist/core/http/router.d.ts +14 -0
  24. package/dist/core/http/router.js +30 -1
  25. package/dist/core/index.d.ts +31 -8
  26. package/dist/core/index.js +17 -5
  27. package/dist/core/logger.d.ts +5 -0
  28. package/dist/core/logger.js +24 -2
  29. package/dist/core/migrations.js +3 -3
  30. package/dist/core/model.d.ts +19 -1
  31. package/dist/core/model.js +72 -4
  32. package/dist/core/provider.d.ts +13 -4
  33. package/dist/core/provider.js +12 -2
  34. package/dist/core/redis.d.ts +78 -0
  35. package/dist/core/redis.js +176 -0
  36. package/dist/core/request-logger.d.ts +26 -0
  37. package/dist/core/request-logger.js +48 -0
  38. package/dist/core/request.d.ts +17 -1
  39. package/dist/core/request.js +27 -1
  40. package/dist/core/scheduler.d.ts +60 -0
  41. package/dist/core/scheduler.js +166 -0
  42. package/dist/core/session.js +17 -2
  43. package/dist/core/storage.d.ts +57 -0
  44. package/dist/core/storage.js +98 -0
  45. package/dist/core/template.d.ts +50 -0
  46. package/dist/core/template.js +753 -0
  47. package/dist/core/testing.d.ts +54 -0
  48. package/dist/core/testing.js +141 -0
  49. package/dist/core/transformer.d.ts +89 -0
  50. package/dist/core/transformer.js +152 -0
  51. package/dist/core/validation.d.ts +20 -0
  52. package/dist/core/validation.js +52 -1
  53. package/dist/core/vite.d.ts +117 -0
  54. package/dist/core/vite.js +258 -0
  55. package/dist/vite/index.d.ts +40 -0
  56. package/dist/vite/index.js +146 -0
  57. 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
+ }
@@ -7,11 +7,14 @@
7
7
  *
8
8
  * const token = await encryption.encrypt({ userId: 1 });
9
9
  * await encryption.decrypt(token); // { userId: 1 } | null
10
+ *
11
+ * const token = await jwt.sign({ sub: "42" }, { expiresIn: "1h" });
12
+ * await jwt.verify(token); // { sub: "42", iat, exp } | null
10
13
  */
11
14
  export declare const hash: {
12
15
  /** Hash a password (PBKDF2-SHA256 with a random salt). */
13
16
  make(password: string, iterations?: number): Promise<string>;
14
- /** Verify a password against a stored hash. */
17
+ /** Verify a password against a stored hash. Returns false for any malformed hash. */
15
18
  verify(hashed: string, password: string): Promise<boolean>;
16
19
  /** Whether a hash was made with fewer iterations than the current default. */
17
20
  needsRehash(hashed: string, iterations?: number): boolean;
@@ -22,3 +25,50 @@ export declare const encryption: {
22
25
  /** Decrypt a value; returns null if the payload is tampered or invalid. */
23
26
  decrypt<T = unknown>(payload: string): Promise<T | null>;
24
27
  };
28
+ /** Standard registered claims, plus whatever custom fields you sign. */
29
+ export interface JwtPayload {
30
+ /** Subject — conventionally the user id. */
31
+ sub?: string;
32
+ /** Issued-at (seconds since the epoch); set automatically by `sign`. */
33
+ iat?: number;
34
+ /** Expiry (seconds since the epoch); set from `expiresIn`. */
35
+ exp?: number;
36
+ /** Not-before (seconds since the epoch); the token is invalid until then. */
37
+ nbf?: number;
38
+ /** Issuer. */
39
+ iss?: string;
40
+ /** Audience. */
41
+ aud?: string;
42
+ [claim: string]: unknown;
43
+ }
44
+ export interface JwtSignOptions {
45
+ /** Lifetime — seconds (number) or a duration string like `"30s"`, `"15m"`, `"1h"`, `"7d"`. */
46
+ expiresIn?: number | string;
47
+ /** Sets the `iss` claim. */
48
+ issuer?: string;
49
+ /** Sets the `aud` claim. */
50
+ audience?: string;
51
+ /** Sets the `sub` claim (overrides any `sub` already in the payload). */
52
+ subject?: string;
53
+ /** Signing secret; defaults to `config('app.key')`. */
54
+ secret?: string;
55
+ }
56
+ export interface JwtVerifyOptions {
57
+ /** Require this `iss`; a token that doesn't match is rejected. */
58
+ issuer?: string;
59
+ /** Require this `aud`; a token that doesn't match is rejected. */
60
+ audience?: string;
61
+ /** Verifying secret; defaults to `config('app.key')`. */
62
+ secret?: string;
63
+ }
64
+ export declare const jwt: {
65
+ /** Sign a payload into an HS256 JWT. Adds `iat`, and `exp` when `expiresIn` is set. */
66
+ sign(payload: JwtPayload, options?: JwtSignOptions): Promise<string>;
67
+ /**
68
+ * Verify an HS256 JWT and return its payload, or `null` if the token is
69
+ * malformed, tampered, expired, not-yet-valid, or fails an issuer/audience
70
+ * check. Only HS256 is accepted — `alg: none` and asymmetric algs are refused,
71
+ * closing the classic JWT algorithm-confusion hole.
72
+ */
73
+ verify<T extends JwtPayload = JwtPayload>(token: string, options?: JwtVerifyOptions): Promise<T | null>;
74
+ };