@jami-studio/core 0.92.27 → 0.92.29

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 (56) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +34 -0
  3. package/corpus/core/package.json +2 -1
  4. package/corpus/core/src/deploy/build.ts +120 -2
  5. package/corpus/core/src/event-bus/bus.ts +138 -140
  6. package/corpus/core/src/event-bus/registry.ts +81 -79
  7. package/corpus/core/src/file-upload/registry.ts +135 -125
  8. package/corpus/core/src/notifications/registry.ts +218 -216
  9. package/corpus/core/src/private-blob/registry.ts +246 -242
  10. package/corpus/core/src/secrets/register.ts +132 -129
  11. package/corpus/core/src/shared/global-scope.ts +75 -0
  12. package/corpus/core/src/sharing/registry.ts +193 -194
  13. package/corpus/core/src/tracking/providers.ts +425 -422
  14. package/corpus/core/src/tracking/registry.ts +102 -102
  15. package/dist/collab/routes.d.ts +1 -1
  16. package/dist/deploy/build.d.ts +44 -0
  17. package/dist/deploy/build.d.ts.map +1 -1
  18. package/dist/deploy/build.js +91 -2
  19. package/dist/deploy/build.js.map +1 -1
  20. package/dist/event-bus/bus.d.ts.map +1 -1
  21. package/dist/event-bus/bus.js +8 -6
  22. package/dist/event-bus/bus.js.map +1 -1
  23. package/dist/event-bus/registry.d.ts.map +1 -1
  24. package/dist/event-bus/registry.js +10 -6
  25. package/dist/event-bus/registry.js.map +1 -1
  26. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  27. package/dist/file-upload/registry.d.ts.map +1 -1
  28. package/dist/file-upload/registry.js +28 -8
  29. package/dist/file-upload/registry.js.map +1 -1
  30. package/dist/notifications/registry.d.ts.map +1 -1
  31. package/dist/notifications/registry.js +6 -5
  32. package/dist/notifications/registry.js.map +1 -1
  33. package/dist/observability/routes.d.ts +5 -5
  34. package/dist/private-blob/registry.d.ts.map +1 -1
  35. package/dist/private-blob/registry.js +18 -13
  36. package/dist/private-blob/registry.js.map +1 -1
  37. package/dist/progress/routes.d.ts +1 -1
  38. package/dist/resources/handlers.d.ts +2 -2
  39. package/dist/secrets/register.d.ts.map +1 -1
  40. package/dist/secrets/register.js +11 -7
  41. package/dist/secrets/register.js.map +1 -1
  42. package/dist/secrets/routes.d.ts +9 -9
  43. package/dist/shared/global-scope.d.ts +55 -0
  44. package/dist/shared/global-scope.d.ts.map +1 -0
  45. package/dist/shared/global-scope.js +70 -0
  46. package/dist/shared/global-scope.js.map +1 -0
  47. package/dist/sharing/registry.d.ts.map +1 -1
  48. package/dist/sharing/registry.js +2 -16
  49. package/dist/sharing/registry.js.map +1 -1
  50. package/dist/tracking/providers.d.ts.map +1 -1
  51. package/dist/tracking/providers.js +11 -9
  52. package/dist/tracking/providers.js.map +1 -1
  53. package/dist/tracking/registry.d.ts.map +1 -1
  54. package/dist/tracking/registry.js +5 -5
  55. package/dist/tracking/registry.js.map +1 -1
  56. package/package.json +2 -1
package/corpus/README.md CHANGED
@@ -27,5 +27,5 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
27
27
 
28
28
  ## Generated Counts
29
29
 
30
- - core files: 2156
30
+ - core files: 2157
31
31
  - template files: 5114
