@podosoft/podokit 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/add.d.ts +10 -1
- package/dist/add.js +20 -7
- 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 +15 -2
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +4 -2
- package/dist/lockfile.d.ts +3 -0
- package/dist/lockfile.js +11 -0
- 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 +27 -5
- package/dist/templates/fullstack/README.md +28 -11
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +25 -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 +9 -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/database.spec.ts +26 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +52 -11
- 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/database/run-migrations.spec.ts +70 -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/error.html +73 -0
- 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 +7 -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/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
- 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/server/asset-paths.ts +11 -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/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 +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- 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/migrate.ts +3 -2
- package/dist/templates/modules/auth/module.manifest.json +0 -1
- 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 +153 -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.service.spec.ts +33 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
- package/dist/templates/todo/AGENTS.md +19 -4
- package/dist/templates/todo/README.md +27 -12
- package/dist/update.d.ts +5 -0
- package/dist/update.js +35 -0
- package/package.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
- 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
|
@@ -4,6 +4,7 @@ import { mkdirSync } from "node:fs";
|
|
|
4
4
|
import { dirname, resolve } from "node:path";
|
|
5
5
|
import type { AppEnv } from "../config/env.validation";
|
|
6
6
|
import { PROVIDERS } from "../config/providers";
|
|
7
|
+
import { answersOnNewConnection, PoolGuard } from "./pool-guard";
|
|
7
8
|
|
|
8
9
|
function isSqliteUrl(url: string): boolean {
|
|
9
10
|
return url === ":memory:" || /^(?:sqlite|file):/.test(url);
|
|
@@ -62,8 +63,9 @@ export function postgresPoolOptions(env: AppEnv): PostgresPoolOptions {
|
|
|
62
63
|
};
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
// Bun SQL and pg report a database that cannot be reached,
|
|
66
|
-
// could not be opened or taken from a full pool in time,
|
|
66
|
+
// Bun SQL and pg report a database that cannot be reached, a connection that
|
|
67
|
+
// could not be opened or taken from a full pool in time, or a query the server
|
|
68
|
+
// did not answer in time, with these shapes.
|
|
67
69
|
const UNAVAILABLE_SQL_CODES = new Set([
|
|
68
70
|
"ERR_POSTGRES_CONNECTION_TIMEOUT",
|
|
69
71
|
"ERR_POSTGRES_CONNECTION_REFUSED",
|
|
@@ -73,33 +75,72 @@ const UNAVAILABLE_SQL_CODES = new Set([
|
|
|
73
75
|
const UNAVAILABLE_PG_MESSAGES = new Set([
|
|
74
76
|
"timeout exceeded when trying to connect",
|
|
75
77
|
"Connection terminated due to connection timeout",
|
|
78
|
+
"Connection terminated unexpectedly",
|
|
79
|
+
"Query read timeout",
|
|
76
80
|
]);
|
|
81
|
+
// Socket errors from a pg client whose server is gone or unreachable.
|
|
82
|
+
const UNAVAILABLE_SOCKET_CODES = new Set([
|
|
83
|
+
"ECONNREFUSED",
|
|
84
|
+
"ECONNRESET",
|
|
85
|
+
"ETIMEDOUT",
|
|
86
|
+
"EHOSTUNREACH",
|
|
87
|
+
"ENETUNREACH",
|
|
88
|
+
"EPIPE",
|
|
89
|
+
]);
|
|
90
|
+
// SQLSTATEs for a server shutting down or refusing connections (class 57P) and
|
|
91
|
+
// for broken connections (class 08); pg reports them as `code`, Bun SQL as `errno`.
|
|
92
|
+
const UNAVAILABLE_SQLSTATES = new Set(["57P01", "57P02", "57P03"]);
|
|
93
|
+
|
|
94
|
+
function unavailableSqlState(value: unknown): boolean {
|
|
95
|
+
return typeof value === "string" && (UNAVAILABLE_SQLSTATES.has(value) || /^08[0-9A-Z]{3}$/.test(value));
|
|
96
|
+
}
|
|
77
97
|
|
|
78
98
|
/** Whether an error, or one it wraps, means the database is unavailable rather than the request being wrong. */
|
|
79
99
|
export function isDatabaseUnavailable(error: unknown): boolean {
|
|
80
100
|
let current: unknown = error;
|
|
81
101
|
for (let depth = 0; depth < 5 && current instanceof Error; depth += 1) {
|
|
82
|
-
if (current instanceof SQL.PostgresError
|
|
102
|
+
if (current instanceof SQL.PostgresError) {
|
|
103
|
+
if (UNAVAILABLE_SQL_CODES.has(current.code) || unavailableSqlState(current.errno)) return true;
|
|
104
|
+
} else {
|
|
105
|
+
const code = (current as { code?: unknown }).code;
|
|
106
|
+
if (typeof code === "string" && (UNAVAILABLE_SOCKET_CODES.has(code) || unavailableSqlState(code))) return true;
|
|
107
|
+
}
|
|
83
108
|
if (UNAVAILABLE_PG_MESSAGES.has(current.message)) return true;
|
|
84
109
|
current = current.cause;
|
|
85
110
|
}
|
|
86
111
|
return false;
|
|
87
112
|
}
|
|
88
113
|
|
|
89
|
-
function createSql(provider: DatabaseProviderName, env: AppEnv): SQL {
|
|
90
|
-
const url = databaseUrl(env);
|
|
91
|
-
return provider === "sqlite"
|
|
92
|
-
? new SQL(url, { adapter: "sqlite", create: true, readwrite: true, strict: true })
|
|
93
|
-
: new SQL(url, postgresPoolOptions(env));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
114
|
export class Database {
|
|
97
115
|
readonly provider: DatabaseProviderName = selectedDatabaseProvider();
|
|
98
116
|
readonly sql: SQL;
|
|
117
|
+
private readonly guard?: PoolGuard;
|
|
99
118
|
private ready?: Promise<void>;
|
|
100
119
|
|
|
101
120
|
constructor(env: AppEnv, sql?: SQL) {
|
|
102
|
-
|
|
121
|
+
if (sql) {
|
|
122
|
+
this.sql = sql;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const url = databaseUrl(env);
|
|
126
|
+
if (this.provider === "sqlite") {
|
|
127
|
+
this.sql = new SQL(url, { adapter: "sqlite", create: true, readwrite: true, strict: true });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.guard = new PoolGuard(() => new SQL(url, postgresPoolOptions(env)), {
|
|
131
|
+
stallTimeoutMs: env.DB_QUERY_TIMEOUT_MS,
|
|
132
|
+
serverAnswers: () => answersOnNewConnection(url, env.READINESS_CHECK_TIMEOUT_MS),
|
|
133
|
+
});
|
|
134
|
+
// The supervisor's next connect() then checks the new pool.
|
|
135
|
+
this.guard.onLost(() => {
|
|
136
|
+
this.ready = undefined;
|
|
137
|
+
});
|
|
138
|
+
this.sql = this.guard.sql;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Takes the callback to run when the pool is replaced because PostgreSQL stopped answering. */
|
|
142
|
+
onLost(listener: (reason: unknown) => void): void {
|
|
143
|
+
this.guard?.onLost(listener);
|
|
103
144
|
}
|
|
104
145
|
|
|
105
146
|
connect(): Promise<void> {
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { SQL } from "bun";
|
|
2
|
+
import { describe, expect, it } from "bun:test";
|
|
3
|
+
import { answersOnNewConnection, PoolGuard } from "./pool-guard";
|
|
4
|
+
|
|
5
|
+
const STALL_MS = 20;
|
|
6
|
+
|
|
7
|
+
/** Settles like a Bun query: a subclass of Promise whose `then` returns a plain promise. */
|
|
8
|
+
class FakeQuery extends Promise<unknown> {
|
|
9
|
+
static override get [Symbol.species](): PromiseConstructor {
|
|
10
|
+
return Promise;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface FakePool {
|
|
15
|
+
sql: SQL;
|
|
16
|
+
closed: () => boolean;
|
|
17
|
+
/** Settles every query that waits for the server. */
|
|
18
|
+
resume: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface FakeServer {
|
|
22
|
+
/** While true, queries wait until the pool closes or the server resumes. */
|
|
23
|
+
stalled: boolean;
|
|
24
|
+
pools: FakePool[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fakeServer(): FakeServer & { open: () => SQL } {
|
|
28
|
+
const server: FakeServer = { stalled: false, pools: [] };
|
|
29
|
+
const open = (): SQL => {
|
|
30
|
+
const waiting = new Set<(error?: Error) => void>();
|
|
31
|
+
let closed = false;
|
|
32
|
+
const query = (): FakeQuery =>
|
|
33
|
+
new FakeQuery((resolve, reject) => {
|
|
34
|
+
if (closed) return reject(new Error("Connection closed"));
|
|
35
|
+
if (!server.stalled) return resolve([{ ok: 1 }]);
|
|
36
|
+
waiting.add((error) => (error ? reject(error) : resolve([{ ok: 1 }])));
|
|
37
|
+
});
|
|
38
|
+
const settleAll = (error?: Error): void => {
|
|
39
|
+
for (const settle of waiting) settle(error);
|
|
40
|
+
waiting.clear();
|
|
41
|
+
};
|
|
42
|
+
const sql = Object.assign((..._args: unknown[]): FakeQuery => query(), {
|
|
43
|
+
begin: async (run: (tx: () => FakeQuery) => Promise<unknown>): Promise<unknown> => run(query),
|
|
44
|
+
close: async (): Promise<void> => {
|
|
45
|
+
closed = true;
|
|
46
|
+
settleAll(new Error("Connection closed"));
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
// The fake covers the members the guard and these tests use.
|
|
50
|
+
const pool = sql as unknown as SQL;
|
|
51
|
+
server.pools.push({ sql: pool, closed: () => closed, resume: () => settleAll() });
|
|
52
|
+
return pool;
|
|
53
|
+
};
|
|
54
|
+
return Object.assign(server, { open });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function guarded(answers: boolean): {
|
|
58
|
+
server: ReturnType<typeof fakeServer>;
|
|
59
|
+
guard: PoolGuard;
|
|
60
|
+
checks: () => number;
|
|
61
|
+
losses: Error[];
|
|
62
|
+
} {
|
|
63
|
+
const server = fakeServer();
|
|
64
|
+
let checks = 0;
|
|
65
|
+
const guard = new PoolGuard(server.open, {
|
|
66
|
+
stallTimeoutMs: STALL_MS,
|
|
67
|
+
serverAnswers: async () => {
|
|
68
|
+
checks += 1;
|
|
69
|
+
return answers;
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
const losses: Error[] = [];
|
|
73
|
+
guard.onLost((reason) => losses.push(reason));
|
|
74
|
+
return { server, guard, checks: () => checks, losses };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function outcome(pending: Promise<unknown>): Promise<string> {
|
|
78
|
+
return pending.then(() => "resolved", (error: unknown) => `rejected: ${error instanceof Error ? error.message : String(error)}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const pause = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
82
|
+
|
|
83
|
+
describe("PoolGuard", () => {
|
|
84
|
+
it("passes queries, fragments, helpers, and transactions through to the pool", async () => {
|
|
85
|
+
const guard = new PoolGuard(() => new SQL(":memory:", { adapter: "sqlite" }), {
|
|
86
|
+
stallTimeoutMs: STALL_MS,
|
|
87
|
+
serverAnswers: async () => true,
|
|
88
|
+
});
|
|
89
|
+
const { sql } = guard;
|
|
90
|
+
try {
|
|
91
|
+
await sql`CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT)`;
|
|
92
|
+
await sql`INSERT INTO item ${sql([{ id: 1, name: "a" }, { id: 2, name: "b" }], "id", "name")}`;
|
|
93
|
+
const filter = sql`WHERE "name" = ${"b"}`;
|
|
94
|
+
expect(await sql<Array<{ id: number }>>`SELECT "id" FROM item ${filter}`).toEqual([{ id: 2 }]);
|
|
95
|
+
expect(await sql`SELECT count(*) AS n FROM item`.values()).toEqual([[2]]);
|
|
96
|
+
const names = await sql.begin(async (tx) => {
|
|
97
|
+
await tx`UPDATE item SET "name" = 'c' WHERE "id" = 1`;
|
|
98
|
+
return tx`SELECT "name" FROM item ORDER BY "id"`;
|
|
99
|
+
});
|
|
100
|
+
expect(names).toEqual([{ name: "c" }, { name: "b" }]);
|
|
101
|
+
expect(await Promise.all([sql`SELECT 1 AS v`, sql.unsafe("SELECT 2 AS v")])).toEqual([[{ v: 1 }], [{ v: 2 }]]);
|
|
102
|
+
} finally {
|
|
103
|
+
await sql.close();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("replaces the pool when a query stalls and the server does not answer", async () => {
|
|
108
|
+
const { server, guard, checks, losses } = guarded(false);
|
|
109
|
+
await guard.sql`SELECT 1`;
|
|
110
|
+
server.stalled = true;
|
|
111
|
+
const stalled = outcome(guard.sql`SELECT 1`);
|
|
112
|
+
const transaction = outcome(guard.sql.begin(async (tx) => tx`SELECT 1`));
|
|
113
|
+
|
|
114
|
+
expect(await stalled).toBe("rejected: Connection closed");
|
|
115
|
+
expect(await transaction).toBe("rejected: Connection closed");
|
|
116
|
+
expect(checks()).toBe(1);
|
|
117
|
+
expect(losses).toHaveLength(1);
|
|
118
|
+
expect(losses[0]?.message).toContain(`unanswered for ${STALL_MS} ms`);
|
|
119
|
+
expect(server.pools.map((pool) => pool.closed())).toEqual([true, false]);
|
|
120
|
+
|
|
121
|
+
server.stalled = false;
|
|
122
|
+
expect(await guard.sql<Array<{ ok: number }>>`SELECT 1`).toEqual([{ ok: 1 }]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("keeps a slow query running while the server answers", async () => {
|
|
126
|
+
const { server, guard, checks, losses } = guarded(true);
|
|
127
|
+
await guard.sql`SELECT 1`;
|
|
128
|
+
server.stalled = true;
|
|
129
|
+
const slow = outcome(guard.sql`SELECT 1`);
|
|
130
|
+
await pause(STALL_MS * 3);
|
|
131
|
+
|
|
132
|
+
expect(checks()).toBe(1);
|
|
133
|
+
server.pools[0]?.resume();
|
|
134
|
+
expect(await slow).toBe("resolved");
|
|
135
|
+
expect(losses).toHaveLength(0);
|
|
136
|
+
expect(server.pools).toHaveLength(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("checks the server once for queries that stall together", async () => {
|
|
140
|
+
const { server, guard, checks, losses } = guarded(false);
|
|
141
|
+
await guard.sql`SELECT 1`;
|
|
142
|
+
server.stalled = true;
|
|
143
|
+
const results = await Promise.all([1, 2, 3].map(() => outcome(guard.sql`SELECT 1`)));
|
|
144
|
+
|
|
145
|
+
expect(results).toEqual(Array(3).fill("rejected: Connection closed"));
|
|
146
|
+
expect(checks()).toBe(1);
|
|
147
|
+
expect(losses).toHaveLength(1);
|
|
148
|
+
expect(server.pools).toHaveLength(2);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("leaves a pool that never answered to the connection timeout", async () => {
|
|
152
|
+
const { server, guard, checks } = guarded(false);
|
|
153
|
+
server.stalled = true;
|
|
154
|
+
const pending = guard.sql`SELECT 1`;
|
|
155
|
+
void pending.then(undefined, () => undefined);
|
|
156
|
+
await pause(STALL_MS * 3);
|
|
157
|
+
|
|
158
|
+
expect(checks()).toBe(0);
|
|
159
|
+
expect(server.pools).toHaveLength(1);
|
|
160
|
+
server.pools[0]?.resume();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("starts the clock only when a query runs, not when it is built", async () => {
|
|
164
|
+
const { server, guard, checks } = guarded(false);
|
|
165
|
+
await guard.sql`SELECT 1`;
|
|
166
|
+
server.stalled = true;
|
|
167
|
+
guard.sql`WHERE true`;
|
|
168
|
+
await pause(STALL_MS * 3);
|
|
169
|
+
|
|
170
|
+
expect(checks()).toBe(0);
|
|
171
|
+
expect(server.pools).toHaveLength(1);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("answersOnNewConnection", () => {
|
|
176
|
+
it("reports a server that accepts the connection but never answers", async () => {
|
|
177
|
+
const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
178
|
+
try {
|
|
179
|
+
const startedAt = Date.now();
|
|
180
|
+
expect(await answersOnNewConnection(`postgres://podokit:podokit@127.0.0.1:${server.port}/podokit`, 200)).toBe(
|
|
181
|
+
false,
|
|
182
|
+
);
|
|
183
|
+
expect(Date.now() - startedAt).toBeLessThan(1_000);
|
|
184
|
+
} finally {
|
|
185
|
+
server.stop(true);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { SQL } from "bun";
|
|
2
|
+
|
|
3
|
+
// Bun 1.4 treats `close({ timeout: 0 })` like no timeout and waits for every
|
|
4
|
+
// pending query, so a stalled pool is closed after the shortest delay instead.
|
|
5
|
+
const CLOSE_NOW_SECONDS = 0.001;
|
|
6
|
+
|
|
7
|
+
// Pool members whose result is pending database work: `unsafe` and `file` build
|
|
8
|
+
// queries, and `begin` and `transaction` settle when the transaction ends.
|
|
9
|
+
const WATCHED_MEMBERS = new Set<PropertyKey>(["unsafe", "file", "begin", "transaction"]);
|
|
10
|
+
|
|
11
|
+
/** Whether PostgreSQL at `url` answers `SELECT 1` on a new connection within `timeoutMs`. */
|
|
12
|
+
export async function answersOnNewConnection(url: string, timeoutMs: number): Promise<boolean> {
|
|
13
|
+
const sql = new SQL(url, { max: 1, connectionTimeout: timeoutMs / 1000 });
|
|
14
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
15
|
+
const deadline = new Promise<boolean>((resolve) => {
|
|
16
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
return await Promise.race([sql`SELECT 1`.then(() => true, () => false), deadline]);
|
|
20
|
+
} finally {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
void sql.close({ timeout: CLOSE_NOW_SECONDS }).catch(() => undefined);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PoolGuardOptions {
|
|
27
|
+
/** How long a query or transaction may stay pending before the server is checked. */
|
|
28
|
+
stallTimeoutMs: number;
|
|
29
|
+
/** Resolves whether the server answers a new connection now; never rejects. */
|
|
30
|
+
serverAnswers: () => Promise<boolean>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Generation {
|
|
34
|
+
sql: SQL;
|
|
35
|
+
/** Whether a query on this pool has succeeded, so it may hold connections the server stopped answering. */
|
|
36
|
+
answered: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Keeps a Bun SQL pool usable when PostgreSQL keeps its connections open but
|
|
41
|
+
* stops answering, as a paused container, a frozen VM, or a network partition
|
|
42
|
+
* does. Bun SQL has no query timeout, `query.cancel()` does not stop a query
|
|
43
|
+
* that is running, and one pool connection cannot be closed on its own, so a
|
|
44
|
+
* query on such a connection waits until the server answers again or TCP gives
|
|
45
|
+
* up, and the pool keeps the connection.
|
|
46
|
+
*
|
|
47
|
+
* `sql` stands in for the pool and passes every call through. A query or
|
|
48
|
+
* transaction still pending after `stallTimeoutMs` makes the guard check the
|
|
49
|
+
* server on a new connection. When the server does not answer either, the guard
|
|
50
|
+
* opens a new pool and closes the old one, which fails every query still waiting
|
|
51
|
+
* on it with `ERR_POSTGRES_CONNECTION_CLOSED`. A slow query on a server that
|
|
52
|
+
* answers keeps running.
|
|
53
|
+
*/
|
|
54
|
+
export class PoolGuard {
|
|
55
|
+
readonly sql: SQL;
|
|
56
|
+
private generation: Generation;
|
|
57
|
+
private checking?: Promise<void>;
|
|
58
|
+
private readonly lostListeners: Array<(reason: Error) => void> = [];
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
private readonly open: () => SQL,
|
|
62
|
+
private readonly options: PoolGuardOptions,
|
|
63
|
+
) {
|
|
64
|
+
this.generation = { sql: open(), answered: false };
|
|
65
|
+
this.sql = new Proxy(this.generation.sql, {
|
|
66
|
+
apply: (_target, _thisArg, args: unknown[]) => {
|
|
67
|
+
const generation = this.generation;
|
|
68
|
+
return this.watch(generation, Reflect.apply(generation.sql, undefined, args));
|
|
69
|
+
},
|
|
70
|
+
get: (_target, key) => this.member(key),
|
|
71
|
+
has: (_target, key) => Reflect.has(this.generation.sql, key),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Takes the callback to run whenever the guard replaces a pool the server stopped answering. */
|
|
76
|
+
onLost(listener: (reason: Error) => void): void {
|
|
77
|
+
this.lostListeners.push(listener);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private member(key: PropertyKey): unknown {
|
|
81
|
+
const generation = this.generation;
|
|
82
|
+
const value: unknown = Reflect.get(generation.sql, key);
|
|
83
|
+
if (typeof value !== "function") return value;
|
|
84
|
+
return (...args: unknown[]): unknown => {
|
|
85
|
+
const result: unknown = Reflect.apply(value, generation.sql, args);
|
|
86
|
+
return WATCHED_MEMBERS.has(key) ? this.watch(generation, result) : result;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private watch(generation: Generation, result: unknown): unknown {
|
|
91
|
+
if (!(result instanceof Promise)) return result;
|
|
92
|
+
const pending: Promise<unknown> = result;
|
|
93
|
+
const then = pending.then;
|
|
94
|
+
// A transaction is a plain promise that is already running, and `await`
|
|
95
|
+
// does not call `then` on a plain promise.
|
|
96
|
+
if (Object.getPrototypeOf(pending) === Promise.prototype) {
|
|
97
|
+
this.track(generation, pending, then);
|
|
98
|
+
return pending;
|
|
99
|
+
}
|
|
100
|
+
// Bun builds a query without running it, and the same object can be nested
|
|
101
|
+
// in another query as a fragment, so the clock starts with the first `then`,
|
|
102
|
+
// which is what runs it. Every call still reaches Bun's own `then`.
|
|
103
|
+
let tracked = false;
|
|
104
|
+
Object.defineProperty(pending, "then", {
|
|
105
|
+
configurable: true,
|
|
106
|
+
writable: true,
|
|
107
|
+
value: (...args: Parameters<Promise<unknown>["then"]>): Promise<unknown> => {
|
|
108
|
+
const chained = then.apply(pending, args);
|
|
109
|
+
if (!tracked) {
|
|
110
|
+
tracked = true;
|
|
111
|
+
this.track(generation, pending, then);
|
|
112
|
+
}
|
|
113
|
+
return chained;
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
return pending;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private track(generation: Generation, pending: Promise<unknown>, then: Promise<unknown>["then"]): void {
|
|
120
|
+
const timer = setTimeout(() => this.stalled(generation), this.options.stallTimeoutMs);
|
|
121
|
+
timer.unref?.();
|
|
122
|
+
then.call(
|
|
123
|
+
pending,
|
|
124
|
+
() => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
generation.answered = true;
|
|
127
|
+
},
|
|
128
|
+
() => clearTimeout(timer),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private stalled(generation: Generation): void {
|
|
133
|
+
// A pool that never answered holds only connections that are still opening,
|
|
134
|
+
// and the connection timeout ends those.
|
|
135
|
+
if (generation !== this.generation || !generation.answered) return;
|
|
136
|
+
this.checking ??= this.check(generation).finally(() => {
|
|
137
|
+
this.checking = undefined;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async check(generation: Generation): Promise<void> {
|
|
142
|
+
if (await this.options.serverAnswers()) return;
|
|
143
|
+
if (generation !== this.generation) return;
|
|
144
|
+
this.generation = { sql: this.open(), answered: false };
|
|
145
|
+
void generation.sql.close({ timeout: CLOSE_NOW_SECONDS }).catch(() => undefined);
|
|
146
|
+
const reason = new Error(
|
|
147
|
+
`PostgreSQL left a query unanswered for ${this.options.stallTimeoutMs} ms and did not answer a new connection; replaced the pool`,
|
|
148
|
+
);
|
|
149
|
+
for (const listener of this.lostListeners) listener(reason);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const LAUNCHER = join(__dirname, "../../scripts/run-migrations.mjs");
|
|
7
|
+
|
|
8
|
+
interface Launch {
|
|
9
|
+
code: number;
|
|
10
|
+
stdout: string;
|
|
11
|
+
stderr: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Runs the migrate:all launcher in an API layout whose entries print their own path. */
|
|
15
|
+
async function launch(entries: string[], nodeEnv: string, body = ""): Promise<Launch> {
|
|
16
|
+
const root = mkdtempSync(join(tmpdir(), "run-migrations-"));
|
|
17
|
+
try {
|
|
18
|
+
mkdirSync(join(root, "scripts"));
|
|
19
|
+
cpSync(LAUNCHER, join(root, "scripts/run-migrations.mjs"));
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
mkdirSync(dirname(join(root, entry)), { recursive: true });
|
|
22
|
+
writeFileSync(join(root, entry), `console.log(${JSON.stringify(entry)});\n${body}`);
|
|
23
|
+
}
|
|
24
|
+
const child = Bun.spawn([process.execPath, "scripts/run-migrations.mjs"], {
|
|
25
|
+
cwd: root,
|
|
26
|
+
env: { ...process.env, NODE_ENV: nodeEnv },
|
|
27
|
+
stdout: "pipe",
|
|
28
|
+
stderr: "pipe",
|
|
29
|
+
});
|
|
30
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
31
|
+
new Response(child.stdout).text(),
|
|
32
|
+
new Response(child.stderr).text(),
|
|
33
|
+
child.exited,
|
|
34
|
+
]);
|
|
35
|
+
return { code, stdout: stdout.trim(), stderr };
|
|
36
|
+
} finally {
|
|
37
|
+
rmSync(root, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("migrate:all launcher", () => {
|
|
42
|
+
it("runs the application migrations from source in development", async () => {
|
|
43
|
+
const result = await launch(["src/database/migrate.ts", "dist/database/migrate.js"], "development");
|
|
44
|
+
expect(result).toMatchObject({ code: 0, stdout: "src/database/migrate.ts" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("runs the compiled application migrations in production", async () => {
|
|
48
|
+
const result = await launch(["src/database/migrate.ts", "dist/database/migrate.js"], "production");
|
|
49
|
+
expect(result).toMatchObject({ code: 0, stdout: "dist/database/migrate.js" });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("prefers the auth module's entry, which applies the Better Auth schema first", async () => {
|
|
53
|
+
expect(await launch(["src/migrate.ts", "src/database/migrate.ts"], "development"))
|
|
54
|
+
.toMatchObject({ code: 0, stdout: "src/migrate.ts" });
|
|
55
|
+
// A production image has only the compiled output.
|
|
56
|
+
expect(await launch(["dist/migrate.js", "dist/database/migrate.js"], "production"))
|
|
57
|
+
.toMatchObject({ code: 0, stdout: "dist/migrate.js" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("exits with the migration's status", async () => {
|
|
61
|
+
const result = await launch(["src/database/migrate.ts"], "development", "process.exit(3);\n");
|
|
62
|
+
expect(result.code).toBe(3);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("fails when no migration entry exists", async () => {
|
|
66
|
+
const result = await launch([], "development");
|
|
67
|
+
expect(result.code).toBe(1);
|
|
68
|
+
expect(result.stderr).toContain("no migration entry in src or dist");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -49,7 +49,9 @@ async function readiness(
|
|
|
49
49
|
const app = new Elysia()
|
|
50
50
|
.onError(({ error, set }) => {
|
|
51
51
|
set.status = error instanceof AppException ? error.statusCode : 500;
|
|
52
|
-
return
|
|
52
|
+
return error instanceof AppException
|
|
53
|
+
? { code: error.code, message: error.message }
|
|
54
|
+
: { code: "INTERNAL_ERROR" };
|
|
53
55
|
})
|
|
54
56
|
.use(healthPlugin(appContext(env, services)));
|
|
55
57
|
const response = await app.handle(new Request("http://localhost/health/ready"));
|
|
@@ -69,10 +71,29 @@ describe("GET /health/ready", () => {
|
|
|
69
71
|
});
|
|
70
72
|
});
|
|
71
73
|
|
|
72
|
-
it("fails when the database is down", async () => {
|
|
74
|
+
it("fails when the database is down and names it", async () => {
|
|
73
75
|
const result = await readiness({ databaseUp: false });
|
|
74
76
|
|
|
75
|
-
expect(result).toEqual({
|
|
77
|
+
expect(result).toEqual({
|
|
78
|
+
status: 503,
|
|
79
|
+
body: { code: "READINESS_FAILED", message: "One or more dependencies are unavailable: db" },
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("names every module check that is down", async () => {
|
|
84
|
+
const result = await readiness({
|
|
85
|
+
databaseUp: true,
|
|
86
|
+
checks: {
|
|
87
|
+
cache: () => Promise.resolve(),
|
|
88
|
+
redis: () => Promise.reject(new Error("Connection closed")),
|
|
89
|
+
events: () => Promise.reject(new Error("Event transport is not subscribed")),
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result).toEqual({
|
|
94
|
+
status: 503,
|
|
95
|
+
body: { code: "READINESS_FAILED", message: "One or more dependencies are unavailable: redis, events" },
|
|
96
|
+
});
|
|
76
97
|
});
|
|
77
98
|
|
|
78
99
|
it("fails when a module check exceeds its timeout", async () => {
|
|
@@ -81,6 +102,9 @@ describe("GET /health/ready", () => {
|
|
|
81
102
|
checks: { storage: () => new Promise<void>(() => {}) },
|
|
82
103
|
});
|
|
83
104
|
|
|
84
|
-
expect(result).toEqual({
|
|
105
|
+
expect(result).toEqual({
|
|
106
|
+
status: 503,
|
|
107
|
+
body: { code: "READINESS_FAILED", message: "One or more dependencies are unavailable: storage" },
|
|
108
|
+
});
|
|
85
109
|
});
|
|
86
110
|
});
|
|
@@ -14,8 +14,16 @@ export const healthPlugin: AppPlugin = ({ readiness }) =>
|
|
|
14
14
|
"/health/ready",
|
|
15
15
|
async () => {
|
|
16
16
|
const { [DATABASE_READINESS_CHECK]: db, ...checks } = await readiness.run();
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const down = [
|
|
18
|
+
...(db === "up" ? [] : ["db"]),
|
|
19
|
+
...Object.entries(checks).flatMap(([name, status]) => status === "down" ? [name] : []),
|
|
20
|
+
];
|
|
21
|
+
if (down.length > 0) {
|
|
22
|
+
throw new AppException(
|
|
23
|
+
"READINESS_FAILED",
|
|
24
|
+
`One or more dependencies are unavailable: ${down.join(", ")}`,
|
|
25
|
+
503,
|
|
26
|
+
);
|
|
19
27
|
}
|
|
20
28
|
return { status: "ready" as const, db: "up" as const, checks };
|
|
21
29
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createApp } from "./app";
|
|
2
2
|
import { validateEnv } from "./config/env.validation";
|
|
3
|
+
import { apiServerOptions } from "./config/server-options";
|
|
3
4
|
import { createCoreServices } from "./core/services";
|
|
4
5
|
|
|
5
6
|
const env = validateEnv(process.env);
|
|
@@ -9,7 +10,7 @@ const app = createApp({ env, services });
|
|
|
9
10
|
async function bootstrap(): Promise<void> {
|
|
10
11
|
try {
|
|
11
12
|
await services.start();
|
|
12
|
-
app.listen({ hostname: "0.0.0.0", port: env.PORT });
|
|
13
|
+
app.listen({ ...apiServerOptions(env), hostname: "0.0.0.0", port: env.PORT });
|
|
13
14
|
process.stdout.write(`API listening on http://0.0.0.0:${env.PORT}\n`);
|
|
14
15
|
} catch (error) {
|
|
15
16
|
await services.close();
|