@podosoft/podokit 1.2.0 → 1.4.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 (187) hide show
  1. package/dist/add.d.ts +13 -2
  2. package/dist/add.js +135 -20
  3. package/dist/api-paths.d.ts +34 -0
  4. package/dist/api-paths.js +103 -0
  5. package/dist/deploy-compose-profile.d.ts +4 -0
  6. package/dist/deploy-compose-profile.js +13 -2
  7. package/dist/deploy-compose-render.d.ts +1 -1
  8. package/dist/deploy-compose-render.js +23 -7
  9. package/dist/deploy-compose.js +2 -1
  10. package/dist/deploy-profile.d.ts +3 -0
  11. package/dist/deploy-profile.js +12 -4
  12. package/dist/deploy-render.js +54 -8
  13. package/dist/deploy.js +21 -1
  14. package/dist/dev.d.ts +14 -0
  15. package/dist/dev.js +152 -10
  16. package/dist/index.js +23 -2
  17. package/dist/lib.d.ts +4 -2
  18. package/dist/lib.js +5 -2
  19. package/dist/lockfile.d.ts +6 -1
  20. package/dist/lockfile.js +26 -6
  21. package/dist/module-api-paths.d.ts +52 -0
  22. package/dist/module-api-paths.js +189 -0
  23. package/dist/remove.d.ts +3 -0
  24. package/dist/remove.js +12 -3
  25. package/dist/templates/fullstack/AGENTS.md +17 -2
  26. package/dist/templates/fullstack/README.md +19 -5
  27. package/dist/templates/fullstack/apps/api/package.json +1 -1
  28. package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
  29. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
  30. package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
  31. package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
  32. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
  33. package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
  34. package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
  35. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
  36. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
  37. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
  38. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
  39. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
  40. package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
  41. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
  42. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
  43. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +8 -1
  44. package/dist/templates/fullstack/apps/api/src/database/database.ts +29 -10
  45. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
  46. package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
  47. package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
  48. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
  49. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
  50. package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
  51. package/dist/templates/fullstack/apps/web/components.json +3 -1
  52. package/dist/templates/fullstack/apps/web/package.json +1 -1
  53. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
  54. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
  55. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
  56. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
  57. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
  58. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
  59. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  60. package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
  61. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +14 -1
  62. package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
  63. package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
  64. package/dist/templates/fullstack/dot-env.example +10 -0
  65. package/dist/templates/fullstack/dot-gitignore +1 -0
  66. package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
  67. package/dist/templates/fullstack/infra/k3s/README.md +3 -1
  68. package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
  69. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
  70. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
  71. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
  72. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
  74. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
  75. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +5 -0
  76. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
  77. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
  78. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
  79. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +59 -32
  80. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +81 -11
  81. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
  82. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
  83. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
  85. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
  86. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
  87. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +13 -19
  88. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
  89. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
  90. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +1 -1
  91. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
  92. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
  93. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +15 -12
  94. package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
  95. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +2 -2
  96. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
  97. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
  98. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +3 -1
  99. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +14 -15
  100. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +13 -8
  101. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
  102. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +14 -3
  103. package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
  104. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +53 -11
  105. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +26 -4
  106. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
  107. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
  108. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
  109. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
  110. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
  112. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
  113. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
  114. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
  115. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
  116. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
  117. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
  118. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
  119. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
  120. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +16 -37
  121. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
  122. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
  123. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
  124. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
  125. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
  126. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
  127. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
  128. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
  129. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  130. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
  131. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
  132. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
  133. package/dist/templates/modules/events-redis/module.manifest.json +4 -0
  134. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
  135. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
  136. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
  137. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
  138. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
  139. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
  140. package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
  141. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
  142. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
  143. package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +147 -0
  144. package/dist/templates/modules/file-upload/module.manifest.json +34 -6
  145. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
  146. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
  147. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
  148. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  149. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
  150. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
  151. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
  152. package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
  153. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
  154. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
  155. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
  156. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
  157. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
  158. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
  159. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
  160. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
  161. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
  162. package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
  163. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
  164. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
  165. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
  166. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
  167. package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
  168. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
  169. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
  170. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
  171. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
  172. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
  173. package/dist/templates/modules/redis/module.manifest.json +4 -0
  174. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
  175. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +48 -1
  176. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +15 -2
  177. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +68 -0
  178. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +25 -1
  179. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
  180. package/dist/templates/modules/sse/module.manifest.json +3 -1
  181. package/dist/templates/todo/AGENTS.md +9 -1
  182. package/dist/templates/todo/README.md +18 -5
  183. package/dist/update.d.ts +22 -0
  184. package/dist/update.js +68 -8
  185. package/package.json +2 -2
  186. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
  187. package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