@@ -1,5 +1,39 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.92.29
4
+
5
+ ### Patch Changes
6
+
7
+ - b9c3561: Cloudflare Pages static-shell deployments: strip `hasLoader`/`hasAction` from
8
+ the React Router client manifest at build time. The CF worker intentionally
9
+ ships no React Router request handler (static app shell keeps the merged
10
+ worker under the platform bundle-size limit), but templates build with
11
+ `ssr: true`, so the hydrated router issued single-fetch `GET <route>.data`
12
+ requests on every client-side navigation into a route with a server loader —
13
+ nothing served them, they 404'd, and React Router tripped the route
14
+ ErrorBoundary (`No result found for routeId "..."`), breaking in-app
15
+ navigation across every app of a unified workspace deployment. With the
16
+ server-only flags stripped, client-side navigation behaves exactly like the
17
+ initial static-shell load: render with client data only. `hasClientLoader`/
18
+ `hasClientAction` are preserved; other presets are unaffected.
19
+
20
+ ## 0.92.28
21
+
22
+ ### Patch Changes
23
+
24
+ - 77b77e7: Per-app global-registry scoping on unified workspace deployments. The
25
+ globalThis-pinned framework registries (file-upload providers, private-blob
26
+ providers, shareable resources, event-bus registry + bus, notification
27
+ channels, tracking providers + queue, secrets registry) were shared across
28
+ every app in the single Cloudflare Pages isolate, so one app's registrations
29
+ served another app's requests — an upload POSTed to /assets was handled by
30
+ the clips app's S3 provider (wrong object prefix). Registries now resolve
31
+ their global keys lazily through a per-module-graph scope
32
+ (`@agent-native/core/global-scope`), and each mounted app's generated worker
33
+ entry sets its scope id via a `_scope-init.js` module evaluated first in the
34
+ bundle's import graph. Dev-mode and single-app deployments stay unscoped, so
35
+ the original multi-ESM-graph dedupe behavior is unchanged.
36
+
3
37
  ## 0.92.27
4
38
 
