@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
|
@@ -68,6 +68,51 @@ export function redisConnectionOptions(
|
|
|
68
68
|
return { ...base, ...overrides };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
export const DEFAULT_REDIS_COMMAND_TIMEOUT_MS = 1_000;
|
|
72
|
+
|
|
73
|
+
/** How long the API waits for Redis to answer one command (`REDIS_COMMAND_TIMEOUT_MS`). */
|
|
74
|
+
export function redisCommandTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
75
|
+
const value = env.REDIS_COMMAND_TIMEOUT_MS;
|
|
76
|
+
if (value === undefined || value === "") return DEFAULT_REDIS_COMMAND_TIMEOUT_MS;
|
|
77
|
+
const parsed = Number(value);
|
|
78
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
79
|
+
throw new Error("REDIS_COMMAND_TIMEOUT_MS must be a positive integer");
|
|
80
|
+
}
|
|
81
|
+
return parsed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class RedisCommandTimeoutError extends Error {
|
|
85
|
+
constructor(timeoutMs: number) {
|
|
86
|
+
super(`Redis did not answer within ${timeoutMs} ms`);
|
|
87
|
+
this.name = "RedisCommandTimeoutError";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Rejects with `RedisCommandTimeoutError` when `reply` has not settled within
|
|
93
|
+
* `timeoutMs`, after calling `onTimeout`. Redis answers a connection's commands
|
|
94
|
+
* in order, so a missing reply holds back every later one: `onTimeout` closes
|
|
95
|
+
* the connection, which fails those too and lets its supervisor reconnect.
|
|
96
|
+
*/
|
|
97
|
+
export async function withReplyTimeout<T>(
|
|
98
|
+
reply: Promise<T>,
|
|
99
|
+
timeoutMs: number,
|
|
100
|
+
onTimeout: () => void,
|
|
101
|
+
): Promise<T> {
|
|
102
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
103
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
104
|
+
timer = setTimeout(() => {
|
|
105
|
+
reject(new RedisCommandTimeoutError(timeoutMs));
|
|
106
|
+
onTimeout();
|
|
107
|
+
}, timeoutMs);
|
|
108
|
+
});
|
|
109
|
+
try {
|
|
110
|
+
return await Promise.race([reply, deadline]);
|
|
111
|
+
} finally {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
71
116
|
/** Resolve the same Redis configuration as BullMQ into a URL for Bun.RedisClient. */
|
|
72
117
|
export function redisConnectionUrl(env: NodeJS.ProcessEnv = process.env): string {
|
|
73
118
|
if (env.REDIS_URL) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { Elysia } from "elysia";
|
|
3
|
+
import { validateEnv } from "./env.validation";
|
|
4
|
+
import { apiServerOptions, DEFAULT_MAX_REQUEST_BODY_SIZE } from "./server-options";
|
|
5
|
+
|
|
6
|
+
describe("apiServerOptions", () => {
|
|
7
|
+
it("defaults to Bun's request body limit and leaves the idle timeout to Elysia", () => {
|
|
8
|
+
const env = validateEnv({});
|
|
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 });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
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
|
+
.toEqual({ maxRequestBodySize: 1_073_741_824 });
|
|
16
|
+
for (const value of ["0", "-1", "1.5", "lots"]) {
|
|
17
|
+
expect(() => validateEnv({ API_MAX_REQUEST_BODY_SIZE: value })).toThrow("API_MAX_REQUEST_BODY_SIZE");
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("lets the application's options win over the environment", () => {
|
|
22
|
+
const env = validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1024" });
|
|
23
|
+
expect(apiServerOptions(env, { maxRequestBodySize: 4096, idleTimeout: 120 }))
|
|
24
|
+
.toEqual({ maxRequestBodySize: 4096, idleTimeout: 120 });
|
|
25
|
+
expect(apiServerOptions(env, (current) => ({ maxRequestBodySize: current.API_MAX_REQUEST_BODY_SIZE * 2 })))
|
|
26
|
+
.toEqual({ maxRequestBodySize: 2048 });
|
|
27
|
+
expect(() => apiServerOptions(env, { maxRequestBodySize: 0 })).toThrow("maxRequestBodySize");
|
|
28
|
+
expect(() => apiServerOptions(env, { idleTimeout: 256 })).toThrow("idleTimeout");
|
|
29
|
+
expect(() => apiServerOptions(env, { idleTimeout: -1 })).toThrow("idleTimeout");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("makes the server refuse a body above the limit with 413", async () => {
|
|
33
|
+
const app = new Elysia()
|
|
34
|
+
.post("/upload", async ({ request }) => ({ size: (await request.arrayBuffer()).byteLength }))
|
|
35
|
+
.listen({ ...apiServerOptions(validateEnv({}), { maxRequestBodySize: 1024 }), hostname: "127.0.0.1", port: 0 });
|
|
36
|
+
try {
|
|
37
|
+
const url = `http://127.0.0.1:${app.server?.port}/upload`;
|
|
38
|
+
const accepted = await fetch(url, { method: "POST", body: new Uint8Array(1024) });
|
|
39
|
+
expect(await accepted.json()).toEqual({ size: 1024 });
|
|
40
|
+
const refused = await fetch(url, { method: "POST", body: new Uint8Array(1025) });
|
|
41
|
+
expect(refused.status).toBe(413);
|
|
42
|
+
} finally {
|
|
43
|
+
await app.stop();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as extensions from "../app.extensions";
|
|
2
|
+
import type { AppEnv } from "./env.validation";
|
|
3
|
+
|
|
4
|
+
/** Bun's default request body limit. */
|
|
5
|
+
export const DEFAULT_MAX_REQUEST_BODY_SIZE = 128 * 1024 * 1024;
|
|
6
|
+
const MAX_IDLE_TIMEOUT_SECONDS = 255;
|
|
7
|
+
|
|
8
|
+
/** Bun server options an application sets from `app.extensions.ts`. */
|
|
9
|
+
export interface ApiServerOptions {
|
|
10
|
+
/** The largest request body in bytes. Defaults to `API_MAX_REQUEST_BODY_SIZE`. */
|
|
11
|
+
maxRequestBodySize?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Seconds a connection may stay idle before it is closed, from 0 (never) to
|
|
14
|
+
* 255. Defaults to Elysia's 30 seconds.
|
|
15
|
+
*/
|
|
16
|
+
idleTimeout?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A fixed set of options, or a function of the validated environment. */
|
|
20
|
+
export type ApiServerOptionsSource = ApiServerOptions | ((env: AppEnv) => ApiServerOptions);
|
|
21
|
+
|
|
22
|
+
// `apiServerOptions` is an optional export of the application-owned
|
|
23
|
+
// app.extensions.ts. The index signature keeps extension files written before
|
|
24
|
+
// it existed assignable, while a wrongly typed export still fails to compile.
|
|
25
|
+
const owned: { readonly [name: string]: unknown; readonly apiServerOptions?: ApiServerOptionsSource } = extensions;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The Bun serve options for the API. Values from `apiServerOptions` win over
|
|
29
|
+
* the environment; the function form receives the environment to combine both.
|
|
30
|
+
*/
|
|
31
|
+
export function apiServerOptions(
|
|
32
|
+
env: AppEnv,
|
|
33
|
+
source: ApiServerOptionsSource | undefined = owned.apiServerOptions,
|
|
34
|
+
): ApiServerOptions & { maxRequestBodySize: number } {
|
|
35
|
+
const options = typeof source === "function" ? source(env) : source ?? {};
|
|
36
|
+
const maxRequestBodySize = options.maxRequestBodySize ?? env.API_MAX_REQUEST_BODY_SIZE;
|
|
37
|
+
if (!Number.isSafeInteger(maxRequestBodySize) || maxRequestBodySize < 1) {
|
|
38
|
+
throw new Error("apiServerOptions.maxRequestBodySize must be a positive integer of bytes");
|
|
39
|
+
}
|
|
40
|
+
const { idleTimeout } = options;
|
|
41
|
+
if (
|
|
42
|
+
idleTimeout !== undefined &&
|
|
43
|
+
(!Number.isInteger(idleTimeout) || idleTimeout < 0 || idleTimeout > MAX_IDLE_TIMEOUT_SECONDS)
|
|
44
|
+
) {
|
|
45
|
+
throw new Error(`apiServerOptions.idleTimeout must be an integer from 0 to ${MAX_IDLE_TIMEOUT_SECONDS} seconds`);
|
|
46
|
+
}
|
|
47
|
+
return { maxRequestBodySize, ...(idleTimeout === undefined ? {} : { idleTimeout }) };
|
|
48
|
+
}
|
|
@@ -16,6 +16,7 @@ describe("API contract inventory", () => {
|
|
|
16
16
|
expect(expected).toContain("POST /api/auth/sign-in/email");
|
|
17
17
|
expect(expected).toContain("PUT /cache/{key}");
|
|
18
18
|
expect(expected).toContain("POST /files");
|
|
19
|
+
expect(expected).toContain("PUT /files/uploads/{id}/parts/{partNumber}");
|
|
19
20
|
});
|
|
20
21
|
|
|
21
22
|
it("uses one structural parameter name for every public blog post route", () => {
|
|
@@ -37,6 +37,7 @@ const MODULE_ROUTES: Record<string, string[]> = {
|
|
|
37
37
|
"GET /profile-images/{fileName}",
|
|
38
38
|
"GET /admin/sessions",
|
|
39
39
|
"DELETE /admin/sessions/{id}",
|
|
40
|
+
"GET /admin/users",
|
|
40
41
|
],
|
|
41
42
|
"api-key-auth": ["GET /machine/ping"],
|
|
42
43
|
"audit-log": ["GET /audit-logs", "GET /audit-logs/actions"],
|
|
@@ -70,7 +71,15 @@ const MODULE_ROUTES: Record<string, string[]> = {
|
|
|
70
71
|
"DELETE /admin/blog/{id}",
|
|
71
72
|
],
|
|
72
73
|
bullmq: ["POST /jobs", "GET /jobs/{id}"],
|
|
73
|
-
"file-upload": [
|
|
74
|
+
"file-upload": [
|
|
75
|
+
"POST /files",
|
|
76
|
+
"GET /files/{key}/url",
|
|
77
|
+
"POST /files/uploads",
|
|
78
|
+
"GET /files/uploads/{id}",
|
|
79
|
+
"PUT /files/uploads/{id}/parts/{partNumber}",
|
|
80
|
+
"POST /files/uploads/{id}/complete",
|
|
81
|
+
"DELETE /files/uploads/{id}",
|
|
82
|
+
],
|
|
74
83
|
"job-progress": ["POST /progress"],
|
|
75
84
|
"object-storage-local": [
|
|
76
85
|
"GET /files/content/*",
|
|
@@ -4,11 +4,15 @@ import { validateEnv } from "../config/env.validation";
|
|
|
4
4
|
import { PROVIDERS } from "../config/providers";
|
|
5
5
|
import {
|
|
6
6
|
AccessPolicy,
|
|
7
|
+
ConnectionSupervisor,
|
|
7
8
|
createCoreServices,
|
|
8
9
|
OpenApiRegistry,
|
|
9
10
|
REQUEST_IDENTITY,
|
|
10
11
|
RequestGuardRegistry,
|
|
11
12
|
ServiceRegistry,
|
|
13
|
+
type ConnectionEvents,
|
|
14
|
+
type RetryTimer,
|
|
15
|
+
type SupervisedConnection,
|
|
12
16
|
} from "./services";
|
|
13
17
|
|
|
14
18
|
function databaseProvider(): DatabaseProviderName {
|
|
@@ -50,6 +54,180 @@ describe("ServiceRegistry", () => {
|
|
|
50
54
|
});
|
|
51
55
|
});
|
|
52
56
|
|
|
57
|
+
describe("ServiceRegistry supervision", () => {
|
|
58
|
+
test("starts and runs start hooks while a supervised dependency is down", async () => {
|
|
59
|
+
const registry = new ServiceRegistry();
|
|
60
|
+
let attempts = 0;
|
|
61
|
+
let hookRan = false;
|
|
62
|
+
registry.supervise("redis", {
|
|
63
|
+
connect: () => {
|
|
64
|
+
attempts += 1;
|
|
65
|
+
return Promise.reject(new Error("Connection closed"));
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
registry.onStart(() => { hookRan = true; });
|
|
69
|
+
registry.freeze();
|
|
70
|
+
|
|
71
|
+
await registry.start();
|
|
72
|
+
expect(hookRan).toBe(true);
|
|
73
|
+
expect(attempts).toBe(1);
|
|
74
|
+
await registry.close();
|
|
75
|
+
await Bun.sleep(400);
|
|
76
|
+
expect(attempts).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("connects a reachable dependency before the start hooks run", async () => {
|
|
80
|
+
const registry = new ServiceRegistry();
|
|
81
|
+
const order: string[] = [];
|
|
82
|
+
registry.supervise("database", {
|
|
83
|
+
connect: async () => {
|
|
84
|
+
await Bun.sleep(5);
|
|
85
|
+
order.push("connected");
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
registry.onStart(() => { order.push("hook"); });
|
|
89
|
+
registry.freeze();
|
|
90
|
+
|
|
91
|
+
await registry.start();
|
|
92
|
+
expect(order).toEqual(["connected", "hook"]);
|
|
93
|
+
await registry.close();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
interface FakeTimer extends RetryTimer {
|
|
98
|
+
delays: number[];
|
|
99
|
+
pending(): number;
|
|
100
|
+
fire(): Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fakeTimer(): FakeTimer {
|
|
104
|
+
const tasks: Array<{ task: () => void; cancelled: boolean }> = [];
|
|
105
|
+
const delays: number[] = [];
|
|
106
|
+
return {
|
|
107
|
+
delays,
|
|
108
|
+
after: (delayMs, task) => {
|
|
109
|
+
const entry = { task, cancelled: false };
|
|
110
|
+
delays.push(delayMs);
|
|
111
|
+
tasks.push(entry);
|
|
112
|
+
return () => { entry.cancelled = true; };
|
|
113
|
+
},
|
|
114
|
+
pending: () => tasks.filter((entry) => !entry.cancelled).length,
|
|
115
|
+
fire: async () => {
|
|
116
|
+
const entry = tasks.shift();
|
|
117
|
+
if (entry && !entry.cancelled) entry.task();
|
|
118
|
+
await Bun.sleep(0);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function recordingEvents(): ConnectionEvents & { log: string[] } {
|
|
124
|
+
const log: string[] = [];
|
|
125
|
+
return {
|
|
126
|
+
log,
|
|
127
|
+
unavailable: (name, reason) => { log.push(`unavailable ${name}: ${reason}`); },
|
|
128
|
+
restored: (name) => { log.push(`restored ${name}`); },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
class FlakyConnection implements SupervisedConnection {
|
|
133
|
+
attempts = 0;
|
|
134
|
+
failuresLeft = 0;
|
|
135
|
+
private listener?: (reason: unknown) => void;
|
|
136
|
+
|
|
137
|
+
connect(): Promise<void> {
|
|
138
|
+
this.attempts += 1;
|
|
139
|
+
if (this.failuresLeft > 0) {
|
|
140
|
+
this.failuresLeft -= 1;
|
|
141
|
+
return Promise.reject(new Error("Connection closed"));
|
|
142
|
+
}
|
|
143
|
+
return Promise.resolve();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
onLost(listener: (reason: unknown) => void): void {
|
|
147
|
+
this.listener = listener;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
drop(): void {
|
|
151
|
+
this.listener?.(new Error("Connection reset"));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe("ConnectionSupervisor", () => {
|
|
156
|
+
const policy = { initialDelayMs: 100, maxDelayMs: 400 };
|
|
157
|
+
|
|
158
|
+
test("retries with doubling delays up to the cap and reports each state change once", async () => {
|
|
159
|
+
const connection = new FlakyConnection();
|
|
160
|
+
connection.failuresLeft = 5;
|
|
161
|
+
const timer = fakeTimer();
|
|
162
|
+
const events = recordingEvents();
|
|
163
|
+
const supervisor = new ConnectionSupervisor("redis", connection, events, { policy, timer });
|
|
164
|
+
|
|
165
|
+
await supervisor.start();
|
|
166
|
+
expect(supervisor.connected).toBe(false);
|
|
167
|
+
while (timer.pending() > 0) await timer.fire();
|
|
168
|
+
|
|
169
|
+
expect(connection.attempts).toBe(6);
|
|
170
|
+
expect(timer.delays).toEqual([100, 200, 400, 400, 400]);
|
|
171
|
+
expect(events.log).toEqual(["unavailable redis: Connection closed", "restored redis"]);
|
|
172
|
+
expect(supervisor.connected).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("reopens a lost connection and starts the backoff over", async () => {
|
|
176
|
+
const connection = new FlakyConnection();
|
|
177
|
+
const timer = fakeTimer();
|
|
178
|
+
const events = recordingEvents();
|
|
179
|
+
const supervisor = new ConnectionSupervisor("redis", connection, events, { policy, timer });
|
|
180
|
+
await supervisor.start();
|
|
181
|
+
expect(supervisor.connected).toBe(true);
|
|
182
|
+
|
|
183
|
+
connection.failuresLeft = 1;
|
|
184
|
+
connection.drop();
|
|
185
|
+
connection.drop();
|
|
186
|
+
expect(supervisor.connected).toBe(false);
|
|
187
|
+
expect(timer.pending()).toBe(1);
|
|
188
|
+
while (timer.pending() > 0) await timer.fire();
|
|
189
|
+
|
|
190
|
+
expect(timer.delays).toEqual([100, 200]);
|
|
191
|
+
expect(connection.attempts).toBe(3);
|
|
192
|
+
expect(events.log).toEqual(["unavailable redis: Connection reset", "restored redis"]);
|
|
193
|
+
expect(supervisor.connected).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("tries again when the connection drops while an attempt runs", async () => {
|
|
197
|
+
const connection = new FlakyConnection();
|
|
198
|
+
const timer = fakeTimer();
|
|
199
|
+
const supervisor = new ConnectionSupervisor("events", {
|
|
200
|
+
connect: async () => {
|
|
201
|
+
await connection.connect();
|
|
202
|
+
if (connection.attempts === 1) connection.drop();
|
|
203
|
+
},
|
|
204
|
+
onLost: (listener) => connection.onLost(listener),
|
|
205
|
+
}, recordingEvents(), { policy, timer });
|
|
206
|
+
|
|
207
|
+
await supervisor.start();
|
|
208
|
+
expect(supervisor.connected).toBe(false);
|
|
209
|
+
await timer.fire();
|
|
210
|
+
expect(connection.attempts).toBe(2);
|
|
211
|
+
expect(supervisor.connected).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("stops retrying once stopped", async () => {
|
|
215
|
+
const connection = new FlakyConnection();
|
|
216
|
+
connection.failuresLeft = 10;
|
|
217
|
+
const timer = fakeTimer();
|
|
218
|
+
const supervisor = new ConnectionSupervisor("redis", connection, recordingEvents(), { policy, timer });
|
|
219
|
+
await supervisor.start();
|
|
220
|
+
expect(timer.pending()).toBe(1);
|
|
221
|
+
|
|
222
|
+
supervisor.stop();
|
|
223
|
+
expect(timer.pending()).toBe(0);
|
|
224
|
+
connection.drop();
|
|
225
|
+
await timer.fire();
|
|
226
|
+
expect(connection.attempts).toBe(1);
|
|
227
|
+
expect(timer.pending()).toBe(0);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
53
231
|
describe("AccessPolicy", () => {
|
|
54
232
|
test("defaults to session access", () => {
|
|
55
233
|
const policy = new AccessPolicy();
|
|
@@ -66,6 +244,24 @@ describe("AccessPolicy", () => {
|
|
|
66
244
|
expect(policy.resolve(new Request("http://localhost/api/auth/sign-in/email"))).toBe("public");
|
|
67
245
|
});
|
|
68
246
|
|
|
247
|
+
test("applies GET rules to HEAD requests and leaves other methods alone", () => {
|
|
248
|
+
const policy = new AccessPolicy();
|
|
249
|
+
policy.register("GET", "/profile-images/:fileName", "public");
|
|
250
|
+
policy.register("POST", "/uploads", "public");
|
|
251
|
+
policy.register("GET", "/reports/:id", "api-key");
|
|
252
|
+
|
|
253
|
+
const head = (path: string): Request => new Request(`http://localhost${path}`, { method: "HEAD" });
|
|
254
|
+
expect(policy.resolve(head("/profile-images/a.png"))).toBe("public");
|
|
255
|
+
expect(policy.resolve(head("/reports/1"))).toBe("api-key");
|
|
256
|
+
expect(policy.resolve(head("/uploads"))).toBe("session");
|
|
257
|
+
expect(policy.resolve(head("/todos"))).toBe("session");
|
|
258
|
+
expect(policy.resolve(new Request("http://localhost/profile-images/a.png", { method: "DELETE" }))).toBe("session");
|
|
259
|
+
|
|
260
|
+
policy.register("HEAD", "/profile-images/:fileName", "session");
|
|
261
|
+
expect(policy.resolve(head("/profile-images/a.png"))).toBe("session");
|
|
262
|
+
expect(policy.resolve(new Request("http://localhost/profile-images/a.png"))).toBe("public");
|
|
263
|
+
});
|
|
264
|
+
|
|
69
265
|
test("uses the most recently registered matching rule", () => {
|
|
70
266
|
const policy = new AccessPolicy();
|
|
71
267
|
policy.register("*", "/content/*", "public");
|
|
@@ -10,9 +10,151 @@ import { DATABASE_READINESS_CHECK, ReadinessService } from "../health/readiness.
|
|
|
10
10
|
|
|
11
11
|
export type { ServiceKey } from "@podosoft/podokit-runtime";
|
|
12
12
|
|
|
13
|
+
/** A dependency that `ServiceRegistry.supervise` keeps connected. */
|
|
14
|
+
export interface SupervisedConnection {
|
|
15
|
+
/**
|
|
16
|
+
* Opens whatever is closed and restores what depends on it, such as
|
|
17
|
+
* subscriptions. Rejects while the dependency is unreachable.
|
|
18
|
+
*/
|
|
19
|
+
connect(): Promise<void>;
|
|
20
|
+
/** Takes the callback to run whenever an open connection drops. */
|
|
21
|
+
onLost?(listener: (reason: unknown) => void): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ReconnectPolicy {
|
|
25
|
+
/** Delay before the first retry. Each further failure doubles it. */
|
|
26
|
+
initialDelayMs: number;
|
|
27
|
+
/** Longest delay between two attempts. */
|
|
28
|
+
maxDelayMs: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_RECONNECT_POLICY: ReconnectPolicy = { initialDelayMs: 250, maxDelayMs: 5_000 };
|
|
32
|
+
|
|
33
|
+
export interface ConnectionEvents {
|
|
34
|
+
unavailable(name: string, reason: string): void;
|
|
35
|
+
restored(name: string, downtimeMs: number): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RetryTimer {
|
|
39
|
+
/** Runs `task` once after `delayMs`; the returned function cancels it. */
|
|
40
|
+
after(delayMs: number, task: () => void): () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const unrefTimer: RetryTimer = {
|
|
44
|
+
after: (delayMs, task) => {
|
|
45
|
+
const timer = setTimeout(task, delayMs);
|
|
46
|
+
timer.unref?.();
|
|
47
|
+
return () => clearTimeout(timer);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export interface SupervisorOptions {
|
|
52
|
+
policy?: ReconnectPolicy;
|
|
53
|
+
timer?: RetryTimer;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function reasonOf(error: unknown): string {
|
|
57
|
+
return error instanceof Error ? error.message : String(error);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Connects in the background and keeps the connection open: a failed attempt is
|
|
62
|
+
* retried with capped exponential backoff, and a lost connection is reopened.
|
|
63
|
+
* Events fire when the state changes, not on every attempt.
|
|
64
|
+
*/
|
|
65
|
+
export class ConnectionSupervisor {
|
|
66
|
+
private up = false;
|
|
67
|
+
private stopped = false;
|
|
68
|
+
private failures = 0;
|
|
69
|
+
private drops = 0;
|
|
70
|
+
private lastDrop: unknown;
|
|
71
|
+
private downSince?: number;
|
|
72
|
+
private cancelRetry?: () => void;
|
|
73
|
+
private firstAttempt?: Promise<void>;
|
|
74
|
+
private readonly policy: ReconnectPolicy;
|
|
75
|
+
private readonly timer: RetryTimer;
|
|
76
|
+
|
|
77
|
+
constructor(
|
|
78
|
+
readonly name: string,
|
|
79
|
+
private readonly connection: SupervisedConnection,
|
|
80
|
+
private readonly events: ConnectionEvents,
|
|
81
|
+
options: SupervisorOptions = {},
|
|
82
|
+
) {
|
|
83
|
+
this.policy = options.policy ?? DEFAULT_RECONNECT_POLICY;
|
|
84
|
+
this.timer = options.timer ?? unrefTimer;
|
|
85
|
+
connection.onLost?.((reason) => this.lost(reason));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get connected(): boolean {
|
|
89
|
+
return this.up;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Resolves after the first attempt, whether or not it connected. */
|
|
93
|
+
start(): Promise<void> {
|
|
94
|
+
this.firstAttempt ??= this.attempt();
|
|
95
|
+
return this.firstAttempt;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
stop(): void {
|
|
99
|
+
this.stopped = true;
|
|
100
|
+
this.up = false;
|
|
101
|
+
this.cancelRetry?.();
|
|
102
|
+
this.cancelRetry = undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private lost(reason: unknown): void {
|
|
106
|
+
if (this.stopped) return;
|
|
107
|
+
this.drops += 1;
|
|
108
|
+
this.lastDrop = reason;
|
|
109
|
+
// While an attempt is pending it sees the changed count and tries again.
|
|
110
|
+
if (!this.up) return;
|
|
111
|
+
this.up = false;
|
|
112
|
+
this.retry(reason);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async attempt(): Promise<void> {
|
|
116
|
+
this.cancelRetry = undefined;
|
|
117
|
+
if (this.stopped) return;
|
|
118
|
+
const drops = this.drops;
|
|
119
|
+
try {
|
|
120
|
+
await this.connection.connect();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (!this.stopped) this.retry(error);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (this.stopped) return;
|
|
126
|
+
// Bun reports a failed reconnect through the same close callback, so a drop
|
|
127
|
+
// during the attempt can mean a part of the connection is closed again.
|
|
128
|
+
if (drops !== this.drops) {
|
|
129
|
+
this.retry(this.lastDrop);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.up = true;
|
|
133
|
+
this.failures = 0;
|
|
134
|
+
if (this.downSince !== undefined) {
|
|
135
|
+
this.events.restored(this.name, Date.now() - this.downSince);
|
|
136
|
+
this.downSince = undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private retry(reason: unknown): void {
|
|
141
|
+
if (this.downSince === undefined) {
|
|
142
|
+
this.downSince = Date.now();
|
|
143
|
+
this.events.unavailable(this.name, reasonOf(reason));
|
|
144
|
+
}
|
|
145
|
+
const delay = Math.min(
|
|
146
|
+
this.policy.maxDelayMs,
|
|
147
|
+
this.policy.initialDelayMs * 2 ** Math.min(this.failures, 30),
|
|
148
|
+
);
|
|
149
|
+
this.failures += 1;
|
|
150
|
+
this.cancelRetry = this.timer.after(delay, () => void this.attempt());
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
13
154
|
export class ServiceRegistry {
|
|
14
155
|
private readonly values = new Map<symbol, unknown>();
|
|
15
156
|
private readonly starters: Array<() => void | Promise<void>> = [];
|
|
157
|
+
private readonly supervisors: ConnectionSupervisor[] = [];
|
|
16
158
|
private readonly closers: Array<() => void | Promise<void>> = [];
|
|
17
159
|
private startPromise?: Promise<void>;
|
|
18
160
|
private closePromise?: Promise<void>;
|
|
@@ -41,17 +183,44 @@ export class ServiceRegistry {
|
|
|
41
183
|
return this.values.get(key) as T | undefined;
|
|
42
184
|
}
|
|
43
185
|
|
|
186
|
+
/** Runs `start` before the server listens; a rejection fails startup. */
|
|
44
187
|
onStart(start: () => void | Promise<void>): void {
|
|
45
188
|
if (this.frozen) throw new Error("Service registry is frozen");
|
|
46
189
|
this.starters.push(start);
|
|
47
190
|
}
|
|
48
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Connects `connection` when the registry starts and keeps it connected.
|
|
194
|
+
* Startup waits only for the first attempt, so the server listens while the
|
|
195
|
+
* dependency is down; register a readiness check that reports it.
|
|
196
|
+
*/
|
|
197
|
+
supervise(name: string, connection: SupervisedConnection): void {
|
|
198
|
+
if (this.frozen) throw new Error("Service registry is frozen");
|
|
199
|
+
this.supervisors.push(new ConnectionSupervisor(name, connection, {
|
|
200
|
+
// Resolved on use: a module such as logging may replace the logger.
|
|
201
|
+
unavailable: (dependency, reason) => {
|
|
202
|
+
this.tryResolve(LOGGER)?.warn(
|
|
203
|
+
{ dependency, reason },
|
|
204
|
+
"Dependency connection failed; retrying in the background",
|
|
205
|
+
);
|
|
206
|
+
},
|
|
207
|
+
restored: (dependency, downtimeMs) => {
|
|
208
|
+
this.tryResolve(LOGGER)?.info({ dependency, downtimeMs }, "Dependency connection restored");
|
|
209
|
+
},
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
|
|
49
213
|
freeze(): void {
|
|
50
214
|
this.frozen = true;
|
|
51
215
|
}
|
|
52
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Makes a first connection attempt for every supervised dependency at once,
|
|
219
|
+
* so those that are up are connected before the start hooks run in order.
|
|
220
|
+
*/
|
|
53
221
|
start(): Promise<void> {
|
|
54
222
|
this.startPromise ??= (async () => {
|
|
223
|
+
await Promise.all(this.supervisors.map((supervisor) => supervisor.start()));
|
|
55
224
|
for (const start of this.starters) await start();
|
|
56
225
|
})();
|
|
57
226
|
return this.startPromise;
|
|
@@ -59,6 +228,7 @@ export class ServiceRegistry {
|
|
|
59
228
|
|
|
60
229
|
close(): Promise<void> {
|
|
61
230
|
this.closePromise ??= (async () => {
|
|
231
|
+
for (const supervisor of this.supervisors) supervisor.stop();
|
|
62
232
|
for (const close of [...this.closers].reverse()) await close();
|
|
63
233
|
})();
|
|
64
234
|
return this.closePromise;
|
|
@@ -90,13 +260,19 @@ export class AccessPolicy {
|
|
|
90
260
|
this.rules.push({ method: method.toUpperCase(), matcher: routeMatcher(pattern), level });
|
|
91
261
|
}
|
|
92
262
|
|
|
263
|
+
/**
|
|
264
|
+
* The level of the most recently registered matching rule. A HEAD request
|
|
265
|
+
* also matches GET rules, because Elysia answers it with the GET handler.
|
|
266
|
+
*/
|
|
93
267
|
resolve(request: Request): AccessLevel {
|
|
94
268
|
const path = new URL(request.url).pathname;
|
|
95
269
|
const method = request.method.toUpperCase();
|
|
96
270
|
const rule = [...this.rules]
|
|
97
271
|
.reverse()
|
|
98
272
|
.find((candidate) =>
|
|
99
|
-
(candidate.method === "*" || candidate.method === method
|
|
273
|
+
(candidate.method === "*" || candidate.method === method ||
|
|
274
|
+
(method === "HEAD" && candidate.method === "GET")) &&
|
|
275
|
+
candidate.matcher.test(path)
|
|
100
276
|
);
|
|
101
277
|
return rule?.level ?? "session";
|
|
102
278
|
}
|
|
@@ -199,7 +375,10 @@ export function createCoreServices(env: AppEnv): ServiceRegistry {
|
|
|
199
375
|
accessPolicy.register("*", "/api-docs-json", "public");
|
|
200
376
|
accessPolicy.register("*", "/api-docs-elysia-json", "public");
|
|
201
377
|
services.register(DATABASE, database, () => database.close());
|
|
202
|
-
services.
|
|
378
|
+
services.supervise(DATABASE_READINESS_CHECK, {
|
|
379
|
+
connect: () => services.resolve(DATABASE).connect(),
|
|
380
|
+
onLost: (listener) => database.onLost(listener),
|
|
381
|
+
});
|
|
203
382
|
services.register(READINESS, readiness);
|
|
204
383
|
readiness.register(DATABASE_READINESS_CHECK, () => services.resolve(DATABASE).ping());
|
|
205
384
|
services.register(LOGGER, logger);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
3
|
+
import { PROVIDERS } from "../config/providers";
|
|
4
|
+
|
|
5
|
+
function databaseProvider(): DatabaseProviderName {
|
|
6
|
+
return PROVIDERS.database;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// SQLite applications migrate with the Bun SQLite migrator, not this TypeORM source.
|
|
10
|
+
describe.skipIf(databaseProvider() !== "postgres")("migration data source", () => {
|
|
11
|
+
it("runs each migration in its own transaction so one can build indexes concurrently", async () => {
|
|
12
|
+
const { dataSourceOptions } = await import("./data-source.js");
|
|
13
|
+
expect(dataSourceOptions.migrationsTransactionMode).toBe("each");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -15,6 +15,9 @@ const migrations = existsSync(compiledMigrations)
|
|
|
15
15
|
const commonOptions = {
|
|
16
16
|
entities: [],
|
|
17
17
|
migrations,
|
|
18
|
+
// One transaction per migration, so a migration can set `transaction = false`
|
|
19
|
+
// for statements such as CREATE INDEX CONCURRENTLY.
|
|
20
|
+
migrationsTransactionMode: "each" as const,
|
|
18
21
|
synchronize: false,
|
|
19
22
|
};
|
|
20
23
|
|