@podosoft/podokit 1.3.0 → 1.4.1

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 (154) hide show
  1. package/README.md +1 -1
  2. package/dist/add.d.ts +10 -1
  3. package/dist/add.js +20 -7
  4. package/dist/api-paths.d.ts +34 -0
  5. package/dist/api-paths.js +103 -0
  6. package/dist/deploy-compose-profile.d.ts +4 -0
  7. package/dist/deploy-compose-profile.js +13 -2
  8. package/dist/deploy-compose-render.d.ts +1 -1
  9. package/dist/deploy-compose-render.js +23 -7
  10. package/dist/deploy-compose.js +2 -1
  11. package/dist/deploy-profile.d.ts +3 -0
  12. package/dist/deploy-profile.js +12 -4
  13. package/dist/deploy-render.js +54 -8
  14. package/dist/deploy.js +21 -1
  15. package/dist/dev.d.ts +14 -0
  16. package/dist/dev.js +152 -10
  17. package/dist/index.js +15 -2
  18. package/dist/lib.d.ts +2 -0
  19. package/dist/lib.js +4 -2
  20. package/dist/lockfile.d.ts +3 -0
  21. package/dist/lockfile.js +11 -0
  22. package/dist/module-api-paths.d.ts +52 -0
  23. package/dist/module-api-paths.js +189 -0
  24. package/dist/remove.d.ts +3 -0
  25. package/dist/remove.js +12 -3
  26. package/dist/templates/fullstack/AGENTS.md +27 -5
  27. package/dist/templates/fullstack/README.md +28 -11
  28. package/dist/templates/fullstack/apps/api/package.json +2 -2
  29. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +25 -0
  30. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
  31. package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
  32. package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
  33. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
  34. package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
  35. package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
  36. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
  37. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
  38. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
  39. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  40. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
  41. package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
  42. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +26 -1
  43. package/dist/templates/fullstack/apps/api/src/database/database.ts +52 -11
  44. package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
  45. package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
  46. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +70 -0
  47. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
  48. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
  49. package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
  50. package/dist/templates/fullstack/apps/web/components.json +3 -1
  51. package/dist/templates/fullstack/apps/web/package.json +1 -1
  52. package/dist/templates/fullstack/apps/web/src/error.html +73 -0
  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 +7 -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/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
  69. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
  70. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
  71. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
  72. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
  74. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
  75. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
  76. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
  77. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
  78. package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
  79. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  80. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  81. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
  82. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
  83. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
  84. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
  85. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
  86. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
  87. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
  88. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
  89. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
  90. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
  91. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
  92. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
  93. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
  94. package/dist/templates/modules/auth/module.manifest.json +0 -1
  95. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
  96. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
  97. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
  98. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
  99. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  100. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
  101. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
  102. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
  103. package/dist/templates/modules/events-redis/module.manifest.json +4 -0
  104. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
  105. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
  106. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
  107. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
  108. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
  109. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
  110. package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
  111. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
  112. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
  113. package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +153 -0
  114. package/dist/templates/modules/file-upload/module.manifest.json +34 -6
  115. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
  116. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
  117. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
  118. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  119. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
  120. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
  121. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
  122. package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
  123. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
  124. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
  125. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
  126. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
  127. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
  128. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
  129. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
  130. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
  131. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
  132. package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
  133. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
  134. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
  135. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
  136. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
  137. package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
  138. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
  139. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
  140. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
  141. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
  142. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
  143. package/dist/templates/modules/redis/module.manifest.json +4 -0
  144. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
  145. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
  146. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
  147. package/dist/templates/todo/AGENTS.md +19 -4
  148. package/dist/templates/todo/README.md +27 -12
  149. package/dist/update.d.ts +5 -0
  150. package/dist/update.js +35 -0
  151. package/package.json +2 -2
  152. package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
  153. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
  154. package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEV_CONFIG_FILE = void 0;
