@podosoft/podokit 0.14.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 +20 -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 +3 -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/apps/web/src/app.html +2 -0
- 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/fullstack-nest-svelte/tests/playwright.config.ts +6 -4
- package/dist/templates/fullstack-nest-svelte/tests/playwright.extensions.ts +44 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +35 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +33 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +18 -10
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +11 -6
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +18 -8
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +15 -9
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +83 -0
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +13 -10
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +69 -3
- package/dist/templates/modules/admin-dashboard/files/tests/seed.teardown.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +13 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +3 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +34 -28
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +29 -39
- package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
- 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/dist/templates/todo/tests/playwright.config.ts +6 -4
- package/dist/templates/todo/tests/playwright.extensions.ts +44 -0
- package/dist/update.js +17 -13
- package/package.json +2 -2
|
@@ -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
|
+
});
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sse",
|
|
3
|
-
"description": "Server-Sent Events
|
|
3
|
+
"description": "Server-Sent Events with memory or Redis transport for multi-replica delivery.",
|
|
4
4
|
"requires": [],
|
|
5
|
+
"requiredProjectFiles": [
|
|
6
|
+
"apps/api/src/config/redis.connection.ts",
|
|
7
|
+
"apps/api/src/health/readiness.service.ts"
|
|
8
|
+
],
|
|
5
9
|
"targetApp": "api",
|
|
6
|
-
"
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"ioredis": "^5.4.1"
|
|
12
|
+
},
|
|
13
|
+
"env": [
|
|
14
|
+
"# Server-Sent Events transport: memory for one API replica, redis for multiple replicas",
|
|
15
|
+
"SSE_TRANSPORT=memory",
|
|
16
|
+
"SSE_REDIS_CHANNEL=podokit:events",
|
|
17
|
+
"SSE_MAX_EVENT_BYTES=65536"
|
|
18
|
+
],
|
|
7
19
|
"inject": [
|
|
8
20
|
{ "file": "apps/api/src/app.module.ts", "marker": "// podokit:end:imports", "text": "import { EventsModule } from \"./events/events.module\";" },
|
|
9
21
|
{ "file": "apps/api/src/app.module.ts", "marker": "// podokit:end:module-imports", "text": "EventsModule," }
|
|
@@ -11,6 +23,7 @@
|
|
|
11
23
|
"instructions": [
|
|
12
24
|
"Stream events: curl -N localhost:5002/events/stream",
|
|
13
25
|
"Publish a message (in another terminal): curl -XPOST localhost:5002/events -H 'content-type: application/json' -d '{\"message\":\"hello\"}'",
|
|
14
|
-
"
|
|
26
|
+
"Set SSE_TRANSPORT=redis for delivery across API replicas; Redis uses the shared REDIS_URL or individual Redis settings.",
|
|
27
|
+
"Inject EventsService and call publishAsync for confirmed delivery, publish for best-effort delivery, or publishLocal for events already fanned out to every replica."
|
|
15
28
|
]
|
|
16
29
|
}
|
|
@@ -70,6 +70,7 @@ Useful commands:
|
|
|
70
70
|
- `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
|
|
71
71
|
- `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
|
|
72
72
|
- `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
|
|
73
|
+
- `podo deploy` — plan, apply, verify, or roll back an immutable Kubernetes release. Use `.agents/skills/podokit-deploy`.
|
|
73
74
|
|
|
74
75
|
## Commit conventions
|
|
75
76
|
|
|
@@ -33,16 +33,17 @@ npx @podosoft/podokit dev logs
|
|
|
33
33
|
npx @podosoft/podokit dev down
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
`dev watch` reads `.podokit/manifest.json` and automatically activates `cache`,
|
|
37
|
+
`storage`, and `queue` when installed modules require Redis, MinIO, or a worker.
|
|
38
|
+
You can still activate an additional Compose profile explicitly:
|
|
38
39
|
|
|
39
40
|
```bash
|
|
40
|
-
npx @podosoft/podokit dev watch
|
|
41
|
-
--profile cache --profile storage --profile queue
|
|
41
|
+
npx @podosoft/podokit dev watch --profile dev
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
Explicit profile flags are preserved for other lifecycle commands. `dev down`
|
|
45
|
+
activates all profiles while removing this project's stack and route. If it is
|
|
46
|
+
the final registered
|
|
46
47
|
project, it also removes the shared gateway and network. Source changes are synced
|
|
47
48
|
through Compose Watch, including Vite HMR on the same portless browser origin.
|
|
48
49
|
|
|
@@ -78,5 +79,8 @@ The API uses TypeORM with PostgreSQL. A sample `Todo` entity and an initial migr
|
|
|
78
79
|
|
|
79
80
|
## Deploy
|
|
80
81
|
|
|
81
|
-
Docker Compose in `infra/docker`;
|
|
82
|
-
|
|
82
|
+
Docker Compose lives in `infra/docker`; the basic k3s manifests in `infra/k3s`
|
|
83
|
+
route every public path through the web proxy. Use `podo deploy init`, `doctor`,
|
|
84
|
+
and `plan` for an exact-image Helm release on an existing cluster. Applying or
|
|
85
|
+
rolling back requires the exact confirmation hash from a fresh plan. See the
|
|
86
|
+
PodoKit [deployment guide](https://github.com/podosoft-dev/podokit/blob/main/docs/deployment.md).
|
|
@@ -16,12 +16,12 @@ RUN npm run build --workspace=apps/api
|
|
|
16
16
|
FROM node:22-alpine AS runtime
|
|
17
17
|
WORKDIR /app
|
|
18
18
|
ENV NODE_ENV=production
|
|
19
|
-
RUN addgroup -S app && adduser -S
|
|
19
|
+
RUN addgroup -S -g 101 app && adduser -S -D -H -u 100 -G app app
|
|
20
20
|
COPY --from=build /app/node_modules ./node_modules
|
|
21
21
|
COPY --from=deps /app/apps/api ./apps/api
|
|
22
22
|
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
|
23
23
|
COPY --from=build /app/apps/api/scripts ./apps/api/scripts
|
|
24
24
|
WORKDIR /app/apps/api
|
|
25
|
-
USER
|
|
25
|
+
USER 100:101
|
|
26
26
|
EXPOSE 3000
|
|
27
27
|
CMD ["node", "dist/main"]
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"start": "node dist/main",
|
|
9
9
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
10
10
|
"test": "jest --passWithNoTests",
|
|
11
|
+
"migrate:all": "node dist/database/migrate",
|
|
11
12
|
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts",
|
|
12
13
|
"migration:run": "npm run typeorm -- migration:run",
|
|
13
14
|
"migration:revert": "npm run typeorm -- migration:revert",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { redisConnectionOptions } from "./redis.connection";
|
|
3
|
+
|
|
4
|
+
describe("redisConnectionOptions", () => {
|
|
5
|
+
it("prefers an authenticated URL over individual settings", () => {
|
|
6
|
+
expect(
|
|
7
|
+
redisConnectionOptions({
|
|
8
|
+
REDIS_URL: "rediss://user:p%40ss@cache.example.com:6381/4",
|
|
9
|
+
REDIS_HOST: "ignored",
|
|
10
|
+
REDIS_PASSWORD: "ignored",
|
|
11
|
+
}),
|
|
12
|
+
).toEqual({
|
|
13
|
+
host: "cache.example.com",
|
|
14
|
+
port: 6381,
|
|
15
|
+
db: 4,
|
|
16
|
+
username: "user",
|
|
17
|
+
password: "p@ss",
|
|
18
|
+
tls: {},
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("uses authenticated host settings when a URL is absent", () => {
|
|
23
|
+
expect(
|
|
24
|
+
redisConnectionOptions({
|
|
25
|
+
REDIS_HOST: "cache",
|
|
26
|
+
REDIS_PORT: "6380",
|
|
27
|
+
REDIS_DB: "2",
|
|
28
|
+
REDIS_USERNAME: "app",
|
|
29
|
+
REDIS_PASSWORD: "secret",
|
|
30
|
+
REDIS_TLS: "true",
|
|
31
|
+
}),
|
|
32
|
+
).toEqual({
|
|
33
|
+
host: "cache",
|
|
34
|
+
port: 6380,
|
|
35
|
+
db: 2,
|
|
36
|
+
username: "app",
|
|
37
|
+
password: "secret",
|
|
38
|
+
tls: {},
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("does not include a malformed URL value in its error", () => {
|
|
43
|
+
const secretUrl = "not-a-url-with-a-secret";
|
|
44
|
+
expect(() => redisConnectionOptions({ REDIS_URL: secretUrl })).toThrow(
|
|
45
|
+
"REDIS_URL must be a valid redis:// or rediss:// URL",
|
|
46
|
+
);
|
|
47
|
+
try {
|
|
48
|
+
redisConnectionOptions({ REDIS_URL: secretUrl });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
expect(String(error)).not.toContain(secretUrl);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|