@@ -1,7 +1,35 @@
1
- import { redisConnectionOptions, type RedisConnectionOptions } from "../config/redis.connection";
1
+ import type { ConnectionOptions } from "bullmq";
2
+ import { redisCommandTimeoutMs, redisConnectionOptions } from "../config/redis.connection";
2
3
 
3
4
  export const DEMO_QUEUE = "demo";
4
5
 
5
- export function redisConnection(): RedisConnectionOptions {
6
- return redisConnectionOptions(process.env, { maxRetriesPerRequest: null });
6
+ /** Wait before reconnect attempt `attempt`: 250 ms, doubling up to 5 seconds. */
7
+ export function reconnectDelay(attempt: number): number {
8
+ return Math.min(250 * 2 ** Math.max(0, attempt - 1), 5_000);
9
+ }
10
+
11
+ /** Workers block on Redis, so BullMQ requires their commands to wait for a reply indefinitely. */
12
+ export function redisConnection(env: NodeJS.ProcessEnv = process.env): ConnectionOptions {
13
+ return {
14
+ ...redisConnectionOptions(env, { maxRetriesPerRequest: null }),
15
+ retryStrategy: reconnectDelay,
16
+ };
17
+ }
18
+
19
+ /**
20
+ * The API's connection for adding and reading jobs. Commands fail at once while
21
+ * it is disconnected and wait at most `REDIS_COMMAND_TIMEOUT_MS` for a reply, so
22
+ * a request that enqueues a job cannot hang on Redis. It does not reconnect by
23
+ * itself: the service registry's supervisor reopens it.
24
+ */
25
+ export function producerConnection(env: NodeJS.ProcessEnv = process.env): ConnectionOptions {
26
+ return {
27
+ ...redisConnectionOptions(env, {
28
+ lazyConnect: true,
29
+ enableOfflineQueue: false,
30
+ connectTimeout: 5_000,
31
+ commandTimeout: redisCommandTimeoutMs(env),
32
+ }),
33
+ retryStrategy: () => null,
34
+ };
7
35
  }
@@ -8,13 +8,17 @@
8
8
  "apps/api/src/config/providers.ts",
9
9
  "apps/api/src/config/redis.connection.ts"
10
10
  ],
11
+ "requiredProjectContents": {
12
+ "apps/api/src/core/services.ts": ["supervise(name: string, connection: SupervisedConnection): void {"],
13
+ "apps/api/src/config/redis.connection.ts": ["export function redisCommandTimeoutMs("]
14
+ },
11
15
  "managedGlobs": [
12
16
  "apps/api/src/jobs/**",
13
17
  "apps/api/src/main-worker.ts"
14
18
  ],
15
19
  "targetApp": "api",
16
20
  "dependencies": {
17
- "bullmq": "^5.34.0"
21
+ "bullmq": "^5.57.0"
18
22
  },