4
+ exports.addApiPaths = addApiPaths;
5
+ exports.removeApiPaths = removeApiPaths;
6
+ exports.pendingApiPaths = pendingApiPaths;
7
+ exports.describeApiPaths = describeApiPaths;
8
+ const node_fs_1 = require("node:fs");
9
+ const node_path_1 = require("node:path");
10
+ const api_paths_1 = require("./api-paths");
11
+ const deploy_compose_profile_1 = require("./deploy-compose-profile");
12
+ const deploy_profile_1 = require("./deploy-profile");
13
+ const deploy_schema_1 = require("./deploy-schema");
14
+ const websocket_paths_1 = require("./websocket-paths");
15
+ /**
16
+ * Modules declare the direct API paths their routes need (`apiPaths` in
17
+ * `module.manifest.json`). The development gateway configuration and the
18
+ * deployment profiles belong to the application, so PodoKit edits only their
19
+ * `apiPaths` lists, keeps every other key in place, and never writes a file
20
+ * that would stop validating.
21
+ */
22
+ exports.DEV_CONFIG_FILE = ".podokit/dev.json";
23
+ const DEPLOY_DIRECTORY = ".podokit/deploy";
24
+ /** `.podokit/dev.json` followed by every deployment profile, project-relative. */
25
+ function configFiles(projectRoot) {
26
+ const directory = (0, node_path_1.join)(projectRoot, DEPLOY_DIRECTORY);
27
+ const profiles = (0, node_fs_1.existsSync)(directory)
28
+ ? (0, node_fs_1.readdirSync)(directory)
29
+ .filter((entry) => entry.endsWith(".json"))
30
+ .sort()
31
+ .map((entry) => `${DEPLOY_DIRECTORY}/${entry}`)
32
+ : [];
33
+ return [exports.DEV_CONFIG_FILE, ...profiles];
34
+ }
35
+ function readDisk(projectRoot, file) {
36
+ const path = (0, node_path_1.join)(projectRoot, file);
37
+ return (0, node_fs_1.existsSync)(path) ? (0, node_fs_1.readFileSync)(path, "utf8") : null;
38
+ }
39
+ function parseDocument(file, text) {
40
+ let root;
41
+ try {
42
+ root = JSON.parse(text);
43
+ }
44
+ catch (error) {
45
+ throw new Error(`Cannot read ${file}: ${error.message}`);
46
+ }
47
+ if (!(0, deploy_schema_1.isRecord)(root))
48
+ throw new Error(`${file} must contain a JSON object.`);
49
+ const style = {
50
+ indent: /^[ \t]+(?=")/m.exec(text)?.[0] ?? " ",
51
+ newline: text.endsWith("\n") ? "\n" : "",
52
+ };
53
+ if (file === exports.DEV_CONFIG_FILE) {
54
+ return {
55
+ root,
56
+ holder: root,
57
+ current: currentPaths(file, root),
58
+ validate: (apiPaths) => {
59
+ const webSocketPaths = (0, websocket_paths_1.parseExactWebSocketPaths)(root.webSocketPaths ?? [], `${exports.DEV_CONFIG_FILE}.webSocketPaths`);
60
+ (0, api_paths_1.parseApiPaths)(apiPaths, `${exports.DEV_CONFIG_FILE}.apiPaths`, webSocketPaths);
61
+ },
62
+ ...style,
63
+ };
64
+ }
65
+ const exposure = root.exposure;
66
+ if (!(0, deploy_schema_1.isRecord)(exposure))
67
+ throw new Error(`${file} has no exposure object.`);
68
+ const parse = root.driver === "docker-compose" ? deploy_compose_profile_1.parseComposeProfile : deploy_profile_1.parseDeploymentProfile;
69
+ return {
70
+ root,
71
+ holder: exposure,
72
+ current: currentPaths(file, exposure),
73
+ // The whole profile is checked, so driver rules such as Kubernetes
74
+ // requiring ingress exposure for apiPaths apply too.
75
+ validate: (apiPaths) => {
76
+ parse({ ...root, exposure: { ...exposure, apiPaths } });
77
+ },
78
+ ...style,
79
+ };
80
+ }
81
+ function currentPaths(file, holder) {
82
+ const value = holder.apiPaths ?? [];
83
+ if (!Array.isArray(value) || value.some((path) => typeof path !== "string")) {
84
+ throw new Error(`apiPaths in ${file} must be an array of paths.`);
85
+ }
86
+ return value;
87
+ }
88
+ function writeDocument(projectRoot, file, document) {
89
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(projectRoot, file), `${JSON.stringify(document.root, null, document.indent)}${document.newline}`);
90
+ }
91
+ /**
92
+ * Add module paths to `.podokit/dev.json` and every deployment profile. A path
93
+ * that an entry already covers is left alone; a path that would make the file
94
+ * invalid, for example by covering a WebSocket path or another entry, is
95
+ * reported instead of written.
96
+ */
97
+ function addApiPaths(projectRoot, paths, options = {}) {
98
+ const result = { changed: [], skipped: [] };
99
+ if (paths.length === 0)
100
+ return result;
101
+ const read = options.read ?? ((file) => readDisk(projectRoot, file));
102
+ for (const file of configFiles(projectRoot)) {
103
+ const text = read(file);
104
+ if (text === null) {
105
+ for (const entry of paths) {
106
+ result.skipped.push({ ...entry, file, reason: "the file does not exist" });
107
+ }
108
+ continue;
109
+ }
110
+ let document;
111
+ try {
112
+ document = parseDocument(file, text);
113
+ }
114
+ catch (error) {
115
+ for (const entry of paths) {
116
+ result.skipped.push({ ...entry, file, reason: error.message });
117
+ }
118
+ continue;
119
+ }
120
+ const merge = (0, api_paths_1.mergeApiPaths)(document.current, paths.map((entry) => entry.path), document.validate);
121
+ for (const entry of paths) {
122
+ const rejected = merge.rejected.find((item) => item.path === entry.path);
123
+ if (rejected)
124
+ result.skipped.push({ ...entry, file, reason: rejected.reason });
125
+ else if (merge.added.includes(entry.path))
126
+ result.changed.push({ ...entry, file });
127
+ }
128
+ if (merge.added.length === 0 || options.dryRun)
129
+ continue;
130
+ document.holder.apiPaths = merge.paths;
131
+ writeDocument(projectRoot, file, document);
132
+ }
133
+ return result;
134
+ }
135
+ /**
136
+ * Remove exactly these paths from `.podokit/dev.json` and every deployment
137
+ * profile. A file that would stop validating without them, such as a Compose
138
+ * profile whose `trustedProxyCidrs` needs a direct path, is left unchanged.
139
+ */
140
+ function removeApiPaths(projectRoot, paths) {
141
+ const result = { changed: [], skipped: [] };
142
+ if (paths.length === 0)
143
+ return result;
144
+ for (const file of configFiles(projectRoot)) {
145
+ const text = readDisk(projectRoot, file);
146
+ if (text === null)
147
+ continue;
148
+ let document;
149
+ try {
150
+ document = parseDocument(file, text);
151
+ }
152
+ catch {
153
+ continue;
154
+ }
155
+ const removed = paths.filter((entry) => document.current.includes(entry.path));
156
+ if (removed.length === 0)
157
+ continue;
158
+ const next = document.current.filter((path) => !removed.some((entry) => entry.path === path));
159
+ try {
160
+ document.validate(next);
161
+ }
162
+ catch (error) {
163
+ for (const entry of removed) {
164
+ result.skipped.push({ ...entry, file, reason: error.message });
165
+ }
166
+ continue;
167
+ }
168
+ document.holder.apiPaths = next;
169
+ writeDocument(projectRoot, file, document);
170
+ for (const entry of removed)
171
+ result.changed.push({ ...entry, file });
172
+ }
173
+ return result;
174
+ }
175
+ /** Paths a module declares that the project has not merged yet. */
176
+ function pendingApiPaths(module, declared) {
177
+ const applied = new Set(module.appliedApiPaths ?? []);
178
+ return declared
179
+ .filter((path) => !applied.has(path))
180
+ .map((path) => ({ module: module.name, path }));
181
+ }
182
+ /** Indented report lines, one per changed or skipped path. */
183
+ function describeApiPaths(result, verb) {
184
+ const sign = verb === "add" ? "+" : "-";
185
+ return [
186
+ ...result.changed.map((entry) => ` ${sign} ${entry.path} ${entry.file} (${entry.module})`),
187
+ ...result.skipped.map((entry) => ` ! ${entry.path} ${entry.file} (${entry.module}): not ${verb === "add" ? "added" : "removed"}; ${entry.reason}`),
188
+ ];
189
+ }
package/dist/remove.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ApiPathResult } from "./module-api-paths";
1
2
  export interface RemoveOptions {
2
3
  projectRoot: string;
3
4
  module: string;
@@ -15,6 +16,8 @@ export interface RemoveResult {
15
16
  keptShared: string[];
16
17
  /** Injection targets whose wiring was removed. */
17
18
  unwired: string[];
19
+ /** Direct API paths removed from `.podokit/dev.json` and deployment profiles. */
20
+ apiPaths: ApiPathResult;
18
21
  }
19
22
  /**
20
23
  * Remove a previously-added module from a generated project: the inverse of
package/dist/remove.js CHANGED
@@ -6,6 +6,7 @@ const node_path_1 = require("node:path");
6
6
  const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
7
7
  const add_1 = require("./add");
8
8
  const lockfile_1 = require("./lockfile");
9
+ const module_api_paths_1 = require("./module-api-paths");
9
10
  function readModuleManifest(moduleDir, vars) {
10
11
  const source = (0, node_fs_1.readFileSync)((0, node_path_1.join)(moduleDir, "module.manifest.json"), "utf8");
11
12
  return JSON.parse(vars ? (0, podokit_template_engine_1.renderTokens)(source, vars) : source);
@@ -59,7 +60,7 @@ function removeModule(options) {
59
60
  .filter((m) => m.name !== module)
60
61
  .map((m) => {
61
62
  const dir = (0, add_1.resolveModuleDir)(m.packageName ?? m.name, modulesDir, projectRoot);
62
- return dir ? { name: m.name, dir, manifest: readModuleManifest(dir, vars) } : null;
63
+ return dir ? { name: m.name, dir, manifest: readModuleManifest(dir, vars), record: m } : null;
63
64
  })
64
65
  .filter((m) => m !== null);
65
66
  // Guard: refuse if another installed module requires this one.
@@ -111,7 +112,15 @@ function removeModule(options) {
111
112
  pruneAppPackage(projectRoot, targetManifest, others);
112
113
  // 4) prune .env.example lines this module added that no other needs.
113
114
  pruneEnvExample(projectRoot, targetManifest, others);
114
- // 5) drop from the manifest (re-number order) and recompute the lock.
115
+ // 5) remove the direct API paths this module contributed that no other declares.
116
+ const sharedApiPaths = new Set(others.flatMap((other) => [
117
+ ...(other.manifest.apiPaths ?? []),
118
+ ...(other.record.appliedApiPaths ?? []),
119
+ ]));
120
+ const apiPaths = (0, module_api_paths_1.removeApiPaths)(projectRoot, [...new Set([...(installedModule?.appliedApiPaths ?? []), ...(targetManifest.apiPaths ?? [])])]
121
+ .filter((path) => !sharedApiPaths.has(path))
122
+ .map((path) => ({ module, path })));
123
+ // 6) drop from the manifest (re-number order) and recompute the lock.
115
124
  manifest.modules = manifest.modules
116
125
  .filter((m) => m.name !== module)
117
126
  .map((m, i) => ({ ...m, order: i }));
@@ -127,7 +136,7 @@ function removeModule(options) {
127
136
  keptEdited.some((path) => (0, lockfile_1.matchGlob)(path, glob)));
128
137
  (0, lockfile_1.writeManifest)(projectRoot, manifest);
129
138
  (0, lockfile_1.writeFilesLock)(projectRoot, (0, lockfile_1.computeFilesLock)(projectRoot, manifest.ownedGlobs, manifest.managedOverrides));
130
- return { module, removed, keptEdited, keptShared, unwired };
139
+ return { module, removed, keptEdited, keptShared, unwired, apiPaths };
131
140
  }
132
141
  /** Remove now-empty directories left behind by a deleted file, up to the root. */
133
142
  function pruneEmptyDirs(projectRoot, relDir) {
@@ -27,7 +27,7 @@ Provider selection does not migrate or delete existing data.
27
27
  ```bash
28
28
  {{packageManager}} install
29
29
  cp .env.example .env
30
- {{apiRun}} migration:run
30
+ {{apiRun}} migrate:all
31
31
  {{rootRun}} dev # api on http://localhost:5002, web on http://localhost:5001
32
32
  ```
33
33
 
@@ -42,10 +42,25 @@ Merge added module settings from `.env.example` into an existing `.env`.
42
42
 
43
43
  - **Ports: web = 5001, api = 5002.** The web app reaches the API only through its
44
44
  SvelteKit server proxy (`/api/*`), never with a direct browser `fetch`.
45
+ - The proxy (`apps/web/src/lib/server/backend-proxy.ts`) is managed and passes only
46
+ allowlisted headers. List extra request and response header names in the
47
+ app-owned `apps/web/src/lib/server/proxy-headers.ts`
48
+ (`APP_FORWARDED_REQUEST_HEADERS`, `APP_RELAYED_RESPONSE_HEADERS`); never edit the
49
+ managed lists. Cap a route's request body in the app-owned
50
+ `apps/web/src/lib/server/body-limits.ts` (`APP_BODY_LIMITS`, 413
51
+ `PAYLOAD_TOO_LARGE`). `BODY_SIZE_LIMIT` is the ceiling for every web route, so
52
+ uploads larger than it must use a direct API path (`apiPaths`) instead.
53
+ - When the root layout or a server hook fails, such as with 503 while the API is
54
+ down, SvelteKit serves the app-owned `apps/web/src/error.html`. It is plain HTML
55
+ with inline styles: no components, app CSS, scripts, or assets, and only the
56
+ `%sveltekit.status%` and `%sveltekit.error.message%` placeholders.
45
57
  - Build: `{{rootRun}} build` · Type-check/lint: `{{rootRun}} lint` · Unit tests: `{{rootRun}} test`
46
58
  - Per workspace: `{{apiRun}} build` / `{{webRun}} build`.
47
- - Application migrations (PostgreSQL or SQLite): `{{apiRun}} migration:run`.
48
- - When `auth` is installed, run `{{apiRun}} migrate:all` for Better Auth and application tables.
59
+ - Set up the database and apply later migrations, including after adding a module, with
60
+ `{{apiRun}} migrate:all` (PostgreSQL or SQLite). With `auth` installed it applies the
61
+ Better Auth schema before the application migrations. `{{apiRun}} migration:run` runs
62
+ only the application migrations and never creates Better Auth tables;
63
+ `{{apiRun}} migration:revert` reverts the latest application migration.
49
64
  - e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`.
50
65
  - Playwright officially runs on Node, so `bunx playwright` respects its Node
51
66
  shebang. Node LTS is a test-tool dependency only; API, web, worker, migrations,
@@ -81,7 +96,7 @@ changes need an e2e/unit test.
81
96
  This project is managed by the `podo` CLI. Files have an ownership tier recorded
82
97
  in `.podokit/files.lock` — respect it:
83
98
 
84
- - **owned** (your code: `apps/web/src/routes/**`, your components/services, `apps/api/src/app.extensions.ts`, and this `AGENTS.md`) — edit freely. Add application-owned services or module descriptors through `app.extensions.ts` instead of changing toolkit registrations.
99
+ - **owned** (your code: `apps/web/src/routes/**`, your components/services, `apps/api/src/app.extensions.ts`, and this `AGENTS.md`) — edit freely. Add application-owned services or module descriptors through `app.extensions.ts` instead of changing toolkit registrations. Set Bun server options (`maxRequestBodySize`, `idleTimeout`) with its `apiServerOptions` export rather than editing `main.ts`; `API_MAX_REQUEST_BODY_SIZE` sets the body limit when the export leaves it out.
85
100
  - **assembled** (e.g. `apps/api/src/app.ts`, `auth.ts`) — contain `// podokit:begin:… / …:end` fenced regions that the toolkit recomputes. Edit outside the fences only; **never hand-edit inside the fences**.
86
101
  - **managed** (bootstrap/config like `main.ts`) — the toolkit may update these; avoid gratuitous edits.
87
102
 
@@ -90,12 +105,19 @@ Useful commands:
90
105
  - `podo dev watch|up|ps|logs|exec|down` — run container development through the
91
106
  shared loopback gateway. `.podokit/dev.json` owns the local/public hosts and the
92
107
  exact API WebSocket allowlist; never use a prefix or wildcard for that allowlist.
108
+ `podo dev up` rebuilds the images from the current source so a recreated
109
+ container never runs code older than the files Compose Watch synced.
110
+ `podo dev down` also stops this project's running `podo dev watch`; do not
111
+ kill Compose processes by hand.
112
+ The containers read the untracked `.env.docker`; `podo dev` creates it from the
113
+ committed `.env.docker.example` with generated secrets when it is missing. Never
114
+ commit `.env.docker` or put secrets in the example.
93
115
  - `podo add <module>` — add a feature (auth, admin-dashboard, redis, bullmq, …). It wires itself into the app; run `podo add` with no argument to list modules.
94
116
  - `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
95
117
  - `podo provider list` / `podo provider set <capability> <provider> [--apply]` — inspect or switch database, cache, object storage, events, and jobs. Set is dry-run by default and never migrates or deletes provider data. Local providers require one API process.
96
118
  - `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
97
119
  - `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
98
- - `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Production API WebSocket paths belong in the deployment profile's exact `exposure.webSocketPaths` allowlist; Compose trusts forwarding headers only from `exposure.trustedProxyCidrs`. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
120
+ - `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Production API WebSocket paths belong in the deployment profile's exact `exposure.webSocketPaths` allowlist, and API routes that take bodies above the web server's `BODY_SIZE_LIMIT` (file uploads) in `exposure.apiPaths` (and `.podokit/dev.json` `apiPaths` for `podo dev`; `podo add` and `podo deploy init` fill in the paths modules declare), which reach the API without the web proxy, so the API must check their size, session, and `Origin` itself; Compose trusts forwarding headers only from `exposure.trustedProxyCidrs`. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
99
121
  - `podo deploy sync` — copy local build output into a running Compose deployment and restart it, for iterating without a release. Not a release: no image is published and the next `apply` discards it. Use `.agents/skills/podokit-deploy-fast`.
100
122
 
101
123
  ## Commit conventions
@@ -63,8 +63,17 @@ the same topology, with one route and no project-specific host port.
63
63
 
64
64
  When the API exposes WebSocket endpoints, add their exact paths to
65
65
  `.podokit/dev.json` under `webSocketPaths`. The shared gateway sends only those paths
66
- directly to the API and leaves every other path on the web app. Add `publicUrl` to the
67
- same file when an HTTPS tunnel preserves its public `Host` header.
66
+ directly to the API and leaves every other path on the web app. Routes that accept
67
+ bodies larger than the web server's `BODY_SIZE_LIMIT`, such as file uploads, go under
68
+ `apiPaths` as prefixes like `/api/files/uploads/`; the gateway removes `/api` and
69
+ sends them to the API without the web proxy. `podo add` adds the paths a module
70
+ declares, such as `file-upload`'s, to this file and to every deployment profile. Add `publicUrl` to the same file when
71
+ an HTTPS tunnel preserves its public `Host` header.
72
+
73
+ The containers read `.env.docker`, which Git ignores. `dev` commands create it from
74
+ the committed `.env.docker.example` when it is missing, as in a fresh clone, and fill
75
+ each empty `*_SECRET` value with a random one; `dev env` does only that. Keep shared
76
+ defaults in the example and credentials only in `.env.docker`.
68
77
 
69
78
  Run lifecycle and container commands from a second terminal:
70
79
 
@@ -73,14 +82,16 @@ Run lifecycle and container commands from a second terminal:
73
82
  {{packageExecutor}} @podosoft/podokit dev up -d # detached stack without source watching
74
83
  {{packageExecutor}} @podosoft/podokit dev ps
75
84
  {{packageExecutor}} @podosoft/podokit dev logs
76
- {{packageExecutor}} @podosoft/podokit dev exec api {{apiRun}} migration:run
85
+ {{packageExecutor}} @podosoft/podokit dev exec api {{apiRun}} migrate:all
77
86
  {{packageExecutor}} @podosoft/podokit dev down
78
87
  ```
79
88
 
80
89
  `dev watch` reads `.podokit/manifest.json` and automatically activates `cache`,
81
90
  `storage`, and `queue` when installed modules require Redis, Silo, or a worker.
82
91
  `dev up` uses the same shared gateway and module profiles without keeping Compose
83
- Watch attached.
92
+ Watch attached. It rebuilds the images from the current source first, because Compose
93
+ Watch syncs edits into running containers only: a container recreated from an older
94
+ image would lose them. Pass `--no-build` to start from the existing images.
84
95
  You can still activate an additional Compose profile explicitly:
85
96
 
86
97
  ```bash
@@ -88,7 +99,8 @@ You can still activate an additional Compose profile explicitly:
88
99
  ```
89
100
 
90
101
  Explicit profile flags are preserved for other lifecycle commands. `dev down`
91
- activates all profiles while removing this project's stack and route. If it is
102
+ first stops this project's running `dev watch`, so the next `dev watch` starts
103
+ cleanly, then activates all profiles while removing this project's stack and route. If it is
92
104
  the final registered
93
105
  project, it also removes the shared gateway and network. Source changes are synced
94
106
  through Compose Watch, including Vite HMR on the same portless browser origin.
@@ -101,7 +113,7 @@ web and API processes on the host. Prepare the project first:
101
113
  ```bash
102
114
  {{packageManager}} install
103
115
  cp .env.example .env
104
- {{apiRun}} migration:run
116
+ {{apiRun}} migrate:all
105
117
  ```
106
118
 
107
119
  For PostgreSQL, start `docker compose -f infra/docker/docker-compose.yml up -d`
@@ -128,12 +140,15 @@ runs PostgreSQL migrations; SQLite uses the local migration runner. Add a
128
140
  numbered migration under `apps/api/src/migrations`, then run it:
129
141
 
130
142
  ```bash
131
- {{apiRun}} migration:run
143
+ {{apiRun}} migrate:all # set up the database and apply new migrations
144
+ {{apiRun}} migration:revert # roll back the last application migration
132
145
  ```
133
146
 
134
- When `auth` is installed, use `{{apiRun}} migrate:all` to create or update both
135
- Better Auth and application tables. Set the module's required environment
136
- values before migrating.
147
+ `migrate:all` is the one command for setup, after adding a module, and in
148
+ production. When `auth` is installed, it applies the Better Auth schema before
149
+ the application migrations, so set the module's required environment values
150
+ first. `{{apiRun}} migration:run` runs only the application migrations and
151
+ never creates Better Auth tables.
137
152
 
138
153
  Want a worked example? Generate the `todo` template instead:
139
154
  `{{packageExecutor}} @podosoft/podokit create my-app --template todo`.
@@ -183,4 +198,6 @@ If the production API exposes WebSocket endpoints, list their exact paths under
183
198
  `exposure.webSocketPaths` in the deployment profile. The Compose driver then keeps a
184
199
  single published port, sends only those paths to API, and sends all other traffic to
185
200
  web. The external TLS proxy continues to use the same single upstream. Keep
186
- `trustedProxyCidrs` empty unless that upstream is a known reverse proxy.
201
+ `trustedProxyCidrs` empty unless that upstream is a known reverse proxy. Large-upload
202
+ routes listed under `exposure.apiPaths` reach the API the same way, with `/api`
203
+ removed; the API then enforces their body limit and origin checks itself.
@@ -9,7 +9,7 @@
9
9
  "lint": "bunx --bun tsc -p tsconfig.json --noEmit",
10
10
  "test": "bun test src",
11
11
  "contract": "bun scripts/verify-api-contract.ts",
12
- "migrate:all": "bun dist/database/migrate.js",
12
+ "migrate:all": "bun scripts/run-migrations.mjs",
13
13
  "typeorm": "bunx typeorm-ts-node-commonjs -d src/database/data-source.ts",
14
14
  "migration:run": "bun src/database/migrate.ts",
15
15
  "migration:revert": "bun src/database/migrate.ts --revert",
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@podosoft/podokit-contracts": "^0.5.0",
20
- "@podosoft/podokit-runtime": "^0.3.0",
20
+ "@podosoft/podokit-runtime": "^0.4.0",
21
21
  "@elysia/cors": "^1.4.2",
22
22
  "@elysia/openapi": "^1.4.15",
23
23
  "dotenv": "^16.4.5",
@@ -0,0 +1,25 @@
1
+ import { existsSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ // Production images ship only the compiled output.
5
+ function entry(name) {
6
+ const compiled = new URL(`../dist/${name}.js`, import.meta.url);
7
+ if (process.env.NODE_ENV === "production" && existsSync(compiled)) return compiled;
8
+ const source = new URL(`../src/${name}.ts`, import.meta.url);
9
+ return existsSync(source) ? source : null;
10
+ }
11
+
12
+ // The auth module adds src/migrate.ts, which applies the Better Auth schema
13
+ // before it runs the application migrations.
14
+ const target = entry("migrate") ?? entry("database/migrate");
15
+ if (!target) {
16
+ process.stderr.write("Run database migrations failed: no migration entry in src or dist\n");
17
+ process.exit(1);
18
+ }
19
+ const subprocess = Bun.spawn([process.execPath, fileURLToPath(target)], {
20
+ stdin: "inherit",
21
+ stdout: "inherit",
22
+ stderr: "inherit",
23
+ });
24
+
25
+ process.exitCode = await subprocess.exited;
@@ -1,3 +1,4 @@
1
+ import type { ApiServerOptionsSource } from "./config/server-options";
1
2
  import type { PodokitModule, ServiceRegistry } from "./core/services";
2
3
 
3
4
  // This owned extension slot is never overwritten by `podo update`. Register
@@ -8,3 +9,9 @@ import type { PodokitModule, ServiceRegistry } from "./core/services";
8
9
  export function configureServices(_services: ServiceRegistry): void {}
9
10
 
10
11
  export const extensionModules: PodokitModule[] = [];
12
+
13
+ // Bun server options such as a larger request body limit for uploads, for
14
+ // example { maxRequestBodySize: 1024 ** 3, idleTimeout: 120 }. Values set here
15
+ // win over API_MAX_REQUEST_BODY_SIZE; a function receives the validated
16
+ // environment instead: (env) => ({ maxRequestBodySize: ... }).
17
+ export const apiServerOptions: ApiServerOptionsSource = {};
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it, mock, type Mock } from "bun:test";
2
- import { cachedOpenApiDocument, type OpenApiDocumentLoader } from "./app";
2
+ import {
3
+ cachedOpenApiDocument,
4
+ setRetryAfter,
5
+ UNAVAILABLE_RETRY_AFTER_SECONDS,
6
+ type OpenApiDocumentLoader,
7
+ } from "./app";
3
8
  import type { OpenApiContribution } from "./core/services";
4
9
 
5
10
  function baseSpec(): Response {
@@ -79,3 +84,17 @@ describe("cachedOpenApiDocument", () => {
79
84
  expect(base).toHaveBeenCalledTimes(2);
80
85
  });
81
86
  });
87
+
88
+ describe("setRetryAfter", () => {
89
+ it("tells clients when to retry", () => {
90
+ const headers: Record<string, string | number> = {};
91
+ setRetryAfter(headers);
92
+ expect(headers).toEqual({ "retry-after": String(UNAVAILABLE_RETRY_AFTER_SECONDS) });
93
+ });
94
+
95
+ it("keeps a value the failing code already set, in any letter case", () => {
96
+ const headers: Record<string, string | number> = { "Retry-After": "7" };
97
+ setRetryAfter(headers);
98
+ expect(headers).toEqual({ "Retry-After": "7" });
99
+ });
100
+ });
@@ -95,6 +95,15 @@ export function cachedOpenApiDocument(source: OpenApiDocumentSource): OpenApiDoc
95
95
  };
96
96
  }
97
97
 
98
+ /** Seconds a client should wait before retrying a request that failed with 503. */
99
+ export const UNAVAILABLE_RETRY_AFTER_SECONDS = 1;
100
+
101
+ /** Adds `Retry-After` to a 503 unless the code that failed the request already chose one. */
102
+ export function setRetryAfter(headers: Record<string, string | number>): void {
103
+ if (Object.keys(headers).some((name) => name.toLowerCase() === "retry-after")) return;
104
+ headers["retry-after"] = String(UNAVAILABLE_RETRY_AFTER_SECONDS);
105
+ }
106
+
98
107
  function errorEnvelope(
99
108
  code: string,
100
109
  message: string,
@@ -165,10 +174,12 @@ const buildApp = (options: CreateAppOptions = {}) => {
165
174
  .onError(({ code, error, path, set }) => {
166
175
  if (error instanceof AppException) {
167
176
  set.status = error.statusCode;
177
+ if (error.statusCode === 503) setRetryAfter(set.headers);
168
178
  return errorEnvelope(error.code, error.message, error.statusCode, path);
169
179
  }
170
180
  if (isDatabaseUnavailable(error)) {
171
181
  set.status = 503;
182
+ setRetryAfter(set.headers);
172
183
  return errorEnvelope("DATABASE_UNAVAILABLE", "The database is temporarily unavailable", 503, path);
173
184
  }
174
185
  const statusCode = code === "NOT_FOUND" ? 404 : code === "VALIDATION" ? 400 : 500;
@@ -4,6 +4,8 @@ import { z } from "zod";
4
4
  const schema = z.object({
5
5
  NODE_ENV: z.string().default("development"),
6
6
  PORT: z.coerce.number().default(5002),
7
+ // Bytes one request body may hold; 128 MiB is Bun's default.
8
+ API_MAX_REQUEST_BODY_SIZE: z.coerce.number().int().positive().default(128 * 1024 * 1024),
7
9
  CORS_ORIGIN: z.string().optional(),
8
10
  DATABASE_URL: z.string().optional(),
9
11
  POSTGRES_HOST: z.string().default("localhost"),
@@ -14,6 +16,7 @@ const schema = z.object({
14
16
  DB_POOL_MAX: z.coerce.number().int().positive().default(20),
15
17
  DB_CONNECT_TIMEOUT_MS: z.coerce.number().int().positive().default(10000),
16
18
  DB_IDLE_TIMEOUT_MS: z.coerce.number().int().nonnegative().default(30000),
19
+ DB_QUERY_TIMEOUT_MS: z.coerce.number().int().positive().default(10000),
17
20
  DB_AUTH_POOL_MAX: z.coerce.number().int().positive().default(10),
18
21
  READINESS_CHECK_TIMEOUT_MS: z.coerce.number().int().positive().default(800),
19
22
  READINESS_CACHE_MS: z.coerce.number().int().nonnegative().default(2000),
@@ -1,5 +1,12 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { redisConnectionOptions, redisConnectionUrl } from "./redis.connection";
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import {
3
+ DEFAULT_REDIS_COMMAND_TIMEOUT_MS,
4
+ RedisCommandTimeoutError,
5
+ redisCommandTimeoutMs,
6
+ redisConnectionOptions,
7
+ redisConnectionUrl,
8
+ withReplyTimeout,
9
+ } from "./redis.connection";
3
10
 
4
11
  describe("redisConnectionOptions", () => {
5
12
  it("prefers an authenticated URL over individual settings", () => {
@@ -69,3 +76,42 @@ describe("redisConnectionUrl", () => {
69
76
  })).toBe("rediss://podokit:p%40ss@cache.example.com:6381/4");
70
77
  });
71
78
  });
79
+
80
+ describe("redisCommandTimeoutMs", () => {
81
+ it("defaults to one second and reads REDIS_COMMAND_TIMEOUT_MS", () => {
82
+ expect(redisCommandTimeoutMs({})).toBe(DEFAULT_REDIS_COMMAND_TIMEOUT_MS);
83
+ expect(redisCommandTimeoutMs({ REDIS_COMMAND_TIMEOUT_MS: "" })).toBe(1_000);
84
+ expect(redisCommandTimeoutMs({ REDIS_COMMAND_TIMEOUT_MS: "250" })).toBe(250);
85
+ });
86
+
87
+ it("rejects a value that is not a positive integer", () => {
88
+ for (const value of ["0", "-1", "1.5", "soon"]) {
89
+ expect(() => redisCommandTimeoutMs({ REDIS_COMMAND_TIMEOUT_MS: value }))
90
+ .toThrow("REDIS_COMMAND_TIMEOUT_MS must be a positive integer");
91
+ }
92
+ });
93
+ });
94
+
95
+ describe("withReplyTimeout", () => {
96
+ it("passes a reply or its error through and leaves the connection alone", async () => {
97
+ const onTimeout = mock(() => undefined);
98
+ const failure = new Error("WRONGTYPE");
99
+
100
+ await expect(withReplyTimeout(Promise.resolve("value"), 50, onTimeout)).resolves.toBe("value");
101
+ await expect(withReplyTimeout(Promise.reject(failure), 50, onTimeout)).rejects.toBe(failure);
102
+ await Bun.sleep(80);
103
+ expect(onTimeout).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it("fails a missing reply after the timeout and closes the connection once", async () => {
107
+ const onTimeout = mock(() => undefined);
108
+ const started = Date.now();
109
+
110
+ const error = await withReplyTimeout(new Promise(() => undefined), 50, onTimeout)
111
+ .then(() => undefined, (reason: unknown) => reason);
112
+
113
+ expect(error).toBeInstanceOf(RedisCommandTimeoutError);
114
+ expect(Date.now() - started).toBeLessThan(1_000);
115
+ expect(onTimeout).toHaveBeenCalledTimes(1);
116
+ });
117
+ });
@@ -68,6 +68,51 @@ export function redisConnectionOptions(
68
68
  return { ...base, ...overrides };
69
69
  }
70
70
 
71
+ export const DEFAULT_REDIS_COMMAND_TIMEOUT_MS = 1_000;
72
+
73
+ /** How long the API waits for Redis to answer one command (`REDIS_COMMAND_TIMEOUT_MS`). */
74
+ export function redisCommandTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
75
+ const value = env.REDIS_COMMAND_TIMEOUT_MS;
76
+ if (value === undefined || value === "") return DEFAULT_REDIS_COMMAND_TIMEOUT_MS;
77
+ const parsed = Number(value);
78
+ if (!Number.isInteger(parsed) || parsed < 1) {
79
+ throw new Error("REDIS_COMMAND_TIMEOUT_MS must be a positive integer");
80
+ }
81
+ return parsed;
82
+ }
83
+
84
+ export class RedisCommandTimeoutError extends Error {
85
+ constructor(timeoutMs: number) {
86
+ super(`Redis did not answer within ${timeoutMs} ms`);
87
+ this.name = "RedisCommandTimeoutError";
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Rejects with `RedisCommandTimeoutError` when `reply` has not settled within
93
+ * `timeoutMs`, after calling `onTimeout`. Redis answers a connection's commands
94
+ * in order, so a missing reply holds back every later one: `onTimeout` closes
95
+ * the connection, which fails those too and lets its supervisor reconnect.
96
+ */
97
+ export async function withReplyTimeout<T>(
98
+ reply: Promise<T>,
99
+ timeoutMs: number,
100
+ onTimeout: () => void,
101
+ ): Promise<T> {
102
+ let timer: ReturnType<typeof setTimeout> | undefined;
103
+ const deadline = new Promise<never>((_resolve, reject) => {
104
+ timer = setTimeout(() => {
105
+ reject(new RedisCommandTimeoutError(timeoutMs));
106
+ onTimeout();
107
+ }, timeoutMs);
108
+ });
109
+ try {
110
+ return await Promise.race([reply, deadline]);
111
+ } finally {
112
+ clearTimeout(timer);
113
+ }
114
+ }
115
+
71
116
  /** Resolve the same Redis configuration as BullMQ into a URL for Bun.RedisClient. */
72
117
  export function redisConnectionUrl(env: NodeJS.ProcessEnv = process.env): string {
73
118
  if (env.REDIS_URL) {