5
39
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.92.27",
3
+ "version": "0.92.29",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/studio-jami/jami-studio#readme",
6
6
  "bugs": {
@@ -97,6 +97,7 @@
97
97
  "./embedding/react": "./dist/embedding/react.js",
98
98
  "./onboarding": "./dist/onboarding/index.js",
99
99
  "./shared": "./dist/shared/index.js",
100
+ "./global-scope": "./dist/shared/global-scope.js",
100
101
  "./voice": "./dist/voice/index.js",
101
102
  "./scripts": "./dist/scripts/index.js",
102
103
  "./application-state": "./dist/application-state/index.js",
@@ -605,6 +605,46 @@ export const CLOUDFLARE_WORKER_NODE_BUILTIN_STUB_MODULES: Record<
605
605
 
606
606
  export interface GenerateWorkerEntryOptions {
607
607
  includeReactRouterSsr?: boolean;
608
+ /**
609
+ * Per-app global-registry scope id for unified workspace deployments.
610
+ * When set, the generated entry's FIRST import is `./_scope-init.js`
611
+ * (written by the build via `generateScopeInitSource`), which calls
612
+ * core's `setGlobalScopeId(appId)` before any other module in the app
613
+ * bundle evaluates — so every globalThis-pinned registry in this app's
614
+ * module graph resolves per-app keys instead of colliding with sibling
615
+ * apps in the shared isolate (issue: /assets uploads served by clips'
616
+ * provider).
617
+ */
618
+ appScopeId?: string | null;
619
+ }
620
+
621
+ /**
622
+ * Source of the `_scope-init.js` module the worker entry imports first on
623
+ * unified workspace deployments. Must stay dependency-free beyond core's
624
+ * lean `global-scope` subpath so nothing registry-touching evaluates before
625
+ * the scope id is set (ESM evaluates imports depth-first in order).
626
+ */
627
+ export function generateScopeInitSource(appScopeId: string): string {
628
+ return `// AUTO-GENERATED by @agent-native/core deploy build
629
+ // Scope globalThis-pinned framework registries to this app's module graph.
630
+ // Evaluated FIRST in the worker entry's import graph so the scope is set
631
+ // before any registry module initializes. See core shared/global-scope.
632
+ import { setGlobalScopeId } from "@agent-native/core/global-scope";
633
+ setGlobalScopeId(${JSON.stringify(appScopeId)});
634
+ `;
635
+ }
636
+
637
+ /**
638
+ * Derive the workspace app id from a mounted app base path ("/assets" →
639
+ * "assets"). Returns null for unmounted (root) builds.
640
+ */
641
+ export function workspaceAppScopeIdFromBasePath(
642
+ basePath: string | undefined,
643
+ ): string | null {
644
+ const normalized = normalizeAppBasePath(basePath ?? "");
645
+ if (!normalized) return null;
646
+ const [first] = normalized.slice(1).split("/");
647
+ return first || null;
608
648
  }
609
649
 
610
650
  interface ReactRouterAssetManifest {
@@ -625,6 +665,7 @@ interface ReactRouterAssetManifestRoute {
625
665
  imports?: string[];
626
666
  css?: string[];
627
667
  hasLoader?: boolean;
668
+ hasAction?: boolean;
628
669
  clientActionModule?: string;
629
670
  clientLoaderModule?: string;
630
671
  clientMiddlewareModule?: string;
@@ -734,6 +775,7 @@ export function generateWorkerEntry(
734
775
  options: GenerateWorkerEntryOptions = {},
735
776
  ): string {
736
777
  const includeReactRouterSsr = options.includeReactRouterSsr ?? true;
778
+ const appScopeId = options.appScopeId ?? null;
737
779
  const routeImports: string[] = [];
738
780
  const routeRegistrations: string[] = [];
739
781
 
@@ -854,7 +896,7 @@ export function generateWorkerEntry(
854
896
 
855
897
  return `
856
898
  // Auto-generated worker entry point for ${preset}
857
- import { H3, defineEventHandler, readBody, toResponse } from "h3";
899
+ ${appScopeId ? 'import "./_scope-init.js";\n' : ""}import { H3, defineEventHandler, readBody, toResponse } from "h3";
858
900
  ${includeReactRouterSsr ? 'import { createRequestHandler } from "react-router";' : ""}
859
901
  ${includeReactRouterSsr ? 'import * as serverBuild from "./server-build.js";' : ""}
860
902
 
@@ -1416,6 +1458,63 @@ function findReactRouterManifest(distDir: string): ReactRouterAssetManifest {
1416
1458
  return JSON.parse(match[1].replace(/;$/, "")) as ReactRouterAssetManifest;
1417
1459
  }
1418
1460
 
1461
+ /**
1462
+ * Align the React Router CLIENT manifest with the static-shell worker.
1463
+ *
1464
+ * The Cloudflare Pages worker intentionally ships NO React Router request
1465
+ * handler (`includeReactRouterSsr = false` keeps the merged worker under the
1466
+ * platform bundle-size limit; navigations get the static app shell instead).
1467
+ * Templates build with `ssr: true`, so the emitted client manifest still
1468
+ * advertises `hasLoader`/`hasAction` for routes with server exports — and the
1469
+ * hydrated router then issues single-fetch `GET <route>.data` requests on
1470
+ * every client-side navigation into such a route. Nothing serves them: the
1471
+ * request 404s and React Router trips the route ErrorBoundary
1472
+ * (`No result found for routeId "..."`), breaking in-app navigation on every
1473
+ * app of a unified workspace deployment.
1474
+ *
1475
+ * Stripping the server-only flags makes client-side navigation behave exactly
1476
+ * like the initial static-shell load already does: render with client data
1477
+ * only (server loaders never run anywhere in this deployment shape).
1478
+ * `hasClientLoader` / `hasClientAction` are preserved untouched.
1479
+ */
1480
+ export function stripServerDataFlagsFromClientManifest(distDir: string): void {
1481
+ const assetsDir = path.join(distDir, "assets");
1482
+ if (!fs.existsSync(assetsDir)) return;
1483
+ const manifestFile = fs
1484
+ .readdirSync(assetsDir)
1485
+ .find((file) => /^manifest-[\w-]+\.js$/.test(file));
1486
+ if (!manifestFile) return;
1487
+
1488
+ const manifestPath = path.join(assetsDir, manifestFile);
1489
+ const source = fs.readFileSync(manifestPath, "utf8");
1490
+ const match = source.match(/^window\.__reactRouterManifest=(.*);?\s*$/);
1491
+ if (!match) return;
1492
+
1493
+ const manifest = JSON.parse(
1494
+ match[1].replace(/;$/, ""),
1495
+ ) as ReactRouterAssetManifest;
1496
+ let stripped = 0;
1497
+ for (const route of Object.values(manifest.routes ?? {})) {
1498
+ if (route.hasLoader) {
1499
+ route.hasLoader = false;
1500
+ stripped++;
1501
+ }
1502
+ if (route.hasAction) {
1503
+ route.hasAction = false;
1504
+ stripped++;
1505
+ }
1506
+ }
1507
+ if (stripped === 0) return;
1508
+
1509
+ fs.writeFileSync(
1510
+ manifestPath,
1511
+ `window.__reactRouterManifest=${JSON.stringify(manifest)};`,
1512
+ );
1513
+ console.log(
1514
+ `[deploy] Stripped ${stripped} server loader/action flag(s) from ${manifestFile} — the static-shell worker has no React Router handler, so single-fetch .data requests would 404.`,
1515
+ );
1516
+ }
1517
+
1419
1518
  function collectModulePreloads(
1420
1519
  manifest: ReactRouterAssetManifest,
1421
1520
  route: ReactRouterAssetManifestRoute,
@@ -1632,6 +1731,12 @@ async function buildCloudflarePages() {
1632
1731
  fs.mkdirSync(tmpDir, { recursive: true });
1633
1732
  writeCloudflarePagesStaticShell({ serverDir, distDir, tmpDir });
1634
1733
 
1734
+ // AFTER the shell is written (its manifest fallback reads the original
1735
+ // hasLoader flag to pick the embedded root-loader turbo stream): align the
1736
+ // client manifest with the handler-less worker so client-side navigations
1737
+ // don't issue .data single-fetch requests that can only 404.
1738
+ stripServerDataFlagsFromClientManifest(distDir);
1739
+
1635
1740
  // Exclude _worker.js from being served as a public asset
1636
1741
  fs.writeFileSync(path.join(distDir, ".assetsignore"), "_worker.js\n");
1637
1742
 
@@ -1668,6 +1773,12 @@ async function buildCloudflarePages() {
1668
1773
 
1669
1774
  // Generate the worker entry
1670
1775
  const immutableAssetPaths = collectImmutableAssetPaths(clientDir);
1776
+ // Per-app global-registry scoping for unified workspace deploys: mounted
1777
+ // builds (APP_BASE_PATH=/<app>) get a scope-init module evaluated first so
1778
+ // globalThis-pinned registries stay per-app in the shared isolate.
1779
+ const appScopeId = workspaceAppScopeIdFromBasePath(
1780
+ normalizeConfiguredAppBasePath(),
1781
+ );
1671
1782
  const entrySource = generateWorkerEntry(
1672
1783
  routes,
1673
1784
  plugins,
@@ -1676,7 +1787,7 @@ async function buildCloudflarePages() {
1676
1787
  workspaceCore,
1677
1788
  immutableAssetPaths,
1678
1789
  normalizeConfiguredAppBasePath(),
1679
- { includeReactRouterSsr },
1790
+ { includeReactRouterSsr, appScopeId },
1680
1791
  );
1681
1792
 
1682
1793
  // Create _worker.js output directory
@@ -1704,6 +1815,13 @@ async function buildCloudflarePages() {
1704
1815
  const tmpEntry = path.join(tmpDir, "index.js");
1705
1816
  fs.writeFileSync(tmpEntry, adjustedEntry);
1706
1817
 
1818
+ if (appScopeId) {
1819
+ fs.writeFileSync(
1820
+ path.join(tmpDir, "_scope-init.js"),
1821
+ generateScopeInitSource(appScopeId),
1822
+ );
1823
+ }
1824
+
1707
1825
  if (includeReactRouterSsr) {
1708
1826
  copyDir(serverDir, path.join(tmpDir, "server"));
1709
1827
  }
@@ -1,140 +1,138 @@
1
- /**
2
- * Typed pub/sub bus for framework events.
3
- *
4
- * Wraps Node's EventEmitter with payload validation against the registered
5
- * Standard Schema for each event. Handler errors are caught and logged so a
6
- * misbehaving subscriber can never crash the emitter.
7
- */
8
-
9
- import { randomUUID } from "node:crypto";
10
- import { EventEmitter } from "node:events";
11
-
12
- import { getEvent } from "./registry.js";
13
- import type { EventMeta } from "./types.js";
14
-
15
- type Handler = (payload: unknown, meta: EventMeta) => void | Promise<void>;
16
-
17
- interface BusState {
18
- emitter: EventEmitter;
19
- subscriptions: Map<string, { event: string; handler: Handler }>;
20
- }
21
-
22
- const BUS_KEY = Symbol.for("@agent-native/core/event-bus.bus");
23
- interface GlobalWithBus {
24
- [BUS_KEY]?: BusState;
25
- }
26
-
27
- function getBus(): BusState {
28
- const g = globalThis as unknown as GlobalWithBus;
29
- if (!g[BUS_KEY]) {
30
- const emitter = new EventEmitter();
31
- // Many integrations may subscribe to the same event; lift the warning
32
- // ceiling rather than printing MaxListenersExceededWarning at runtime.
33
- emitter.setMaxListeners(0);
34
- g[BUS_KEY] = { emitter, subscriptions: new Map() };
35
- }
36
- return g[BUS_KEY]!;
37
- }
38
-
39
- export function subscribe(event: string, handler: Handler): string {
40
- if (typeof event !== "string" || !event) {
41
- throw new Error("subscribe: event name is required");
42
- }
43
- if (typeof handler !== "function") {
44
- throw new Error("subscribe: handler must be a function");
45
- }
46
- const bus = getBus();
47
- const id = randomUUID();
48
- bus.subscriptions.set(id, { event, handler });
49
- bus.emitter.on(event, handler);
50
- return id;
51
- }
52
-
53
- export function unsubscribe(id: string): boolean {
54
- const bus = getBus();
55
- const sub = bus.subscriptions.get(id);
56
- if (!sub) return false;
57
- bus.emitter.off(sub.event, sub.handler);
58
- bus.subscriptions.delete(id);
59
- return true;
60
- }
61
-
62
- export function emit(
63
- event: string,
64
- payload: unknown,
65
- meta?: Partial<EventMeta>,
66
- ): void {
67
- if (typeof event !== "string" || !event) {
68
- throw new Error("emit: event name is required");
69
- }
70
- const bus = getBus();
71
- const def = getEvent(event);
72
-
73
- let validated: unknown = payload;
74
- if (def) {
75
- const result = def.payloadSchema["~standard"].validate(payload);
76
- if (result instanceof Promise) {
77
- console.warn(
78
- `[event-bus] Payload schema for "${event}" returned a Promise — ` +
79
- `async validation is not supported. Dispatching unvalidated payload.`,
80
- );
81
- } else if (result.issues) {
82
- console.warn(
83
- `[event-bus] Payload validation failed for "${event}":`,
84
- result.issues,
85
- );
86
- return;
87
- } else {
88
- validated = (result as { value: unknown }).value;
89
- }
90
- } else {
91
- console.warn(
92
- `[event-bus] Emitting unregistered event "${event}". ` +
93
- `Call registerEvent() to declare it.`,
94
- );
95
- }
96
-
97
- const fullMeta: EventMeta = {
98
- eventId: meta?.eventId ?? randomUUID(),
99
- emittedAt: meta?.emittedAt ?? new Date().toISOString(),
100
- owner: meta?.owner,
101
- };
102
-
103
- // Snapshot listeners so a handler that subscribes/unsubscribes during
104
- // dispatch doesn't perturb this emission.
105
- const listeners = bus.emitter.listeners(event) as Handler[];
106
- for (const listener of listeners) {
107
- try {
108
- const r = listener(validated, fullMeta);
109
- if (r && typeof (r as Promise<void>).catch === "function") {
110
- (r as Promise<void>).catch((err) => {
111
- console.error(
112
- `[event-bus] Async handler for "${event}" rejected:`,
113
- err,
114
- );
115
- });
116
- }
117
- } catch (err) {
118
- console.error(`[event-bus] Handler for "${event}" threw:`, err);
119
- }
120
- }
121
- }
122
-
123
- export function listSubscriptions(
124
- event?: string,
125
- ): { id: string; event: string }[] {
126
- const bus = getBus();
127
- const out: { id: string; event: string }[] = [];
128
- for (const [id, sub] of bus.subscriptions) {
129
- if (event && sub.event !== event) continue;
130
- out.push({ id, event: sub.event });
131
- }
132
- return out;
133
- }
134
-
135
- /** Test helper — drops all subscriptions. */
136
- export function __resetEventBus(): void {
137
- const bus = getBus();
138
- bus.emitter.removeAllListeners();
139
- bus.subscriptions.clear();
140
- }
1
+ /**
2
+ * Typed pub/sub bus for framework events.
3
+ *
4
+ * Wraps Node's EventEmitter with payload validation against the registered
5
+ * Standard Schema for each event. Handler errors are caught and logged so a
6
+ * misbehaving subscriber can never crash the emitter.
7
+ */
8
+
9
+ import { randomUUID } from "node:crypto";
10
+ import { EventEmitter } from "node:events";
11
+
12
+ import { getScopedGlobal } from "../shared/global-scope.js";
13
+ import { getEvent } from "./registry.js";
14
+ import type { EventMeta } from "./types.js";
15
+
16
+ type Handler = (payload: unknown, meta: EventMeta) => void | Promise<void>;
17
+
18
+ interface BusState {
19
+ emitter: EventEmitter;
20
+ subscriptions: Map<string, { event: string; handler: Handler }>;
21
+ }
22
+
23
+ // globalThis-pinned so one app's ESM graphs share one bus, but scope-aware +
24
+ // lazily resolved so unified workspace deployments (all apps in one isolate)
25
+ // keep per-app buses — one app's handlers never fire for a same-named event
26
+ // emitted by a sibling app. See shared/global-scope.
27
+ function getBus(): BusState {
28
+ return getScopedGlobal("agent-native.event-bus.bus", () => {
29
+ const emitter = new EventEmitter();
30
+ // Many integrations may subscribe to the same event; lift the warning
31
+ // ceiling rather than printing MaxListenersExceededWarning at runtime.
32
+ emitter.setMaxListeners(0);
33
+ return { emitter, subscriptions: new Map() };
34
+ });
35
+ }
36
+
37
+ export function subscribe(event: string, handler: Handler): string {
38
+ if (typeof event !== "string" || !event) {
39
+ throw new Error("subscribe: event name is required");
40
+ }
41
+ if (typeof handler !== "function") {
42
+ throw new Error("subscribe: handler must be a function");
43
+ }
44
+ const bus = getBus();
45
+ const id = randomUUID();
46
+ bus.subscriptions.set(id, { event, handler });
47
+ bus.emitter.on(event, handler);
48
+ return id;
49
+ }
50
+
51
+ export function unsubscribe(id: string): boolean {
52
+ const bus = getBus();
53
+ const sub = bus.subscriptions.get(id);
54
+ if (!sub) return false;
55
+ bus.emitter.off(sub.event, sub.handler);
56
+ bus.subscriptions.delete(id);
57
+ return true;
58
+ }
59
+
60
+ export function emit(
61
+ event: string,
62
+ payload: unknown,
63
+ meta?: Partial<EventMeta>,
64
+ ): void {
65
+ if (typeof event !== "string" || !event) {
66
+ throw new Error("emit: event name is required");
67
+ }
68
+ const bus = getBus();
69
+ const def = getEvent(event);
70
+
71
+ let validated: unknown = payload;
72
+ if (def) {
73
+ const result = def.payloadSchema["~standard"].validate(payload);
74
+ if (result instanceof Promise) {
75
+ console.warn(
76
+ `[event-bus] Payload schema for "${event}" returned a Promise — ` +
77
+ `async validation is not supported. Dispatching unvalidated payload.`,
78
+ );
79
+ } else if (result.issues) {
80
+ console.warn(
81
+ `[event-bus] Payload validation failed for "${event}":`,
82
+ result.issues,
83
+ );
84
+ return;
85
+ } else {
86
+ validated = (result as { value: unknown }).value;
87
+ }
88
+ } else {
89
+ console.warn(
90
+ `[event-bus] Emitting unregistered event "${event}". ` +
91
+ `Call registerEvent() to declare it.`,
92
+ );
93
+ }
94
+
95
+ const fullMeta: EventMeta = {
96
+ eventId: meta?.eventId ?? randomUUID(),
97
+ emittedAt: meta?.emittedAt ?? new Date().toISOString(),
98
+ owner: meta?.owner,
99
+ };
100
+
101
+ // Snapshot listeners so a handler that subscribes/unsubscribes during
102
+ // dispatch doesn't perturb this emission.
103
+ const listeners = bus.emitter.listeners(event) as Handler[];
104
+ for (const listener of listeners) {
105
+ try {
106
+ const r = listener(validated, fullMeta);
107
+ if (r && typeof (r as Promise<void>).catch === "function") {
108
+ (r as Promise<void>).catch((err) => {
109
+ console.error(
110
+ `[event-bus] Async handler for "${event}" rejected:`,
111
+ err,
112
+ );
113
+ });
114
+ }
115
+ } catch (err) {
116
+ console.error(`[event-bus] Handler for "${event}" threw:`, err);
117
+ }
118
+ }
119
+ }
120
+
121
+ export function listSubscriptions(
122
+ event?: string,
123
+ ): { id: string; event: string }[] {
124
+ const bus = getBus();
125
+ const out: { id: string; event: string }[] = [];
126
+ for (const [id, sub] of bus.subscriptions) {
127
+ if (event && sub.event !== event) continue;
128
+ out.push({ id, event: sub.event });
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /** Test helper — drops all subscriptions. */
134
+ export function __resetEventBus(): void {
135
+ const bus = getBus();
136
+ bus.emitter.removeAllListeners();
137
+ bus.subscriptions.clear();
138
+ }