@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
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { EventStreamClient, type StreamMessage } from "./events";
|
|
3
|
+
|
|
4
|
+
class FakeEventSource extends EventTarget {
|
|
5
|
+
static instances: FakeEventSource[] = [];
|
|
6
|
+
closed = false;
|
|
7
|
+
|
|
8
|
+
constructor(readonly url: string) {
|
|
9
|
+
super();
|
|
10
|
+
FakeEventSource.instances.push(this);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
close(): void {
|
|
14
|
+
this.closed = true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get topics(): string | null {
|
|
18
|
+
return new URL(this.url, "http://localhost").searchParams.get("topics");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
emitOpen(): void {
|
|
22
|
+
this.dispatchEvent(new Event("open"));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
emitMessage(topic: string, data: unknown, lastEventId = ""): void {
|
|
26
|
+
this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({ topic, data }), lastEventId }));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
emitResync(): void {
|
|
30
|
+
this.dispatchEvent(new MessageEvent("resync", { data: "{}" }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
emitError(): void {
|
|
34
|
+
this.dispatchEvent(new Event("error"));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function latest(): FakeEventSource {
|
|
39
|
+
const source = FakeEventSource.instances.at(-1);
|
|
40
|
+
if (!source) throw new Error("no EventSource was opened");
|
|
41
|
+
return source;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function recorder(): { handler: (message: StreamMessage) => void; messages: StreamMessage[] } {
|
|
45
|
+
const messages: StreamMessage[] = [];
|
|
46
|
+
return { handler: (message) => messages.push(message), messages };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
FakeEventSource.instances = [];
|
|
51
|
+
vi.useFakeTimers();
|
|
52
|
+
vi.stubGlobal("EventSource", FakeEventSource);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
vi.useRealTimers();
|
|
57
|
+
vi.unstubAllGlobals();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("EventStreamClient", () => {
|
|
61
|
+
it("shares one EventSource and routes messages by topic", () => {
|
|
62
|
+
const client = new EventStreamClient();
|
|
63
|
+
const orders = recorder();
|
|
64
|
+
const jobs = recorder();
|
|
65
|
+
client.subscribe("orders", orders.handler);
|
|
66
|
+
client.subscribe("job-progress", jobs.handler);
|
|
67
|
+
vi.runOnlyPendingTimers();
|
|
68
|
+
|
|
69
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
70
|
+
expect(latest().url.startsWith("/api/events/stream?")).toBe(true);
|
|
71
|
+
expect(latest().topics).toBe("job-progress,orders");
|
|
72
|
+
|
|
73
|
+
latest().emitOpen();
|
|
74
|
+
latest().emitMessage("orders", { id: 1 }, "7");
|
|
75
|
+
latest().emitMessage("unknown", "ignored");
|
|
76
|
+
latest().dispatchEvent(new MessageEvent("message", { data: "not json" }));
|
|
77
|
+
|
|
78
|
+
expect(orders.messages).toEqual([{ resync: false, topic: "orders", id: "7", data: { id: 1 } }]);
|
|
79
|
+
expect(jobs.messages).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps the stream across a same-tick resubscribe and closes after the last one leaves", () => {
|
|
83
|
+
const client = new EventStreamClient();
|
|
84
|
+
const first = client.subscribe("orders", () => undefined);
|
|
85
|
+
vi.runOnlyPendingTimers();
|
|
86
|
+
const source = latest();
|
|
87
|
+
|
|
88
|
+
first();
|
|
89
|
+
const second = client.subscribe("orders", () => undefined);
|
|
90
|
+
vi.runOnlyPendingTimers();
|
|
91
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
92
|
+
expect(source.closed).toBe(false);
|
|
93
|
+
|
|
94
|
+
second();
|
|
95
|
+
second();
|
|
96
|
+
vi.runOnlyPendingTimers();
|
|
97
|
+
expect(source.closed).toBe(true);
|
|
98
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("reconnects for a new topic and resyncs only subscribers that were already live", () => {
|
|
102
|
+
const client = new EventStreamClient();
|
|
103
|
+
const orders = recorder();
|
|
104
|
+
client.subscribe("orders", orders.handler);
|
|
105
|
+
vi.runOnlyPendingTimers();
|
|
106
|
+
const first = latest();
|
|
107
|
+
first.emitOpen();
|
|
108
|
+
expect(orders.messages).toEqual([]);
|
|
109
|
+
|
|
110
|
+
const jobs = recorder();
|
|
111
|
+
client.subscribe("job-progress", jobs.handler);
|
|
112
|
+
vi.runOnlyPendingTimers();
|
|
113
|
+
const second = latest();
|
|
114
|
+
expect(first.closed).toBe(true);
|
|
115
|
+
expect(second.topics).toBe("job-progress,orders");
|
|
116
|
+
|
|
117
|
+
first.emitMessage("orders", "stale");
|
|
118
|
+
second.emitOpen();
|
|
119
|
+
expect(orders.messages).toEqual([{ resync: true, topic: "orders" }]);
|
|
120
|
+
expect(jobs.messages).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("does not reconnect when a topic is already on the stream", () => {
|
|
124
|
+
const client = new EventStreamClient();
|
|
125
|
+
client.subscribe("orders", () => undefined);
|
|
126
|
+
vi.runOnlyPendingTimers();
|
|
127
|
+
latest().emitOpen();
|
|
128
|
+
const late = recorder();
|
|
129
|
+
client.subscribe("orders", late.handler);
|
|
130
|
+
vi.runOnlyPendingTimers();
|
|
131
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
132
|
+
latest().emitMessage("orders", 1, "1");
|
|
133
|
+
expect(late.messages).toEqual([{ resync: false, topic: "orders", id: "1", data: 1 }]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("passes a server resync to every subscriber", () => {
|
|
137
|
+
const client = new EventStreamClient();
|
|
138
|
+
const orders = recorder();
|
|
139
|
+
const jobs = recorder();
|
|
140
|
+
client.subscribe("orders", orders.handler);
|
|
141
|
+
client.subscribe("job-progress", jobs.handler);
|
|
142
|
+
vi.runOnlyPendingTimers();
|
|
143
|
+
latest().emitOpen();
|
|
144
|
+
|
|
145
|
+
latest().emitResync();
|
|
146
|
+
|
|
147
|
+
expect(orders.messages).toEqual([{ resync: true, topic: "orders" }]);
|
|
148
|
+
expect(jobs.messages).toEqual([{ resync: true, topic: "job-progress" }]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("reconnects with exponential backoff and resyncs after reconnecting", () => {
|
|
152
|
+
const client = new EventStreamClient({ minBackoffMs: 1_000, maxBackoffMs: 4_000, random: () => 1 });
|
|
153
|
+
const orders = recorder();
|
|
154
|
+
client.subscribe("orders", orders.handler);
|
|
155
|
+
vi.runOnlyPendingTimers();
|
|
156
|
+
latest().emitOpen();
|
|
157
|
+
|
|
158
|
+
const delays: number[] = [];
|
|
159
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
160
|
+
const failing = latest();
|
|
161
|
+
failing.emitError();
|
|
162
|
+
expect(failing.closed).toBe(true);
|
|
163
|
+
const count = FakeEventSource.instances.length;
|
|
164
|
+
let waited = 0;
|
|
165
|
+
while (FakeEventSource.instances.length === count) {
|
|
166
|
+
vi.advanceTimersByTime(250);
|
|
167
|
+
waited += 250;
|
|
168
|
+
}
|
|
169
|
+
delays.push(waited);
|
|
170
|
+
}
|
|
171
|
+
expect(delays).toEqual([1_000, 2_000, 4_000, 4_000]);
|
|
172
|
+
expect(orders.messages).toEqual([]);
|
|
173
|
+
|
|
174
|
+
latest().emitOpen();
|
|
175
|
+
expect(orders.messages).toEqual([{ resync: true, topic: "orders" }]);
|
|
176
|
+
|
|
177
|
+
latest().emitError();
|
|
178
|
+
vi.advanceTimersByTime(1_000);
|
|
179
|
+
expect(FakeEventSource.instances).toHaveLength(6);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("stops retrying once every subscriber has left", () => {
|
|
183
|
+
const client = new EventStreamClient({ random: () => 0 });
|
|
184
|
+
const unsubscribe = client.subscribe("orders", () => undefined);
|
|
185
|
+
vi.runOnlyPendingTimers();
|
|
186
|
+
latest().emitError();
|
|
187
|
+
unsubscribe();
|
|
188
|
+
vi.runAllTimers();
|
|
189
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("isolates a failing subscriber from the others", () => {
|
|
193
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
194
|
+
const client = new EventStreamClient();
|
|
195
|
+
const healthy = recorder();
|
|
196
|
+
client.subscribe("orders", () => {
|
|
197
|
+
throw new Error("boom");
|
|
198
|
+
});
|
|
199
|
+
client.subscribe("orders", healthy.handler);
|
|
200
|
+
vi.runOnlyPendingTimers();
|
|
201
|
+
latest().emitMessage("orders", 1, "1");
|
|
202
|
+
expect(healthy.messages).toHaveLength(1);
|
|
203
|
+
expect(error).toHaveBeenCalledTimes(1);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("rejects invalid topics and does nothing without EventSource", () => {
|
|
207
|
+
const client = new EventStreamClient();
|
|
208
|
+
expect(() => client.subscribe("a,b", () => undefined)).toThrow("Invalid event topic");
|
|
209
|
+
vi.unstubAllGlobals();
|
|
210
|
+
vi.stubGlobal("EventSource", undefined);
|
|
211
|
+
const unsubscribe = client.subscribe("orders", () => undefined);
|
|
212
|
+
vi.runOnlyPendingTimers();
|
|
213
|
+
unsubscribe();
|
|
214
|
+
expect(FakeEventSource.instances).toHaveLength(0);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// One shared EventSource per tab for the sse module's /events/stream.
|
|
2
|
+
//
|
|
3
|
+
// Subscribe from $effect or onMount so the stream only opens in the browser:
|
|
4
|
+
//
|
|
5
|
+
// $effect(() => subscribe("job-progress", (message) => {
|
|
6
|
+
// if (message.resync) void reload();
|
|
7
|
+
// else progress = message.data;
|
|
8
|
+
// }));
|
|
9
|
+
//
|
|
10
|
+
// A resync message means events may have been missed (the server dropped a
|
|
11
|
+
// backlog, or the stream reconnected). Reload the state the topic describes.
|
|
12
|
+
|
|
13
|
+
const EVENT_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
14
|
+
|
|
15
|
+
export type StreamMessage =
|
|
16
|
+
| { resync: false; topic: string; id: string; data: unknown }
|
|
17
|
+
| { resync: true; topic: string };
|
|
18
|
+
|
|
19
|
+
export type StreamHandler = (message: StreamMessage) => void;
|
|
20
|
+
|
|
21
|
+
export interface EventStreamClientOptions {
|
|
22
|
+
url?: string;
|
|
23
|
+
minBackoffMs?: number;
|
|
24
|
+
maxBackoffMs?: number;
|
|
25
|
+
random?: () => number;
|
|
26
|
+
createSource?: (url: string) => EventSource;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Subscription {
|
|
30
|
+
topic: string;
|
|
31
|
+
handler: StreamHandler;
|
|
32
|
+
/** True once the subscription has been connected; a later reconnect owes it a resync. */
|
|
33
|
+
live: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function streamPayload(data: unknown): { topic: string; data: unknown } | undefined {
|
|
37
|
+
if (typeof data !== "string") return undefined;
|
|
38
|
+
let parsed: unknown;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(data);
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
45
|
+
const { topic, data: payload } = parsed as Record<string, unknown>;
|
|
46
|
+
return typeof topic === "string" ? { topic, data: payload } : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class EventStreamClient {
|
|
50
|
+
private readonly subscriptions = new Map<string, Set<Subscription>>();
|
|
51
|
+
private source?: EventSource;
|
|
52
|
+
private sourceTopics = new Set<string>();
|
|
53
|
+
private open = false;
|
|
54
|
+
private attempt = 0;
|
|
55
|
+
private syncTimer?: ReturnType<typeof setTimeout>;
|
|
56
|
+
private retryTimer?: ReturnType<typeof setTimeout>;
|
|
57
|
+
|
|
58
|
+
constructor(private readonly options: EventStreamClientOptions = {}) {}
|
|
59
|
+
|
|
60
|
+
subscribe(topic: string, handler: StreamHandler): () => void {
|
|
61
|
+
if (!EVENT_NAME.test(topic)) throw new TypeError(`Invalid event topic: ${topic}`);
|
|
62
|
+
if (!this.options.createSource && typeof EventSource === "undefined") return () => undefined;
|
|
63
|
+
const subscription: Subscription = {
|
|
64
|
+
topic,
|
|
65
|
+
handler,
|
|
66
|
+
live: this.open && this.sourceTopics.has(topic),
|
|
67
|
+
};
|
|
68
|
+
const subscriptions = this.subscriptions.get(topic) ?? new Set<Subscription>();
|
|
69
|
+
subscriptions.add(subscription);
|
|
70
|
+
this.subscriptions.set(topic, subscriptions);
|
|
71
|
+
this.scheduleSync();
|
|
72
|
+
let subscribed = true;
|
|
73
|
+
return () => {
|
|
74
|
+
if (!subscribed) return;
|
|
75
|
+
subscribed = false;
|
|
76
|
+
subscriptions.delete(subscription);
|
|
77
|
+
if (subscriptions.size === 0 && this.subscriptions.get(topic) === subscriptions) {
|
|
78
|
+
this.subscriptions.delete(topic);
|
|
79
|
+
}
|
|
80
|
+
this.scheduleSync();
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Deferred so components that unsubscribe and resubscribe in one tick, or several
|
|
85
|
+
// components that subscribe together, cause at most one reconnect.
|
|
86
|
+
private scheduleSync(): void {
|
|
87
|
+
if (this.syncTimer !== undefined) return;
|
|
88
|
+
this.syncTimer = setTimeout(() => {
|
|
89
|
+
this.syncTimer = undefined;
|
|
90
|
+
this.sync();
|
|
91
|
+
}, 0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private sync(): void {
|
|
95
|
+
const topics = [...this.subscriptions.keys()];
|
|
96
|
+
if (topics.length === 0) {
|
|
97
|
+
this.stop();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Topics that lost their last subscriber stay on the connection until the next
|
|
101
|
+
// reconnect; reconnecting only to drop them would cost every other topic a resync.
|
|
102
|
+
const connecting = this.source !== undefined || this.retryTimer !== undefined;
|
|
103
|
+
if (connecting && topics.every((topic) => this.sourceTopics.has(topic))) return;
|
|
104
|
+
this.connect(topics);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private connect(topics: string[]): void {
|
|
108
|
+
this.closeSource();
|
|
109
|
+
this.clearRetry();
|
|
110
|
+
this.sourceTopics = new Set(topics);
|
|
111
|
+
const query = new URLSearchParams({ topics: [...topics].sort().join(",") });
|
|
112
|
+
const createSource = this.options.createSource ?? ((url: string) => new EventSource(url));
|
|
113
|
+
const source = createSource(`${this.options.url ?? "/api/events/stream"}?${query}`);
|
|
114
|
+
this.source = source;
|
|
115
|
+
source.addEventListener("open", () => {
|
|
116
|
+
if (this.source === source) this.opened();
|
|
117
|
+
});
|
|
118
|
+
source.addEventListener("message", (event) => {
|
|
119
|
+
if (this.source === source) this.dispatch(event);
|
|
120
|
+
});
|
|
121
|
+
source.addEventListener("resync", () => {
|
|
122
|
+
if (this.source === source) this.resync(false);
|
|
123
|
+
});
|
|
124
|
+
source.addEventListener("error", () => {
|
|
125
|
+
if (this.source === source) this.failed();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private opened(): void {
|
|
130
|
+
this.open = true;
|
|
131
|
+
this.attempt = 0;
|
|
132
|
+
this.resync(true);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private resync(reconnected: boolean): void {
|
|
136
|
+
for (const subscriptions of [...this.subscriptions.values()]) {
|
|
137
|
+
for (const subscription of [...subscriptions]) {
|
|
138
|
+
if (!this.sourceTopics.has(subscription.topic)) continue;
|
|
139
|
+
const owed = !reconnected || subscription.live;
|
|
140
|
+
subscription.live = true;
|
|
141
|
+
if (owed) this.notify(subscription, { resync: true, topic: subscription.topic });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private dispatch(event: MessageEvent): void {
|
|
147
|
+
const payload = streamPayload(event.data);
|
|
148
|
+
if (!payload) return;
|
|
149
|
+
const subscriptions = this.subscriptions.get(payload.topic);
|
|
150
|
+
if (!subscriptions) return;
|
|
151
|
+
const message: StreamMessage = {
|
|
152
|
+
resync: false,
|
|
153
|
+
topic: payload.topic,
|
|
154
|
+
id: event.lastEventId,
|
|
155
|
+
data: payload.data,
|
|
156
|
+
};
|
|
157
|
+
for (const subscription of [...subscriptions]) this.notify(subscription, message);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// EventSource retries on its own at a fixed interval and gives up for good on an
|
|
161
|
+
// HTTP error, so close it and retry with exponential backoff instead.
|
|
162
|
+
private failed(): void {
|
|
163
|
+
this.closeSource();
|
|
164
|
+
const minimum = this.options.minBackoffMs ?? 1_000;
|
|
165
|
+
const maximum = this.options.maxBackoffMs ?? 30_000;
|
|
166
|
+
const ceiling = Math.min(maximum, minimum * 2 ** this.attempt);
|
|
167
|
+
const delay = Math.round(ceiling / 2 + (this.options.random ?? Math.random)() * (ceiling / 2));
|
|
168
|
+
this.attempt += 1;
|
|
169
|
+
this.retryTimer = setTimeout(() => {
|
|
170
|
+
this.retryTimer = undefined;
|
|
171
|
+
const topics = [...this.subscriptions.keys()];
|
|
172
|
+
if (topics.length > 0) this.connect(topics);
|
|
173
|
+
}, delay);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private notify(subscription: Subscription, message: StreamMessage): void {
|
|
177
|
+
try {
|
|
178
|
+
subscription.handler(message);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error(error);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private closeSource(): void {
|
|
185
|
+
this.source?.close();
|
|
186
|
+
this.source = undefined;
|
|
187
|
+
this.open = false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private clearRetry(): void {
|
|
191
|
+
if (this.retryTimer !== undefined) clearTimeout(this.retryTimer);
|
|
192
|
+
this.retryTimer = undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private stop(): void {
|
|
196
|
+
this.closeSource();
|
|
197
|
+
this.clearRetry();
|
|
198
|
+
this.attempt = 0;
|
|
199
|
+
this.sourceTopics = new Set();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export const eventStream = new EventStreamClient();
|
|
204
|
+
|
|
205
|
+
export function subscribe(topic: string, handler: StreamHandler): () => void {
|
|
206
|
+
return eventStream.subscribe(topic, handler);
|
|
207
|
+
}
|