@podosoft/podokit 1.1.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/deploy-render.js +5 -0
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
- package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
- package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
- package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
- package/dist/templates/fullstack/compose.dev.yaml +21 -0
- package/dist/templates/fullstack/dot-env.docker +3 -0
- package/dist/templates/fullstack/dot-env.example +14 -0
- package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
- package/dist/templates/modules/audit-log/module.manifest.json +7 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
- package/dist/templates/modules/auth/module.manifest.json +2 -2
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
- package/dist/templates/modules/file-upload/module.manifest.json +9 -3
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
- package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
- package/dist/templates/modules/job-progress/module.manifest.json +3 -3
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
- package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
- package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
- package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
- package/dist/templates/modules/sse/module.manifest.json +17 -4
- package/package.json +2 -2
|
@@ -1,30 +1,384 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
1
|
+
import { afterEach, describe, expect, it, spyOn } from "bun:test";
|
|
2
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
3
3
|
import { MemoryEventBus } from "@podosoft/podokit-runtime";
|
|
4
|
-
import {
|
|
4
|
+
import { ReadinessService } from "../health/readiness.service";
|
|
5
|
+
import { InvalidEventError, type EventSubscriber } from "./event-envelope";
|
|
6
|
+
import {
|
|
7
|
+
encodeFrame,
|
|
8
|
+
eventStreamResponse,
|
|
9
|
+
type EventStream,
|
|
10
|
+
type Scheduler,
|
|
11
|
+
type StreamFrame,
|
|
12
|
+
} from "./event-stream";
|
|
13
|
+
import { EventsService, type EventsServiceOptions } from "./events.service";
|
|
14
|
+
|
|
15
|
+
interface FakeScheduler extends Scheduler {
|
|
16
|
+
active(): number;
|
|
17
|
+
tick(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fakeScheduler(): FakeScheduler {
|
|
21
|
+
const tasks = new Set<() => void>();
|
|
22
|
+
return {
|
|
23
|
+
every: (_intervalMs, task) => {
|
|
24
|
+
const entry = (): void => task();
|
|
25
|
+
tasks.add(entry);
|
|
26
|
+
return () => {
|
|
27
|
+
tasks.delete(entry);
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
active: () => tasks.size,
|
|
31
|
+
tick: () => {
|
|
32
|
+
for (const task of [...tasks]) task();
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function user(userId: string, roles: string[] = [], organizationId?: string): EventSubscriber {
|
|
38
|
+
return { userId, roles, ...(organizationId ? { organizationId } : {}) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const services: EventsService[] = [];
|
|
42
|
+
|
|
43
|
+
async function service(
|
|
44
|
+
options: EventsServiceOptions = {},
|
|
45
|
+
bus = new MemoryEventBus(),
|
|
46
|
+
): Promise<EventsService> {
|
|
47
|
+
const events = new EventsService(bus, new ReadinessService(), {
|
|
48
|
+
scheduler: fakeScheduler(),
|
|
49
|
+
...options,
|
|
50
|
+
});
|
|
51
|
+
await events.connect();
|
|
52
|
+
services.push(events);
|
|
53
|
+
return events;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function open(
|
|
57
|
+
events: EventsService,
|
|
58
|
+
subscriber: EventSubscriber,
|
|
59
|
+
topics?: string[],
|
|
60
|
+
): Promise<EventStream> {
|
|
61
|
+
return events.open(subscriber, topics, new AbortController().signal);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function decoded(frame: StreamFrame | undefined): unknown {
|
|
65
|
+
return frame?.kind === "event" ? JSON.parse(frame.data) : frame;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Publishes a broadcast marker and returns every frame the stream saw before it. */
|
|
69
|
+
async function framesBefore(
|
|
70
|
+
events: EventsService,
|
|
71
|
+
stream: EventStream,
|
|
72
|
+
topic = "marker",
|
|
73
|
+
): Promise<unknown[]> {
|
|
74
|
+
const marker = `marker-${Math.random()}`;
|
|
75
|
+
await events.publishAsync({ topic, data: marker });
|
|
76
|
+
const frames: unknown[] = [];
|
|
77
|
+
for (;;) {
|
|
78
|
+
const frame = await stream.next();
|
|
79
|
+
const value = decoded(frame);
|
|
80
|
+
if (frame?.kind === "event" && (value as { topic: unknown }).topic === topic) {
|
|
81
|
+
if ((value as { data: unknown }).data === marker) return frames;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
frames.push(value);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
afterEach(async () => {
|
|
89
|
+
for (const events of services.splice(0)) await events.close();
|
|
90
|
+
});
|
|
5
91
|
|
|
6
92
|
describe("EventsService", () => {
|
|
7
|
-
it("publishes through the transport and delivers
|
|
93
|
+
it("publishes through the transport and delivers to a subscribed stream", async () => {
|
|
8
94
|
const readiness = new ReadinessService();
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
await
|
|
13
|
-
|
|
14
|
-
|
|
95
|
+
const events = new EventsService(new MemoryEventBus(), readiness, { scheduler: fakeScheduler() });
|
|
96
|
+
services.push(events);
|
|
97
|
+
await events.connect();
|
|
98
|
+
const stream = await open(events, user("a"), ["changes"]);
|
|
99
|
+
|
|
100
|
+
await events.publishAsync({ topic: "changes", data: { type: "changed" } });
|
|
101
|
+
|
|
102
|
+
expect(await stream.next()).toEqual({
|
|
103
|
+
kind: "event",
|
|
104
|
+
id: "1",
|
|
105
|
+
data: JSON.stringify({ topic: "changes", data: { type: "changed" } }),
|
|
106
|
+
});
|
|
15
107
|
await expect(readiness.run()).resolves.toEqual({ events: "up" });
|
|
16
|
-
await service.close();
|
|
17
108
|
});
|
|
18
109
|
|
|
19
110
|
it("rejects events larger than the configured limit", async () => {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
new ReadinessService(),
|
|
23
|
-
);
|
|
24
|
-
await service.connect();
|
|
25
|
-
await expect(service.publishAsync({ message: "too large" })).rejects.toThrow(
|
|
111
|
+
const events = await service({}, new MemoryEventBus({ maxEventBytes: 8 }));
|
|
112
|
+
await expect(events.publishAsync({ topic: "t", data: "too large" })).rejects.toThrow(
|
|
26
113
|
"Event exceeds maxEventBytes",
|
|
27
114
|
);
|
|
28
|
-
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("rejects invalid topics, ids, and empty audiences", async () => {
|
|
118
|
+
const events = await service();
|
|
119
|
+
await expect(events.publishAsync({ topic: "bad topic", data: 1 })).rejects.toBeInstanceOf(
|
|
120
|
+
InvalidEventError,
|
|
121
|
+
);
|
|
122
|
+
await expect(events.publishAsync({ topic: "t", id: "a\nb", data: 1 })).rejects.toBeInstanceOf(
|
|
123
|
+
InvalidEventError,
|
|
124
|
+
);
|
|
125
|
+
await expect(events.publishAsync({ topic: "t", audience: {}, data: 1 })).rejects.toThrow(
|
|
126
|
+
"audience must set userIds, roles, or organizationId",
|
|
127
|
+
);
|
|
128
|
+
expect(() => events.publish({ topic: "", data: 1 })).toThrow(InvalidEventError);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("uses a given event id instead of the connection sequence", async () => {
|
|
132
|
+
const events = await service();
|
|
133
|
+
const stream = await open(events, user("a"), ["t"]);
|
|
134
|
+
await events.publishAsync({ topic: "t", id: "job-1:40", data: 40 });
|
|
135
|
+
expect(await stream.next()).toMatchObject({ kind: "event", id: "job-1:40" });
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("audience filtering", () => {
|
|
140
|
+
it("delivers a user audience only to that user", async () => {
|
|
141
|
+
const events = await service();
|
|
142
|
+
const alice = await open(events, user("alice"), ["orders", "marker"]);
|
|
143
|
+
const bob = await open(events, user("bob"), ["orders", "marker"]);
|
|
144
|
+
|
|
145
|
+
await events.publishAsync({ topic: "orders", audience: { userIds: ["bob"] }, data: "bob's" });
|
|
146
|
+
|
|
147
|
+
expect(await framesBefore(events, alice)).toEqual([]);
|
|
148
|
+
expect(await framesBefore(events, bob)).toEqual([{ topic: "orders", data: "bob's" }]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("delivers a role audience to users holding any listed role", async () => {
|
|
152
|
+
const events = await service();
|
|
153
|
+
const admin = await open(events, user("root", ["user", "admin"]), ["ops", "marker"]);
|
|
154
|
+
const member = await open(events, user("member", ["user"]), ["ops", "marker"]);
|
|
155
|
+
|
|
156
|
+
await events.publishAsync({ topic: "ops", audience: { roles: ["admin", "auditor"] }, data: 1 });
|
|
157
|
+
|
|
158
|
+
expect(await framesBefore(events, admin)).toEqual([{ topic: "ops", data: 1 }]);
|
|
159
|
+
expect(await framesBefore(events, member)).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("requires every audience field to match", async () => {
|
|
163
|
+
const events = await service();
|
|
164
|
+
const orgAdmin = await open(events, user("a", ["admin"], "org-1"), ["t", "marker"]);
|
|
165
|
+
const otherOrgAdmin = await open(events, user("b", ["admin"], "org-2"), ["t", "marker"]);
|
|
166
|
+
const orgMember = await open(events, user("c", [], "org-1"), ["t", "marker"]);
|
|
167
|
+
|
|
168
|
+
await events.publishAsync({
|
|
169
|
+
topic: "t",
|
|
170
|
+
audience: { organizationId: "org-1", roles: ["admin"] },
|
|
171
|
+
data: "org-1 admins",
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(await framesBefore(events, orgAdmin)).toEqual([{ topic: "t", data: "org-1 admins" }]);
|
|
175
|
+
expect(await framesBefore(events, otherOrgAdmin)).toEqual([]);
|
|
176
|
+
expect(await framesBefore(events, orgMember)).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("delivers an empty recipient list to nobody", async () => {
|
|
180
|
+
const events = await service();
|
|
181
|
+
const stream = await open(events, user("a"), ["t", "marker"]);
|
|
182
|
+
await events.publishAsync({ topic: "t", audience: { userIds: [] }, data: 1 });
|
|
183
|
+
expect(await framesBefore(events, stream)).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("filters on every replica that shares the transport", async () => {
|
|
187
|
+
const bus = new MemoryEventBus();
|
|
188
|
+
const publisher = await service({}, bus);
|
|
189
|
+
const replica = await service({}, bus);
|
|
190
|
+
const alice = await open(replica, user("alice"), ["t", "marker"]);
|
|
191
|
+
const bob = await open(replica, user("bob"), ["t", "marker"]);
|
|
192
|
+
|
|
193
|
+
await publisher.publishAsync({ topic: "t", audience: { userIds: ["alice"] }, data: "hi" });
|
|
194
|
+
|
|
195
|
+
expect(await framesBefore(publisher, alice)).toEqual([{ topic: "t", data: "hi" }]);
|
|
196
|
+
expect(await framesBefore(publisher, bob)).toEqual([]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("discards malformed transport messages", async () => {
|
|
200
|
+
const bus = new MemoryEventBus();
|
|
201
|
+
const events = await service({}, bus);
|
|
202
|
+
const stream = await open(events, user("a"));
|
|
203
|
+
const stderr = spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
204
|
+
try {
|
|
205
|
+
await bus.publish("sse", { type: "message", message: "legacy" });
|
|
206
|
+
await bus.publish("sse", { topic: "t", audience: { userIds: "a" }, data: 1 });
|
|
207
|
+
expect(stderr).toHaveBeenCalledTimes(2);
|
|
208
|
+
} finally {
|
|
209
|
+
stderr.mockRestore();
|
|
210
|
+
}
|
|
211
|
+
expect(await framesBefore(events, stream)).toEqual([]);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("topic filtering", () => {
|
|
216
|
+
it("delivers only subscribed topics", async () => {
|
|
217
|
+
const events = await service();
|
|
218
|
+
const stream = await open(events, user("a"), ["a", "marker"]);
|
|
219
|
+
await events.publishAsync({ topic: "b", data: "b" });
|
|
220
|
+
await events.publishAsync({ topic: "a", data: "a" });
|
|
221
|
+
expect(await framesBefore(events, stream)).toEqual([{ topic: "a", data: "a" }]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("gives a stream without topics every topic except guarded ones", async () => {
|
|
225
|
+
const events = await service();
|
|
226
|
+
events.topics.register("admin-alerts", (subscriber) => subscriber.roles.includes("admin"));
|
|
227
|
+
const stream = await open(events, user("root", ["admin"]));
|
|
228
|
+
await events.publishAsync({ topic: "admin-alerts", data: "guarded" });
|
|
229
|
+
await events.publishAsync({ topic: "news", data: "open" });
|
|
230
|
+
expect(await framesBefore(events, stream)).toEqual([{ topic: "news", data: "open" }]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("lets authorizers restrict who may subscribe", async () => {
|
|
234
|
+
const events = await service();
|
|
235
|
+
events.topics.register("admin-alerts", (subscriber) => subscriber.roles.includes("admin"));
|
|
236
|
+
events.topics.register("project:*", (subscriber, topic) =>
|
|
237
|
+
topic === `project:${subscriber.organizationId}`
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const denied = open(events, user("member"), ["admin-alerts"]);
|
|
241
|
+
await expect(denied).rejects.toBeInstanceOf(AppException);
|
|
242
|
+
await expect(denied).rejects.toMatchObject({ code: "EVENTS_TOPIC_FORBIDDEN", statusCode: 403 });
|
|
243
|
+
await expect(open(events, user("a", [], "p1"), ["project:p2"])).rejects.toMatchObject({
|
|
244
|
+
statusCode: 403,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const admin = await open(events, user("root", ["admin"]), ["admin-alerts", "marker"]);
|
|
248
|
+
const project = await open(events, user("a", [], "p1"), ["project:p1", "marker"]);
|
|
249
|
+
await events.publishAsync({ topic: "admin-alerts", data: "alert" });
|
|
250
|
+
await events.publishAsync({ topic: "project:p1", data: "update" });
|
|
251
|
+
expect(await framesBefore(events, admin)).toEqual([{ topic: "admin-alerts", data: "alert" }]);
|
|
252
|
+
expect(await framesBefore(events, project)).toEqual([{ topic: "project:p1", data: "update" }]);
|
|
253
|
+
expect(events.streamCount).toBe(2);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("per-connection safety", () => {
|
|
258
|
+
it("drops the backlog of a slow client and sends one resync", async () => {
|
|
259
|
+
const events = await service({ queueLimit: 3 });
|
|
260
|
+
const stream = await open(events, user("a"), ["t"]);
|
|
261
|
+
for (let index = 1; index <= 5; index += 1) {
|
|
262
|
+
await events.publishAsync({ topic: "t", data: index });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
expect(await stream.next()).toEqual({ kind: "resync" });
|
|
266
|
+
expect(decoded(await stream.next())).toEqual({ topic: "t", data: 5 });
|
|
267
|
+
await events.publishAsync({ topic: "t", data: 6 });
|
|
268
|
+
expect(decoded(await stream.next())).toEqual({ topic: "t", data: 6 });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("reuses one heartbeat timer per connection and releases it on disconnect", async () => {
|
|
272
|
+
const scheduler = fakeScheduler();
|
|
273
|
+
const events = await service({ scheduler });
|
|
274
|
+
const controllers = [new AbortController(), new AbortController(), new AbortController()];
|
|
275
|
+
const streams = await Promise.all(
|
|
276
|
+
controllers.map((controller, index) =>
|
|
277
|
+
events.open(user(`u${index}`), ["t"], controller.signal)
|
|
278
|
+
),
|
|
279
|
+
);
|
|
280
|
+
expect(scheduler.active()).toBe(3);
|
|
281
|
+
|
|
282
|
+
for (let index = 0; index < 1_000; index += 1) {
|
|
283
|
+
await events.publishAsync({ topic: "t", data: index });
|
|
284
|
+
for (const stream of streams) await stream.next();
|
|
285
|
+
}
|
|
286
|
+
expect(scheduler.active()).toBe(3);
|
|
287
|
+
|
|
288
|
+
scheduler.tick();
|
|
289
|
+
const first = streams[0];
|
|
290
|
+
if (!first) throw new Error("missing stream");
|
|
291
|
+
expect(await first.next()).toEqual({ kind: "heartbeat" });
|
|
292
|
+
expect(scheduler.active()).toBe(3);
|
|
293
|
+
|
|
294
|
+
for (const controller of controllers) controller.abort();
|
|
295
|
+
expect(scheduler.active()).toBe(0);
|
|
296
|
+
expect(events.streamCount).toBe(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("wakes a waiting stream on abort and detaches it immediately", async () => {
|
|
300
|
+
const scheduler = fakeScheduler();
|
|
301
|
+
const events = await service({ scheduler });
|
|
302
|
+
const controller = new AbortController();
|
|
303
|
+
const added = spyOn(controller.signal, "addEventListener");
|
|
304
|
+
const removed = spyOn(controller.signal, "removeEventListener");
|
|
305
|
+
const stream = await events.open(user("a"), ["t"], controller.signal);
|
|
306
|
+
const waiting = stream.next();
|
|
307
|
+
|
|
308
|
+
controller.abort();
|
|
309
|
+
|
|
310
|
+
expect(await waiting).toBeUndefined();
|
|
311
|
+
expect(stream.isClosed).toBe(true);
|
|
312
|
+
expect(events.streamCount).toBe(0);
|
|
313
|
+
expect(scheduler.active()).toBe(0);
|
|
314
|
+
expect(added).toHaveBeenCalledTimes(1);
|
|
315
|
+
expect(removed).toHaveBeenCalledTimes(1);
|
|
316
|
+
await events.publishAsync({ topic: "t", data: "late" });
|
|
317
|
+
expect(await stream.next()).toBeUndefined();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("closes a stream opened with an already aborted signal", async () => {
|
|
321
|
+
const scheduler = fakeScheduler();
|
|
322
|
+
const events = await service({ scheduler });
|
|
323
|
+
const controller = new AbortController();
|
|
324
|
+
controller.abort();
|
|
325
|
+
const stream = await events.open(user("a"), ["t"], controller.signal);
|
|
326
|
+
expect(await stream.next()).toBeUndefined();
|
|
327
|
+
expect(events.streamCount).toBe(0);
|
|
328
|
+
expect(scheduler.active()).toBe(0);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("ends streams after the maximum connection time", async () => {
|
|
332
|
+
const scheduler = fakeScheduler();
|
|
333
|
+
let now = 0;
|
|
334
|
+
const events = await service({ scheduler, maxConnectionMs: 60_000, now: () => now });
|
|
335
|
+
const stream = await open(events, user("a"), ["t"]);
|
|
336
|
+
now = 59_999;
|
|
337
|
+
scheduler.tick();
|
|
338
|
+
expect(await stream.next()).toEqual({ kind: "heartbeat" });
|
|
339
|
+
now = 60_000;
|
|
340
|
+
scheduler.tick();
|
|
341
|
+
expect(await stream.next()).toBeUndefined();
|
|
342
|
+
expect(events.streamCount).toBe(0);
|
|
343
|
+
expect(scheduler.active()).toBe(0);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("closes every stream when the service closes", async () => {
|
|
347
|
+
const scheduler = fakeScheduler();
|
|
348
|
+
const events = await service({ scheduler });
|
|
349
|
+
const stream = await open(events, user("a"));
|
|
350
|
+
await events.close();
|
|
351
|
+
expect(await stream.next()).toBeUndefined();
|
|
352
|
+
expect(scheduler.active()).toBe(0);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe("SSE wire format", () => {
|
|
357
|
+
it("encodes data events as default messages and control frames separately", () => {
|
|
358
|
+
expect(encodeFrame({ kind: "event", id: "7", data: '{"topic":"t","data":1}' })).toBe(
|
|
359
|
+
'id: 7\ndata: {"topic":"t","data":1}\n\n',
|
|
360
|
+
);
|
|
361
|
+
expect(encodeFrame({ kind: "resync" })).toBe("event: resync\ndata: {}\n\n");
|
|
362
|
+
expect(encodeFrame({ kind: "heartbeat" })).toBe(": heartbeat\n\n");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("streams a retry hint first and releases the stream when the body is cancelled", async () => {
|
|
366
|
+
const events = await service({ retryMs: 1_500 });
|
|
367
|
+
const stream = await open(events, user("a"), ["t"]);
|
|
368
|
+
const response = eventStreamResponse(stream, events.retryMs);
|
|
369
|
+
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
|
370
|
+
const reader = response.body?.getReader();
|
|
371
|
+
if (!reader) throw new Error("missing body");
|
|
372
|
+
const decoder = new TextDecoder();
|
|
373
|
+
|
|
374
|
+
expect(decoder.decode((await reader.read()).value)).toBe("retry: 1500\n\n");
|
|
375
|
+
await events.publishAsync({ topic: "t", data: "line\nbreak" });
|
|
376
|
+
expect(decoder.decode((await reader.read()).value)).toBe(
|
|
377
|
+
'id: 1\ndata: {"topic":"t","data":"line\\nbreak"}\n\n',
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
await reader.cancel();
|
|
381
|
+
expect(stream.isClosed).toBe(true);
|
|
382
|
+
expect(events.streamCount).toBe(0);
|
|
29
383
|
});
|
|
30
384
|
});
|
|
@@ -1,73 +1,169 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
2
|
import type { EventBus, Unsubscribe } from "@podosoft/podokit-runtime";
|
|
3
|
+
import { ReadinessService } from "../health/readiness.service";
|
|
4
|
+
import {
|
|
5
|
+
audienceMatcher,
|
|
6
|
+
readEnvelope,
|
|
7
|
+
validEnvelope,
|
|
8
|
+
type EventEnvelope,
|
|
9
|
+
type EventSubscriber,
|
|
10
|
+
} from "./event-envelope";
|
|
11
|
+
import {
|
|
12
|
+
EventStream,
|
|
13
|
+
timerScheduler,
|
|
14
|
+
type EventStreamOptions,
|
|
15
|
+
type Scheduler,
|
|
16
|
+
} from "./event-stream";
|
|
17
|
+
import { TopicAuthorizers } from "./event-topics";
|
|
18
|
+
|
|
19
|
+
/** Every replica subscribes to this one transport topic and filters locally. */
|
|
20
|
+
const TRANSPORT_TOPIC = "sse";
|
|
3
21
|
|
|
4
|
-
|
|
22
|
+
export interface EventsServiceOptions {
|
|
23
|
+
/** Events buffered for one slow client before it is told to resync. */
|
|
24
|
+
queueLimit?: number;
|
|
25
|
+
heartbeatMs?: number;
|
|
26
|
+
/** Reconnect delay the stream advertises to EventSource clients. */
|
|
27
|
+
retryMs?: number;
|
|
28
|
+
/** Streams end after this long so clients reconnect with a fresh identity. */
|
|
29
|
+
maxConnectionMs?: number;
|
|
30
|
+
scheduler?: Scheduler;
|
|
31
|
+
now?: () => number;
|
|
32
|
+
}
|
|
5
33
|
|
|
6
34
|
export class EventsService {
|
|
7
|
-
|
|
35
|
+
readonly topics = new TopicAuthorizers();
|
|
36
|
+
readonly retryMs: number;
|
|
37
|
+
private readonly streamOptions: EventStreamOptions;
|
|
38
|
+
private readonly streams = new Set<EventStream>();
|
|
39
|
+
private readonly byTopic = new Map<string, Set<EventStream>>();
|
|
40
|
+
private readonly everyTopic = new Set<EventStream>();
|
|
8
41
|
private unregisterReadiness?: () => void;
|
|
9
42
|
private unsubscribe?: Unsubscribe;
|
|
10
43
|
|
|
11
44
|
constructor(
|
|
12
45
|
private readonly transport: EventBus,
|
|
13
46
|
private readonly readiness: ReadinessService,
|
|
14
|
-
|
|
47
|
+
options: EventsServiceOptions = {},
|
|
48
|
+
) {
|
|
49
|
+
this.retryMs = options.retryMs ?? 3_000;
|
|
50
|
+
this.streamOptions = {
|
|
51
|
+
queueLimit: options.queueLimit ?? 256,
|
|
52
|
+
heartbeatMs: options.heartbeatMs ?? 5_000,
|
|
53
|
+
maxConnectionMs: options.maxConnectionMs ?? 30 * 60_000,
|
|
54
|
+
scheduler: options.scheduler ?? timerScheduler,
|
|
55
|
+
now: options.now ?? Date.now,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get streamCount(): number {
|
|
60
|
+
return this.streams.size;
|
|
61
|
+
}
|
|
15
62
|
|
|
16
63
|
async connect(): Promise<void> {
|
|
17
|
-
this.unsubscribe = await this.transport.subscribe(
|
|
64
|
+
this.unsubscribe = await this.transport.subscribe(TRANSPORT_TOPIC, (value) => {
|
|
65
|
+
const envelope = readEnvelope(value);
|
|
66
|
+
if (typeof envelope === "string") {
|
|
67
|
+
process.stderr.write(`Discard invalid event: ${envelope}\n`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.deliver(envelope);
|
|
71
|
+
});
|
|
18
72
|
this.unregisterReadiness = this.readiness.register("events", () => this.transport.ready());
|
|
19
73
|
}
|
|
20
74
|
|
|
21
|
-
|
|
22
|
-
|
|
75
|
+
/** Best-effort delivery for state that is already committed. Throws on an invalid event. */
|
|
76
|
+
publish(event: EventEnvelope): void {
|
|
77
|
+
const envelope = validEnvelope(event);
|
|
78
|
+
void this.transport.publish(TRANSPORT_TOPIC, envelope).catch((error: unknown) => {
|
|
23
79
|
const message = error instanceof Error ? error.message : String(error);
|
|
24
80
|
process.stderr.write(`Publish event failed: ${message}\n`);
|
|
25
81
|
});
|
|
26
82
|
}
|
|
27
83
|
|
|
28
|
-
|
|
29
|
-
|
|
84
|
+
/** Resolves once the transport accepted the event; rejects when it did not. */
|
|
85
|
+
async publishAsync(event: EventEnvelope): Promise<void> {
|
|
86
|
+
await this.transport.publish(TRANSPORT_TOPIC, validEnvelope(event));
|
|
30
87
|
}
|
|
31
88
|
|
|
32
|
-
|
|
33
|
-
|
|
89
|
+
/** Delivers to this replica only, for events another channel already fanned out. */
|
|
90
|
+
publishLocal(event: EventEnvelope): void {
|
|
91
|
+
this.deliver(validEnvelope(event));
|
|
34
92
|
}
|
|
35
93
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (queue.length === 0) {
|
|
53
|
-
const delivered = await Promise.race([
|
|
54
|
-
new Promise<"event">((resolve) => {
|
|
55
|
-
wake = () => resolve("event");
|
|
56
|
-
}),
|
|
57
|
-
Bun.sleep(5_000).then(() => "heartbeat" as const),
|
|
58
|
-
]);
|
|
59
|
-
if (delivered === "heartbeat") yield { type: "heartbeat", n: heartbeat++ };
|
|
60
|
-
}
|
|
61
|
-
while (queue.length > 0) yield queue.shift();
|
|
94
|
+
/**
|
|
95
|
+
* Opens a stream for `subscriber`. Without `topics` the stream receives every
|
|
96
|
+
* topic that has no authorizer; guarded topics must be requested by name.
|
|
97
|
+
*/
|
|
98
|
+
async open(
|
|
99
|
+
subscriber: EventSubscriber,
|
|
100
|
+
topics: readonly string[] | undefined,
|
|
101
|
+
signal: AbortSignal,
|
|
102
|
+
): Promise<EventStream> {
|
|
103
|
+
for (const topic of topics ?? []) {
|
|
104
|
+
if (!(await this.topics.allows(subscriber, topic))) {
|
|
105
|
+
throw new AppException(
|
|
106
|
+
"EVENTS_TOPIC_FORBIDDEN",
|
|
107
|
+
`You cannot subscribe to ${topic}`,
|
|
108
|
+
403,
|
|
109
|
+
);
|
|
62
110
|
}
|
|
63
|
-
} finally {
|
|
64
|
-
unsubscribe();
|
|
65
111
|
}
|
|
112
|
+
const stream = new EventStream(
|
|
113
|
+
subscriber,
|
|
114
|
+
topics ? new Set(topics) : undefined,
|
|
115
|
+
this.streamOptions,
|
|
116
|
+
(closed) => this.detach(closed),
|
|
117
|
+
);
|
|
118
|
+
this.attach(stream);
|
|
119
|
+
stream.start(signal);
|
|
120
|
+
return stream;
|
|
66
121
|
}
|
|
67
122
|
|
|
68
123
|
async close(): Promise<void> {
|
|
69
124
|
this.unregisterReadiness?.();
|
|
70
125
|
await this.unsubscribe?.();
|
|
71
|
-
this.
|
|
126
|
+
for (const stream of [...this.streams]) stream.close();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private deliver(event: EventEnvelope): void {
|
|
130
|
+
const matches = audienceMatcher(event.audience);
|
|
131
|
+
let payload: string | undefined;
|
|
132
|
+
const offer = (stream: EventStream): void => {
|
|
133
|
+
if (!matches(stream.subscriber)) return;
|
|
134
|
+
payload ??= JSON.stringify({ topic: event.topic, data: event.data });
|
|
135
|
+
stream.offer(payload, event.id);
|
|
136
|
+
};
|
|
137
|
+
for (const stream of this.byTopic.get(event.topic) ?? []) offer(stream);
|
|
138
|
+
if (!this.topics.guarded(event.topic)) {
|
|
139
|
+
for (const stream of this.everyTopic) offer(stream);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private attach(stream: EventStream): void {
|
|
144
|
+
this.streams.add(stream);
|
|
145
|
+
if (!stream.topics) {
|
|
146
|
+
this.everyTopic.add(stream);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
for (const topic of stream.topics) {
|
|
150
|
+
const streams = this.byTopic.get(topic) ?? new Set<EventStream>();
|
|
151
|
+
streams.add(stream);
|
|
152
|
+
this.byTopic.set(topic, streams);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private detach(stream: EventStream): void {
|
|
157
|
+
this.streams.delete(stream);
|
|
158
|
+
if (!stream.topics) {
|
|
159
|
+
this.everyTopic.delete(stream);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
for (const topic of stream.topics) {
|
|
163
|
+
const streams = this.byTopic.get(topic);
|
|
164
|
+
if (!streams) continue;
|
|
165
|
+
streams.delete(stream);
|
|
166
|
+
if (streams.size === 0) this.byTopic.delete(topic);
|
|
167
|
+
}
|
|
72
168
|
}
|
|
73
169
|
}
|