@podosoft/podokit 1.4.1 → 1.4.2
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-render.js +2 -1
- package/dist/deploy-render.js +14 -8
- package/dist/lockfile.d.ts +8 -1
- package/dist/lockfile.js +20 -3
- package/dist/templates/fullstack/AGENTS.md +12 -0
- package/dist/templates/fullstack/README.md +1 -1
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +3 -1
- 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 +1 -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 +175 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +170 -11
- package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
- package/dist/templates/fullstack/dot-env.docker.example +4 -0
- package/dist/templates/fullstack/dot-env.example +7 -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/web/src/hooks.server.test.ts +24 -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/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/helpers/exclusive.ts +80 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +47 -43
- 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/two-factor.ui.spec.ts +54 -50
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +11 -5
- 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/todo/README.md +1 -1
- package/package.json +1 -1
|
@@ -311,7 +311,8 @@ function renderComposeDocument(profile, release, images, stateDigest, workerComm
|
|
|
311
311
|
healthcheckPath: "/health/ready",
|
|
312
312
|
}));
|
|
313
313
|
services.push(applicationService(profile, "web", images.web, release, stateDigest, {
|
|
314
|
-
|
|
314
|
+
// The web server's own route: an API outage must not mark it unhealthy.
|
|
315
|
+
healthcheckPath: "/healthz",
|
|
315
316
|
publish: !hasGateway,
|
|
316
317
|
}));
|
|
317
318
|
if (hasGateway) {
|
package/dist/deploy-render.js
CHANGED
|
@@ -386,8 +386,10 @@ metadata:
|
|
|
386
386
|
${entries.length ? `data:\n${entries.map(([key, value]) => ` ${key}: ${quote(value)}`).join("\n")}` : "data: {}"}
|
|
387
387
|
`;
|
|
388
388
|
}
|
|
389
|
-
//
|
|
390
|
-
|
|
389
|
+
// Every rendered API and web probe allows 2 s instead of the Kubernetes default
|
|
390
|
+
// of 1 s: above the API's 800 ms per-check readiness bound, and enough for a busy
|
|
391
|
+
// web server.
|
|
392
|
+
const PROBE_TIMEOUT_SECONDS = 2;
|
|
391
393
|
function deployment(profile, kind, workload, image, rolloutStateDigest, runtimeConfigDigest) {
|
|
392
394
|
const name = `${profile.target.release}-${kind}`;
|
|
393
395
|
const workloadSecret = kind === "api" ? profile.secrets.api : profile.secrets.web;
|
|
@@ -399,25 +401,29 @@ function deployment(profile, kind, workload, image, rolloutStateDigest, runtimeC
|
|
|
399
401
|
? ` readinessProbe:
|
|
400
402
|
httpGet: { path: /health/ready, port: 3000 }
|
|
401
403
|
periodSeconds: 5
|
|
402
|
-
timeoutSeconds: ${
|
|
404
|
+
timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
|
|
403
405
|
livenessProbe:
|
|
404
406
|
httpGet: { path: /health, port: 3000 }
|
|
405
407
|
initialDelaySeconds: 20
|
|
406
408
|
periodSeconds: 10
|
|
407
|
-
timeoutSeconds: ${
|
|
409
|
+
timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
|
|
408
410
|
startupProbe:
|
|
409
411
|
httpGet: { path: /health, port: 3000 }
|
|
410
412
|
failureThreshold: 30
|
|
411
413
|
periodSeconds: 2
|
|
412
|
-
timeoutSeconds: ${
|
|
414
|
+
timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
|
|
413
415
|
`
|
|
414
|
-
:
|
|
415
|
-
|
|
416
|
+
: // The web server's own route: an API outage must not take the web pods
|
|
417
|
+
// out of service or restart them.
|
|
418
|
+
` readinessProbe:
|
|
419
|
+
httpGet: { path: /healthz, port: 3000 }
|
|
416
420
|
periodSeconds: 5
|
|
421
|
+
timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
|
|
417
422
|
livenessProbe:
|
|
418
|
-
httpGet: { path:
|
|
423
|
+
httpGet: { path: /healthz, port: 3000 }
|
|
419
424
|
initialDelaySeconds: 20
|
|
420
425
|
periodSeconds: 10
|
|
426
|
+
timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
|
|
421
427
|
`;
|
|
422
428
|
return `apiVersion: apps/v1
|
|
423
429
|
kind: Deployment
|
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;
|
|
@@ -168,7 +169,17 @@ function classifyTier(relPath, content, ownedGlobs, managedOverrides = []) {
|
|
|
168
169
|
return "assembled";
|
|
169
170
|
return "managed";
|
|
170
171
|
}
|
|
171
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* Whether a path is a local environment file such as `.env`, `.env.docker` or
|
|
174
|
+
* `apps/api/.env.local`. Git ignores these and they hold secrets, so the lock
|
|
175
|
+
* never records them; the committed `*.example` files are project files.
|
|
176
|
+
*/
|
|
177
|
+
function isLocalEnvFile(relPath) {
|
|
178
|
+
const name = relPath.slice(relPath.lastIndexOf("/") + 1);
|
|
179
|
+
return (name === ".env" || name.startsWith(".env.")) && !name.endsWith(".example");
|
|
180
|
+
}
|
|
181
|
+
/** List project files as POSIX-relative paths, skipping generated/vendored dirs
|
|
182
|
+
* and local environment files. */
|
|
172
183
|
function walkFiles(projectRoot, prefix = "") {
|
|
173
184
|
const out = [];
|
|
174
185
|
for (const entry of (0, node_fs_1.readdirSync)((0, node_path_1.join)(projectRoot, prefix))) {
|
|
@@ -178,7 +189,7 @@ function walkFiles(projectRoot, prefix = "") {
|
|
|
178
189
|
if ((0, node_fs_1.statSync)((0, node_path_1.join)(projectRoot, rel)).isDirectory()) {
|
|
179
190
|
out.push(...walkFiles(projectRoot, rel));
|
|
180
191
|
}
|
|
181
|
-
else {
|
|
192
|
+
else if (!isLocalEnvFile(rel)) {
|
|
182
193
|
out.push(rel);
|
|
183
194
|
}
|
|
184
195
|
}
|
|
@@ -198,7 +209,13 @@ function computeFilesLock(projectRoot, ownedGlobs, managedOverrides = []) {
|
|
|
198
209
|
}
|
|
199
210
|
function readFilesLock(projectRoot) {
|
|
200
211
|
const file = filesLockPath(projectRoot);
|
|
201
|
-
|
|
212
|
+
if (!(0, node_fs_1.existsSync)(file))
|
|
213
|
+
return null;
|
|
214
|
+
const lock = JSON.parse((0, node_fs_1.readFileSync)(file, "utf8"));
|
|
215
|
+
// Earlier versions recorded local env files. Leaving them out here keeps
|
|
216
|
+
// update from treating them as PodoKit files, and the next write drops them.
|
|
217
|
+
const files = Object.entries(lock.files).filter(([path]) => !isLocalEnvFile(path));
|
|
218
|
+
return { ...lock, files: Object.fromEntries(files) };
|
|
202
219
|
}
|
|
203
220
|
/**
|
|
204
221
|
* Compare the current disk against the lock: report managed/assembled files the
|
|
@@ -50,6 +50,18 @@ Merge added module settings from `.env.example` into an existing `.env`.
|
|
|
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
52
|
uploads larger than it must use a direct API path (`apiPaths`) instead.
|
|
53
|
+
- Server-side API calls (`serverApiClient`) give up after `BACKEND_TIMEOUT_MS`
|
|
54
|
+
(2000), and the proxy answers 504 `BACKEND_TIMEOUT` when the API sends no
|
|
55
|
+
response headers within `BACKEND_PROXY_TIMEOUT_MS` (30000). Once the API's
|
|
56
|
+
`GET /health` stops answering too, calls throw `BackendUnavailableError` and the
|
|
57
|
+
proxy answers 503 `BACKEND_UNAVAILABLE` at once until it answers again. Handle
|
|
58
|
+
any non-`ApiError` failure in a load function as a temporary outage. Raising
|
|
59
|
+
the API's `apiServerOptions.idleTimeout` needs a matching
|
|
60
|
+
`BACKEND_PROXY_TIMEOUT_MS`.
|
|
61
|
+
- `GET /healthz` (`apps/web/src/routes/healthz/+server.ts`) is the web server's own
|
|
62
|
+
health route for the Kubernetes probes, the Compose healthcheck, and the image
|
|
63
|
+
`HEALTHCHECK`. Keep it free of API calls, so an API outage does not take the web
|
|
64
|
+
out of service.
|
|
53
65
|
- When the root layout or a server hook fails, such as with 503 while the API is
|
|
54
66
|
down, SvelteKit serves the app-owned `apps/web/src/error.html`. It is plain HTML
|
|
55
67
|
with inline styles: no components, app CSS, scripts, or assets, and only the
|
|
@@ -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",
|
|
@@ -13,5 +13,7 @@ export const extensionModules: PodokitModule[] = [];
|
|
|
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
15
|
// win over API_MAX_REQUEST_BODY_SIZE; a function receives the validated
|
|
16
|
-
// environment instead: (env) => ({ maxRequestBodySize: ... }).
|
|
16
|
+
// environment instead: (env) => ({ maxRequestBodySize: ... }). A longer
|
|
17
|
+
// idleTimeout also needs BACKEND_PROXY_TIMEOUT_MS on the web server, which stops
|
|
18
|
+
// waiting for response headers after 30 seconds by default.
|
|
17
19
|
export const apiServerOptions: ApiServerOptionsSource = {};
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { SQL } from "bun";
|
|
2
2
|
import { describe, expect, it } from "bun:test";
|
|
3
3
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { connect, createServer, type AddressInfo } from "node:net";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
6
|
import { dirname, join } from "node:path";
|
|
7
|
+
import { QueryFailedError } from "typeorm";
|
|
6
8
|
import { validateEnv } from "../config/env.validation";
|
|
7
9
|
import { PROVIDERS } from "../config/providers";
|
|
8
10
|
import {
|
|
@@ -199,6 +201,45 @@ describe("isDatabaseUnavailable", () => {
|
|
|
199
201
|
expect(isDatabaseUnavailable(new Error("Failed to save", { cause: socket("ECONNREFUSED") }))).toBe(true);
|
|
200
202
|
});
|
|
201
203
|
|
|
204
|
+
it("recognizes host lookup failures from Bun and Node", () => {
|
|
205
|
+
// Bun reports an unknown host as ENOTFOUND and a lookup timeout as ETIMEOUT;
|
|
206
|
+
// Node reports a lookup it could not complete as EAI_AGAIN.
|
|
207
|
+
for (const code of ["ENOTFOUND", "ETIMEOUT", "EAI_AGAIN"]) {
|
|
208
|
+
expect(isDatabaseUnavailable(Object.assign(new Error(`getaddrinfo ${code} db`), { code }))).toBe(true);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("recognizes a pg client that was ended or broke while a query waited", () => {
|
|
213
|
+
for (const message of [
|
|
214
|
+
"Connection terminated",
|
|
215
|
+
"Client was closed and is not queryable",
|
|
216
|
+
"Client has encountered a connection error and is not queryable",
|
|
217
|
+
]) {
|
|
218
|
+
expect(isDatabaseUnavailable(new Error(message))).toBe(true);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("follows the per-address errors of a connection that tried several addresses", () => {
|
|
223
|
+
const refused = (address: string): Error =>
|
|
224
|
+
Object.assign(new Error(`connect ECONNREFUSED ${address}:5432`), { code: "ECONNREFUSED" });
|
|
225
|
+
expect(isDatabaseUnavailable(new AggregateError([refused("::1"), refused("127.0.0.1")]))).toBe(true);
|
|
226
|
+
expect(isDatabaseUnavailable(new AggregateError([new Error("first"), new Error("second")]))).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("follows the driver error that TypeORM wraps", () => {
|
|
230
|
+
const failed = (driverError: Error): QueryFailedError => new QueryFailedError("SELECT 1", [], driverError);
|
|
231
|
+
expect(isDatabaseUnavailable(failed(new Error("Connection terminated")))).toBe(true);
|
|
232
|
+
const refused = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:5432"), { code: "ECONNREFUSED" });
|
|
233
|
+
expect(isDatabaseUnavailable(failed(new AggregateError([refused])))).toBe(true);
|
|
234
|
+
expect(isDatabaseUnavailable(failed(Object.assign(new Error("duplicate key"), { code: "23505" })))).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("stops at an error that wraps itself", () => {
|
|
238
|
+
const loop = new Error("outer");
|
|
239
|
+
loop.cause = new Error("inner", { cause: loop });
|
|
240
|
+
expect(isDatabaseUnavailable(loop)).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
|
|
202
243
|
it("leaves query and application errors alone", () => {
|
|
203
244
|
expect(isDatabaseUnavailable(new SQL.PostgresError("syntax error", { code: "ERR_POSTGRES_SYNTAX_ERROR" })))
|
|
204
245
|
.toBe(false);
|
|
@@ -212,6 +253,31 @@ describe("isDatabaseUnavailable", () => {
|
|
|
212
253
|
expect(isDatabaseUnavailable(undefined)).toBe(false);
|
|
213
254
|
});
|
|
214
255
|
|
|
256
|
+
it("recognizes a real lookup of a host that does not exist", async () => {
|
|
257
|
+
// .invalid never resolves (RFC 2606). A resolver that does not answer at all
|
|
258
|
+
// fails with ETIMEOUT after about 16 seconds, which is recognized too.
|
|
259
|
+
const error: unknown = await fetch("http://database.invalid/").then(() => undefined, (reason: unknown) => reason);
|
|
260
|
+
expect(isDatabaseUnavailable(error)).toBe(true);
|
|
261
|
+
}, 30_000);
|
|
262
|
+
|
|
263
|
+
it("recognizes a real refused connection to localhost", async () => {
|
|
264
|
+
const server = createServer();
|
|
265
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
266
|
+
const { port } = server.address() as AddressInfo;
|
|
267
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
268
|
+
// localhost may resolve to ::1 and 127.0.0.1; a client that tries both fails
|
|
269
|
+
// with an AggregateError of the two refusals.
|
|
270
|
+
const error = await new Promise<unknown>((resolve) => {
|
|
271
|
+
const socket = connect({ host: "localhost", port });
|
|
272
|
+
socket.once("connect", () => {
|
|
273
|
+
socket.destroy();
|
|
274
|
+
resolve(undefined);
|
|
275
|
+
});
|
|
276
|
+
socket.once("error", resolve);
|
|
277
|
+
});
|
|
278
|
+
expect(isDatabaseUnavailable(error)).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
|
|
215
281
|
it("recognizes a real Bun SQL connection timeout", async () => {
|
|
216
282
|
// Accepts the TCP connection and never answers, like an overloaded server.
|
|
217
283
|
const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
@@ -76,9 +76,14 @@ const UNAVAILABLE_PG_MESSAGES = new Set([
|
|
|
76
76
|
"timeout exceeded when trying to connect",
|
|
77
77
|
"Connection terminated due to connection timeout",
|
|
78
78
|
"Connection terminated unexpectedly",
|
|
79
|
+
// The client was ended, for example by a pool replacement, while a query waited.
|
|
80
|
+
"Connection terminated",
|
|
81
|
+
"Client was closed and is not queryable",
|
|
82
|
+
"Client has encountered a connection error and is not queryable",
|
|
79
83
|
"Query read timeout",
|
|
80
84
|
]);
|
|
81
|
-
// Socket errors from a pg client whose server is gone or
|
|
85
|
+
// Socket and host lookup errors from a pg client whose server is gone or
|
|
86
|
+
// unreachable. Bun reports a lookup timeout as ETIMEOUT, Node as EAI_AGAIN.
|
|
82
87
|
const UNAVAILABLE_SOCKET_CODES = new Set([
|
|
83
88
|
"ECONNREFUSED",
|
|
84
89
|
"ECONNRESET",
|
|
@@ -86,6 +91,9 @@ const UNAVAILABLE_SOCKET_CODES = new Set([
|
|
|
86
91
|
"EHOSTUNREACH",
|
|
87
92
|
"ENETUNREACH",
|
|
88
93
|
"EPIPE",
|
|
94
|
+
"ENOTFOUND",
|
|
95
|
+
"ETIMEOUT",
|
|
96
|
+
"EAI_AGAIN",
|
|
89
97
|
]);
|
|
90
98
|
// SQLSTATEs for a server shutting down or refusing connections (class 57P) and
|
|
91
99
|
// for broken connections (class 08); pg reports them as `code`, Bun SQL as `errno`.
|
|
@@ -95,18 +103,33 @@ function unavailableSqlState(value: unknown): boolean {
|
|
|
95
103
|
return typeof value === "string" && (UNAVAILABLE_SQLSTATES.has(value) || /^08[0-9A-Z]{3}$/.test(value));
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
function signalsUnavailable(error: Error): boolean {
|
|
107
|
+
if (error instanceof SQL.PostgresError) {
|
|
108
|
+
if (UNAVAILABLE_SQL_CODES.has(error.code) || unavailableSqlState(error.errno)) return true;
|
|
109
|
+
} else {
|
|
110
|
+
const code = (error as { code?: unknown }).code;
|
|
111
|
+
if (typeof code === "string" && (UNAVAILABLE_SOCKET_CODES.has(code) || unavailableSqlState(code))) return true;
|
|
112
|
+
}
|
|
113
|
+
return UNAVAILABLE_PG_MESSAGES.has(error.message);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Bounds the walk over wrapped errors, which may form a cycle.
|
|
117
|
+
const MAX_WRAPPED_ERRORS = 16;
|
|
118
|
+
|
|
98
119
|
/** Whether an error, or one it wraps, means the database is unavailable rather than the request being wrong. */
|
|
99
120
|
export function isDatabaseUnavailable(error: unknown): boolean {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
121
|
+
const pending: unknown[] = [error];
|
|
122
|
+
const seen = new Set<Error>();
|
|
123
|
+
while (pending.length > 0 && seen.size < MAX_WRAPPED_ERRORS) {
|
|
124
|
+
const current = pending.shift();
|
|
125
|
+
if (!(current instanceof Error) || seen.has(current)) continue;
|
|
126
|
+
seen.add(current);
|
|
127
|
+
if (signalsUnavailable(current)) return true;
|
|
128
|
+
// TypeORM's QueryFailedError keeps the driver's error as `driverError`, and
|
|
129
|
+
// a connection that tried several addresses, such as IPv6 and IPv4 for
|
|
130
|
+
// localhost, fails with an AggregateError of the per-address errors.
|
|
131
|
+
pending.push(current.cause, (current as { driverError?: unknown }).driverError);
|
|
132
|
+
if (current instanceof AggregateError) pending.push(...current.errors);
|
|
110
133
|
}
|
|
111
134
|
return false;
|
|
112
135
|
}
|
|
@@ -11,18 +11,29 @@ interface Launch {
|
|
|
11
11
|
stderr: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
interface LaunchOptions {
|
|
15
|
+
/** Starts the launcher from the directory above the API, like a repository root. */
|
|
16
|
+
fromParent?: boolean;
|
|
17
|
+
files?: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
/** 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
|
|
21
|
+
async function launch(entries: string[], nodeEnv: string, body = "", options: LaunchOptions = {}): Promise<Launch> {
|
|
22
|
+
const parent = mkdtempSync(join(tmpdir(), "run-migrations-"));
|
|
23
|
+
const root = join(parent, "api");
|
|
17
24
|
try {
|
|
18
|
-
mkdirSync(join(root, "scripts"));
|
|
25
|
+
mkdirSync(join(root, "scripts"), { recursive: true });
|
|
19
26
|
cpSync(LAUNCHER, join(root, "scripts/run-migrations.mjs"));
|
|
20
27
|
for (const entry of entries) {
|
|
21
28
|
mkdirSync(dirname(join(root, entry)), { recursive: true });
|
|
22
29
|
writeFileSync(join(root, entry), `console.log(${JSON.stringify(entry)});\n${body}`);
|
|
23
30
|
}
|
|
24
|
-
const
|
|
25
|
-
|
|
31
|
+
for (const [path, content] of Object.entries(options.files ?? {})) {
|
|
32
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
33
|
+
writeFileSync(join(root, path), content);
|
|
34
|
+
}
|
|
35
|
+
const child = Bun.spawn([process.execPath, options.fromParent ? "api/scripts/run-migrations.mjs" : "scripts/run-migrations.mjs"], {
|
|
36
|
+
cwd: options.fromParent ? parent : root,
|
|
26
37
|
env: { ...process.env, NODE_ENV: nodeEnv },
|
|
27
38
|
stdout: "pipe",
|
|
28
39
|
stderr: "pipe",
|
|
@@ -34,7 +45,7 @@ async function launch(entries: string[], nodeEnv: string, body = ""): Promise<La
|
|
|
34
45
|
]);
|
|
35
46
|
return { code, stdout: stdout.trim(), stderr };
|
|
36
47
|
} finally {
|
|
37
|
-
rmSync(
|
|
48
|
+
rmSync(parent, { recursive: true, force: true });
|
|
38
49
|
}
|
|
39
50
|
}
|
|
40
51
|
|
|
@@ -62,6 +73,43 @@ describe("migrate:all launcher", () => {
|
|
|
62
73
|
expect(result.code).toBe(3);
|
|
63
74
|
});
|
|
64
75
|
|
|
76
|
+
it("runs the migration in the API directory when started from elsewhere", async () => {
|
|
77
|
+
const apiRoot = 'realpathSync(join(import.meta.dir, "../.."))';
|
|
78
|
+
const result = await launch(
|
|
79
|
+
["src/database/migrate.ts"],
|
|
80
|
+
"development",
|
|
81
|
+
`import { realpathSync } from "node:fs";\nimport { join } from "node:path";\n` +
|
|
82
|
+
`console.log(realpathSync(process.cwd()) === ${apiRoot});\n`,
|
|
83
|
+
{ fromParent: true },
|
|
84
|
+
);
|
|
85
|
+
expect(result).toMatchObject({ code: 0, stdout: "src/database/migrate.ts\ntrue" });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("applies the API's decorator settings when started from elsewhere", async () => {
|
|
89
|
+
// TypeORM entities need legacy decorators, which Bun takes from the
|
|
90
|
+
// tsconfig.json of the working directory rather than of the source file.
|
|
91
|
+
const result = await launch(
|
|
92
|
+
["src/database/migrate.ts"],
|
|
93
|
+
"development",
|
|
94
|
+
'await import("../entity.ts");\n',
|
|
95
|
+
{
|
|
96
|
+
fromParent: true,
|
|
97
|
+
files: {
|
|
98
|
+
"tsconfig.json": JSON.stringify({ compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true } }),
|
|
99
|
+
"src/entity.ts": [
|
|
100
|
+
"const Column = (): PropertyDecorator => (target) => console.log(typeof target);",
|
|
101
|
+
"export class Item {",
|
|
102
|
+
" @Column()",
|
|
103
|
+
" name!: string;",
|
|
104
|
+
"}",
|
|
105
|
+
"",
|
|
106
|
+
].join("\n"),
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
expect(result).toMatchObject({ code: 0, stdout: "src/database/migrate.ts\nobject" });
|
|
111
|
+
});
|
|
112
|
+
|
|
65
113
|
it("fails when no migration entry exists", async () => {
|
|
66
114
|
const result = await launch([], "development");
|
|
67
115
|
expect(result.code).toBe(1);
|
|
@@ -37,5 +37,5 @@ WORKDIR /app/apps/web
|
|
|
37
37
|
USER bun
|
|
38
38
|
EXPOSE 3000
|
|
39
39
|
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=6 \
|
|
40
|
-
CMD bun -e "fetch('http://127.0.0.1:' + (process.env.PORT ?? '3000') + '/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
|
40
|
+
CMD bun -e "fetch('http://127.0.0.1:' + (process.env.PORT ?? '3000') + '/healthz').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
|
41
41
|
CMD ["bun", "./build"]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { ApiError } from "@podosoft/podokit-api-client";
|
|
2
|
+
import type { RequestEvent } from "@sveltejs/kit";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { BackendUnavailableError, serverApiClient } from "./api";
|
|
5
|
+
import { backendReachable, checkBackend, reportBackendAnswer } from "./backend-proxy";
|
|
6
|
+
|
|
7
|
+
type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
8
|
+
|
|
9
|
+
function requestEvent(fetch: Fetch): RequestEvent {
|
|
10
|
+
return {
|
|
11
|
+
request: new Request("http://app.localhost/services", { headers: { cookie: "session=abc" } }),
|
|
12
|
+
getClientAddress: () => "203.0.113.9",
|
|
13
|
+
fetch,
|
|
14
|
+
} as unknown as RequestEvent;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Never answers on its own, like a blackholed connection or a hung API, and
|
|
18
|
+
// rejects when its signal aborts.
|
|
19
|
+
const hang: Fetch = (_input, init) =>
|
|
20
|
+
new Promise((_settle, reject) => {
|
|
21
|
+
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason));
|
|
22
|
+
});
|
|
23
|
+
const refuse: Fetch = async () => {
|
|
24
|
+
throw new TypeError("Unable to connect. Is the computer able to access the url?");
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// The background GET /health check uses the global fetch.
|
|
28
|
+
function stubHealth(health: Fetch): ReturnType<typeof vi.fn<Fetch>> {
|
|
29
|
+
const fetch = vi.fn<Fetch>(health);
|
|
30
|
+
vi.stubGlobal("fetch", fetch);
|
|
31
|
+
return fetch;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("serverApiClient", () => {
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
reportBackendAnswer();
|
|
37
|
+
vi.unstubAllGlobals();
|
|
38
|
+
vi.unstubAllEnvs();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("forwards the cookie and client address", async () => {
|
|
42
|
+
let headers: Headers | undefined;
|
|
43
|
+
const api = serverApiClient(requestEvent(async (_input, init) => {
|
|
44
|
+
headers = new Headers(init?.headers);
|
|
45
|
+
return Response.json({ ok: true });
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
await expect(api.get("/site/settings")).resolves.toEqual({ ok: true });
|
|
49
|
+
expect(headers?.get("cookie")).toBe("session=abc");
|
|
50
|
+
expect(headers?.get("x-forwarded-for")).toBe("203.0.113.9");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("gives up on a call that outlasts BACKEND_TIMEOUT_MS", async () => {
|
|
54
|
+
vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
|
|
55
|
+
stubHealth(async () => new Response("ok"));
|
|
56
|
+
const api = serverApiClient(requestEvent(hang));
|
|
57
|
+
|
|
58
|
+
const started = Date.now();
|
|
59
|
+
await expect(api.get("/reports/slow")).rejects.toMatchObject({ name: "TimeoutError" });
|
|
60
|
+
expect(Date.now() - started).toBeLessThan(5_000);
|
|
61
|
+
await checkBackend();
|
|
62
|
+
expect(backendReachable()).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("marks the API unreachable when its health check fails as well", async () => {
|
|
66
|
+
vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
|
|
67
|
+
stubHealth(hang);
|
|
68
|
+
|
|
69
|
+
await expect(serverApiClient(requestEvent(hang)).get("/site/settings")).rejects.toMatchObject({
|
|
70
|
+
name: "TimeoutError",
|
|
71
|
+
});
|
|
72
|
+
await checkBackend();
|
|
73
|
+
expect(backendReachable()).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("fails at once without contacting the API while it is unreachable", async () => {
|
|
77
|
+
stubHealth(refuse);
|
|
78
|
+
await checkBackend();
|
|
79
|
+
const fetch = vi.fn<Fetch>(async () => Response.json({}));
|
|
80
|
+
|
|
81
|
+
await expect(serverApiClient(requestEvent(fetch)).get("/blog")).rejects.toBeInstanceOf(BackendUnavailableError);
|
|
82
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("fails calls in flight once the API is found unreachable", async () => {
|
|
86
|
+
stubHealth(refuse);
|
|
87
|
+
const pending = serverApiClient(requestEvent(hang)).get("/site/settings");
|
|
88
|
+
const failed = expect(pending).rejects.toBeInstanceOf(BackendUnavailableError);
|
|
89
|
+
|
|
90
|
+
await checkBackend();
|
|
91
|
+
|
|
92
|
+
await failed;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("treats an error status as an answer from a reachable API", async () => {
|
|
96
|
+
const health = stubHealth(refuse);
|
|
97
|
+
const api = serverApiClient(requestEvent(async () =>
|
|
98
|
+
Response.json({ success: false, error: { code: "INTERNAL", message: "boom", statusCode: 500 } }, { status: 500 })
|
|
99
|
+
));
|
|
100
|
+
|
|
101
|
+
await expect(api.get("/blog")).rejects.toBeInstanceOf(ApiError);
|
|
102
|
+
expect(health).not.toHaveBeenCalled();
|
|
103
|
+
expect(backendReachable()).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("does not report a call the caller aborted as an outage", async () => {
|
|
107
|
+
const health = stubHealth(refuse);
|
|
108
|
+
const caller = new AbortController();
|
|
109
|
+
const pending = serverApiClient(requestEvent(hang)).get("/blog", { signal: caller.signal });
|
|
110
|
+
caller.abort();
|
|
111
|
+
|
|
112
|
+
await expect(pending).rejects.toBeDefined();
|
|
113
|
+
expect(health).not.toHaveBeenCalled();
|
|
114
|
+
expect(backendReachable()).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -1,18 +1,48 @@
|
|
|
1
1
|
import type { RequestEvent } from "@sveltejs/kit";
|
|
2
2
|
import { createApiClient, type ApiClient } from "@podosoft/podokit-api-client";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
backendBaseUrl,
|
|
5
|
+
backendOutageSignal,
|
|
6
|
+
backendReachable,
|
|
7
|
+
backendTimeoutMs,
|
|
8
|
+
reportBackendAnswer,
|
|
9
|
+
reportBackendFailure,
|
|
10
|
+
resolveClientIp,
|
|
11
|
+
} from "./backend-proxy";
|
|
12
|
+
|
|
13
|
+
/** Thrown without contacting the API while it is known to be unreachable. */
|
|
14
|
+
export class BackendUnavailableError extends Error {
|
|
15
|
+
constructor() {
|
|
16
|
+
super("The API is unavailable");
|
|
17
|
+
this.name = "BackendUnavailableError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
4
20
|
|
|
5
21
|
// Server-side client for load functions and hooks: points at the internal
|
|
6
|
-
// backend URL and forwards the incoming request's cookies.
|
|
22
|
+
// backend URL and forwards the incoming request's cookies. Each call is bounded
|
|
23
|
+
// by BACKEND_TIMEOUT_MS, so a page renders its fallbacks instead of waiting for
|
|
24
|
+
// an API that does not answer.
|
|
7
25
|
export function serverApiClient(event: RequestEvent): ApiClient {
|
|
8
26
|
const cookie = event.request.headers.get("cookie") ?? "";
|
|
9
27
|
const clientIp = resolveClientIp(event.getClientAddress);
|
|
10
28
|
const fetchWithCookies: typeof fetch = Object.assign(
|
|
11
|
-
(input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
29
|
+
async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]): Promise<Response> => {
|
|
30
|
+
if (!backendReachable()) throw new BackendUnavailableError();
|
|
12
31
|
const headers = new Headers(init?.headers);
|
|
13
32
|
if (cookie) headers.set("cookie", cookie);
|
|
14
33
|
if (clientIp) headers.set("x-forwarded-for", clientIp);
|
|
15
|
-
|
|
34
|
+
const outage = backendOutageSignal();
|
|
35
|
+
const signals = [AbortSignal.timeout(backendTimeoutMs()), outage];
|
|
36
|
+
if (init?.signal) signals.push(init.signal);
|
|
37
|
+
try {
|
|
38
|
+
const response = await event.fetch(input, { ...init, headers, signal: AbortSignal.any(signals) });
|
|
39
|
+
reportBackendAnswer();
|
|
40
|
+
return response;
|
|
41
|
+
} catch (cause: unknown) {
|
|
42
|
+
if (outage.aborted) throw new BackendUnavailableError();
|
|
43
|
+
if (!init?.signal?.aborted) void reportBackendFailure();
|
|
44
|
+
throw cause;
|
|
45
|
+
}
|
|
16
46
|
},
|
|
17
47
|
{ preconnect: fetch.preconnect },
|
|
18
48
|
);
|