@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.
Files changed (122) hide show
  1. package/README.md +20 -0
  2. package/dist/add.d.ts +4 -0
  3. package/dist/add.js +59 -1
  4. package/dist/create.js +8 -1
  5. package/dist/deploy-profile.d.ts +92 -0
  6. package/dist/deploy-profile.js +603 -0
  7. package/dist/deploy-render.d.ts +18 -0
  8. package/dist/deploy-render.js +651 -0
  9. package/dist/deploy.d.ts +65 -0
  10. package/dist/deploy.js +851 -0
  11. package/dist/dev.d.ts +1 -0
  12. package/dist/dev.js +29 -5
  13. package/dist/index.d.ts +8 -0
  14. package/dist/index.js +147 -1
  15. package/dist/lib.d.ts +6 -0
  16. package/dist/lib.js +18 -1
  17. package/dist/lockfile.js +3 -0
  18. package/dist/remove.js +7 -0
  19. package/dist/templates/fullstack-nest-svelte/AGENTS.md +1 -0
  20. package/dist/templates/fullstack-nest-svelte/README.md +19 -8
  21. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +2 -2
  22. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
  23. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.spec.ts +53 -0
  24. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.ts +69 -0
  25. package/dist/templates/fullstack-nest-svelte/apps/api/src/database/migrate.ts +16 -0
  26. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +19 -4
  27. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.module.ts +5 -1
  28. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.spec.ts +22 -0
  29. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.ts +33 -0
  30. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +2 -2
  31. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.html +2 -0
  32. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +3 -5
  33. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
  34. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
  35. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +37 -0
  36. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/rollback.md +18 -0
  37. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
  38. package/dist/templates/fullstack-nest-svelte/dot-env.example +6 -0
  39. package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +1 -1
  40. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +0 -4
  41. package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +6 -4
  42. package/dist/templates/fullstack-nest-svelte/tests/playwright.extensions.ts +44 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +35 -4
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +33 -4
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +6 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +18 -10
  47. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +11 -6
  48. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +18 -8
  49. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +15 -9
  50. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +1 -0
  51. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +83 -0
  52. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +13 -10
  53. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +69 -3
  54. package/dist/templates/modules/admin-dashboard/files/tests/seed.teardown.ts +46 -0
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +13 -0
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +3 -6
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +34 -28
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +29 -39
  59. package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
  60. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.spec.ts +36 -0
  61. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.ts +20 -0
  62. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.spec.ts +49 -0
  63. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.ts +3 -11
  64. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.module.ts +3 -1
  65. package/dist/templates/modules/api-key-auth/module.manifest.json +1 -1
  66. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +32 -0
  67. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +35 -0
  68. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -2
  69. package/dist/templates/modules/auth/files/apps/api/src/health/health.controller.ts +19 -4
  70. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +4 -5
  71. package/dist/templates/modules/bullmq/module.manifest.json +4 -1
  72. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +36 -0
  73. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +26 -5
  74. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.spec.ts +35 -0
  75. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +21 -2
  76. package/dist/templates/modules/object-storage-s3/module.manifest.json +3 -0
  77. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +85 -0
  78. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +110 -0
  79. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +132 -0
  80. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +100 -0
  81. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.integration.spec.ts +91 -0
  82. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +228 -0
  83. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +97 -38
  84. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +11 -6
  85. package/dist/templates/modules/rate-limit/module.manifest.json +23 -9
  86. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +28 -0
  87. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +52 -11
  88. package/dist/templates/modules/redis/module.manifest.json +16 -1
  89. package/dist/templates/modules/sse/files/apps/api/src/events/events.controller.ts +17 -3
  90. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +9 -2
  91. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +61 -0
  92. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +44 -4
  93. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.redis.spec.ts +42 -0
  94. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.spec.ts +97 -0
  95. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +165 -0
  96. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +41 -0
  97. package/dist/templates/modules/sse/module.manifest.json +16 -3
  98. package/dist/templates/todo/AGENTS.md +1 -0
  99. package/dist/templates/todo/README.md +12 -8
  100. package/dist/templates/todo/apps/api/Dockerfile +2 -2
  101. package/dist/templates/todo/apps/api/package.json +1 -0
  102. package/dist/templates/todo/apps/api/src/config/redis.connection.spec.ts +53 -0
  103. package/dist/templates/todo/apps/api/src/config/redis.connection.ts +69 -0
  104. package/dist/templates/todo/apps/api/src/database/migrate.ts +16 -0
  105. package/dist/templates/todo/apps/api/src/health/health.controller.ts +19 -4
  106. package/dist/templates/todo/apps/api/src/health/health.module.ts +5 -1
  107. package/dist/templates/todo/apps/api/src/health/readiness.service.spec.ts +22 -0
  108. package/dist/templates/todo/apps/api/src/health/readiness.service.ts +33 -0
  109. package/dist/templates/todo/apps/web/Dockerfile +2 -2
  110. package/dist/templates/todo/compose.dev.yaml +3 -5
  111. package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
  112. package/dist/templates/todo/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
  113. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +28 -0
  114. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/rollback.md +5 -0
  115. package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
  116. package/dist/templates/todo/dot-env.example +6 -0
  117. package/dist/templates/todo/infra/k3s/api-deployment.yaml +1 -1
  118. package/dist/templates/todo/infra/k3s/ingress.yaml +0 -4
  119. package/dist/templates/todo/tests/playwright.config.ts +6 -4
  120. package/dist/templates/todo/tests/playwright.extensions.ts +44 -0
  121. package/dist/update.js +17 -13
  122. 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: [EventsService],
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 { Injectable, type MessageEvent } from "@nestjs/common";
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: a /events/stream endpoint (heartbeat + published messages) and a POST /events publisher.",
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
- "env": [],
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
- "Inject EventsService anywhere to push updates (e.g. job progress)."
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
- If installed modules require extra services, use their profiles on the initial
37
- watch command instead:
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
- Pass the same profile flags to other lifecycle commands when applicable. `dev down`
45
- removes this project's stack and route. If it is the final registered
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`; example k3s manifests in `infra/k3s`
82
- (use `secret.example.yaml` as a template — never commit real secrets).
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 app -G app
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 app
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
+ });