@uxland/primary-shell 7.45.17 → 7.46.1
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/dist/{component-DE0499JZ.js → component-DjtDM3TG.js} +2 -2
- package/dist/{component-DE0499JZ.js.map → component-DjtDM3TG.js.map} +1 -1
- package/dist/{index-DUgttilv.js → index-CdACnQdU.js} +1451 -1818
- package/dist/index-CdACnQdU.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/index.umd.cjs +119 -138
- package/dist/index.umd.cjs.map +1 -1
- package/dist/primary/shell/src/api/broker/primaria-broker.d.ts +2 -0
- package/package.json +1 -2
- package/src/api/broker/factory.test.ts +244 -0
- package/src/api/broker/factory.ts +112 -144
- package/src/api/broker/primaria-broker.ts +2 -0
- package/src/features/bootstrapper.ts +1 -2
- package/src/handle-plugins.ts +33 -12
- package/dist/index-DUgttilv.js.map +0 -1
|
@@ -2,6 +2,8 @@ import { HarmonixBroker } from '@uxland/harmonix';
|
|
|
2
2
|
import { BrokerEvents } from './broker-events';
|
|
3
3
|
export interface PrimariaBroker extends HarmonixBroker {
|
|
4
4
|
events: BrokerEvents;
|
|
5
|
+
/** Internal: removes every registration (shell teardown only). */
|
|
6
|
+
_clear(): void;
|
|
5
7
|
}
|
|
6
8
|
export type { HarmonixBroker, IEvent, IEventClass, IRequest, IRequestClass, } from '@uxland/harmonix';
|
|
7
9
|
export type { BrokerDisposableHandler } from '@uxland/harmonix';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uxland/primary-shell",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.46.1",
|
|
4
4
|
"description": "Primaria Shell",
|
|
5
5
|
"author": "UXLand <dev@uxland.es>",
|
|
6
6
|
"homepage": "https://github.com/uxland/harmonix/tree/app#readme",
|
|
@@ -42,7 +42,6 @@
|
|
|
42
42
|
"inversify-inject-decorators": "^3.1.0",
|
|
43
43
|
"jwt-decode": "^4.0.0",
|
|
44
44
|
"lit": "^3.1.0",
|
|
45
|
-
"mediatr-ts": "^1.2.1",
|
|
46
45
|
"react": "^19.0.0",
|
|
47
46
|
"react-dom": "^19.0.0",
|
|
48
47
|
"reflect-metadata": "^0.2.2",
|
|
@@ -126,4 +126,248 @@ describe("Broker test", () => {
|
|
|
126
126
|
});
|
|
127
127
|
});
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
describe("Disposal and re-registration", () => {
|
|
131
|
+
class MyRequest implements IRequest<string> {
|
|
132
|
+
constructor(public greeting: string) {}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
it("disposing a request handler lets the same request be registered again (teardown + bootstrap)", () => {
|
|
136
|
+
const disposable = sut.registerRequest(MyRequest, () => "first");
|
|
137
|
+
disposable.dispose();
|
|
138
|
+
// Re-registering after dispose must NOT throw (the dispose-bug regression).
|
|
139
|
+
expect(() => sut.registerRequest(MyRequest, () => "second")).not.toThrow();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("routes to the handler registered after a dispose/re-register cycle", async () => {
|
|
143
|
+
const first = vi.fn().mockResolvedValue("first");
|
|
144
|
+
const second = vi.fn().mockResolvedValue("second");
|
|
145
|
+
const disposable = sut.registerRequest(MyRequest, first);
|
|
146
|
+
disposable.dispose();
|
|
147
|
+
sut.registerRequest(MyRequest, second);
|
|
148
|
+
|
|
149
|
+
const result = await sut.send(new MyRequest("hi"));
|
|
150
|
+
|
|
151
|
+
expect(result).toBe("second");
|
|
152
|
+
expect(first).not.toHaveBeenCalled();
|
|
153
|
+
expect(second).toHaveBeenCalledWith({ greeting: "hi" });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("sending a request with no handler registered throws", async () => {
|
|
157
|
+
await expect(sut.send(new MyRequest("hi"))).rejects.toThrow();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("sending after the only handler was disposed throws", async () => {
|
|
161
|
+
const disposable = sut.registerRequest(MyRequest, () => "x");
|
|
162
|
+
disposable.dispose();
|
|
163
|
+
await expect(sut.send(new MyRequest("hi"))).rejects.toThrow();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("a stale disposer does not drop a newer registration for the same request", async () => {
|
|
167
|
+
const first = vi.fn().mockResolvedValue("first");
|
|
168
|
+
const second = vi.fn().mockResolvedValue("second");
|
|
169
|
+
const firstDisposable = sut.registerRequest(MyRequest, first);
|
|
170
|
+
firstDisposable.dispose();
|
|
171
|
+
sut.registerRequest(MyRequest, second);
|
|
172
|
+
// Disposing the already-disposed first handler must not remove `second`.
|
|
173
|
+
firstDisposable.dispose();
|
|
174
|
+
|
|
175
|
+
const result = await sut.send(new MyRequest("hi"));
|
|
176
|
+
expect(result).toBe("second");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("Event subscription disposal", () => {
|
|
181
|
+
class MyEvent implements IEvent {
|
|
182
|
+
constructor(public greeting: string) {}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
it("a disposed subscriber stops receiving events while the rest keep receiving them", async () => {
|
|
186
|
+
const kept = vi.fn();
|
|
187
|
+
const removed = vi.fn();
|
|
188
|
+
sut.subscribe(MyEvent, kept);
|
|
189
|
+
const disposable = sut.subscribe(MyEvent, removed);
|
|
190
|
+
|
|
191
|
+
disposable.dispose();
|
|
192
|
+
await sut.publish(new MyEvent("Hello"));
|
|
193
|
+
|
|
194
|
+
expect(kept).toHaveBeenCalledWith({ greeting: "Hello" });
|
|
195
|
+
expect(removed).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("publishing an event instance with no subscribers does not throw", () => {
|
|
199
|
+
expect(() => sut.publish(new MyEvent("Hello"))).not.toThrow();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("the same handler can re-subscribe after disposing", async () => {
|
|
203
|
+
const handler = vi.fn();
|
|
204
|
+
const disposable = sut.subscribe("MyEvent", handler);
|
|
205
|
+
disposable.dispose();
|
|
206
|
+
sut.subscribe("MyEvent", handler);
|
|
207
|
+
|
|
208
|
+
await sut.publish("MyEvent", { greeting: "Hello" });
|
|
209
|
+
|
|
210
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
211
|
+
expect(handler).toHaveBeenCalledWith({ greeting: "Hello" });
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("_clear", () => {
|
|
216
|
+
class MyRequest implements IRequest<string> {}
|
|
217
|
+
|
|
218
|
+
it("removes all registrations so the registry can be reused cleanly", () => {
|
|
219
|
+
sut.registerRequest(MyRequest, () => "x");
|
|
220
|
+
sut._clear();
|
|
221
|
+
// After a clear, registering the same request again must not throw.
|
|
222
|
+
expect(() => sut.registerRequest(MyRequest, () => "y")).not.toThrow();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("Request handler semantics", () => {
|
|
227
|
+
class MyRequest implements IRequest<string> {
|
|
228
|
+
constructor(public greeting: string) {}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
it("rejects when the handler throws synchronously", async () => {
|
|
232
|
+
sut.registerRequest(MyRequest, () => {
|
|
233
|
+
throw new Error("boom");
|
|
234
|
+
});
|
|
235
|
+
await expect(sut.send(new MyRequest("x"))).rejects.toThrow("boom");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("rejects when the handler returns a rejected promise", async () => {
|
|
239
|
+
sut.registerRequest(MyRequest, async () => {
|
|
240
|
+
throw new Error("async boom");
|
|
241
|
+
});
|
|
242
|
+
await expect(sut.send(new MyRequest("x"))).rejects.toThrow("async boom");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("resolves a synchronous (non-promise) handler return value", async () => {
|
|
246
|
+
sut.registerRequest(MyRequest, () => "sync-value");
|
|
247
|
+
await expect(sut.send(new MyRequest("x"))).resolves.toBe("sync-value");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("Publish semantics", () => {
|
|
252
|
+
it("resolves only after async subscribers have completed", async () => {
|
|
253
|
+
let completed = false;
|
|
254
|
+
sut.subscribe("evt", async () => {
|
|
255
|
+
await Promise.resolve();
|
|
256
|
+
completed = true;
|
|
257
|
+
});
|
|
258
|
+
await sut.publish("evt", {});
|
|
259
|
+
expect(completed).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("isolates a throwing subscriber: the others are still notified and publish does not reject", async () => {
|
|
263
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
264
|
+
const before = vi.fn();
|
|
265
|
+
const after = vi.fn();
|
|
266
|
+
sut.subscribe("evt", before);
|
|
267
|
+
sut.subscribe("evt", () => {
|
|
268
|
+
throw new Error("bad subscriber");
|
|
269
|
+
});
|
|
270
|
+
sut.subscribe("evt", () => Promise.reject(new Error("bad async subscriber")));
|
|
271
|
+
sut.subscribe("evt", after);
|
|
272
|
+
|
|
273
|
+
await expect(sut.publish("evt", { a: 1 })).resolves.toBeUndefined();
|
|
274
|
+
|
|
275
|
+
expect(before).toHaveBeenCalledWith({ a: 1 });
|
|
276
|
+
expect(after).toHaveBeenCalledWith({ a: 1 });
|
|
277
|
+
expect(errorSpy).toHaveBeenCalledTimes(2);
|
|
278
|
+
errorSpy.mockRestore();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("a subscriber added during dispatch is not invoked in the same round (snapshot)", async () => {
|
|
282
|
+
const added = vi.fn();
|
|
283
|
+
const adder = vi.fn(() => {
|
|
284
|
+
sut.subscribe("evt", added);
|
|
285
|
+
});
|
|
286
|
+
sut.subscribe("evt", adder);
|
|
287
|
+
|
|
288
|
+
await sut.publish("evt", {});
|
|
289
|
+
expect(adder).toHaveBeenCalledTimes(1);
|
|
290
|
+
expect(added).not.toHaveBeenCalled();
|
|
291
|
+
|
|
292
|
+
await sut.publish("evt", {});
|
|
293
|
+
expect(added).toHaveBeenCalledTimes(1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("a subscriber that disposes itself during dispatch is still called that round but not the next", async () => {
|
|
297
|
+
let disposable: ReturnType<typeof sut.subscribe>;
|
|
298
|
+
const handler = vi.fn(() => disposable.dispose());
|
|
299
|
+
disposable = sut.subscribe("evt", handler);
|
|
300
|
+
|
|
301
|
+
await sut.publish("evt", {});
|
|
302
|
+
await sut.publish("evt", {});
|
|
303
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("Namespace isolation", () => {
|
|
308
|
+
it("event class keys and event string keys are independent", async () => {
|
|
309
|
+
class Evt implements IEvent {}
|
|
310
|
+
const classHandler = vi.fn();
|
|
311
|
+
const stringHandler = vi.fn();
|
|
312
|
+
sut.subscribe(Evt, classHandler);
|
|
313
|
+
sut.subscribe("Evt", stringHandler);
|
|
314
|
+
|
|
315
|
+
await sut.publish(new Evt());
|
|
316
|
+
expect(classHandler).toHaveBeenCalledTimes(1);
|
|
317
|
+
expect(stringHandler).not.toHaveBeenCalled();
|
|
318
|
+
|
|
319
|
+
await sut.publish("Evt", {});
|
|
320
|
+
expect(stringHandler).toHaveBeenCalledTimes(1);
|
|
321
|
+
expect(classHandler).toHaveBeenCalledTimes(1);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("the request registry and the event registry do not collide for the same name", async () => {
|
|
325
|
+
const requestHandler = vi.fn().mockResolvedValue("r");
|
|
326
|
+
const eventHandler = vi.fn();
|
|
327
|
+
sut.registerRequest("Shared", requestHandler);
|
|
328
|
+
sut.subscribe("Shared", eventHandler);
|
|
329
|
+
|
|
330
|
+
await sut.send("Shared", { v: 1 });
|
|
331
|
+
expect(requestHandler).toHaveBeenCalledWith({ v: 1 });
|
|
332
|
+
expect(eventHandler).not.toHaveBeenCalled();
|
|
333
|
+
|
|
334
|
+
await sut.publish("Shared", { v: 2 });
|
|
335
|
+
expect(eventHandler).toHaveBeenCalledWith({ v: 2 });
|
|
336
|
+
expect(requestHandler).toHaveBeenCalledTimes(1);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("Constructor-identity keying", () => {
|
|
341
|
+
it("keys requests by constructor identity, not by class name (two same-named classes coexist)", async () => {
|
|
342
|
+
const makeDup = () => class Dup implements IRequest<string> {};
|
|
343
|
+
const A = makeDup();
|
|
344
|
+
const B = makeDup();
|
|
345
|
+
expect(A.name).toBe(B.name); // both named "Dup"
|
|
346
|
+
|
|
347
|
+
const handlerA = vi.fn().mockResolvedValue("A");
|
|
348
|
+
const handlerB = vi.fn().mockResolvedValue("B");
|
|
349
|
+
sut.registerRequest(A, handlerA);
|
|
350
|
+
// The old name-based registry would have thrown here; identity keying must not.
|
|
351
|
+
expect(() => sut.registerRequest(B, handlerB)).not.toThrow();
|
|
352
|
+
|
|
353
|
+
await expect(sut.send(new A())).resolves.toBe("A");
|
|
354
|
+
await expect(sut.send(new B())).resolves.toBe("B");
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe("Idempotent disposal", () => {
|
|
359
|
+
class MyRequest implements IRequest<string> {}
|
|
360
|
+
|
|
361
|
+
it("disposing a request handler twice is safe", () => {
|
|
362
|
+
const disposable = sut.registerRequest(MyRequest, () => "x");
|
|
363
|
+
disposable.dispose();
|
|
364
|
+
expect(() => disposable.dispose()).not.toThrow();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("disposing an event subscription twice is safe", () => {
|
|
368
|
+
const disposable = sut.subscribe("evt", () => {});
|
|
369
|
+
disposable.dispose();
|
|
370
|
+
expect(() => disposable.dispose()).not.toThrow();
|
|
371
|
+
});
|
|
372
|
+
});
|
|
129
373
|
});
|
|
@@ -1,170 +1,138 @@
|
|
|
1
1
|
import { IEvent, IEventClass, IRequest, IRequestClass } from "@uxland/harmonix";
|
|
2
|
-
import { Mediator, mediatorSettings, notificationHandler, requestHandler } from "mediatr-ts";
|
|
3
2
|
import { BROKER_EVENTS } from "./broker-events";
|
|
4
|
-
import {
|
|
3
|
+
import type { BrokerDisposableHandler, PrimariaBroker } from "./primaria-broker";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* In-process message broker.
|
|
7
|
+
*
|
|
8
|
+
* The whole plugin system runs in a single JS heap (plugins are ES modules,
|
|
9
|
+
* no postMessage / workers / serialization), so dispatch is a plain map lookup.
|
|
10
|
+
*
|
|
11
|
+
* A message is keyed either by its **class constructor** (typed, intra-plugin
|
|
12
|
+
* requests/events) or by a **string name** (decoupled, cross-plugin events).
|
|
13
|
+
* Both live in the same map: `send(new Foo())` keys on `instance.constructor`,
|
|
14
|
+
* which is the very class passed to `registerRequest(Foo, …)`, so identity
|
|
15
|
+
* matches without any reflection or dynamically generated classes.
|
|
16
|
+
*
|
|
17
|
+
* - Requests (request/response): exactly one handler per key.
|
|
18
|
+
* - Events (publish/subscribe): any number of subscribers per key.
|
|
19
|
+
*
|
|
20
|
+
* Handlers receive a shallow copy of the message data (`{ ...instance }` for a
|
|
21
|
+
* class, or the raw payload for the string API), matching the previous
|
|
22
|
+
* behaviour exactly.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// biome-ignore lint/complexity/noBannedTypes: a class constructor is a Function, used here as a map key
|
|
26
|
+
type MessageKey = Function | string;
|
|
27
|
+
|
|
28
|
+
// biome-ignore lint/suspicious/noExplicitAny: handlers are typed at the public-interface boundary
|
|
29
|
+
type Handler = (message: any) => unknown;
|
|
5
30
|
|
|
6
|
-
type messageHandler = (payload: unknown) => unknown | Promise<unknown>;
|
|
7
|
-
|
|
8
|
-
const disposableFactory = (handler: any) => {
|
|
9
|
-
return {
|
|
10
|
-
dispose: () => {
|
|
11
|
-
mediatorSettings.resolver.remove(handler.name);
|
|
12
|
-
const mappingIndex = mediatorSettings.dispatcher.notifications._mappings.findIndex(
|
|
13
|
-
(mapping) => mapping.handler.name === handler.name,
|
|
14
|
-
);
|
|
15
|
-
mappingIndex > -1 &&
|
|
16
|
-
mediatorSettings.dispatcher.notifications._mappings.splice(mappingIndex, 1);
|
|
17
|
-
},
|
|
18
|
-
};
|
|
19
|
-
};
|
|
20
31
|
class Broker implements PrimariaBroker {
|
|
21
32
|
public readonly events = BROKER_EVENTS;
|
|
22
|
-
private mediator: Mediator;
|
|
23
|
-
constructor() {
|
|
24
|
-
this.mediator = new Mediator();
|
|
25
|
-
mediatorSettings.resolver.clear();
|
|
26
|
-
}
|
|
27
|
-
// biome-ignore lint/complexity/noBannedTypes: <explanation>
|
|
28
|
-
private eventConstructorMap: Map<string, Function> = new Map();
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
private
|
|
34
|
+
/** One handler per request type (request/response). */
|
|
35
|
+
private readonly requestHandlers = new Map<MessageKey, Handler>();
|
|
36
|
+
|
|
37
|
+
/** Many subscribers per event type (publish/subscribe). */
|
|
38
|
+
private readonly eventSubscribers = new Map<MessageKey, Set<Handler>>();
|
|
39
|
+
|
|
40
|
+
// ---- Request / response ---------------------------------------------------
|
|
32
41
|
|
|
33
42
|
send<TRequest extends IRequest<TResponse>, TResponse>(message: TRequest): Promise<TResponse>;
|
|
34
43
|
send<TPayload, TResponse>(requestName: string, payload: TPayload): Promise<TResponse>;
|
|
35
|
-
send<TResponse>(request: IRequest<TResponse> | string, payload?: unknown): Promise<TResponse> {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
publish(event: string | IEvent, payload?: unknown): Promise<void> {
|
|
45
|
-
const eventPayload =
|
|
46
|
-
typeof event === "string" ? this.getEvent(event as string, payload) : event;
|
|
47
|
-
const classConstructor =
|
|
48
|
-
eventPayload.constructor || Object.getPrototypeOf(eventPayload).constructor;
|
|
49
|
-
const handlerSubscriptions = mediatorSettings.dispatcher.notifications._mappings.filter(
|
|
50
|
-
(m) => m.notification === classConstructor,
|
|
51
|
-
);
|
|
52
|
-
if (!handlerSubscriptions.length) return Promise.resolve();
|
|
53
|
-
return this.mediator.publish(eventPayload);
|
|
54
|
-
}
|
|
55
|
-
subscribe<TEvent extends IEvent>(
|
|
56
|
-
event: TEvent,
|
|
57
|
-
handler: (message: TEvent) => void,
|
|
58
|
-
): BrokerDisposableHandler;
|
|
59
|
-
subscribe<TPayload>(
|
|
60
|
-
eventName: string,
|
|
61
|
-
handler: (message: TPayload) => void,
|
|
62
|
-
): BrokerDisposableHandler;
|
|
63
|
-
subscribe(event: string | IEventClass, handler: messageHandler): BrokerDisposableHandler {
|
|
64
|
-
const eventType = typeof event === "function" ? event : this.getEventType(event as string);
|
|
65
|
-
const wrappedHandler = createDynamicEventHandler(handler);
|
|
66
|
-
notificationHandler(eventType)(wrappedHandler);
|
|
67
|
-
return disposableFactory(wrappedHandler);
|
|
44
|
+
async send<TResponse>(request: IRequest<TResponse> | string, payload?: unknown): Promise<TResponse> {
|
|
45
|
+
const key: MessageKey = typeof request === "string" ? request : request.constructor;
|
|
46
|
+
const handler = this.requestHandlers.get(key);
|
|
47
|
+
if (!handler) {
|
|
48
|
+
const name = typeof request === "string" ? request : request.constructor?.name;
|
|
49
|
+
throw new Error(`Broker: no request handler registered for "${name}".`);
|
|
50
|
+
}
|
|
51
|
+
const message = typeof request === "string" ? { ...(payload as object) } : { ...request };
|
|
52
|
+
return (await handler(message)) as TResponse;
|
|
68
53
|
}
|
|
54
|
+
|
|
69
55
|
registerRequest<TRequest extends IRequest<TResponse>, TResponse>(
|
|
70
|
-
request: TRequest
|
|
71
|
-
handler: (message: TRequest) => TResponse
|
|
56
|
+
request: IRequestClass<TRequest>,
|
|
57
|
+
handler: (message: TRequest) => TResponse | Promise<TResponse>,
|
|
72
58
|
): BrokerDisposableHandler;
|
|
73
59
|
registerRequest<TPayload, TResponse>(
|
|
74
60
|
requestName: string,
|
|
75
|
-
handler: (message: TPayload) => TResponse
|
|
61
|
+
handler: (message: TPayload) => TResponse | Promise<TResponse>,
|
|
76
62
|
): BrokerDisposableHandler;
|
|
77
|
-
registerRequest(
|
|
78
|
-
request
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
63
|
+
registerRequest(request: IRequestClass<unknown> | string, handler: Handler): BrokerDisposableHandler {
|
|
64
|
+
if (this.requestHandlers.has(request)) {
|
|
65
|
+
const name = typeof request === "string" ? request : request.name;
|
|
66
|
+
throw new Error(`Broker: a request handler is already registered for "${name}".`);
|
|
67
|
+
}
|
|
68
|
+
this.requestHandlers.set(request, handler);
|
|
69
|
+
return {
|
|
70
|
+
dispose: () => {
|
|
71
|
+
// Identity-guarded: a stale/duplicate dispose must not drop a handler
|
|
72
|
+
// registered later under the same key (after a teardown + bootstrap).
|
|
73
|
+
if (this.requestHandlers.get(request) === handler) {
|
|
74
|
+
this.requestHandlers.delete(request);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
91
78
|
}
|
|
92
79
|
|
|
93
|
-
|
|
94
|
-
const requestConstructor = this.getRequestType(requestName);
|
|
95
|
-
return new requestConstructor(payload) as IRequest<unknown>;
|
|
96
|
-
}
|
|
80
|
+
// ---- Publish / subscribe --------------------------------------------------
|
|
97
81
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
82
|
+
publish<TEvent extends IEvent>(event: TEvent): Promise<void>;
|
|
83
|
+
publish<TPayload>(eventName: string, payload: TPayload): Promise<void>;
|
|
84
|
+
async publish(event: IEvent | string, payload?: unknown): Promise<void> {
|
|
85
|
+
const key: MessageKey = typeof event === "string" ? event : event.constructor;
|
|
86
|
+
const subscribers = this.eventSubscribers.get(key);
|
|
87
|
+
if (!subscribers || subscribers.size === 0) return;
|
|
88
|
+
const message = typeof event === "string" ? payload : { ...event };
|
|
89
|
+
const name = typeof event === "string" ? event : event.constructor?.name;
|
|
90
|
+
// Snapshot the set: a subscriber may subscribe/dispose while we iterate.
|
|
91
|
+
// Each subscriber is isolated: a throwing/rejecting subscriber is logged but
|
|
92
|
+
// never blocks the others nor rejects publish() (fire-and-forget event bus).
|
|
93
|
+
await Promise.all(
|
|
94
|
+
[...subscribers].map(async (handler) => {
|
|
95
|
+
try {
|
|
96
|
+
await handler(message);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error(`Broker: a subscriber for event "${name}" threw and was isolated.`, error);
|
|
99
|
+
}
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
104
102
|
}
|
|
105
103
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
104
|
+
subscribe<TEvent extends IEvent>(
|
|
105
|
+
event: IEventClass,
|
|
106
|
+
handler: (message: TEvent) => void | Promise<void>,
|
|
107
|
+
): BrokerDisposableHandler;
|
|
108
|
+
subscribe<TPayload>(eventName: string, handler: (message: TPayload) => void | Promise<void>): BrokerDisposableHandler;
|
|
109
|
+
subscribe(event: IEventClass | string, handler: Handler): BrokerDisposableHandler {
|
|
110
|
+
let subscribers = this.eventSubscribers.get(event);
|
|
111
|
+
if (!subscribers) {
|
|
112
|
+
subscribers = new Set();
|
|
113
|
+
this.eventSubscribers.set(event, subscribers);
|
|
110
114
|
}
|
|
111
|
-
|
|
115
|
+
subscribers.add(handler);
|
|
116
|
+
return {
|
|
117
|
+
dispose: () => {
|
|
118
|
+
const current = this.eventSubscribers.get(event);
|
|
119
|
+
if (!current) return;
|
|
120
|
+
current.delete(handler);
|
|
121
|
+
if (current.size === 0) this.eventSubscribers.delete(event);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
112
124
|
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const createDynamicEventClass = (eventName: string) => {
|
|
116
|
-
return new Function(`return class Event_${eventName} {
|
|
117
|
-
constructor(payload) {
|
|
118
|
-
this.payload = payload;
|
|
119
|
-
}
|
|
120
|
-
}`)();
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const createDynamicRequestClass = (requestName: string) => {
|
|
124
|
-
return new Function(`return class Request_${requestName} {
|
|
125
|
-
constructor(payload) {
|
|
126
|
-
Object.assign(this, payload);
|
|
127
|
-
}
|
|
128
|
-
}`)();
|
|
129
|
-
};
|
|
130
125
|
|
|
131
|
-
|
|
126
|
+
// ---- Lifecycle ------------------------------------------------------------
|
|
132
127
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Removes every registration. Used on shell teardown so a re-initialization
|
|
130
|
+
* starts from a clean registry (the broker is a module-level singleton).
|
|
131
|
+
*/
|
|
132
|
+
_clear(): void {
|
|
133
|
+
this.requestHandlers.clear();
|
|
134
|
+
this.eventSubscribers.clear();
|
|
135
|
+
}
|
|
141
136
|
}
|
|
142
137
|
|
|
143
|
-
const
|
|
144
|
-
const className = `RequestHandler_${generateUniqueRandomSuffix()}`;
|
|
145
|
-
return new Function(
|
|
146
|
-
"handler",
|
|
147
|
-
`return class ${className}{
|
|
148
|
-
handle(notification){
|
|
149
|
-
const handlerResult = handler({...notification});
|
|
150
|
-
return handlerResult instanceof Promise ? handlerResult : Promise.resolve(handlerResult);
|
|
151
|
-
}
|
|
152
|
-
}`,
|
|
153
|
-
)(handler);
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
const createDynamicEventHandler = (handler: messageHandler) => {
|
|
157
|
-
const className = `EventHandler_${generateUniqueRandomSuffix()}`;
|
|
158
|
-
return new Function(
|
|
159
|
-
"handler",
|
|
160
|
-
`return class ${className}{
|
|
161
|
-
handle(notification){
|
|
162
|
-
const payload = notification.payload !== undefined ? notification.payload : {...notification};
|
|
163
|
-
const handlerResult = handler(payload);
|
|
164
|
-
return handlerResult instanceof Promise ? handlerResult : Promise.resolve(handlerResult);
|
|
165
|
-
}
|
|
166
|
-
}`,
|
|
167
|
-
)(handler);
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
export const createBroker = (): PrimariaBroker => new Broker();
|
|
138
|
+
export const createBroker = (): PrimariaBroker => new Broker();
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { mediatorSettings } from "mediatr-ts";
|
|
2
1
|
import { PrimariaApi } from "../api/api";
|
|
3
2
|
import { container } from "../infrastructure/ioc/container";
|
|
4
3
|
import { TYPES } from "../infrastructure/ioc/types";
|
|
@@ -18,5 +17,5 @@ export const disposeFeatures = (api: PrimariaApi) => {
|
|
|
18
17
|
disposeExitShell();
|
|
19
18
|
container.unbindAll();
|
|
20
19
|
api.regionManager._destroy();
|
|
21
|
-
|
|
20
|
+
api.broker._clear();
|
|
22
21
|
};
|
package/src/handle-plugins.ts
CHANGED
|
@@ -22,20 +22,41 @@ interface PrimariaBootstrappedPlugin extends BootstrappedPlugin<PrimariaApi> {
|
|
|
22
22
|
importedPlugin: PrimariaPlugin;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
25
|
+
const CORE_FIRST_ID = "activity-history";
|
|
26
|
+
|
|
27
|
+
// activity-history must finish initializing before any other plugin: it
|
|
28
|
+
// registers the broker requests the rest depend on. In production it is loaded
|
|
29
|
+
// from the plugin store (versioned/uploadable) like any other plugin; this
|
|
30
|
+
// bundled copy is the fallback used when the caller does not provide it — e.g.
|
|
31
|
+
// third-party sandboxes that install primary-shell without a plugin store.
|
|
32
|
+
const bundledActivityHistory: PluginDefinition = {
|
|
33
|
+
pluginId: CORE_FIRST_ID,
|
|
34
|
+
importer: () =>
|
|
35
|
+
Promise.resolve({
|
|
36
|
+
initialize: activityHistoryInitialize,
|
|
37
|
+
dispose: activityHistoryDispose,
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
35
40
|
|
|
36
41
|
export const bootstrapPlugins = async (plugins: PluginDefinition[], startup?: PrimariaStartupPlugin) => {
|
|
37
|
-
const
|
|
38
|
-
|
|
42
|
+
const incoming = plugins ?? [];
|
|
43
|
+
const provided = incoming.filter((plugin) => plugin.pluginId === CORE_FIRST_ID);
|
|
44
|
+
const rest = incoming.filter((plugin) => plugin.pluginId !== CORE_FIRST_ID);
|
|
45
|
+
|
|
46
|
+
// Prefer the store/sandbox-provided activity-history; fall back to the bundled
|
|
47
|
+
// copy. Either way it is bootstrapped — and fully initialized — before the
|
|
48
|
+
// rest, which run in parallel as before.
|
|
49
|
+
const corePlugins = provided.length ? provided : [bundledActivityHistory];
|
|
50
|
+
|
|
51
|
+
const coreBootstrapped = (await pluginBootstrapper(
|
|
52
|
+
corePlugins,
|
|
53
|
+
primariaApiFactory,
|
|
54
|
+
)) as BootstrappedPlugin<PrimariaApi>[];
|
|
55
|
+
const restBootstrapped = rest.length
|
|
56
|
+
? ((await pluginBootstrapper(rest, primariaApiFactory)) as BootstrappedPlugin<PrimariaApi>[])
|
|
57
|
+
: [];
|
|
58
|
+
|
|
59
|
+
bootstrappedPlugins = [...coreBootstrapped, ...restBootstrapped];
|
|
39
60
|
handleStartupPlugin(bootstrappedPlugins as PrimariaBootstrappedPlugin[], startup);
|
|
40
61
|
};
|
|
41
62
|
|