@podosoft/podokit 0.15.0 → 0.16.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/README.md +17 -0
- package/dist/add.d.ts +4 -0
- package/dist/add.js +59 -1
- package/dist/create.js +8 -1
- package/dist/deploy-profile.d.ts +92 -0
- package/dist/deploy-profile.js +603 -0
- package/dist/deploy-render.d.ts +18 -0
- package/dist/deploy-render.js +651 -0
- package/dist/deploy.d.ts +65 -0
- package/dist/deploy.js +851 -0
- package/dist/dev.d.ts +1 -0
- package/dist/dev.js +29 -5
- package/dist/index.d.ts +8 -0
- package/dist/index.js +147 -1
- package/dist/lib.d.ts +6 -0
- package/dist/lib.js +18 -1
- package/dist/lockfile.js +1 -0
- package/dist/remove.js +7 -0
- package/dist/templates/fullstack-nest-svelte/AGENTS.md +1 -0
- package/dist/templates/fullstack-nest-svelte/README.md +19 -8
- package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.spec.ts +53 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.ts +69 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/database/migrate.ts +16 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.module.ts +5 -1
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.spec.ts +22 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.ts +33 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +2 -2
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +3 -5
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +37 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/rollback.md +18 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.example +6 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +1 -1
- package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +0 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +6 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.spec.ts +36 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.ts +20 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.spec.ts +49 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.ts +3 -11
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.module.ts +3 -1
- package/dist/templates/modules/api-key-auth/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +32 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +35 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -2
- package/dist/templates/modules/auth/files/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +4 -5
- package/dist/templates/modules/bullmq/module.manifest.json +4 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +36 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +26 -5
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.spec.ts +35 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +21 -2
- package/dist/templates/modules/object-storage-s3/module.manifest.json +3 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +85 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +110 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +132 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +100 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.integration.spec.ts +91 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +228 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +97 -38
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +11 -6
- package/dist/templates/modules/rate-limit/module.manifest.json +23 -9
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +28 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +52 -11
- package/dist/templates/modules/redis/module.manifest.json +16 -1
- package/dist/templates/modules/sse/files/apps/api/src/events/events.controller.ts +17 -3
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +9 -2
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +61 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +44 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.redis.spec.ts +42 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.spec.ts +97 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +165 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +41 -0
- package/dist/templates/modules/sse/module.manifest.json +16 -3
- package/dist/templates/todo/AGENTS.md +1 -0
- package/dist/templates/todo/README.md +12 -8
- package/dist/templates/todo/apps/api/Dockerfile +2 -2
- package/dist/templates/todo/apps/api/package.json +1 -0
- package/dist/templates/todo/apps/api/src/config/redis.connection.spec.ts +53 -0
- package/dist/templates/todo/apps/api/src/config/redis.connection.ts +69 -0
- package/dist/templates/todo/apps/api/src/database/migrate.ts +16 -0
- package/dist/templates/todo/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/todo/apps/api/src/health/health.module.ts +5 -1
- package/dist/templates/todo/apps/api/src/health/readiness.service.spec.ts +22 -0
- package/dist/templates/todo/apps/api/src/health/readiness.service.ts +33 -0
- package/dist/templates/todo/apps/web/Dockerfile +2 -2
- package/dist/templates/todo/compose.dev.yaml +3 -5
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +28 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/rollback.md +5 -0
- package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
- package/dist/templates/todo/dot-env.example +6 -0
- package/dist/templates/todo/infra/k3s/api-deployment.yaml +1 -1
- package/dist/templates/todo/infra/k3s/ingress.yaml +0 -4
- package/package.json +2 -2
|
@@ -1,14 +1,35 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
Optional,
|
|
4
|
+
type OnModuleDestroy,
|
|
5
|
+
type OnModuleInit,
|
|
6
|
+
} from "@nestjs/common";
|
|
2
7
|
import Redis from "ioredis";
|
|
8
|
+
import { redisConnectionOptions } from "../config/redis.connection";
|
|
9
|
+
import { ReadinessService } from "../health/readiness.service";
|
|
3
10
|
|
|
4
11
|
@Injectable()
|
|
5
|
-
export class RedisService implements OnModuleDestroy {
|
|
6
|
-
readonly client = new Redis({
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
export class RedisService implements OnModuleInit, OnModuleDestroy {
|
|
13
|
+
readonly client = new Redis(redisConnectionOptions(process.env, {
|
|
14
|
+
lazyConnect: true,
|
|
15
|
+
enableReadyCheck: true,
|
|
16
|
+
enableOfflineQueue: false,
|
|
17
|
+
maxRetriesPerRequest: 1,
|
|
18
|
+
connectTimeout: 5_000,
|
|
19
|
+
commandTimeout: 5_000,
|
|
20
|
+
}));
|
|
11
21
|
private readonly subscribers: Redis[] = [];
|
|
22
|
+
private unregisterReadiness?: () => void;
|
|
23
|
+
|
|
24
|
+
constructor(@Optional() private readonly readiness?: ReadinessService) {}
|
|
25
|
+
|
|
26
|
+
async onModuleInit(): Promise<void> {
|
|
27
|
+
await this.client.connect();
|
|
28
|
+
await this.client.ping();
|
|
29
|
+
this.unregisterReadiness = this.readiness?.register("redis", async () => {
|
|
30
|
+
await this.client.ping();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
12
33
|
|
|
13
34
|
get(key: string): Promise<string | null> {
|
|
14
35
|
return this.client.get(key);
|
|
@@ -27,17 +48,37 @@ export class RedisService implements OnModuleDestroy {
|
|
|
27
48
|
return this.client.publish(channel, message);
|
|
28
49
|
}
|
|
29
50
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
51
|
+
async subscribe(
|
|
52
|
+
channel: string,
|
|
53
|
+
handler: (message: string) => void,
|
|
54
|
+
): Promise<() => Promise<void>> {
|
|
55
|
+
const sub = new Redis(redisConnectionOptions(process.env, {
|
|
56
|
+
lazyConnect: true,
|
|
57
|
+
enableReadyCheck: true,
|
|
58
|
+
maxRetriesPerRequest: null,
|
|
59
|
+
connectTimeout: 5_000,
|
|
60
|
+
}));
|
|
61
|
+
try {
|
|
62
|
+
await sub.connect();
|
|
63
|
+
await sub.subscribe(channel);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
await sub.quit().catch(() => undefined);
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
33
68
|
this.subscribers.push(sub);
|
|
34
|
-
void sub.subscribe(channel);
|
|
35
69
|
sub.on("message", (ch, message) => {
|
|
36
70
|
if (ch === channel) handler(message);
|
|
37
71
|
});
|
|
72
|
+
return async () => {
|
|
73
|
+
const index = this.subscribers.indexOf(sub);
|
|
74
|
+
if (index >= 0) this.subscribers.splice(index, 1);
|
|
75
|
+
await sub.unsubscribe(channel);
|
|
76
|
+
await sub.quit();
|
|
77
|
+
};
|
|
38
78
|
}
|
|
39
79
|
|
|
40
80
|
async onModuleDestroy(): Promise<void> {
|
|
81
|
+
this.unregisterReadiness?.();
|
|
41
82
|
await Promise.allSettled([this.client.quit(), ...this.subscribers.map((s) => s.quit())]);
|
|
42
83
|
}
|
|
43
84
|
}
|
|
@@ -2,12 +2,26 @@
|
|
|
2
2
|
"name": "redis",
|
|
3
3
|
"description": "Redis client (ioredis) with get/set/del and publish/subscribe, plus demo cache endpoints.",
|
|
4
4
|
"requires": [],
|
|
5
|
+
"requiredProjectFiles": [
|
|
6
|
+
"apps/api/src/config/redis.connection.ts",
|
|
7
|
+
"apps/api/src/health/readiness.service.ts"
|
|
8
|
+
],
|
|
9
|
+
"requiredProjectContents": {
|
|
10
|
+
"apps/api/src/config/redis.connection.ts": [
|
|
11
|
+
"connectTimeout?: number;",
|
|
12
|
+
"commandTimeout?: number;"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"managedGlobs": [
|
|
16
|
+
"apps/api/src/redis/**",
|
|
17
|
+
"tests/api/redis.api.spec.ts"
|
|
18
|
+
],
|
|
5
19
|
"targetApp": "api",
|
|
6
20
|
"dependencies": {
|
|
7
21
|
"ioredis": "^5.4.1"
|
|
8
22
|
},
|
|
9
23
|
"env": [
|
|
10
|
-
"# redis
|
|
24
|
+
"# redis uses REDIS_URL or the REDIS_HOST / REDIS_PORT / REDIS_USERNAME / REDIS_PASSWORD / REDIS_DB settings defined above"
|
|
11
25
|
],
|
|
12
26
|
"inject": [
|
|
13
27
|
{ "file": "apps/api/src/app.module.ts", "marker": "// podokit:end:imports", "text": "import { RedisModule } from \"./redis/redis.module\";" },
|
|
@@ -15,6 +29,7 @@
|
|
|
15
29
|
],
|
|
16
30
|
"instructions": [
|
|
17
31
|
"Start Redis: docker compose -f infra/docker/docker-compose.yml up -d",
|
|
32
|
+
"For production, prefer REDIS_URL; authenticated host settings are also supported.",
|
|
18
33
|
"Set a value: curl -XPUT localhost:5002/cache/greeting -H 'content-type: application/json' -d '{\"value\":\"hi\",\"ttl\":60}'",
|
|
19
34
|
"Read it back: curl localhost:5002/cache/greeting"
|
|
20
35
|
]
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Body,
|
|
3
|
+
Controller,
|
|
4
|
+
Post,
|
|
5
|
+
ServiceUnavailableException,
|
|
6
|
+
Sse,
|
|
7
|
+
type MessageEvent,
|
|
8
|
+
} from "@nestjs/common";
|
|
2
9
|
import { ApiTags } from "@nestjs/swagger";
|
|
3
10
|
import { interval, map, merge, type Observable } from "rxjs";
|
|
4
11
|
import { EventsService } from "./events.service";
|
|
@@ -18,8 +25,15 @@ export class EventsController {
|
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
@Post()
|
|
21
|
-
publish(@Body() dto: PublishEventDto): { ok: true } {
|
|
22
|
-
|
|
28
|
+
async publish(@Body() dto: PublishEventDto): Promise<{ ok: true }> {
|
|
29
|
+
try {
|
|
30
|
+
await this.events.publishAsync({ type: "message", message: dto.message });
|
|
31
|
+
} catch {
|
|
32
|
+
throw new ServiceUnavailableException({
|
|
33
|
+
code: "EVENTS_TRANSPORT_UNAVAILABLE",
|
|
34
|
+
message: "Event transport is unavailable",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
23
37
|
return { ok: true };
|
|
24
38
|
}
|
|
25
39
|
}
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { Global, Module } from "@nestjs/common";
|
|
2
2
|
import { EventsController } from "./events.controller";
|
|
3
|
-
import { EventsService } from "./events.service";
|
|
3
|
+
import { EVENTS_TRANSPORT, EventsService } from "./events.service";
|
|
4
|
+
import { createEventsTransport } from "./events.transport";
|
|
4
5
|
|
|
5
6
|
// Global so any module can inject EventsService to broadcast updates.
|
|
6
7
|
@Global()
|
|
7
8
|
@Module({
|
|
8
9
|
controllers: [EventsController],
|
|
9
|
-
providers: [
|
|
10
|
+
providers: [
|
|
11
|
+
{
|
|
12
|
+
provide: EVENTS_TRANSPORT,
|
|
13
|
+
useFactory: () => createEventsTransport(),
|
|
14
|
+
},
|
|
15
|
+
EventsService,
|
|
16
|
+
],
|
|
10
17
|
exports: [EventsService],
|
|
11
18
|
})
|
|
12
19
|
export class EventsModule {}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { firstValueFrom, take } from "rxjs";
|
|
3
|
+
import { ReadinessService } from "../health/readiness.service";
|
|
4
|
+
import { EventsService } from "./events.service";
|
|
5
|
+
import type { EventHandler, EventsTransport } from "./events.transport";
|
|
6
|
+
|
|
7
|
+
class TestTransport implements EventsTransport {
|
|
8
|
+
handler?: EventHandler;
|
|
9
|
+
published: unknown[] = [];
|
|
10
|
+
available = true;
|
|
11
|
+
|
|
12
|
+
connect(handler: EventHandler): Promise<void> {
|
|
13
|
+
this.handler = handler;
|
|
14
|
+
return Promise.resolve();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
publish(data: unknown): Promise<void> {
|
|
18
|
+
if (!this.available) return Promise.reject(new Error("unavailable"));
|
|
19
|
+
this.published.push(data);
|
|
20
|
+
this.handler?.(data);
|
|
21
|
+
return Promise.resolve();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
ready(): Promise<void> {
|
|
25
|
+
return this.available ? Promise.resolve() : Promise.reject(new Error("unavailable"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
close(): Promise<void> {
|
|
29
|
+
return Promise.resolve();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("EventsService", () => {
|
|
34
|
+
it("publishes through the transport and exposes local delivery", async () => {
|
|
35
|
+
const transport = new TestTransport();
|
|
36
|
+
const readiness = new ReadinessService();
|
|
37
|
+
const service = new EventsService(transport, readiness);
|
|
38
|
+
await service.onModuleInit();
|
|
39
|
+
|
|
40
|
+
const next = firstValueFrom(service.asObservable().pipe(take(1)));
|
|
41
|
+
await service.publishAsync({ type: "changed" });
|
|
42
|
+
await expect(next).resolves.toEqual({ data: { type: "changed" } });
|
|
43
|
+
expect(transport.published).toEqual([{ type: "changed" }]);
|
|
44
|
+
|
|
45
|
+
await expect(readiness.run()).resolves.toEqual({ events: "up" });
|
|
46
|
+
await service.onModuleDestroy();
|
|
47
|
+
await expect(readiness.run()).resolves.toEqual({});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("reports a failed transport through publishAsync and readiness", async () => {
|
|
51
|
+
const transport = new TestTransport();
|
|
52
|
+
const readiness = new ReadinessService();
|
|
53
|
+
const service = new EventsService(transport, readiness);
|
|
54
|
+
await service.onModuleInit();
|
|
55
|
+
transport.available = false;
|
|
56
|
+
|
|
57
|
+
await expect(service.publishAsync({ type: "changed" })).rejects.toThrow("unavailable");
|
|
58
|
+
await expect(readiness.run()).resolves.toEqual({ events: "down" });
|
|
59
|
+
await service.onModuleDestroy();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -1,17 +1,57 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Inject,
|
|
3
|
+
Injectable,
|
|
4
|
+
Logger,
|
|
5
|
+
type MessageEvent,
|
|
6
|
+
type OnModuleDestroy,
|
|
7
|
+
type OnModuleInit,
|
|
8
|
+
} from "@nestjs/common";
|
|
2
9
|
import { Observable, Subject } from "rxjs";
|
|
10
|
+
import { ReadinessService } from "../health/readiness.service";
|
|
11
|
+
import type { EventsTransport } from "./events.transport";
|
|
12
|
+
|
|
13
|
+
export const EVENTS_TRANSPORT = Symbol("EVENTS_TRANSPORT");
|
|
3
14
|
|
|
4
15
|
@Injectable()
|
|
5
|
-
export class EventsService {
|
|
16
|
+
export class EventsService implements OnModuleInit, OnModuleDestroy {
|
|
17
|
+
private readonly logger = new Logger(EventsService.name);
|
|
6
18
|
private readonly subject = new Subject<MessageEvent>();
|
|
19
|
+
private unregisterReadiness?: () => void;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
@Inject(EVENTS_TRANSPORT) private readonly transport: EventsTransport,
|
|
23
|
+
private readonly readiness: ReadinessService,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
async onModuleInit(): Promise<void> {
|
|
27
|
+
await this.transport.connect((data) => this.publishLocal(data));
|
|
28
|
+
this.unregisterReadiness = this.readiness.register("events", () => this.transport.ready());
|
|
29
|
+
}
|
|
7
30
|
|
|
8
|
-
// Push an event to all connected clients. Inject this service anywhere
|
|
9
|
-
// (e.g. a queue processor) to broadcast progress or notifications.
|
|
10
31
|
publish(data: unknown): void {
|
|
32
|
+
void this.publishAsync(data).catch((error: unknown) => {
|
|
33
|
+
this.logger.error(
|
|
34
|
+
"Failed to publish event",
|
|
35
|
+
error instanceof Error ? error.stack : undefined,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
publishAsync(data: unknown): Promise<void> {
|
|
41
|
+
return this.transport.publish(data);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
publishLocal(data: unknown): void {
|
|
11
45
|
this.subject.next({ data } as MessageEvent);
|
|
12
46
|
}
|
|
13
47
|
|
|
14
48
|
asObservable(): Observable<MessageEvent> {
|
|
15
49
|
return this.subject.asObservable();
|
|
16
50
|
}
|
|
51
|
+
|
|
52
|
+
async onModuleDestroy(): Promise<void> {
|
|
53
|
+
this.unregisterReadiness?.();
|
|
54
|
+
this.subject.complete();
|
|
55
|
+
await this.transport.close();
|
|
56
|
+
}
|
|
17
57
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { RedisEventsTransport } from "./events.transport";
|
|
3
|
+
|
|
4
|
+
const redisUrl = process.env.REDIS_TEST_URL;
|
|
5
|
+
const integrationTest = redisUrl ? it : it.skip;
|
|
6
|
+
|
|
7
|
+
describe("RedisEventsTransport integration", () => {
|
|
8
|
+
integrationTest("delivers once to every connected replica", async () => {
|
|
9
|
+
const channel = `podokit:test:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
10
|
+
const env = {
|
|
11
|
+
REDIS_URL: redisUrl,
|
|
12
|
+
SSE_REDIS_CHANNEL: channel,
|
|
13
|
+
SSE_MAX_EVENT_BYTES: "1024",
|
|
14
|
+
};
|
|
15
|
+
const first = new RedisEventsTransport(env);
|
|
16
|
+
const second = new RedisEventsTransport(env);
|
|
17
|
+
const firstEvents: unknown[] = [];
|
|
18
|
+
const secondEvents: unknown[] = [];
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
await first.connect((data) => firstEvents.push(data));
|
|
22
|
+
await second.connect((data) => secondEvents.push(data));
|
|
23
|
+
await first.publish({ type: "replica-test" });
|
|
24
|
+
|
|
25
|
+
const deadline = Date.now() + 3000;
|
|
26
|
+
while (
|
|
27
|
+
Date.now() < deadline &&
|
|
28
|
+
(firstEvents.length < 1 || secondEvents.length < 1)
|
|
29
|
+
) {
|
|
30
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
31
|
+
}
|
|
32
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
33
|
+
|
|
34
|
+
expect(firstEvents).toEqual([{ type: "replica-test" }]);
|
|
35
|
+
expect(secondEvents).toEqual([{ type: "replica-test" }]);
|
|
36
|
+
await expect(first.ready()).resolves.toBeUndefined();
|
|
37
|
+
await expect(second.ready()).resolves.toBeUndefined();
|
|
38
|
+
} finally {
|
|
39
|
+
await Promise.all([first.close(), second.close()]);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import {
|
|
3
|
+
MemoryEventsTransport,
|
|
4
|
+
RedisEventsTransport,
|
|
5
|
+
type RedisClientFactory,
|
|
6
|
+
} from "./events.transport";
|
|
7
|
+
import type { RedisConnectionOptions } from "../config/redis.connection";
|
|
8
|
+
|
|
9
|
+
class FakeRedisClient {
|
|
10
|
+
status = "wait";
|
|
11
|
+
readonly published: Array<{ channel: string; message: string }> = [];
|
|
12
|
+
readonly subscriptions: string[] = [];
|
|
13
|
+
private messageListener?: (channel: string, message: string) => void;
|
|
14
|
+
|
|
15
|
+
connect(): Promise<void> {
|
|
16
|
+
this.status = "ready";
|
|
17
|
+
return Promise.resolve();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
publish(channel: string, message: string): Promise<number> {
|
|
21
|
+
this.published.push({ channel, message });
|
|
22
|
+
return Promise.resolve(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
subscribe(channel: string): Promise<number> {
|
|
26
|
+
this.subscriptions.push(channel);
|
|
27
|
+
return Promise.resolve(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ping(): Promise<string> {
|
|
31
|
+
return this.status === "ready" ? Promise.resolve("PONG") : Promise.reject(new Error("down"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
quit(): Promise<string> {
|
|
35
|
+
this.status = "end";
|
|
36
|
+
return Promise.resolve("OK");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
on(_event: "message", listener: (channel: string, message: string) => void): this {
|
|
40
|
+
this.messageListener = listener;
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
off(_event: "message", listener: (channel: string, message: string) => void): this {
|
|
45
|
+
if (this.messageListener === listener) this.messageListener = undefined;
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
emit(channel: string, message: string): void {
|
|
50
|
+
this.messageListener?.(channel, message);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("events transports", () => {
|
|
55
|
+
it("delivers memory events through the configured handler", async () => {
|
|
56
|
+
const transport = new MemoryEventsTransport({ SSE_MAX_EVENT_BYTES: "128" });
|
|
57
|
+
const received: unknown[] = [];
|
|
58
|
+
await transport.connect((data) => received.push(data));
|
|
59
|
+
await transport.publish({ type: "changed" });
|
|
60
|
+
expect(received).toEqual([{ type: "changed" }]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("publishes Redis events only through the subscription echo", async () => {
|
|
64
|
+
const clients: FakeRedisClient[] = [];
|
|
65
|
+
const factory: RedisClientFactory = (_options: RedisConnectionOptions) => {
|
|
66
|
+
const client = new FakeRedisClient();
|
|
67
|
+
clients.push(client);
|
|
68
|
+
return client;
|
|
69
|
+
};
|
|
70
|
+
const transport = new RedisEventsTransport(
|
|
71
|
+
{
|
|
72
|
+
REDIS_URL: "redis://user:password@cache.example.com:6379/2",
|
|
73
|
+
SSE_REDIS_CHANNEL: "events:test",
|
|
74
|
+
},
|
|
75
|
+
factory,
|
|
76
|
+
);
|
|
77
|
+
const received: unknown[] = [];
|
|
78
|
+
|
|
79
|
+
await transport.connect((data) => received.push(data));
|
|
80
|
+
await transport.publish({ type: "changed" });
|
|
81
|
+
expect(received).toEqual([]);
|
|
82
|
+
expect(clients[0]?.published).toEqual([
|
|
83
|
+
{ channel: "events:test", message: '{"type":"changed"}' },
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
clients[1]?.emit("events:test", '{"type":"changed"}');
|
|
87
|
+
expect(received).toEqual([{ type: "changed" }]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects events larger than the configured limit", async () => {
|
|
91
|
+
const transport = new MemoryEventsTransport({ SSE_MAX_EVENT_BYTES: "8" });
|
|
92
|
+
await transport.connect(() => undefined);
|
|
93
|
+
await expect(transport.publish({ message: "too large" })).rejects.toThrow(
|
|
94
|
+
"Event data exceeds SSE_MAX_EVENT_BYTES",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Logger } from "@nestjs/common";
|
|
2
|
+
import Redis from "ioredis";
|
|
3
|
+
import {
|
|
4
|
+
redisConnectionOptions,
|
|
5
|
+
type RedisConnectionOptions,
|
|
6
|
+
} from "../config/redis.connection";
|
|
7
|
+
|
|
8
|
+
export type EventHandler = (data: unknown) => void;
|
|
9
|
+
|
|
10
|
+
export interface EventsTransport {
|
|
11
|
+
connect(handler: EventHandler): Promise<void>;
|
|
12
|
+
publish(data: unknown): Promise<void>;
|
|
13
|
+
ready(): Promise<void>;
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RedisClient {
|
|
18
|
+
readonly status: string;
|
|
19
|
+
connect(): Promise<void>;
|
|
20
|
+
publish(channel: string, message: string): Promise<number>;
|
|
21
|
+
subscribe(channel: string): Promise<unknown>;
|
|
22
|
+
ping(): Promise<string>;
|
|
23
|
+
quit(): Promise<string>;
|
|
24
|
+
on(event: "message", listener: (channel: string, message: string) => void): this;
|
|
25
|
+
off(event: "message", listener: (channel: string, message: string) => void): this;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type RedisClientFactory = (options: RedisConnectionOptions) => RedisClient;
|
|
29
|
+
|
|
30
|
+
function defaultRedisClientFactory(options: RedisConnectionOptions): RedisClient {
|
|
31
|
+
return new Redis(options);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function maxEventBytes(env: NodeJS.ProcessEnv): number {
|
|
35
|
+
const value = Number(env.SSE_MAX_EVENT_BYTES ?? 65_536);
|
|
36
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
37
|
+
throw new Error("SSE_MAX_EVENT_BYTES must be a positive integer");
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function encodedEvent(data: unknown, maximumBytes: number): string {
|
|
43
|
+
const message = JSON.stringify(data);
|
|
44
|
+
if (message === undefined) {
|
|
45
|
+
throw new Error("Event data must be JSON serializable");
|
|
46
|
+
}
|
|
47
|
+
if (Buffer.byteLength(message, "utf8") > maximumBytes) {
|
|
48
|
+
throw new Error("Event data exceeds SSE_MAX_EVENT_BYTES");
|
|
49
|
+
}
|
|
50
|
+
return message;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class MemoryEventsTransport implements EventsTransport {
|
|
54
|
+
private handler?: EventHandler;
|
|
55
|
+
private readonly maximumBytes: number;
|
|
56
|
+
|
|
57
|
+
constructor(env: NodeJS.ProcessEnv = process.env) {
|
|
58
|
+
this.maximumBytes = maxEventBytes(env);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
connect(handler: EventHandler): Promise<void> {
|
|
62
|
+
this.handler = handler;
|
|
63
|
+
return Promise.resolve();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async publish(data: unknown): Promise<void> {
|
|
67
|
+
const message = encodedEvent(data, this.maximumBytes);
|
|
68
|
+
this.handler?.(JSON.parse(message) as unknown);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
ready(): Promise<void> {
|
|
72
|
+
return Promise.resolve();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
close(): Promise<void> {
|
|
76
|
+
this.handler = undefined;
|
|
77
|
+
return Promise.resolve();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class RedisEventsTransport implements EventsTransport {
|
|
82
|
+
private readonly logger = new Logger(RedisEventsTransport.name);
|
|
83
|
+
private readonly publisher: RedisClient;
|
|
84
|
+
private readonly subscriber: RedisClient;
|
|
85
|
+
private readonly channel: string;
|
|
86
|
+
private readonly maximumBytes: number;
|
|
87
|
+
private handler?: EventHandler;
|
|
88
|
+
|
|
89
|
+
constructor(
|
|
90
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
91
|
+
createClient: RedisClientFactory = defaultRedisClientFactory,
|
|
92
|
+
) {
|
|
93
|
+
this.channel = env.SSE_REDIS_CHANNEL?.trim() || "podokit:events";
|
|
94
|
+
this.maximumBytes = maxEventBytes(env);
|
|
95
|
+
this.publisher = createClient(
|
|
96
|
+
redisConnectionOptions(env, {
|
|
97
|
+
lazyConnect: true,
|
|
98
|
+
enableReadyCheck: true,
|
|
99
|
+
maxRetriesPerRequest: 1,
|
|
100
|
+
connectTimeout: 5_000,
|
|
101
|
+
commandTimeout: 5_000,
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
this.subscriber = createClient(
|
|
105
|
+
redisConnectionOptions(env, {
|
|
106
|
+
lazyConnect: true,
|
|
107
|
+
enableReadyCheck: true,
|
|
108
|
+
maxRetriesPerRequest: null,
|
|
109
|
+
connectTimeout: 5_000,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private readonly onMessage = (channel: string, message: string): void => {
|
|
115
|
+
if (channel !== this.channel) return;
|
|
116
|
+
if (Buffer.byteLength(message, "utf8") > this.maximumBytes) {
|
|
117
|
+
this.logger.warn("Discarded oversized event transport message");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
this.handler?.(JSON.parse(message) as unknown);
|
|
122
|
+
} catch {
|
|
123
|
+
this.logger.warn("Discarded malformed event transport message");
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
async connect(handler: EventHandler): Promise<void> {
|
|
128
|
+
this.handler = handler;
|
|
129
|
+
this.subscriber.on("message", this.onMessage);
|
|
130
|
+
try {
|
|
131
|
+
await Promise.all([this.publisher.connect(), this.subscriber.connect()]);
|
|
132
|
+
await this.subscriber.subscribe(this.channel);
|
|
133
|
+
await this.ready();
|
|
134
|
+
} catch (error) {
|
|
135
|
+
this.subscriber.off("message", this.onMessage);
|
|
136
|
+
this.handler = undefined;
|
|
137
|
+
await Promise.allSettled([this.publisher.quit(), this.subscriber.quit()]);
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async publish(data: unknown): Promise<void> {
|
|
143
|
+
await this.publisher.publish(this.channel, encodedEvent(data, this.maximumBytes));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async ready(): Promise<void> {
|
|
147
|
+
if (this.subscriber.status !== "ready") {
|
|
148
|
+
throw new Error("Redis event subscriber is not ready");
|
|
149
|
+
}
|
|
150
|
+
await this.publisher.ping();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async close(): Promise<void> {
|
|
154
|
+
this.subscriber.off("message", this.onMessage);
|
|
155
|
+
this.handler = undefined;
|
|
156
|
+
await Promise.allSettled([this.publisher.quit(), this.subscriber.quit()]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function createEventsTransport(env: NodeJS.ProcessEnv = process.env): EventsTransport {
|
|
161
|
+
const name = env.SSE_TRANSPORT?.trim().toLowerCase() || "memory";
|
|
162
|
+
if (name === "memory") return new MemoryEventsTransport(env);
|
|
163
|
+
if (name === "redis") return new RedisEventsTransport(env);
|
|
164
|
+
throw new Error('SSE_TRANSPORT must be either "memory" or "redis"');
|
|
165
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
|
|
3
3
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
|
+
const secondaryApi = process.env.E2E_SECONDARY_API_URL;
|
|
4
5
|
const origin = { origin: base };
|
|
5
6
|
|
|
6
7
|
async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
|
|
@@ -44,3 +45,43 @@ test("sse: a published message is delivered over the event stream @smoke", async
|
|
|
44
45
|
expect(seen).toBe(true);
|
|
45
46
|
await ctx.dispose();
|
|
46
47
|
});
|
|
48
|
+
|
|
49
|
+
test("sse: a second API replica delivers through Redis", async ({ playwright }) => {
|
|
50
|
+
test.skip(!secondaryApi, "a secondary API replica is not configured");
|
|
51
|
+
const ctx = await session(playwright);
|
|
52
|
+
const cookies = (await ctx.storageState()).cookies.map((c) => `${c.name}=${c.value}`).join("; ");
|
|
53
|
+
const ac = new AbortController();
|
|
54
|
+
const stream = await fetch(`${base}/api/events/stream`, {
|
|
55
|
+
headers: { ...origin, cookie: cookies },
|
|
56
|
+
signal: ac.signal,
|
|
57
|
+
});
|
|
58
|
+
expect(stream.ok).toBe(true);
|
|
59
|
+
expect(stream.body).not.toBeNull();
|
|
60
|
+
|
|
61
|
+
const msg = `replica-${Date.now()}`;
|
|
62
|
+
const published = await fetch(`${secondaryApi}/events`, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: {
|
|
65
|
+
...origin,
|
|
66
|
+
cookie: cookies,
|
|
67
|
+
"content-type": "application/json",
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify({ message: msg }),
|
|
70
|
+
});
|
|
71
|
+
expect(published.ok).toBe(true);
|
|
72
|
+
|
|
73
|
+
const reader = stream.body!.getReader();
|
|
74
|
+
const decoder = new TextDecoder();
|
|
75
|
+
let seen = 0;
|
|
76
|
+
let buffered = "";
|
|
77
|
+
const deadline = Date.now() + 8000;
|
|
78
|
+
while (Date.now() < deadline && seen === 0) {
|
|
79
|
+
const { value, done } = await reader.read();
|
|
80
|
+
if (done) break;
|
|
81
|
+
buffered += decoder.decode(value, { stream: true });
|
|
82
|
+
seen = buffered.split(msg).length - 1;
|
|
83
|
+
}
|
|
84
|
+
ac.abort();
|
|
85
|
+
expect(seen).toBe(1);
|
|
86
|
+
await ctx.dispose();
|
|
87
|
+
});
|