@podosoft/podokit 1.4.1 → 1.5.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/deploy-compose-profile.js +2 -2
- package/dist/deploy-compose-render.js +3 -1
- package/dist/deploy-profile.js +2 -2
- package/dist/deploy-render.js +16 -8
- package/dist/deploy-schema.d.ts +7 -0
- package/dist/deploy-schema.js +40 -0
- package/dist/lockfile.d.ts +8 -1
- package/dist/lockfile.js +23 -4
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +5 -2
- package/dist/templates/fullstack/AGENTS.md +27 -2
- package/dist/templates/fullstack/README.md +1 -1
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -2
- package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
- package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
- package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +66 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +34 -11
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +54 -6
- package/dist/templates/fullstack/apps/web/Dockerfile +5 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +436 -6
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +352 -38
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
- package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
- package/dist/templates/fullstack/dot-env.docker.example +6 -0
- package/dist/templates/fullstack/dot-env.example +23 -0
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
- package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
- package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +54 -47
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +19 -8
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
- package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
- package/dist/templates/todo/AGENTS.md +41 -5
- package/dist/templates/todo/README.md +1 -1
- package/package.json +2 -2
|
@@ -357,6 +357,8 @@ function buildDefaultComposeProfile(projectName, options) {
|
|
|
357
357
|
CORS_ORIGIN: `https://${host}`,
|
|
358
358
|
ADDRESS_HEADER: "x-forwarded-for",
|
|
359
359
|
XFF_DEPTH: "1",
|
|
360
|
+
TRUSTED_PROXY_HOPS: "1",
|
|
361
|
+
TRUSTED_PROXY_HEADER: "x-forwarded-for",
|
|
360
362
|
},
|
|
361
363
|
verification: {
|
|
362
364
|
baseUrl: `https://${host}`,
|
|
@@ -418,8 +420,6 @@ function initializeComposeProfile(projectRoot, name, options) {
|
|
|
418
420
|
RATE_LIMIT_AUTH_TTL: "60",
|
|
419
421
|
RATE_LIMIT_AUTH_MAX: "20",
|
|
420
422
|
RATE_LIMIT_RUNTIME_MAX: "1000",
|
|
421
|
-
RATE_LIMIT_TRUSTED_PROXY_HOPS: "1",
|
|
422
|
-
RATE_LIMIT_PROXY_HEADER: "x-forwarded-for",
|
|
423
423
|
RATE_LIMIT_STORAGE_TIMEOUT_MS: "1000",
|
|
424
424
|
RATE_LIMIT_UNAVAILABLE_RETRY_AFTER: "1",
|
|
425
425
|
}
|
|
@@ -37,6 +37,7 @@ function derivedRuntimeConfig(profile) {
|
|
|
37
37
|
const result = {
|
|
38
38
|
PORT: String(CONTAINER_PORT),
|
|
39
39
|
BACKEND_INTERNAL_URL: `http://${serviceName(profile, "api")}:${CONTAINER_PORT}`,
|
|
40
|
+
...(0, deploy_schema_1.idleTimeoutConfig)(profile.runtimeConfig),
|
|
40
41
|
...profile.runtimeConfig,
|
|
41
42
|
};
|
|
42
43
|
if (profile.dependencies.postgres.mode === "managed") {
|
|
@@ -311,7 +312,8 @@ function renderComposeDocument(profile, release, images, stateDigest, workerComm
|
|
|
311
312
|
healthcheckPath: "/health/ready",
|
|
312
313
|
}));
|
|
313
314
|
services.push(applicationService(profile, "web", images.web, release, stateDigest, {
|
|
314
|
-
|
|
315
|
+
// The web server's own route: an API outage must not mark it unhealthy.
|
|
316
|
+
healthcheckPath: "/healthz",
|
|
315
317
|
publish: !hasGateway,
|
|
316
318
|
}));
|
|
317
319
|
if (hasGateway) {
|
package/dist/deploy-profile.js
CHANGED
|
@@ -397,6 +397,8 @@ function initializeDeploymentProfile(projectRoot, name, options) {
|
|
|
397
397
|
CORS_ORIGIN: `https://${host}`,
|
|
398
398
|
ADDRESS_HEADER: "x-forwarded-for",
|
|
399
399
|
XFF_DEPTH: "1",
|
|
400
|
+
TRUSTED_PROXY_HOPS: "1",
|
|
401
|
+
TRUSTED_PROXY_HEADER: "x-forwarded-for",
|
|
400
402
|
...(usesAuth ? { BETTER_AUTH_URL: `https://${host}` } : {}),
|
|
401
403
|
...(modules.has("rate-limit")
|
|
402
404
|
? {
|
|
@@ -406,8 +408,6 @@ function initializeDeploymentProfile(projectRoot, name, options) {
|
|
|
406
408
|
RATE_LIMIT_AUTH_TTL: "60",
|
|
407
409
|
RATE_LIMIT_AUTH_MAX: "20",
|
|
408
410
|
RATE_LIMIT_RUNTIME_MAX: "1000",
|
|
409
|
-
RATE_LIMIT_TRUSTED_PROXY_HOPS: "1",
|
|
410
|
-
RATE_LIMIT_PROXY_HEADER: "x-forwarded-for",
|
|
411
411
|
RATE_LIMIT_STORAGE_TIMEOUT_MS: "1000",
|
|
412
412
|
RATE_LIMIT_UNAVAILABLE_RETRY_AFTER: "1",
|
|
413
413
|
}
|
package/dist/deploy-render.js
CHANGED
|
@@ -6,6 +6,7 @@ const node_crypto_1 = require("node:crypto");
|
|
|
6
6
|
const node_path_1 = require("node:path");
|
|
7
7
|
const deploy_profile_1 = require("./deploy-profile");
|
|
8
8
|
const api_paths_1 = require("./api-paths");
|
|
9
|
+
const deploy_schema_1 = require("./deploy-schema");
|
|
9
10
|
const lockfile_1 = require("./lockfile");
|
|
10
11
|
const toolchain_1 = require("./toolchain");
|
|
11
12
|
function quote(value) {
|
|
@@ -356,6 +357,7 @@ function derivedRuntimeConfig(profile) {
|
|
|
356
357
|
BACKEND_INTERNAL_URL: `http://${profile.target.release}-api:3000`,
|
|
357
358
|
PROTOCOL_HEADER: "x-forwarded-proto",
|
|
358
359
|
HOST_HEADER: "x-forwarded-host",
|
|
360
|
+
...(0, deploy_schema_1.idleTimeoutConfig)(profile.runtimeConfig),
|
|
359
361
|
...profile.runtimeConfig,
|
|
360
362
|
};
|
|
361
363
|
if (profile.dependencies.postgres.mode === "inCluster") {
|
|
@@ -386,8 +388,10 @@ metadata:
|
|
|
386
388
|
${entries.length ? `data:\n${entries.map(([key, value]) => ` ${key}: ${quote(value)}`).join("\n")}` : "data: {}"}
|
|
387
389
|
`;
|
|
388
390
|
}
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
+
// Every rendered API and web probe allows 2 s instead of the Kubernetes default
|
|
392
|
+
// of 1 s: above the API's 800 ms per-check readiness bound, and enough for a busy
|
|
393
|
+
// web server.
|
|
394
|
+
const PROBE_TIMEOUT_SECONDS = 2;
|
|
391
395
|
function deployment(profile, kind, workload, image, rolloutStateDigest, runtimeConfigDigest) {
|
|
392
396
|
const name = `${profile.target.release}-${kind}`;
|
|
393
397
|
const workloadSecret = kind === "api" ? profile.secrets.api : profile.secrets.web;
|
|
@@ -399,25 +403,29 @@ function deployment(profile, kind, workload, image, rolloutStateDigest, runtimeC
|
|
|
399
403
|
? ` readinessProbe:
|
|
400
404
|
httpGet: { path: /health/ready, port: 3000 }
|
|
401
405
|
periodSeconds: 5
|
|
402
|
-
timeoutSeconds: ${
|
|
406
|
+
timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
|
|
403
407
|
livenessProbe:
|
|
404
408
|
httpGet: { path: /health, port: 3000 }
|
|
405
409
|
initialDelaySeconds: 20
|
|
406
410
|
periodSeconds: 10
|
|
407
|
-
timeoutSeconds: ${
|
|
411
|
+
timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
|
|
408
412
|
startupProbe:
|
|
409
413
|
httpGet: { path: /health, port: 3000 }
|
|
410
414
|
failureThreshold: 30
|
|
411
415
|
periodSeconds: 2
|
|
412
|
-
timeoutSeconds: ${
|
|
416
|
+
timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
|
|
413
417
|
`
|
|
414
|
-
:
|
|
415
|
-
|
|
418
|
+
: // The web server's own route: an API outage must not take the web pods
|
|
419
|
+
// out of service or restart them.
|
|
420
|
+
` readinessProbe:
|
|
421
|
+
httpGet: { path: /healthz, port: 3000 }
|
|
416
422
|
periodSeconds: 5
|
|
423
|
+
timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
|
|
417
424
|
livenessProbe:
|
|
418
|
-
httpGet: { path:
|
|
425
|
+
httpGet: { path: /healthz, port: 3000 }
|
|
419
426
|
initialDelaySeconds: 20
|
|
420
427
|
periodSeconds: 10
|
|
428
|
+
timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
|
|
421
429
|
`;
|
|
422
430
|
return `apiVersion: apps/v1
|
|
423
431
|
kind: Deployment
|
package/dist/deploy-schema.d.ts
CHANGED
|
@@ -50,6 +50,13 @@ export declare function validateImage(value: string, field: string): void;
|
|
|
50
50
|
export declare function validateImageRepository(value: string, field: string): void;
|
|
51
51
|
export declare function parseReleaseProfile(release: Record<string, unknown>): ReleaseProfile;
|
|
52
52
|
export declare function parseRuntimeConfig(runtimeConfig: Record<string, unknown>): Record<string, string>;
|
|
53
|
+
/**
|
|
54
|
+
* The idle timeouts of the web server (CONNECTION_IDLE_TIMEOUT) and the API
|
|
55
|
+
* (API_IDLE_TIMEOUT), both run by Bun. Each is the profile's own value, or else
|
|
56
|
+
* the fewest seconds that keep a silent connection open past the /api proxy's
|
|
57
|
+
* BACKEND_PROXY_TIMEOUT_MS deadline: 35 for the default 30000 ms.
|
|
58
|
+
*/
|
|
59
|
+
export declare function idleTimeoutConfig(runtimeConfig: Record<string, string>): Record<string, string>;
|
|
53
60
|
export declare function validateHttpsOrigin(value: string): void;
|
|
54
61
|
export declare function parseVerification(verification: Record<string, unknown>): VerificationProfile;
|
|
55
62
|
export declare function profileDirectory(projectRoot: string): string;
|
package/dist/deploy-schema.js
CHANGED
|
@@ -17,6 +17,7 @@ exports.validateImage = validateImage;
|
|
|
17
17
|
exports.validateImageRepository = validateImageRepository;
|
|
18
18
|
exports.parseReleaseProfile = parseReleaseProfile;
|
|
19
19
|
exports.parseRuntimeConfig = parseRuntimeConfig;
|
|
20
|
+
exports.idleTimeoutConfig = idleTimeoutConfig;
|
|
20
21
|
exports.validateHttpsOrigin = validateHttpsOrigin;
|
|
21
22
|
exports.parseVerification = parseVerification;
|
|
22
23
|
exports.profileDirectory = profileDirectory;
|
|
@@ -35,6 +36,11 @@ exports.MANAGED_RUNTIME_CONFIG_KEYS = [
|
|
|
35
36
|
"PROTOCOL_HEADER",
|
|
36
37
|
"HOST_HEADER",
|
|
37
38
|
];
|
|
39
|
+
/** Keys the generated API no longer reads; it refuses to start while they are set. */
|
|
40
|
+
const RENAMED_RUNTIME_CONFIG_KEYS = {
|
|
41
|
+
RATE_LIMIT_TRUSTED_PROXY_HOPS: "TRUSTED_PROXY_HOPS",
|
|
42
|
+
RATE_LIMIT_PROXY_HEADER: "TRUSTED_PROXY_HEADER",
|
|
43
|
+
};
|
|
38
44
|
function containsUrlCredentials(value) {
|
|
39
45
|
if (!value.includes("://"))
|
|
40
46
|
return false;
|
|
@@ -183,6 +189,10 @@ function parseRuntimeConfig(runtimeConfig) {
|
|
|
183
189
|
if (exports.MANAGED_RUNTIME_CONFIG_KEYS.includes(key)) {
|
|
184
190
|
throw new Error(`Deployment runtimeConfig key "${key}" is managed by PodoKit.`);
|
|
185
191
|
}
|
|
192
|
+
const renamed = RENAMED_RUNTIME_CONFIG_KEYS[key];
|
|
193
|
+
if (renamed) {
|
|
194
|
+
throw new Error(`Deployment runtimeConfig key "${key}" was renamed to "${renamed}".`);
|
|
195
|
+
}
|
|
186
196
|
if (typeof entry !== "string") {
|
|
187
197
|
throw new Error(`Deployment runtimeConfig value "${key}" must be a string.`);
|
|
188
198
|
}
|
|
@@ -193,6 +203,36 @@ function parseRuntimeConfig(runtimeConfig) {
|
|
|
193
203
|
}
|
|
194
204
|
return parsed;
|
|
195
205
|
}
|
|
206
|
+
/** Bun closes silent connections on a 4-second tick, up to 4 seconds before their idle timeout. */
|
|
207
|
+
const IDLE_TICK_SECONDS = 4;
|
|
208
|
+
const MAX_IDLE_TIMEOUT_SECONDS = 255;
|
|
209
|
+
const DEFAULT_BACKEND_PROXY_TIMEOUT_MS = 30_000;
|
|
210
|
+
/**
|
|
211
|
+
* The idle timeouts of the web server (CONNECTION_IDLE_TIMEOUT) and the API
|
|
212
|
+
* (API_IDLE_TIMEOUT), both run by Bun. Each is the profile's own value, or else
|
|
213
|
+
* the fewest seconds that keep a silent connection open past the /api proxy's
|
|
214
|
+
* BACKEND_PROXY_TIMEOUT_MS deadline: 35 for the default 30000 ms.
|
|
215
|
+
*/
|
|
216
|
+
function idleTimeoutConfig(runtimeConfig) {
|
|
217
|
+
const proxyTimeout = runtimeConfig.BACKEND_PROXY_TIMEOUT_MS?.trim();
|
|
218
|
+
const proxyMs = proxyTimeout && /^\d+$/.test(proxyTimeout) ? Number(proxyTimeout) : DEFAULT_BACKEND_PROXY_TIMEOUT_MS;
|
|
219
|
+
const derived = Math.floor(proxyMs / 1000) + IDLE_TICK_SECONDS + 1;
|
|
220
|
+
const config = {};
|
|
221
|
+
for (const name of ["CONNECTION_IDLE_TIMEOUT", "API_IDLE_TIMEOUT"]) {
|
|
222
|
+
const configured = runtimeConfig[name];
|
|
223
|
+
if (configured !== undefined) {
|
|
224
|
+
config[name] = configured;
|
|
225
|
+
}
|
|
226
|
+
else if (derived > MAX_IDLE_TIMEOUT_SECONDS) {
|
|
227
|
+
throw new Error(`Deployment runtimeConfig BACKEND_PROXY_TIMEOUT_MS ${proxyMs} outlasts the longest idle timeout ` +
|
|
228
|
+
`(${MAX_IDLE_TIMEOUT_SECONDS} seconds); set ${name} to "0" to turn it off.`);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
config[name] = String(derived);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return config;
|
|
235
|
+
}
|
|
196
236
|
function validateHttpsOrigin(value) {
|
|
197
237
|
let parsed;
|
|
198
238
|
try {
|
package/dist/lockfile.d.ts
CHANGED
|
@@ -82,7 +82,14 @@ export declare function podokitVersion(): string;
|
|
|
82
82
|
export declare function matchGlob(path: string, glob: string): boolean;
|
|
83
83
|
/** Classify a file's ownership tier. See ADR-0010 priority order. */
|
|
84
84
|
export declare function classifyTier(relPath: string, content: string | Buffer, ownedGlobs: string[], managedOverrides?: string[]): Tier;
|
|
85
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* Whether a path is a local environment file such as `.env`, `.env.docker` or
|
|
87
|
+
* `apps/api/.env.local`. Git ignores these and they hold secrets, so the lock
|
|
88
|
+
* never records them; the committed `*.example` files are project files.
|
|
89
|
+
*/
|
|
90
|
+
export declare function isLocalEnvFile(relPath: string): boolean;
|
|
91
|
+
/** List project files as POSIX-relative paths, skipping generated/vendored dirs
|
|
92
|
+
* and local environment files. */
|
|
86
93
|
export declare function walkFiles(projectRoot: string, prefix?: string): string[];
|
|
87
94
|
/** Recompute the files.lock by hashing and classifying every project file. */
|
|
88
95
|
export declare function computeFilesLock(projectRoot: string, ownedGlobs: string[], managedOverrides?: string[]): FilesLock;
|
package/dist/lockfile.js
CHANGED
|
@@ -11,6 +11,7 @@ exports.writeFilesLock = writeFilesLock;
|
|
|
11
11
|
exports.podokitVersion = podokitVersion;
|
|
12
12
|
exports.matchGlob = matchGlob;
|
|
13
13
|
exports.classifyTier = classifyTier;
|
|
14
|
+
exports.isLocalEnvFile = isLocalEnvFile;
|
|
14
15
|
exports.walkFiles = walkFiles;
|
|
15
16
|
exports.computeFilesLock = computeFilesLock;
|
|
16
17
|
exports.readFilesLock = readFilesLock;
|
|
@@ -54,9 +55,11 @@ exports.DEFAULT_OWNED_GLOBS = [
|
|
|
54
55
|
"apps/web/src/lib/i18n/catalogs/app/**",
|
|
55
56
|
// Owned Elysia extension slot — register application services and plugins here.
|
|
56
57
|
"apps/api/src/app.extensions.ts",
|
|
57
|
-
// Owned additions to the managed /api proxy's header lists
|
|
58
|
+
// Owned additions to the managed /api proxy's header lists, body limits, and
|
|
59
|
+
// response deadlines.
|
|
58
60
|
"apps/web/src/lib/server/proxy-headers.ts",
|
|
59
61
|
"apps/web/src/lib/server/body-limits.ts",
|
|
62
|
+
"apps/web/src/lib/server/proxy-timeouts.ts",
|
|
60
63
|
// Owned fallback page SvelteKit serves when the root layout or a hook fails.
|
|
61
64
|
"apps/web/src/error.html",
|
|
62
65
|
// AI agent guidance — yours to customize; update never touches it.
|
|
@@ -168,7 +171,17 @@ function classifyTier(relPath, content, ownedGlobs, managedOverrides = []) {
|
|
|
168
171
|
return "assembled";
|
|
169
172
|
return "managed";
|
|
170
173
|
}
|
|
171
|
-
/**
|
|
174
|
+
/**
|
|
175
|
+
* Whether a path is a local environment file such as `.env`, `.env.docker` or
|
|
176
|
+
* `apps/api/.env.local`. Git ignores these and they hold secrets, so the lock
|
|
177
|
+
* never records them; the committed `*.example` files are project files.
|
|
178
|
+
*/
|
|
179
|
+
function isLocalEnvFile(relPath) {
|
|
180
|
+
const name = relPath.slice(relPath.lastIndexOf("/") + 1);
|
|
181
|
+
return (name === ".env" || name.startsWith(".env.")) && !name.endsWith(".example");
|
|
182
|
+
}
|
|
183
|
+
/** List project files as POSIX-relative paths, skipping generated/vendored dirs
|
|
184
|
+
* and local environment files. */
|
|
172
185
|
function walkFiles(projectRoot, prefix = "") {
|
|
173
186
|
const out = [];
|
|
174
187
|
for (const entry of (0, node_fs_1.readdirSync)((0, node_path_1.join)(projectRoot, prefix))) {
|
|
@@ -178,7 +191,7 @@ function walkFiles(projectRoot, prefix = "") {
|
|
|
178
191
|
if ((0, node_fs_1.statSync)((0, node_path_1.join)(projectRoot, rel)).isDirectory()) {
|
|
179
192
|
out.push(...walkFiles(projectRoot, rel));
|
|
180
193
|
}
|
|
181
|
-
else {
|
|
194
|
+
else if (!isLocalEnvFile(rel)) {
|
|
182
195
|
out.push(rel);
|
|
183
196
|
}
|
|
184
197
|
}
|
|
@@ -198,7 +211,13 @@ function computeFilesLock(projectRoot, ownedGlobs, managedOverrides = []) {
|
|
|
198
211
|
}
|
|
199
212
|
function readFilesLock(projectRoot) {
|
|
200
213
|
const file = filesLockPath(projectRoot);
|
|
201
|
-
|
|
214
|
+
if (!(0, node_fs_1.existsSync)(file))
|
|
215
|
+
return null;
|
|
216
|
+
const lock = JSON.parse((0, node_fs_1.readFileSync)(file, "utf8"));
|
|
217
|
+
// Earlier versions recorded local env files. Leaving them out here keeps
|
|
218
|
+
// update from treating them as PodoKit files, and the next write drops them.
|
|
219
|
+
const files = Object.entries(lock.files).filter(([path]) => !isLocalEnvFile(path));
|
|
220
|
+
return { ...lock, files: Object.fromEntries(files) };
|
|
202
221
|
}
|
|
203
222
|
/**
|
|
204
223
|
* Compare the current disk against the lock: report managed/assembled files the
|
package/dist/provider.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface ApplyProviderChangeOptions {
|
|
|
18
18
|
modulesDir?: string;
|
|
19
19
|
podokitVersion?: string;
|
|
20
20
|
}
|
|
21
|
+
/** The managed provider source; it must render byte for byte like the template's copy. */
|
|
21
22
|
export declare function providerSource(providers: ProviderSelections): string;
|
|
22
23
|
export declare function listProviders(projectRoot: string): ProviderListItem[];
|
|
23
24
|
export declare function planProviderChange(projectRoot: string, capabilityInput: string, provider: string, modulesDir?: string): ProviderChangePlan;
|
package/dist/provider.js
CHANGED
|
@@ -27,15 +27,18 @@ function assertProvider(capability, provider) {
|
|
|
27
27
|
}
|
|
28
28
|
return capability;
|
|
29
29
|
}
|
|
30
|
+
/** The managed provider source; it must render byte for byte like the template's copy. */
|
|
30
31
|
function providerSource(providers) {
|
|
31
32
|
return `import type { ProviderSelections } from "@podosoft/podokit-runtime";\n\n` +
|
|
32
|
-
|
|
33
|
+
`// Typed with every provider name, not the selected ones: the module of a\n` +
|
|
34
|
+
`// provider that stays installed after a switch compares against its own name.\n` +
|
|
35
|
+
`export const PROVIDERS: Readonly<ProviderSelections> = {\n` +
|
|
33
36
|
` database: "${providers.database}",\n` +
|
|
34
37
|
` cache: "${providers.cache}",\n` +
|
|
35
38
|
` "object-storage": "${providers["object-storage"]}",\n` +
|
|
36
39
|
` events: "${providers.events}",\n` +
|
|
37
40
|
` jobs: "${providers.jobs}",\n` +
|
|
38
|
-
`}
|
|
41
|
+
`};\n`;
|
|
39
42
|
}
|
|
40
43
|
function listProviders(projectRoot) {
|
|
41
44
|
const manifest = requireManifest(projectRoot);
|
|
@@ -49,7 +49,32 @@ Merge added module settings from `.env.example` into an existing `.env`.
|
|
|
49
49
|
managed lists. Cap a route's request body in the app-owned
|
|
50
50
|
`apps/web/src/lib/server/body-limits.ts` (`APP_BODY_LIMITS`, 413
|
|
51
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.
|
|
52
|
+
uploads larger than it must use a direct API path (`apiPaths`) instead. Give a
|
|
53
|
+
route a longer, shorter, or no response deadline in the app-owned
|
|
54
|
+
`apps/web/src/lib/server/proxy-timeouts.ts` (`APP_PROXY_TIMEOUTS`, `null` for
|
|
55
|
+
none); never edit the managed deadline. Both lists take path prefixes with
|
|
56
|
+
`:name` parameters, such as `/api/hosts/:hostId/file-transfers`, and the most
|
|
57
|
+
specific prefix wins.
|
|
58
|
+
- Server-side API calls (`serverApiClient`) give up after `BACKEND_TIMEOUT_MS`
|
|
59
|
+
(2000), and the proxy answers 504 `BACKEND_TIMEOUT` when the API sends no
|
|
60
|
+
response headers within `BACKEND_PROXY_TIMEOUT_MS` (30000). Once the API's
|
|
61
|
+
`GET /health` stops answering too, calls throw `BackendUnavailableError` and the
|
|
62
|
+
proxy answers 503 `BACKEND_UNAVAILABLE` at once until it answers again. Handle
|
|
63
|
+
any non-`ApiError` failure in a load function as a temporary outage. The API
|
|
64
|
+
(`API_IDLE_TIMEOUT`) and the built web server (`CONNECTION_IDLE_TIMEOUT`) close
|
|
65
|
+
a connection that sends nothing for that many seconds (35 each): raise both
|
|
66
|
+
with any longer `BACKEND_PROXY_TIMEOUT_MS` or `APP_PROXY_TIMEOUTS` entry, more
|
|
67
|
+
than 4 above it, or set `0` when a route has no deadline. The proxy refuses to
|
|
68
|
+
load when `CONNECTION_IDLE_TIMEOUT` cannot cover a deadline.
|
|
69
|
+
- Read a caller's address in the API with `clientAddressOf(request.headers)` from
|
|
70
|
+
`apps/api/src/core/client-address.ts`, never from `X-Forwarded-For` directly. The
|
|
71
|
+
API resolves it once per request from `TRUSTED_PROXY_HOPS` (1: the web proxy) and
|
|
72
|
+
`TRUSTED_PROXY_HEADER`; Better Auth sessions, audit entries, and rate limits use
|
|
73
|
+
the same address.
|
|
74
|
+
- `GET /healthz` (`apps/web/src/routes/healthz/+server.ts`) is the web server's own
|
|
75
|
+
health route for the Kubernetes probes, the Compose healthcheck, and the image
|
|
76
|
+
`HEALTHCHECK`. Keep it free of API calls, so an API outage does not take the web
|
|
77
|
+
out of service.
|
|
53
78
|
- When the root layout or a server hook fails, such as with 503 while the API is
|
|
54
79
|
down, SvelteKit serves the app-owned `apps/web/src/error.html`. It is plain HTML
|
|
55
80
|
with inline styles: no components, app CSS, scripts, or assets, and only the
|
|
@@ -96,7 +121,7 @@ changes need an e2e/unit test.
|
|
|
96
121
|
This project is managed by the `podo` CLI. Files have an ownership tier recorded
|
|
97
122
|
in `.podokit/files.lock` — respect it:
|
|
98
123
|
|
|
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`
|
|
124
|
+
- **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` and `API_IDLE_TIMEOUT` apply when the export leaves them out.
|
|
100
125
|
- **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**.
|
|
101
126
|
- **managed** (bootstrap/config like `main.ts`) — the toolkit may update these; avoid gratuitous edits.
|
|
102
127
|
|
|
@@ -131,7 +131,7 @@ Run each service in a separate terminal from the project root:
|
|
|
131
131
|
```
|
|
132
132
|
|
|
133
133
|
- API: http://localhost:5002 — health at `/health`, docs at `/api-docs`
|
|
134
|
-
- Web: http://localhost:5001
|
|
134
|
+
- Web: http://localhost:5001 — its own health at `/healthz`, which never calls the API
|
|
135
135
|
|
|
136
136
|
## Database & migrations
|
|
137
137
|
|
|
@@ -16,7 +16,11 @@ if (!target) {
|
|
|
16
16
|
process.stderr.write("Run database migrations failed: no migration entry in src or dist\n");
|
|
17
17
|
process.exit(1);
|
|
18
18
|
}
|
|
19
|
+
// Run in the API directory wherever this was started from: Bun reads decorator
|
|
20
|
+
// settings from the tsconfig.json of the working directory, and the data source
|
|
21
|
+
// finds .env and the migrations relative to it.
|
|
19
22
|
const subprocess = Bun.spawn([process.execPath, fileURLToPath(target)], {
|
|
23
|
+
cwd: fileURLToPath(new URL("..", import.meta.url)),
|
|
20
24
|
stdin: "inherit",
|
|
21
25
|
stdout: "inherit",
|
|
22
26
|
stderr: "inherit",
|
|
@@ -12,6 +12,11 @@ export const extensionModules: PodokitModule[] = [];
|
|
|
12
12
|
|
|
13
13
|
// Bun server options such as a larger request body limit for uploads, for
|
|
14
14
|
// example { maxRequestBodySize: 1024 ** 3, idleTimeout: 120 }. Values set here
|
|
15
|
-
// win over API_MAX_REQUEST_BODY_SIZE; a function receives
|
|
16
|
-
// environment instead: (env) => ({ maxRequestBodySize: ... }).
|
|
15
|
+
// win over API_MAX_REQUEST_BODY_SIZE and API_IDLE_TIMEOUT; a function receives
|
|
16
|
+
// the validated environment instead: (env) => ({ maxRequestBodySize: ... }). A
|
|
17
|
+
// longer idleTimeout also needs a longer proxy deadline on the web server, which
|
|
18
|
+
// stops waiting for response headers after 30 seconds by default:
|
|
19
|
+
// BACKEND_PROXY_TIMEOUT_MS, or one route's entry in APP_PROXY_TIMEOUTS
|
|
20
|
+
// (apps/web/src/lib/server/proxy-timeouts.ts), and the web server's
|
|
21
|
+
// CONNECTION_IDLE_TIMEOUT (35 seconds).
|
|
17
22
|
export const apiServerOptions: ApiServerOptionsSource = {};
|
|
@@ -4,6 +4,7 @@ import { AppException, type ErrorEnvelope } from "@podosoft/podokit-contracts";
|
|
|
4
4
|
import { Elysia } from "elysia";
|
|
5
5
|
import { configureServices, extensionModules } from "./app.extensions";
|
|
6
6
|
import { validateEnv, type AppEnv } from "./config/env.validation";
|
|
7
|
+
import { stampClientAddress } from "./core/client-address";
|
|
7
8
|
import {
|
|
8
9
|
appContext,
|
|
9
10
|
createCoreServices,
|
|
@@ -137,6 +138,7 @@ const buildApp = (options: CreateAppOptions = {}) => {
|
|
|
137
138
|
services.freeze();
|
|
138
139
|
const context = appContext(env, services);
|
|
139
140
|
const corsOrigin = env.CORS_ORIGIN?.split(",").map((origin) => origin.trim());
|
|
141
|
+
const clientAddressConfig = { trustedProxyHops: env.TRUSTED_PROXY_HOPS, header: env.TRUSTED_PROXY_HEADER };
|
|
140
142
|
|
|
141
143
|
const app = new Elysia({ name: "podokit" })
|
|
142
144
|
.use(cors({ origin: corsOrigin ?? true, credentials: true }))
|
|
@@ -157,11 +159,12 @@ const buildApp = (options: CreateAppOptions = {}) => {
|
|
|
157
159
|
.onRequest(async ({ request, server, set }) => {
|
|
158
160
|
const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID();
|
|
159
161
|
set.headers["x-request-id"] = requestId;
|
|
162
|
+
const remoteAddress = server?.requestIP(request)?.address;
|
|
163
|
+
const clientAddress = stampClientAddress(request, remoteAddress, clientAddressConfig);
|
|
160
164
|
await context.requestGuards.run({
|
|
161
165
|
request,
|
|
162
|
-
...(
|
|
163
|
-
|
|
164
|
-
: {}),
|
|
166
|
+
...(remoteAddress ? { remoteAddress } : {}),
|
|
167
|
+
...(clientAddress ? { clientAddress } : {}),
|
|
165
168
|
setHeader: (name, value) => { set.headers[name] = value; },
|
|
166
169
|
});
|
|
167
170
|
})
|
|
@@ -6,6 +6,22 @@ const schema = z.object({
|
|
|
6
6
|
PORT: z.coerce.number().default(5002),
|
|
7
7
|
// Bytes one request body may hold; 128 MiB is Bun's default.
|
|
8
8
|
API_MAX_REQUEST_BODY_SIZE: z.coerce.number().int().positive().default(128 * 1024 * 1024),
|
|
9
|
+
// Seconds a connection may stay silent, 0 (never closed) to 255. Bun closes idle
|
|
10
|
+
// connections on a 4-second sweep, so 35 outlasts the web proxy's 30-second
|
|
11
|
+
// deadline. An empty value means unset rather than 0.
|
|
12
|
+
API_IDLE_TIMEOUT: z.preprocess(
|
|
13
|
+
(value) => (value === "" ? undefined : value),
|
|
14
|
+
z.coerce.number().int().min(0).max(255).default(35),
|
|
15
|
+
),
|
|
16
|
+
// Reverse proxies in front of the API whose forwarded header names the client;
|
|
17
|
+
// the generated web proxy is one. 0 uses the socket peer.
|
|
18
|
+
TRUSTED_PROXY_HOPS: z.coerce.number().int().nonnegative().default(0),
|
|
19
|
+
TRUSTED_PROXY_HEADER: z
|
|
20
|
+
.string()
|
|
21
|
+
.trim()
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.regex(/^[a-z0-9!#$%&'*+.^_`|~-]+$/, "must be an HTTP header name")
|
|
24
|
+
.default("x-forwarded-for"),
|
|
9
25
|
CORS_ORIGIN: z.string().optional(),
|
|
10
26
|
DATABASE_URL: z.string().optional(),
|
|
11
27
|
POSTGRES_HOST: z.string().default("localhost"),
|
|
@@ -24,7 +40,19 @@ const schema = z.object({
|
|
|
24
40
|
|
|
25
41
|
export type AppEnv = z.infer<typeof schema>;
|
|
26
42
|
|
|
43
|
+
// Renamed settings fail loudly: falling back to the default would count and
|
|
44
|
+
// record every browser request under the web server's address.
|
|
45
|
+
const RENAMED: Record<string, string> = {
|
|
46
|
+
RATE_LIMIT_TRUSTED_PROXY_HOPS: "TRUSTED_PROXY_HOPS",
|
|
47
|
+
RATE_LIMIT_PROXY_HEADER: "TRUSTED_PROXY_HEADER",
|
|
48
|
+
};
|
|
49
|
+
|
|
27
50
|
export function validateEnv(config: Record<string, unknown>): AppEnv {
|
|
51
|
+
for (const [previous, current] of Object.entries(RENAMED)) {
|
|
52
|
+
if (config[previous] !== undefined) {
|
|
53
|
+
throw new Error(`Invalid environment: ${previous} was renamed to ${current}.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
28
56
|
const parsed = schema.safeParse(config);
|
|
29
57
|
if (!parsed.success) {
|
|
30
58
|
throw new Error(`Invalid environment:\n${parsed.error.toString()}`);
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { ProviderSelections } from "@podosoft/podokit-runtime";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Typed with every provider name, not the selected ones: the module of a
|
|
4
|
+
// provider that stays installed after a switch compares against its own name.
|
|
5
|
+
export const PROVIDERS: Readonly<ProviderSelections> = {
|
|
4
6
|
database: "{{databaseProvider}}",
|
|
5
7
|
cache: "{{cacheProvider}}",
|
|
6
8
|
"object-storage": "{{objectStorageProvider}}",
|
|
7
9
|
events: "{{eventsProvider}}",
|
|
8
10
|
jobs: "{{jobsProvider}}",
|
|
9
|
-
}
|
|
11
|
+
};
|
|
@@ -4,26 +4,37 @@ import { validateEnv } from "./env.validation";
|
|
|
4
4
|
import { apiServerOptions, DEFAULT_MAX_REQUEST_BODY_SIZE } from "./server-options";
|
|
5
5
|
|
|
6
6
|
describe("apiServerOptions", () => {
|
|
7
|
-
it("defaults to Bun's request body limit and
|
|
7
|
+
it("defaults to Bun's request body limit and an idle timeout that outlasts the web proxy", () => {
|
|
8
8
|
const env = validateEnv({});
|
|
9
9
|
expect(env.API_MAX_REQUEST_BODY_SIZE).toBe(DEFAULT_MAX_REQUEST_BODY_SIZE);
|
|
10
|
-
expect(apiServerOptions(env, {})).toEqual({ maxRequestBodySize: DEFAULT_MAX_REQUEST_BODY_SIZE });
|
|
10
|
+
expect(apiServerOptions(env, {})).toEqual({ maxRequestBodySize: DEFAULT_MAX_REQUEST_BODY_SIZE, idleTimeout: 35 });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("reads API_IDLE_TIMEOUT from 0 to 255 seconds and treats an empty value as unset", () => {
|
|
14
|
+
expect(validateEnv({ API_IDLE_TIMEOUT: "125" }).API_IDLE_TIMEOUT).toBe(125);
|
|
15
|
+
expect(validateEnv({ API_IDLE_TIMEOUT: "0" }).API_IDLE_TIMEOUT).toBe(0);
|
|
16
|
+
expect(validateEnv({ API_IDLE_TIMEOUT: "255" }).API_IDLE_TIMEOUT).toBe(255);
|
|
17
|
+
expect(validateEnv({ API_IDLE_TIMEOUT: "" }).API_IDLE_TIMEOUT).toBe(35);
|
|
18
|
+
for (const value of ["256", "-1", "1.5", "soon"]) {
|
|
19
|
+
expect(() => validateEnv({ API_IDLE_TIMEOUT: value })).toThrow("API_IDLE_TIMEOUT");
|
|
20
|
+
}
|
|
21
|
+
expect(apiServerOptions(validateEnv({ API_IDLE_TIMEOUT: "0" }), {}).idleTimeout).toBe(0);
|
|
11
22
|
});
|
|
12
23
|
|
|
13
24
|
it("reads API_MAX_REQUEST_BODY_SIZE and rejects values that are not byte counts", () => {
|
|
14
|
-
expect(apiServerOptions(validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1073741824" }), {}))
|
|
15
|
-
.
|
|
25
|
+
expect(apiServerOptions(validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1073741824" }), {}).maxRequestBodySize)
|
|
26
|
+
.toBe(1_073_741_824);
|
|
16
27
|
for (const value of ["0", "-1", "1.5", "lots"]) {
|
|
17
28
|
expect(() => validateEnv({ API_MAX_REQUEST_BODY_SIZE: value })).toThrow("API_MAX_REQUEST_BODY_SIZE");
|
|
18
29
|
}
|
|
19
30
|
});
|
|
20
31
|
|
|
21
32
|
it("lets the application's options win over the environment", () => {
|
|
22
|
-
const env = validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1024" });
|
|
33
|
+
const env = validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1024", API_IDLE_TIMEOUT: "60" });
|
|
23
34
|
expect(apiServerOptions(env, { maxRequestBodySize: 4096, idleTimeout: 120 }))
|
|
24
35
|
.toEqual({ maxRequestBodySize: 4096, idleTimeout: 120 });
|
|
25
36
|
expect(apiServerOptions(env, (current) => ({ maxRequestBodySize: current.API_MAX_REQUEST_BODY_SIZE * 2 })))
|
|
26
|
-
.toEqual({ maxRequestBodySize: 2048 });
|
|
37
|
+
.toEqual({ maxRequestBodySize: 2048, idleTimeout: 60 });
|
|
27
38
|
expect(() => apiServerOptions(env, { maxRequestBodySize: 0 })).toThrow("maxRequestBodySize");
|
|
28
39
|
expect(() => apiServerOptions(env, { idleTimeout: 256 })).toThrow("idleTimeout");
|
|
29
40
|
expect(() => apiServerOptions(env, { idleTimeout: -1 })).toThrow("idleTimeout");
|
|
@@ -11,7 +11,7 @@ export interface ApiServerOptions {
|
|
|
11
11
|
maxRequestBodySize?: number;
|
|
12
12
|
/**
|
|
13
13
|
* Seconds a connection may stay idle before it is closed, from 0 (never) to
|
|
14
|
-
* 255. Defaults to
|
|
14
|
+
* 255. Defaults to `API_IDLE_TIMEOUT`.
|
|
15
15
|
*/
|
|
16
16
|
idleTimeout?: number;
|
|
17
17
|
}
|
|
@@ -31,18 +31,15 @@ const owned: { readonly [name: string]: unknown; readonly apiServerOptions?: Api
|
|
|
31
31
|
export function apiServerOptions(
|
|
32
32
|
env: AppEnv,
|
|
33
33
|
source: ApiServerOptionsSource | undefined = owned.apiServerOptions,
|
|
34
|
-
): ApiServerOptions
|
|
34
|
+
): Required<ApiServerOptions> {
|
|
35
35
|
const options = typeof source === "function" ? source(env) : source ?? {};
|
|
36
36
|
const maxRequestBodySize = options.maxRequestBodySize ?? env.API_MAX_REQUEST_BODY_SIZE;
|
|
37
37
|
if (!Number.isSafeInteger(maxRequestBodySize) || maxRequestBodySize < 1) {
|
|
38
38
|
throw new Error("apiServerOptions.maxRequestBodySize must be a positive integer of bytes");
|
|
39
39
|
}
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
42
|
-
idleTimeout !== undefined &&
|
|
43
|
-
(!Number.isInteger(idleTimeout) || idleTimeout < 0 || idleTimeout > MAX_IDLE_TIMEOUT_SECONDS)
|
|
44
|
-
) {
|
|
40
|
+
const idleTimeout = options.idleTimeout ?? env.API_IDLE_TIMEOUT;
|
|
41
|
+
if (!Number.isInteger(idleTimeout) || idleTimeout < 0 || idleTimeout > MAX_IDLE_TIMEOUT_SECONDS) {
|
|
45
42
|
throw new Error(`apiServerOptions.idleTimeout must be an integer from 0 to ${MAX_IDLE_TIMEOUT_SECONDS} seconds`);
|
|
46
43
|
}
|
|
47
|
-
return { maxRequestBodySize,
|
|
44
|
+
return { maxRequestBodySize, idleTimeout };
|
|
48
45
|
}
|