@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.
Files changed (44) hide show
  1. package/dist/deploy-compose-render.js +2 -1
  2. package/dist/deploy-render.js +14 -8
  3. package/dist/lockfile.d.ts +8 -1
  4. package/dist/lockfile.js +20 -3
  5. package/dist/templates/fullstack/AGENTS.md +12 -0
  6. package/dist/templates/fullstack/README.md +1 -1
  7. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
  8. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +3 -1
  9. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +66 -0
  10. package/dist/templates/fullstack/apps/api/src/database/database.ts +34 -11
  11. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +54 -6
  12. package/dist/templates/fullstack/apps/web/Dockerfile +1 -1
  13. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
  14. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
  15. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +175 -0
  16. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +170 -11
  17. package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
  18. package/dist/templates/fullstack/dot-env.docker.example +4 -0
  19. package/dist/templates/fullstack/dot-env.example +7 -0
  20. package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
  21. package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
  22. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
  23. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
  25. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
  26. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
  27. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
  28. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
  29. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
  30. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +47 -43
  31. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
  32. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
  33. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
  34. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
  35. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
  36. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
  37. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
  38. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
  39. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
  40. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +11 -5
  41. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
  42. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
  43. package/dist/templates/todo/README.md +1 -1
  44. 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
- healthcheckPath: "/",
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) {
@@ -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
- // Above the API's 800 ms per-check readiness bound; the Kubernetes default is 1 s.
390
- const API_PROBE_TIMEOUT_SECONDS = 2;
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: ${API_PROBE_TIMEOUT_SECONDS}
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: ${API_PROBE_TIMEOUT_SECONDS}
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: ${API_PROBE_TIMEOUT_SECONDS}
414
+ timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
413
415
  `
414
- : ` readinessProbe:
415
- httpGet: { path: /, port: 3000 }
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: /, port: 3000 }
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
@@ -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
- /** List project files as POSIX-relative paths, skipping generated/vendored dirs. */
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
- /** List project files as POSIX-relative paths, skipping generated/vendored dirs. */
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
- return (0, node_fs_1.existsSync)(file) ? JSON.parse((0, node_fs_1.readFileSync)(file, "utf8")) : null;
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 unreachable.
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
- let current: unknown = error;
101
- for (let depth = 0; depth < 5 && current instanceof Error; depth += 1) {
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
- }
108
- if (UNAVAILABLE_PG_MESSAGES.has(current.message)) return true;
109
- current = current.cause;
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 root = mkdtempSync(join(tmpdir(), "run-migrations-"));
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 child = Bun.spawn([process.execPath, "scripts/run-migrations.mjs"], {
25
- cwd: root,
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(root, { recursive: true, force: true });
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 { backendBaseUrl, resolveClientIp } from "./backend-proxy";
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
- return event.fetch(input, { ...init, headers });
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
  );