@podosoft/podokit 1.1.5 → 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 +17 -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/schema-validation.ts +13 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +5 -0
- 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/migrate.ts +3 -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,27 +1,105 @@
|
|
|
1
1
|
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
2
|
import { EVENTS } from "@podosoft/podokit-runtime";
|
|
3
|
-
import { Elysia,
|
|
3
|
+
import { Elysia, t } from "elysia";
|
|
4
4
|
import {
|
|
5
|
+
READINESS,
|
|
6
|
+
REQUEST_IDENTITY,
|
|
5
7
|
type AppPlugin,
|
|
8
|
+
type ElysiaPlugin,
|
|
6
9
|
type PodokitModule,
|
|
7
|
-
|
|
10
|
+
type RequestIdentity,
|
|
11
|
+
type ServiceKey,
|
|
8
12
|
} from "../core/services";
|
|
13
|
+
import {
|
|
14
|
+
EVENT_NAME_PATTERN,
|
|
15
|
+
InvalidEventError,
|
|
16
|
+
type EventEnvelope,
|
|
17
|
+
type EventSubscriber,
|
|
18
|
+
} from "./event-envelope";
|
|
19
|
+
import { eventStreamResponse } from "./event-stream";
|
|
20
|
+
import { parseTopics } from "./event-topics";
|
|
9
21
|
import { EventsService } from "./events.service";
|
|
10
22
|
|
|
11
|
-
export const SSE_EVENTS = Symbol("sse-events") as
|
|
23
|
+
export const SSE_EVENTS = Symbol("sse-events") as ServiceKey<EventsService>;
|
|
24
|
+
|
|
25
|
+
/** Resolves who opens a stream. Register one in app.extensions.ts to replace the default. */
|
|
26
|
+
export interface EventIdentity {
|
|
27
|
+
subscriber(request: Request): Promise<EventSubscriber | undefined>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const EVENT_IDENTITY = Symbol("event-identity") as ServiceKey<EventIdentity>;
|
|
31
|
+
|
|
32
|
+
/** The default identity: the request user with their roles and active organization. */
|
|
33
|
+
export function requestEventIdentity(identity: RequestIdentity): EventIdentity {
|
|
34
|
+
return {
|
|
35
|
+
subscriber: async (request) => {
|
|
36
|
+
const userId = await identity.userId(request);
|
|
37
|
+
if (!userId) return undefined;
|
|
38
|
+
const [roles, organizationId] = await Promise.all([
|
|
39
|
+
identity.roles(request),
|
|
40
|
+
identity.activeOrganizationId(request),
|
|
41
|
+
]);
|
|
42
|
+
return organizationId === undefined ? { userId, roles } : { userId, roles, organizationId };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
12
46
|
|
|
13
|
-
const
|
|
14
|
-
|
|
47
|
+
const nameSchema = t.String({ pattern: EVENT_NAME_PATTERN });
|
|
48
|
+
const idListSchema = t.Array(t.String({ minLength: 1, maxLength: 256 }), { maxItems: 1_000 });
|
|
49
|
+
const audienceSchema = t.Object(
|
|
50
|
+
{
|
|
51
|
+
userIds: t.Optional(idListSchema),
|
|
52
|
+
roles: t.Optional(idListSchema),
|
|
53
|
+
organizationId: t.Optional(t.String({ minLength: 1, maxLength: 256 })),
|
|
54
|
+
},
|
|
55
|
+
{ additionalProperties: false, minProperties: 1 },
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
async function requireAdmin(identity: RequestIdentity, request: Request): Promise<void> {
|
|
59
|
+
if (!(await identity.userId(request))) {
|
|
60
|
+
throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
|
|
61
|
+
}
|
|
62
|
+
if (!(await identity.isAdmin(request))) {
|
|
63
|
+
throw new AppException("ADMIN_REQUIRED", "Administrator access is required", 403);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function eventsRoutes(
|
|
68
|
+
events: EventsService,
|
|
69
|
+
identity: RequestIdentity,
|
|
70
|
+
eventIdentity: EventIdentity = requestEventIdentity(identity),
|
|
71
|
+
): ElysiaPlugin {
|
|
15
72
|
return new Elysia({ name: "podokit.events" })
|
|
16
|
-
.get("/events/stream", async
|
|
17
|
-
|
|
73
|
+
.get("/events/stream", async ({ request, query }) => {
|
|
74
|
+
const subscriber = await eventIdentity.subscriber(request);
|
|
75
|
+
if (!subscriber) throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
|
|
76
|
+
const stream = await events.open(subscriber, parseTopics(query.topics), request.signal);
|
|
77
|
+
return eventStreamResponse(stream, events.retryMs);
|
|
18
78
|
}, {
|
|
19
|
-
|
|
79
|
+
query: t.Object({ topics: t.Optional(t.String({ maxLength: 4_096 })) }),
|
|
80
|
+
detail: {
|
|
81
|
+
tags: ["events"],
|
|
82
|
+
summary: "Stream server-sent events",
|
|
83
|
+
description:
|
|
84
|
+
"Signed-in users only. `topics` is a comma-separated list; without it the stream " +
|
|
85
|
+
"carries every topic that has no authorizer. Each data event is a default " +
|
|
86
|
+
"`message` whose data is `{ topic, data }`. A `resync` event means events were " +
|
|
87
|
+
"dropped and the client should reload its state.",
|
|
88
|
+
},
|
|
20
89
|
})
|
|
21
|
-
.post("/events", async ({ body, set }) => {
|
|
90
|
+
.post("/events", async ({ request, body, set }) => {
|
|
91
|
+
await requireAdmin(identity, request);
|
|
92
|
+
const event: EventEnvelope = {
|
|
93
|
+
topic: body.topic ?? "message",
|
|
94
|
+
data: { message: body.message },
|
|
95
|
+
...(body.audience ? { audience: body.audience } : {}),
|
|
96
|
+
};
|
|
22
97
|
try {
|
|
23
|
-
await events.publishAsync(
|
|
24
|
-
} catch {
|
|
98
|
+
await events.publishAsync(event);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error instanceof InvalidEventError) {
|
|
101
|
+
throw new AppException("EVENTS_INVALID", error.message, 400);
|
|
102
|
+
}
|
|
25
103
|
throw new AppException(
|
|
26
104
|
"EVENTS_TRANSPORT_UNAVAILABLE",
|
|
27
105
|
"Event transport is unavailable",
|
|
@@ -31,10 +109,25 @@ const eventsPlugin: AppPlugin = ({ services }) => {
|
|
|
31
109
|
set.status = 201;
|
|
32
110
|
return { ok: true as const };
|
|
33
111
|
}, {
|
|
34
|
-
body: t.Object({
|
|
35
|
-
|
|
112
|
+
body: t.Object({
|
|
113
|
+
message: t.String({ maxLength: 4_096 }),
|
|
114
|
+
topic: t.Optional(nameSchema),
|
|
115
|
+
audience: t.Optional(audienceSchema),
|
|
116
|
+
}),
|
|
117
|
+
detail: {
|
|
118
|
+
tags: ["events"],
|
|
119
|
+
summary: "Publish an event",
|
|
120
|
+
description: "Administrators only. Publishes `{ message }` to `topic` (default `message`).",
|
|
121
|
+
},
|
|
36
122
|
});
|
|
37
|
-
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const eventsPlugin: AppPlugin = ({ services }) =>
|
|
126
|
+
eventsRoutes(
|
|
127
|
+
services.resolve(SSE_EVENTS),
|
|
128
|
+
services.resolve(REQUEST_IDENTITY),
|
|
129
|
+
services.tryResolve(EVENT_IDENTITY),
|
|
130
|
+
);
|
|
38
131
|
|
|
39
132
|
export const eventsModule: PodokitModule = {
|
|
40
133
|
name: "sse",
|
|
@@ -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
|
});
|