19
23
  "scripts": {
20
24
  "start:worker": "bun dist/main-worker.js",
@@ -8,9 +8,7 @@ export const eventsRedisModule: PodokitModule = {
8
8
  configure: (_env, services): void => {
9
9
  if (PROVIDERS.events !== "redis") return;
10
10
  const events = new RedisEventBus();
11
- services.register(EVENTS, events, async () => {
12
- await events.close();
13
- });
14
- services.onStart(() => events.ready());
11
+ services.register(EVENTS, events, () => events.close());
12
+ services.supervise("events", events);
15
13
  },
16
14
  };
@@ -0,0 +1,224 @@
1
+ import type { RedisClient } from "bun";
2
+ import { describe, expect, it } from "bun:test";
3
+ import { RedisCommandTimeoutError } from "../config/redis.connection";
4
+ import { RedisEventBus } from "./redis-event.bus";
5
+
6
+ type Listener = (message: string, channel: string) => void;
7
+
8
+ /**
9
+ * One Redis server shared by fake clients: it can go down, and it drops every
10
+ * connection when it does. While paused it keeps connections open and answers nothing.
11
+ */
12
+ class FakeRedis {
13
+ up = true;
14
+ paused = false;
15
+ readonly clients: FakeClient[] = [];
16
+
17
+ client(): RedisClient {
18
+ const client = new FakeClient(this);
19
+ this.clients.push(client);
20
+ return client as unknown as RedisClient;
21
+ }
22
+
23
+ stop(): void {
24
+ this.up = false;
25
+ for (const client of this.clients) client.drop();
26
+ }
27
+
28
+ deliver(channel: string, message: string): number {
29
+ let receivers = 0;
30
+ for (const client of this.clients) {
31
+ if (!client.connected || !client.channels.has(channel)) continue;
32
+ receivers += 1;
33
+ client.listener?.(message, channel);
34
+ }
35
+ return receivers;
36
+ }
37
+ }
38
+
39
+ class FakeClient {
40
+ connected = false;
41
+ onclose: ((error: Error) => void) | null = null;
42
+ readonly channels = new Set<string>();
43
+ listener?: Listener;
44
+ private readonly pending = new Set<(error: Error) => void>();
45
+
46
+ constructor(private readonly server: FakeRedis) {}
47
+
48
+ /** Never answers while the server is paused; closing the connection fails the command. */
49
+ private answer<T>(value: () => T): Promise<T> {
50
+ if (!this.server.paused) return Promise.resolve(value());
51
+ return new Promise<T>((_resolve, reject) => {
52
+ this.pending.add(reject);
53
+ });
54
+ }
55
+
56
+ connect(): Promise<void> {
57
+ if (!this.server.up) {
58
+ this.onclose?.(new Error("Connection closed"));
59
+ return Promise.reject(new Error("Connection closed"));
60
+ }
61
+ this.connected = true;
62
+ return Promise.resolve();
63
+ }
64
+
65
+ ping(): Promise<string> {
66
+ return this.connected ? this.answer(() => "PONG") : Promise.reject(new Error("Connection closed"));
67
+ }
68
+
69
+ publish(channel: string, message: string): Promise<number> {
70
+ if (!this.connected) {
71
+ return Promise.reject(new Error("Connection is closed and offline queue is disabled"));
72
+ }
73
+ return this.answer(() => this.server.deliver(channel, message));
74
+ }
75
+
76
+ subscribe(channel: string, listener: Listener): Promise<number> {
77
+ if (!this.connected) return Promise.reject(new Error("Connection closed"));
78
+ return this.answer(() => {
79
+ this.channels.add(channel);
80
+ this.listener = listener;
81
+ return this.channels.size;
82
+ });
83
+ }
84
+
85
+ drop(): void {
86
+ if (!this.connected) return;
87
+ // Bun forgets the server subscription but keeps its listener.
88
+ this.connected = false;
89
+ this.channels.clear();
90
+ this.onclose?.(new Error("Connection closed"));
91
+ }
92
+
93
+ close(): void {
94
+ if (!this.connected) return;
95
+ this.connected = false;
96
+ for (const reject of this.pending) reject(new Error("Connection closed"));
97
+ this.pending.clear();
98
+ this.onclose?.(new Error("Connection closed"));
99
+ }
100
+ }
101
+
102
+ function bus(server: FakeRedis): RedisEventBus {
103
+ return new RedisEventBus(
104
+ { EVENTS_REDIS_CHANNEL: "test:events", REDIS_COMMAND_TIMEOUT_MS: "50" },
105
+ () => server.client(),
106
+ );
107
+ }
108
+
109
+ describe("RedisEventBus reconnects", () => {
110
+ it("keeps handlers registered without Redis and delivers once the channel is subscribed", async () => {
111
+ const server = new FakeRedis();
112
+ server.up = false;
113
+ const events = bus(server);
114
+ let interruptions = 0;
115
+ events.onInterruption(() => { interruptions += 1; });
116
+ const received: unknown[] = [];
117
+ await events.subscribe("sse", (event) => { received.push(event); });
118
+
119
+ await expect(events.connect()).rejects.toThrow("Connection closed");
120
+ await expect(events.ready()).rejects.toThrow("not subscribed");
121
+ server.up = true;
122
+ await events.connect();
123
+ await events.ready();
124
+
125
+ await events.publish("sse", { id: 1 });
126
+ expect(received).toEqual([{ id: 1 }]);
127
+ // Streams opened before the first subscription may have missed events.
128
+ expect(interruptions).toBe(1);
129
+ events.close();
130
+ });
131
+
132
+ it("subscribes again after Redis drops the connections and reports one interruption", async () => {
133
+ const server = new FakeRedis();
134
+ const events = bus(server);
135
+ const lost: unknown[] = [];
136
+ events.onLost((reason) => { lost.push(reason); });
137
+ let interruptions = 0;
138
+ events.onInterruption(() => { interruptions += 1; });
139
+ const received: unknown[] = [];
140
+ await events.subscribe("config.changed", (event) => { received.push(event); });
141
+ await events.connect();
142
+ expect(interruptions).toBe(0);
143
+
144
+ server.stop();
145
+ expect(lost.length).toBeGreaterThan(0);
146
+ await expect(events.ready()).rejects.toThrow("not subscribed");
147
+ await expect(events.publish("config.changed", { origin: "a" })).rejects.toThrow("offline queue");
148
+ server.up = true;
149
+ await events.connect();
150
+
151
+ expect(interruptions).toBe(1);
152
+ await events.publish("config.changed", { origin: "b" });
153
+ expect(received).toEqual([{ origin: "b" }]);
154
+ events.close();
155
+ });
156
+
157
+ it("reports no interruption when only the publisher reconnects", async () => {
158
+ const server = new FakeRedis();
159
+ const events = bus(server);
160
+ let interruptions = 0;
161
+ events.onInterruption(() => { interruptions += 1; });
162
+ await events.connect();
163
+
164
+ server.clients[0]?.drop();
165
+ await events.connect();
166
+ expect(interruptions).toBe(0);
167
+ await events.ready();
168
+ events.close();
169
+ });
170
+
171
+ it("does not report connections it closes itself", async () => {
172
+ const server = new FakeRedis();
173
+ const events = bus(server);
174
+ const lost: unknown[] = [];
175
+ events.onLost((reason) => { lost.push(reason); });
176
+ await events.connect();
177
+
178
+ events.close();
179
+ expect(lost).toEqual([]);
180
+ });
181
+
182
+ it("fails a publish Redis does not answer and hands the publisher to the supervisor", async () => {
183
+ const server = new FakeRedis();
184
+ const events = bus(server);
185
+ const lost: unknown[] = [];
186
+ events.onLost((reason) => { lost.push(reason); });
187
+ let interruptions = 0;
188
+ events.onInterruption(() => { interruptions += 1; });
189
+ const received: unknown[] = [];
190
+ await events.subscribe("sse", (event) => { received.push(event); });
191
+ await events.connect();
192
+
193
+ server.paused = true;
194
+ const started = Date.now();
195
+ await expect(events.publish("sse", { id: 1 })).rejects.toBeInstanceOf(RedisCommandTimeoutError);
196
+ expect(Date.now() - started).toBeLessThan(1_000);
197
+ expect(lost.length).toBe(1);
198
+ await expect(events.ready()).rejects.toThrow("Connection closed");
199
+
200
+ server.paused = false;
201
+ await events.connect();
202
+ await events.publish("sse", { id: 2 });
203
+ expect(received).toEqual([{ id: 2 }]);
204
+ expect(interruptions).toBe(0);
205
+ events.close();
206
+ });
207
+
208
+ it("fails a subscription Redis does not confirm and subscribes on the next attempt", async () => {
209
+ const server = new FakeRedis();
210
+ server.paused = true;
211
+ const events = bus(server);
212
+ let interruptions = 0;
213
+ events.onInterruption(() => { interruptions += 1; });
214
+
215
+ await expect(events.connect()).rejects.toBeInstanceOf(RedisCommandTimeoutError);
216
+ await expect(events.ready()).rejects.toThrow("not subscribed");
217
+
218
+ server.paused = false;
219
+ await events.connect();
220
+ await events.ready();
221
+ expect(interruptions).toBe(1);
222
+ events.close();
223
+ });
224
+ });
@@ -1,48 +1,83 @@
1
1
  import { RedisClient } from "bun";
2
2
  import type { EventBus, EventHandler, Unsubscribe } from "@podosoft/podokit-runtime";
3
- import { redisConnectionUrl } from "../config/redis.connection";
3
+ import {
4
+ redisCommandTimeoutMs,
5
+ redisConnectionUrl,
6
+ withReplyTimeout,
7
+ } from "../config/redis.connection";
8
+ import type { SupervisedConnection } from "../core/services";
4
9
 
5
- export class RedisEventBus implements EventBus {
10
+ /**
11
+ * Publishing fails at once while disconnected instead of queueing, and the
12
+ * supervisor reconnects instead of Bun, which gives up after a fixed number of
13
+ * attempts and never restores subscriptions.
14
+ */
15
+ function redisClientFactory(env: NodeJS.ProcessEnv): () => RedisClient {
16
+ const url = redisConnectionUrl(env);
17
+ return () => new RedisClient(url, {
18
+ connectionTimeout: 5_000,
19
+ autoReconnect: false,
20
+ enableOfflineQueue: false,
21
+ });
22
+ }
23
+
24
+ /**
25
+ * Every replica subscribes to one Redis channel and dispatches to its local
26
+ * handlers by topic. Handlers are local, so they survive reconnects; the
27
+ * channel subscription is opened again on each reconnect.
28
+ */
29
+ export class RedisEventBus implements EventBus, SupervisedConnection {
6
30
  private readonly publisher: RedisClient;
7
- private readonly subscriber: RedisClient;
8
31
  private readonly channel: string;
9
32
  private readonly maxEventBytes: number;
33
+ private readonly commandTimeoutMs: number;
34
+ private readonly createClient: () => RedisClient;
10
35
  private readonly handlers = new Map<string, Set<EventHandler>>();
11
- private listener?: (message: string) => void;
36
+ private readonly interruptionListeners = new Set<() => void>();
37
+ // Set while a connection holds the channel subscription.
38
+ private subscriber?: RedisClient;
39
+ // Whether events may have been missed since the channel was last subscribed.
40
+ private missedEvents = false;
41
+ private lostListener?: (reason: unknown) => void;
42
+ private closed = false;
12
43
 
13
- constructor(env: NodeJS.ProcessEnv = process.env) {
14
- this.publisher = new RedisClient(redisConnectionUrl(env), { connectionTimeout: 5_000 });
15
- this.subscriber = new RedisClient(redisConnectionUrl(env), { connectionTimeout: 5_000 });
44
+ constructor(
45
+ env: NodeJS.ProcessEnv = process.env,
46
+ createClient: () => RedisClient = redisClientFactory(env),
47
+ ) {
48
+ this.createClient = createClient;
49
+ this.publisher = createClient();
50
+ this.publisher.onclose = (error) => this.report(error);
16
51
  this.channel = env.EVENTS_REDIS_CHANNEL?.trim() || "podokit:events";
17
52
  this.maxEventBytes = Number(env.SSE_MAX_EVENT_BYTES ?? 65_536);
18
53
  if (!Number.isSafeInteger(this.maxEventBytes) || this.maxEventBytes < 1) {
19
54
  throw new Error("SSE_MAX_EVENT_BYTES must be a positive integer");
20
55
  }
56
+ this.commandTimeoutMs = redisCommandTimeoutMs(env);
57
+ }
58
+
59
+ onLost(listener: (reason: unknown) => void): void {
60
+ this.lostListener = listener;
21
61
  }
22
62
 
63
+ /** Opens whichever connection is closed and subscribes the channel again. */
23
64
  async connect(): Promise<void> {
24
- if (this.listener) return;
25
- await Promise.all([this.publisher.connect(), this.subscriber.connect()]);
26
- this.listener = (message: string): void => {
27
- if (Buffer.byteLength(message, "utf8") > this.maxEventBytes) return;
28
- try {
29
- const envelope = JSON.parse(message) as unknown;
30
- if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return;
31
- const record = envelope as Record<string, unknown>;
32
- if (typeof record.topic !== "string") return;
33
- for (const handler of this.handlers.get(record.topic) ?? []) {
34
- void Promise.resolve(handler(record.event)).catch(() => undefined);
35
- }
36
- } catch {
37
- process.stderr.write("Discard malformed event transport message\n");
38
- }
39
- };
40
- await this.subscriber.subscribe(this.channel, this.listener);
65
+ try {
66
+ if (!this.publisher.connected) await this.publisher.connect();
67
+ this.subscriber ??= await this.openSubscriber();
68
+ } catch (error) {
69
+ if (!this.subscriber) this.missedEvents = true;
70
+ throw error;
71
+ }
72
+ if (!this.missedEvents) return;
73
+ this.missedEvents = false;
74
+ for (const listener of [...this.interruptionListeners]) listener();
41
75
  }
42
76
 
77
+ /** Rejects until the channel is subscribed and while the publisher cannot reach Redis. */
43
78
  async ready(): Promise<void> {
44
- await this.connect();
45
- await this.publisher.ping();
79
+ if (!this.subscriber) throw new Error("Event transport is not subscribed");
80
+ await this.send(() => this.publisher.ping());
46
81
  }
47
82
 
48
83
  async publish(topic: string, event: unknown): Promise<void> {
@@ -51,25 +86,79 @@ export class RedisEventBus implements EventBus {
51
86
  if (Buffer.byteLength(message, "utf8") > this.maxEventBytes) {
52
87
  throw new Error(`Event exceeds maxEventBytes (${this.maxEventBytes})`);
53
88
  }
54
- await this.publisher.publish(this.channel, message);
89
+ await this.send(() => this.publisher.publish(this.channel, message));
55
90
  }
56
91
 
57
- async subscribe(topic: string, handler: EventHandler): Promise<Unsubscribe> {
58
- await this.connect();
92
+ subscribe(topic: string, handler: EventHandler): Promise<Unsubscribe> {
59
93
  const handlers = this.handlers.get(topic) ?? new Set<EventHandler>();
60
94
  handlers.add(handler);
61
95
  this.handlers.set(topic, handlers);
62
- return () => {
96
+ return Promise.resolve(() => {
63
97
  handlers.delete(handler);
64
- if (handlers.size === 0) this.handlers.delete(topic);
98
+ if (handlers.size === 0 && this.handlers.get(topic) === handlers) this.handlers.delete(topic);
99
+ });
100
+ }
101
+
102
+ onInterruption(listener: () => void): () => void {
103
+ this.interruptionListeners.add(listener);
104
+ return () => {
105
+ this.interruptionListeners.delete(listener);
65
106
  };
66
107
  }
67
108
 
68
- async close(): Promise<void> {
69
- if (this.listener) await this.subscriber.unsubscribe(this.channel, this.listener);
109
+ close(): void {
110
+ this.closed = true;
70
111
  this.handlers.clear();
71
- this.listener = undefined;
112
+ this.interruptionListeners.clear();
113
+ this.subscriber?.close();
114
+ this.subscriber = undefined;
72
115
  this.publisher.close();
73
- this.subscriber.close();
116
+ }
117
+
118
+ /** Fails a publisher command Redis does not answer in time and closes the connection for the supervisor to reopen. */
119
+ private async send<T>(command: () => Promise<T>): Promise<T> {
120
+ return withReplyTimeout(command(), this.commandTimeoutMs, () => this.publisher.close());
121
+ }
122
+
123
+ private readonly receive = (message: string): void => {
124
+ if (Buffer.byteLength(message, "utf8") > this.maxEventBytes) return;
125
+ try {
126
+ const envelope = JSON.parse(message) as unknown;
127
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return;
128
+ const record = envelope as Record<string, unknown>;
129
+ if (typeof record.topic !== "string") return;
130
+ for (const handler of this.handlers.get(record.topic) ?? []) {
131
+ void Promise.resolve(handler(record.event)).catch(() => undefined);
132
+ }
133
+ } catch {
134
+ process.stderr.write("Discard malformed event transport message\n");
135
+ }
136
+ };
137
+
138
+ /** A fresh connection, because Bun keeps the listeners of a dropped one without the server subscription. */
139
+ private async openSubscriber(): Promise<RedisClient> {
140
+ const subscriber = this.createClient();
141
+ subscriber.onclose = (error) => {
142
+ if (this.subscriber !== subscriber) return;
143
+ this.subscriber = undefined;
144
+ this.missedEvents = true;
145
+ this.report(error);
146
+ };
147
+ try {
148
+ await subscriber.connect();
149
+ await withReplyTimeout(
150
+ subscriber.subscribe(this.channel, this.receive),
151
+ this.commandTimeoutMs,
152
+ () => subscriber.close(),
153
+ );
154
+ } catch (error) {
155
+ subscriber.close();
156
+ throw error;
157
+ }
158
+ return subscriber;
159
+ }
160
+
161
+ private report(reason: unknown): void {
162
+ if (!this.closed) this.lostListener?.(reason);
74
163
  }
75
164
  }
@@ -7,6 +7,10 @@
7
7
  "apps/api/src/config/providers.ts",
8
8
  "apps/api/src/config/redis.connection.ts"
9
9
  ],
10
+ "requiredProjectContents": {
11
+ "apps/api/src/core/services.ts": ["supervise(name: string, connection: SupervisedConnection): void {"],
12
+ "apps/api/src/config/redis.connection.ts": ["export async function withReplyTimeout<T>("]
13
+ },
10
14
  "managedGlobs": [
11
15
  "apps/api/src/events-provider-redis/**"
12
16
  ],
@@ -0,0 +1,136 @@
1
+ import { AppException } from "@podosoft/podokit-contracts";
2
+ import { Elysia, t } from "elysia";
3
+ import type { ElysiaPlugin, RequestIdentity } from "../core/services";
4
+ import type { ChunkedUploads } from "./chunked-uploads";
5
+
6
+ const JSON_TYPE = "application/json";
7
+ const BINARY_TYPE = "application/octet-stream";
8
+ const CONTENT_TYPE = "^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+(\\s*;.*)?$";
9
+
10
+ const uploadId = t.Object({ id: t.String({ minLength: 1, maxLength: 64 }) });
11
+
12
+ /** The origins in CORS_ORIGIN, which a browser's Origin header must match. */
13
+ export function allowedOrigins(corsOrigin: string | undefined): string[] {
14
+ return (corsOrigin ?? "")
15
+ .split(",")
16
+ .map((origin) => origin.trim())
17
+ .filter((origin) => origin.length > 0)
18
+ .map((origin) => {
19
+ try {
20
+ return new URL(origin).origin;
21
+ } catch {
22
+ return origin;
23
+ }
24
+ });
25
+ }
26
+
27
+ function mediaType(request: Request): string {
28
+ return (request.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
29
+ }
30
+
31
+ /**
32
+ * Upload routes may be served on a direct API path, which skips the web
33
+ * server's cross-site form check. Requiring a JSON or binary content type makes
34
+ * every cross-origin browser request preflighted, which CORS refuses for other
35
+ * origins, and a browser Origin outside CORS_ORIGIN is rejected outright.
36
+ */
37
+ function guard(origins: readonly string[], type?: string): (context: { request: Request }) => void {
38
+ return ({ request }) => {
39
+ const origin = request.headers.get("origin");
40
+ if (origins.length > 0 && origin !== null && !origins.includes(origin)) {
41
+ throw new AppException("ORIGIN_NOT_ALLOWED", "Requests from this origin cannot change uploads", 403);
42
+ }
43
+ if (type !== undefined && mediaType(request) !== type) {
44
+ throw new AppException("UNSUPPORTED_MEDIA_TYPE", `Send this request as ${type}`, 415);
45
+ }
46
+ };
47
+ }
48
+
49
+ function declaredLength(request: Request): number | null {
50
+ const value = request.headers.get("content-length");
51
+ if (value === null) return null;
52
+ return /^\d+$/.test(value.trim()) ? Number(value) : Number.NaN;
53
+ }
54
+
55
+ /**
56
+ * Resumable uploads: create a session, PUT its parts in any order (again to
57
+ * replace one), read the status to resume, then complete or delete it. Only the
58
+ * owner sees a session; others receive 404 so session ids cannot be probed.
59
+ */
60
+ export function chunkedUploadRoutes(
61
+ uploads: ChunkedUploads,
62
+ identity: RequestIdentity,
63
+ origins: readonly string[],
64
+ ): ElysiaPlugin {
65
+ return new Elysia({ name: "podokit.files.uploads" })
66
+ .post("/files/uploads", async ({ request, body, set }) => {
67
+ const created = await uploads.create(await identity.userId(request), body);
68
+ set.status = 201;
69
+ return created;
70
+ }, {
71
+ transform: guard(origins, JSON_TYPE),
72
+ body: t.Object({
73
+ filename: t.String({ minLength: 1, maxLength: 255 }),
74
+ size: t.Integer({ minimum: 1 }),
75
+ contentType: t.Optional(t.String({ maxLength: 255, pattern: CONTENT_TYPE })),
76
+ }),
77
+ detail: {
78
+ tags: ["files"],
79
+ summary: "Start a resumable upload",
80
+ description:
81
+ "Returns the session id, the part size, and the part count. Send every part with PUT /files/uploads/{id}/parts/{partNumber}, then complete the upload.",
82
+ },
83
+ })
84
+ .get("/files/uploads/:id", async ({ request, params }) =>
85
+ uploads.status(await identity.userId(request), params.id), {
86
+ params: uploadId,
87
+ detail: {
88
+ tags: ["files"],
89
+ summary: "Read a resumable upload",
90
+ description: "Lists the stored part numbers, so a client can resume by sending the others.",
91
+ },
92
+ })
93
+ .put("/files/uploads/:id/parts/:partNumber", async ({ request, params }) =>
94
+ uploads.putPart(
95
+ await identity.userId(request),
96
+ params.id,
97
+ params.partNumber,
98
+ declaredLength(request),
99
+ request.body,
100
+ ), {
101
+ // The body is streamed to storage as it arrives, never parsed or buffered here.
102
+ parse: "none",
103
+ transform: guard(origins, BINARY_TYPE),
104
+ params: t.Object({ id: t.String({ minLength: 1, maxLength: 64 }), partNumber: t.Numeric() }),
105
+ detail: {
106
+ tags: ["files"],
107
+ summary: "Store one part of a resumable upload",
108
+ description:
109
+ "Send the raw bytes as application/octet-stream with a Content-Length equal to the part size (the last part holds the remainder). Sending a part again replaces it.",
110
+ requestBody: {
111
+ required: true,
112
+ content: { [BINARY_TYPE]: { schema: { type: "string", format: "binary" } } },
113
+ },
114
+ },
115
+ })
116
+ .post("/files/uploads/:id/complete", async ({ request, params }) =>
117
+ uploads.complete(await identity.userId(request), params.id), {
118
+ transform: guard(origins, JSON_TYPE),
119
+ params: uploadId,
120
+ body: t.Optional(t.Object({})),
121
+ detail: {
122
+ tags: ["files"],
123
+ summary: "Complete a resumable upload",
124
+ description:
125
+ "Joins the parts into the stored file once every part is present. Repeating the call returns the same result.",
126
+ },
127
+ })
128
+ .delete("/files/uploads/:id", async ({ request, params, set }) => {
129
+ await uploads.abort(await identity.userId(request), params.id);
130
+ set.status = 204;
131
+ }, {
132
+ transform: guard(origins),
133
+ params: uploadId,
134
+ detail: { tags: ["files"], summary: "Cancel a resumable upload and discard its parts" },
135
+ });
136
+ }