@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.
- package/dist/add.d.ts +13 -2
- package/dist/add.js +135 -20
- package/dist/api-paths.d.ts +34 -0
- package/dist/api-paths.js +103 -0
- package/dist/deploy-compose-profile.d.ts +4 -0
- package/dist/deploy-compose-profile.js +13 -2
- package/dist/deploy-compose-render.d.ts +1 -1
- package/dist/deploy-compose-render.js +23 -7
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.d.ts +3 -0
- package/dist/deploy-profile.js +12 -4
- package/dist/deploy-render.js +54 -8
- package/dist/deploy.js +21 -1
- package/dist/dev.d.ts +14 -0
- package/dist/dev.js +152 -10
- package/dist/index.js +23 -2
- package/dist/lib.d.ts +4 -2
- package/dist/lib.js +5 -2
- package/dist/lockfile.d.ts +6 -1
- package/dist/lockfile.js +26 -6
- package/dist/module-api-paths.d.ts +52 -0
- package/dist/module-api-paths.js +189 -0
- package/dist/remove.d.ts +3 -0
- package/dist/remove.js +12 -3
- package/dist/templates/fullstack/AGENTS.md +17 -2
- package/dist/templates/fullstack/README.md +19 -5
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
- package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
- package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +8 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +29 -10
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
- package/dist/templates/fullstack/apps/web/components.json +3 -1
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +14 -1
- package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
- package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
- package/dist/templates/fullstack/dot-env.example +10 -0
- package/dist/templates/fullstack/dot-gitignore +1 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
- package/dist/templates/fullstack/infra/k3s/README.md +3 -1
- package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +59 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +81 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +13 -19
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +15 -12
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +3 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +14 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +13 -8
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +14 -3
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +53 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +16 -37
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
- package/dist/templates/modules/events-redis/module.manifest.json +4 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +147 -0
- package/dist/templates/modules/file-upload/module.manifest.json +34 -6
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
- package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
- package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
- package/dist/templates/modules/redis/module.manifest.json +4 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +48 -1
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +15 -2
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +68 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +25 -1
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
- package/dist/templates/modules/sse/module.manifest.json +3 -1
- package/dist/templates/todo/AGENTS.md +9 -1
- package/dist/templates/todo/README.md +18 -5
- package/dist/update.d.ts +22 -0
- package/dist/update.js +68 -8
- package/package.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
package/dist/lockfile.js
CHANGED
|
@@ -54,6 +54,9 @@ exports.DEFAULT_OWNED_GLOBS = [
|
|
|
54
54
|
"apps/web/src/lib/i18n/catalogs/app/**",
|
|
55
55
|
// Owned Elysia extension slot — register application services and plugins here.
|
|
56
56
|
"apps/api/src/app.extensions.ts",
|
|
57
|
+
// Owned additions to the managed /api proxy's header lists and body limits.
|
|
58
|
+
"apps/web/src/lib/server/proxy-headers.ts",
|
|
59
|
+
"apps/web/src/lib/server/body-limits.ts",
|
|
57
60
|
// AI agent guidance — yours to customize; update never touches it.
|
|
58
61
|
"AGENTS.md",
|
|
59
62
|
"CLAUDE.md",
|
|
@@ -257,29 +260,46 @@ function mergeOwnedGlobs(existing, add) {
|
|
|
257
260
|
* `ownedGlobs` a module declares are merged into the manifest so its owned paths
|
|
258
261
|
* survive every future recompute. No-op on projects without a manifest.
|
|
259
262
|
*/
|
|
260
|
-
function recordModules(projectRoot, moduleInputs, version, ownedGlobs, lockSnapshot, managedOverrides
|
|
263
|
+
function recordModules(projectRoot, moduleInputs, version, ownedGlobs, lockSnapshot, managedOverrides,
|
|
264
|
+
/** Installed module that newly recorded modules must configure before. */
|
|
265
|
+
insertBefore) {
|
|
261
266
|
const manifest = readManifest(projectRoot);
|
|
262
267
|
if (!manifest)
|
|
263
268
|
return;
|
|
264
269
|
const stampedWith = version ?? podokitVersion();
|
|
265
|
-
const
|
|
270
|
+
const fresh = [];
|
|
266
271
|
for (const input of moduleInputs) {
|
|
267
272
|
const module = typeof input === "string" ? { name: input } : input;
|
|
268
|
-
const existing = manifest.modules.find((entry) => entry.name === module.name)
|
|
273
|
+
const existing = manifest.modules.find((entry) => entry.name === module.name)
|
|
274
|
+
?? fresh.find((entry) => entry.name === module.name);
|
|
269
275
|
if (existing) {
|
|
270
276
|
existing.packageName = module.packageName ?? existing.packageName;
|
|
271
277
|
existing.moduleVersion = module.moduleVersion ?? existing.moduleVersion;
|
|
278
|
+
const apiPaths = [
|
|
279
|
+
...new Set([...(existing.appliedApiPaths ?? []), ...(module.appliedApiPaths ?? [])]),
|
|
280
|
+
];
|
|
281
|
+
if (apiPaths.length)
|
|
282
|
+
existing.appliedApiPaths = apiPaths;
|
|
272
283
|
continue;
|
|
273
284
|
}
|
|
274
|
-
|
|
285
|
+
fresh.push({
|
|
275
286
|
name: module.name,
|
|
276
|
-
order: manifest.modules.length,
|
|
287
|
+
order: manifest.modules.length + fresh.length,
|
|
277
288
|
addedWith: stampedWith,
|
|
278
289
|
packageName: module.packageName,
|
|
279
290
|
moduleVersion: module.moduleVersion,
|
|
280
291
|
appliedMigrations: module.appliedMigrations,
|
|
292
|
+
...(module.appliedApiPaths?.length ? { appliedApiPaths: module.appliedApiPaths } : {}),
|
|
281
293
|
});
|
|
282
|
-
|
|
294
|
+
}
|
|
295
|
+
const ordered = [...manifest.modules].sort((a, b) => a.order - b.order);
|
|
296
|
+
const position = insertBefore ? ordered.findIndex((entry) => entry.name === insertBefore) : -1;
|
|
297
|
+
if (position === -1) {
|
|
298
|
+
manifest.modules.push(...fresh);
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
ordered.splice(position, 0, ...fresh);
|
|
302
|
+
manifest.modules = ordered.map((entry, order) => ({ ...entry, order }));
|
|
283
303
|
}
|
|
284
304
|
if (ownedGlobs?.length)
|
|
285
305
|
manifest.ownedGlobs = mergeOwnedGlobs(manifest.ownedGlobs, ownedGlobs);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ManifestModule } from "./lockfile";
|
|
2
|
+
/**
|
|
3
|
+
* Modules declare the direct API paths their routes need (`apiPaths` in
|
|
4
|
+
* `module.manifest.json`). The development gateway configuration and the
|
|
5
|
+
* deployment profiles belong to the application, so PodoKit edits only their
|
|
6
|
+
* `apiPaths` lists, keeps every other key in place, and never writes a file
|
|
7
|
+
* that would stop validating.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEV_CONFIG_FILE = ".podokit/dev.json";
|
|
10
|
+
/** A path that a module declares. */
|
|
11
|
+
export interface ModuleApiPath {
|
|
12
|
+
module: string;
|
|
13
|
+
path: string;
|
|
14
|
+
}
|
|
15
|
+
/** A path written to, or removed from, one configuration file. */
|
|
16
|
+
export interface ApiPathChange extends ModuleApiPath {
|
|
17
|
+
file: string;
|
|
18
|
+
}
|
|
19
|
+
/** A path left as it was in one configuration file, and why. */
|
|
20
|
+
export interface ApiPathSkip extends ApiPathChange {
|
|
21
|
+
reason: string;
|
|
22
|
+
}
|
|
23
|
+
export interface ApiPathResult {
|
|
24
|
+
changed: ApiPathChange[];
|
|
25
|
+
skipped: ApiPathSkip[];
|
|
26
|
+
}
|
|
27
|
+
export interface ApiPathOptions {
|
|
28
|
+
/** Report the changes without writing them. */
|
|
29
|
+
dryRun?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Text of a configuration file, or null when it does not exist. Defaults to
|
|
32
|
+
* the file on disk; an update plan passes the file it would create.
|
|
33
|
+
*/
|
|
34
|
+
read?: (file: string) => string | null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Add module paths to `.podokit/dev.json` and every deployment profile. A path
|
|
38
|
+
* that an entry already covers is left alone; a path that would make the file
|
|
39
|
+
* invalid, for example by covering a WebSocket path or another entry, is
|
|
40
|
+
* reported instead of written.
|
|
41
|
+
*/
|
|
42
|
+
export declare function addApiPaths(projectRoot: string, paths: readonly ModuleApiPath[], options?: ApiPathOptions): ApiPathResult;
|
|
43
|
+
/**
|
|
44
|
+
* Remove exactly these paths from `.podokit/dev.json` and every deployment
|
|
45
|
+
* profile. A file that would stop validating without them, such as a Compose
|
|
46
|
+
* profile whose `trustedProxyCidrs` needs a direct path, is left unchanged.
|
|
47
|
+
*/
|
|
48
|
+
export declare function removeApiPaths(projectRoot: string, paths: readonly ModuleApiPath[]): ApiPathResult;
|
|
49
|
+
/** Paths a module declares that the project has not merged yet. */
|
|
50
|
+
export declare function pendingApiPaths(module: Pick<ManifestModule, "name" | "appliedApiPaths">, declared: readonly string[]): ModuleApiPath[];
|
|
51
|
+
/** Indented report lines, one per changed or skipped path. */
|
|
52
|
+
export declare function describeApiPaths(result: ApiPathResult, verb: "add" | "remove"): string[];
|
|
@@ -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)
|
|
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) {
|
|
@@ -42,6 +42,14 @@ 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.
|
|
45
53
|
- Build: `{{rootRun}} build` · Type-check/lint: `{{rootRun}} lint` · Unit tests: `{{rootRun}} test`
|
|
46
54
|
- Per workspace: `{{apiRun}} build` / `{{webRun}} build`.
|
|
47
55
|
- Application migrations (PostgreSQL or SQLite): `{{apiRun}} migration:run`.
|
|
@@ -81,7 +89,7 @@ changes need an e2e/unit test.
|
|
|
81
89
|
This project is managed by the `podo` CLI. Files have an ownership tier recorded
|
|
82
90
|
in `.podokit/files.lock` — respect it:
|
|
83
91
|
|
|
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.
|
|
92
|
+
- **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
93
|
- **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
94
|
- **managed** (bootstrap/config like `main.ts`) — the toolkit may update these; avoid gratuitous edits.
|
|
87
95
|
|
|
@@ -90,12 +98,19 @@ Useful commands:
|
|
|
90
98
|
- `podo dev watch|up|ps|logs|exec|down` — run container development through the
|
|
91
99
|
shared loopback gateway. `.podokit/dev.json` owns the local/public hosts and the
|
|
92
100
|
exact API WebSocket allowlist; never use a prefix or wildcard for that allowlist.
|
|
101
|
+
`podo dev up` rebuilds the images from the current source so a recreated
|
|
102
|
+
container never runs code older than the files Compose Watch synced.
|
|
103
|
+
`podo dev down` also stops this project's running `podo dev watch`; do not
|
|
104
|
+
kill Compose processes by hand.
|
|
105
|
+
The containers read the untracked `.env.docker`; `podo dev` creates it from the
|
|
106
|
+
committed `.env.docker.example` with generated secrets when it is missing. Never
|
|
107
|
+
commit `.env.docker` or put secrets in the example.
|
|
93
108
|
- `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
109
|
- `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
|
|
95
110
|
- `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
111
|
- `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
|
|
97
112
|
- `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`.
|
|
113
|
+
- `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
114
|
- `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
115
|
|
|
101
116
|
## 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.
|
|
67
|
-
|
|
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
|
|
|
@@ -80,7 +89,9 @@ Run lifecycle and container commands from a second terminal:
|
|
|
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
|
-
|
|
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.
|
|
@@ -183,4 +195,6 @@ If the production API exposes WebSocket endpoints, list their exact paths under
|
|
|
183
195
|
`exposure.webSocketPaths` in the deployment profile. The Compose driver then keeps a
|
|
184
196
|
single published port, sends only those paths to API, and sends all other traffic to
|
|
185
197
|
web. The external TLS proxy continues to use the same single upstream. Keep
|
|
186
|
-
`trustedProxyCidrs` empty unless that upstream is a known reverse proxy.
|
|
198
|
+
`trustedProxyCidrs` empty unless that upstream is a known reverse proxy. Large-upload
|
|
199
|
+
routes listed under `exposure.apiPaths` reach the API the same way, with `/api`
|
|
200
|
+
removed; the API then enforces their body limit and origin checks itself.
|
|
@@ -5,6 +5,7 @@ const targets = [
|
|
|
5
5
|
{ entrypoint: "src/database/migrate.ts", outdir: "dist/database" },
|
|
6
6
|
{ entrypoint: "src/migrate.ts", outdir: "dist" },
|
|
7
7
|
{ entrypoint: "src/main-worker.ts", outdir: "dist" },
|
|
8
|
+
{ entrypoint: "src/bootstrap-admin-runtime.ts", outdir: "dist" },
|
|
8
9
|
].filter((target) => existsSync(target.entrypoint));
|
|
9
10
|
|
|
10
11
|
for (const target of targets) {
|
|
@@ -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 {
|
|
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 {
|
|
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
|
+
});
|