@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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +34 -0
- package/corpus/core/package.json +2 -1
- package/corpus/core/src/deploy/build.ts +120 -2
- package/corpus/core/src/event-bus/bus.ts +138 -140
- package/corpus/core/src/event-bus/registry.ts +81 -79
- package/corpus/core/src/file-upload/registry.ts +135 -125
- package/corpus/core/src/notifications/registry.ts +218 -216
- package/corpus/core/src/private-blob/registry.ts +246 -242
- package/corpus/core/src/secrets/register.ts +132 -129
- package/corpus/core/src/shared/global-scope.ts +75 -0
- package/corpus/core/src/sharing/registry.ts +193 -194
- package/corpus/core/src/tracking/providers.ts +425 -422
- package/corpus/core/src/tracking/registry.ts +102 -102
- package/dist/collab/routes.d.ts +1 -1
- package/dist/deploy/build.d.ts +44 -0
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +91 -2
- package/dist/deploy/build.js.map +1 -1
- package/dist/event-bus/bus.d.ts.map +1 -1
- package/dist/event-bus/bus.js +8 -6
- package/dist/event-bus/bus.js.map +1 -1
- package/dist/event-bus/registry.d.ts.map +1 -1
- package/dist/event-bus/registry.js +10 -6
- package/dist/event-bus/registry.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/file-upload/registry.d.ts.map +1 -1
- package/dist/file-upload/registry.js +28 -8
- package/dist/file-upload/registry.js.map +1 -1
- package/dist/notifications/registry.d.ts.map +1 -1
- package/dist/notifications/registry.js +6 -5
- package/dist/notifications/registry.js.map +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/private-blob/registry.d.ts.map +1 -1
- package/dist/private-blob/registry.js +18 -13
- package/dist/private-blob/registry.js.map +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/secrets/register.d.ts.map +1 -1
- package/dist/secrets/register.js +11 -7
- package/dist/secrets/register.js.map +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/shared/global-scope.d.ts +55 -0
- package/dist/shared/global-scope.d.ts.map +1 -0
- package/dist/shared/global-scope.js +70 -0
- package/dist/shared/global-scope.js.map +1 -0
- package/dist/sharing/registry.d.ts.map +1 -1
- package/dist/sharing/registry.js +2 -16
- package/dist/sharing/registry.js.map +1 -1
- package/dist/tracking/providers.d.ts.map +1 -1
- package/dist/tracking/providers.js +11 -9
- package/dist/tracking/providers.js.map +1 -1
- package/dist/tracking/registry.d.ts.map +1 -1
- package/dist/tracking/registry.js +5 -5
- package/dist/tracking/registry.js.map +1 -1
- package/package.json +2 -1
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -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
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.92.
|
|
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 {
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
function getBus(): BusState {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
emitter
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
+
}
|