@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
@@ -3,9 +3,13 @@ import type { AddressInfo } from "node:net";
3
3
  import { gzipSync } from "node:zlib";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import {
6
+ backendReachable,
6
7
  bodyLimitFor,
8
+ checkBackend,
9
+ envMilliseconds,
7
10
  IN_MEMORY_RESPONSE_BYTES,
8
11
  proxyRequest,
12
+ reportBackendAnswer,
9
13
  rawSubpath,
10
14
  resolveClientIp,
11
15
  sortedBodyLimits,
@@ -564,3 +568,174 @@ describe("backend proxy application body limits", () => {
564
568
  );
565
569
  });
566
570
  });
571
+
572
+ describe("backend proxy outages", () => {
573
+ type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
574
+
575
+ afterEach(() => {
576
+ reportBackendAnswer();
577
+ vi.unstubAllGlobals();
578
+ vi.unstubAllEnvs();
579
+ });
580
+
581
+ // Never answers on its own, like a blackholed connection or a hung API, and
582
+ // rejects when its signal aborts.
583
+ const hang: Fetch = (_input, init) =>
584
+ new Promise((_settle, reject) => {
585
+ init?.signal?.addEventListener("abort", () => reject(init.signal?.reason));
586
+ });
587
+ const refuse: Fetch = async () => {
588
+ throw new TypeError("Unable to connect. Is the computer able to access the url?");
589
+ };
590
+
591
+ // Routes the background GET /health check and the proxied request separately.
592
+ function stubApi(health: Fetch, upstream: Fetch): ReturnType<typeof vi.fn<Fetch>> {
593
+ const fetch = vi.fn<Fetch>((input, init) =>
594
+ String(input).endsWith("/health") ? health(input, init) : upstream(input, init),
595
+ );
596
+ vi.stubGlobal("fetch", fetch);
597
+ return fetch;
598
+ }
599
+
600
+ async function takeApiDown(): Promise<void> {
601
+ stubApi(refuse, refuse);
602
+ await checkBackend();
603
+ expect(backendReachable()).toBe(false);
604
+ }
605
+
606
+ it("answers 503 with the error envelope when the API cannot be reached", async () => {
607
+ stubApi(refuse, refuse);
608
+
609
+ const response = await proxyRequest(new Request("http://app.localhost/api/blog"), "http://api:5002/blog");
610
+
611
+ expect(response.status).toBe(503);
612
+ expect(response.headers.get("retry-after")).toBe("2");
613
+ expect(response.headers.get("cache-control")).toBe("no-store");
614
+ expect(await response.json()).toMatchObject({
615
+ success: false,
616
+ error: { code: "BACKEND_UNAVAILABLE", statusCode: 503, path: "/api/blog" },
617
+ });
618
+ await checkBackend();
619
+ expect(backendReachable()).toBe(false);
620
+ });
621
+
622
+ it("answers 504 when the API sends no response headers before the deadline", async () => {
623
+ vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "20");
624
+ stubApi(async () => new Response("ok"), hang);
625
+
626
+ const response = await proxyRequest(new Request("http://app.localhost/api/report"), "http://api:5002/report");
627
+
628
+ expect(response.status).toBe(504);
629
+ expect(response.headers.get("retry-after")).toBeNull();
630
+ expect(await response.json()).toMatchObject({ error: { code: "BACKEND_TIMEOUT", statusCode: 504 } });
631
+ await checkBackend();
632
+ expect(backendReachable()).toBe(true);
633
+ });
634
+
635
+ it("fails requests still waiting for headers once the API is found unreachable", async () => {
636
+ vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
637
+ stubApi(hang, hang);
638
+
639
+ const started = Date.now();
640
+ const response = await proxyRequest(new Request("http://app.localhost/api/blog"), "http://api:5002/blog");
641
+
642
+ expect(Date.now() - started).toBeLessThan(5_000);
643
+ expect(response.status).toBe(503);
644
+ expect(await response.json()).toMatchObject({ error: { code: "BACKEND_UNAVAILABLE" } });
645
+ expect(backendReachable()).toBe(false);
646
+ });
647
+
648
+ it("keeps waiting for a slow endpoint while the API answers its health check", async () => {
649
+ vi.stubEnv("BACKEND_INTERNAL_URL", "http://api:5002");
650
+ vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
651
+ const fetch = stubApi(
652
+ async () => new Response("ok"),
653
+ () => new Promise((settle) => setTimeout(() => settle(Response.json({ done: true })), 80)),
654
+ );
655
+
656
+ const response = await proxyRequest(new Request("http://app.localhost/api/report"), "http://api:5002/report");
657
+
658
+ expect(response.status).toBe(200);
659
+ expect(await response.json()).toEqual({ done: true });
660
+ expect(fetch.mock.calls.map(([input]) => String(input))).toEqual([
661
+ "http://api:5002/report",
662
+ "http://api:5002/health",
663
+ ]);
664
+ expect(backendReachable()).toBe(true);
665
+ });
666
+
667
+ it("answers at once without contacting the API while it is unreachable", async () => {
668
+ await takeApiDown();
669
+ const fetch = stubApi(refuse, async () => new Response(null, { status: 204 }));
670
+
671
+ const response = await proxyRequest(
672
+ new Request("http://app.localhost/api/blog", { method: "POST", body: "{}" }),
673
+ "http://api:5002/blog",
674
+ );
675
+
676
+ expect(response.status).toBe(503);
677
+ expect(fetch).not.toHaveBeenCalled();
678
+ });
679
+
680
+ it("checks the API again after the retry interval and recovers on any HTTP answer", async () => {
681
+ await takeApiDown();
682
+ const fetch = stubApi(async () => new Response("unavailable", { status: 500 }), refuse);
683
+
684
+ expect(backendReachable(Date.now() + 2_000)).toBe(false);
685
+ await checkBackend();
686
+
687
+ expect(fetch).toHaveBeenCalledTimes(1);
688
+ expect(backendReachable()).toBe(true);
689
+ });
690
+
691
+ it("relays an error status without checking the API", async () => {
692
+ const fetch = stubApi(refuse, async () => Response.json({ success: false }, { status: 500 }));
693
+
694
+ const response = await proxyRequest(new Request("http://app.localhost/api/blog"), "http://api:5002/blog");
695
+
696
+ expect(response.status).toBe(500);
697
+ expect(fetch).toHaveBeenCalledTimes(1);
698
+ expect(backendReachable()).toBe(true);
699
+ });
700
+
701
+ it("does not report a browser that disconnected as an outage", async () => {
702
+ const fetch = stubApi(refuse, hang);
703
+ const client = new AbortController();
704
+ const pending = proxyRequest(
705
+ new Request("http://app.localhost/api/events", { signal: client.signal }),
706
+ "http://api:5002/events",
707
+ );
708
+ client.abort();
709
+
710
+ await expect(pending).rejects.toBeDefined();
711
+ expect(fetch).toHaveBeenCalledTimes(1);
712
+ expect(backendReachable()).toBe(true);
713
+ });
714
+
715
+ it("stops the deadline once response headers arrive, so a stream keeps flowing", async () => {
716
+ vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "20");
717
+ let upstreamSignal: AbortSignal | undefined;
718
+ stubApi(refuse, async (_input, init) => {
719
+ upstreamSignal = init?.signal ?? undefined;
720
+ return new Response(new ReadableStream(), { headers: { "content-type": "text/event-stream" } });
721
+ });
722
+
723
+ const response = await proxyRequest(new Request("http://app.localhost/api/events"), "http://api:5002/events");
724
+ await new Promise((settle) => setTimeout(settle, 60));
725
+
726
+ expect(response.status).toBe(200);
727
+ expect(upstreamSignal?.aborted).toBe(false);
728
+ });
729
+
730
+ it.each(["0", "-1", "1.5", "soon", "2147483648"])("rejects the invalid timeout %s", (value) => {
731
+ vi.stubEnv("BACKEND_TIMEOUT_MS", value);
732
+ expect(() => envMilliseconds("BACKEND_TIMEOUT_MS", 2_000)).toThrow("whole number of milliseconds");
733
+ });
734
+
735
+ it("uses the default timeout when the variable is unset or empty", () => {
736
+ vi.stubEnv("BACKEND_TIMEOUT_MS", "");
737
+ expect(envMilliseconds("BACKEND_TIMEOUT_MS", 2_000)).toBe(2_000);
738
+ vi.stubEnv("BACKEND_TIMEOUT_MS", "1500");
739
+ expect(envMilliseconds("BACKEND_TIMEOUT_MS", 2_000)).toBe(1_500);
740
+ });
741
+ });
@@ -218,6 +218,135 @@ export function backendBaseUrl(): string {
218
218
  return process.env.BACKEND_INTERNAL_URL ?? "http://localhost:5002";
219
219
  }
220
220
 
221
+ /**
222
+ * Milliseconds one server-side API call may take, response body included, and
223
+ * how long the proxy waits for response headers before it checks whether the
224
+ * API still answers at all.
225
+ */
226
+ const DEFAULT_BACKEND_TIMEOUT_MS = 2_000;
227
+ /**
228
+ * Milliseconds the proxy waits for the API's response headers, matching the
229
+ * API's default idle timeout. Streamed bodies are not cut off once they start.
230
+ */
231
+ const DEFAULT_BACKEND_PROXY_TIMEOUT_MS = 30_000;
232
+ /** Seconds between checks while the API is unreachable, also sent as Retry-After. */
233
+ const BACKEND_RETRY_AFTER_SECONDS = 2;
234
+ const MAX_TIMER_MS = 2_147_483_647;
235
+
236
+ /** A whole number of milliseconds from the environment, or the default when unset or empty. */
237
+ export function envMilliseconds(name: string, fallback: number): number {
238
+ const raw = process.env[name]?.trim();
239
+ if (!raw) return fallback;
240
+ const value = Number(raw);
241
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_TIMER_MS) {
242
+ throw new Error(
243
+ `${name} must be a whole number of milliseconds from 1 to ${MAX_TIMER_MS}: ${JSON.stringify(raw)}`,
244
+ );
245
+ }
246
+ return value;
247
+ }
248
+
249
+ export function backendTimeoutMs(): number {
250
+ return envMilliseconds("BACKEND_TIMEOUT_MS", DEFAULT_BACKEND_TIMEOUT_MS);
251
+ }
252
+
253
+ function backendProxyTimeoutMs(): number {
254
+ return envMilliseconds("BACKEND_PROXY_TIMEOUT_MS", DEFAULT_BACKEND_PROXY_TIMEOUT_MS);
255
+ }
256
+
257
+ // An invalid value stops the server when this module loads instead of failing
258
+ // every request that reads it.
259
+ backendTimeoutMs();
260
+ backendProxyTimeoutMs();
261
+
262
+ // The API counts as unreachable only when GET /health gets no HTTP answer
263
+ // within BACKEND_TIMEOUT_MS. It is checked when a request gets no answer, or
264
+ // when the proxy waits longer than that for response headers, so one slow
265
+ // endpoint does not fail every request in the process. Once the API is found
266
+ // unreachable, requests still waiting for it fail, new ones fail at once instead
267
+ // of each waiting for a refused connection, a DNS lookup, or a hung server, and
268
+ // a background check runs at most every BACKEND_RETRY_AFTER_SECONDS. Any HTTP
269
+ // answer, including an error status, means the API is reachable.
270
+ let unreachable = false;
271
+ let answers = 0;
272
+ let lastCheckEnded = 0;
273
+ let pendingCheck: Promise<void> | undefined;
274
+ let outage = new AbortController();
275
+
276
+ /** Aborts when the API is found unreachable, failing the requests that still wait for it. */
277
+ export function backendOutageSignal(): AbortSignal {
278
+ return outage.signal;
279
+ }
280
+
281
+ /** Check whether the API answers at all. Concurrent callers share one check. */
282
+ export function checkBackend(): Promise<void> {
283
+ if (!pendingCheck) {
284
+ const answersBefore = answers;
285
+ pendingCheck = fetch(`${backendBaseUrl()}/health`, { signal: AbortSignal.timeout(backendTimeoutMs()) })
286
+ .then(
287
+ (response) => {
288
+ reportBackendAnswer();
289
+ return response.body?.cancel().catch(() => undefined);
290
+ },
291
+ () => {
292
+ if (answers !== answersBefore) return;
293
+ unreachable = true;
294
+ outage.abort(new DOMException("The API is unavailable", "AbortError"));
295
+ outage = new AbortController();
296
+ },
297
+ )
298
+ .finally(() => {
299
+ lastCheckEnded = Date.now();
300
+ pendingCheck = undefined;
301
+ });
302
+ }
303
+ return pendingCheck;
304
+ }
305
+
306
+ /** Record an HTTP answer from the API, whatever its status. */
307
+ export function reportBackendAnswer(): void {
308
+ answers += 1;
309
+ unreachable = false;
310
+ }
311
+
312
+ /** Record a request that got no HTTP answer, and check the API unless it is already known to be down. */
313
+ export function reportBackendFailure(): Promise<void> {
314
+ return unreachable ? Promise.resolve() : checkBackend();
315
+ }
316
+
317
+ /**
318
+ * False while the API is known to be unreachable. Such a call also starts a
319
+ * background check when none ran in the last BACKEND_RETRY_AFTER_SECONDS.
320
+ */
321
+ export function backendReachable(now = Date.now()): boolean {
322
+ if (!unreachable) return true;
323
+ if (!pendingCheck && now - lastCheckEnded >= BACKEND_RETRY_AFTER_SECONDS * 1000) void checkBackend();
324
+ return false;
325
+ }
326
+
327
+ function backendFailure(path: string, statusCode: 503 | 504): Response {
328
+ const timedOut = statusCode === 504;
329
+ return Response.json(
330
+ {
331
+ success: false,
332
+ error: {
333
+ code: timedOut ? "BACKEND_TIMEOUT" : "BACKEND_UNAVAILABLE",
334
+ message: timedOut ? "The API did not respond in time" : "The API is unavailable",
335
+ statusCode,
336
+ path,
337
+ timestamp: new Date().toISOString(),
338
+ },
339
+ },
340
+ {
341
+ status: statusCode,
342
+ headers: {
343
+ "cache-control": "no-store",
344
+ ...(timedOut ? {} : { "retry-after": String(BACKEND_RETRY_AFTER_SECONDS) }),
345
+ },
346
+ },
347
+ );
348
+ }
349
+
221
350
  // Normalize the resolved client IP for the forwarded header. better-auth stores
222
351
  // IPv4 as-is but anonymizes IPv6 (masks the host bits), so an IPv6 loopback would
223
352
  // be recorded as "::" — map loopback to 127.0.0.1 for a clean local value, and
@@ -252,12 +381,16 @@ export async function proxyRequest(
252
381
  const forwardedIp = normalizeClientIp(clientAddress);
253
382
  if (forwardedIp) headers.set("x-forwarded-for", forwardedIp);
254
383
 
384
+ const { pathname } = new URL(request.url);
385
+ if (!backendReachable()) {
386
+ request.body?.cancel().catch(() => undefined);
387
+ return backendFailure(pathname, 503);
388
+ }
255
389
  const hasBody = request.method !== "GET" && request.method !== "HEAD";
256
390
  let body: ArrayBuffer | undefined;
257
391
  if (hasBody) {
258
392
  // Preserve multipart uploads and other binary bodies byte-for-byte. Reading
259
393
  // them as text corrupts bytes that are not valid UTF-8 before forwarding.
260
- const { pathname } = new URL(request.url);
261
394
  const maxBytes = bodyLimitFor(pathname);
262
395
  if (maxBytes === undefined) {
263
396
  body = await request.arrayBuffer();
@@ -267,16 +400,42 @@ export async function proxyRequest(
267
400
  body = limited;
268
401
  }
269
402
  }
270
- const upstream = await fetch(targetUrl, {
271
- method: request.method,
272
- headers,
273
- body,
274
- redirect: "manual",
275
- // Abort the upstream request when the browser disconnects. Without this, every
276
- // closed EventSource or aborted request keeps its API socket open until Bun's
277
- // fetch pool is exhausted and the web process stops serving requests.
278
- signal: request.signal,
279
- });
403
+ // The deadline, the health check, and the outage signal cover only the wait
404
+ // for response headers, so streamed bodies such as server-sent events are not
405
+ // cut off.
406
+ const waiting = new AbortController();
407
+ const deadline = setTimeout(
408
+ () => waiting.abort(new DOMException("The API did not respond in time", "TimeoutError")),
409
+ backendProxyTimeoutMs(),
410
+ );
411
+ const healthCheck = setTimeout(() => void checkBackend(), backendTimeoutMs());
412
+ const outage = backendOutageSignal();
413
+ const failOnOutage = (): void => waiting.abort(outage.reason);
414
+ outage.addEventListener("abort", failOnOutage);
415
+ let upstream: Response;
416
+ try {
417
+ upstream = await fetch(targetUrl, {
418
+ method: request.method,
419
+ headers,
420
+ body,
421
+ redirect: "manual",
422
+ // Abort the upstream request when the browser disconnects. Without this, every
423
+ // closed EventSource or aborted request keeps its API socket open until Bun's
424
+ // fetch pool is exhausted and the web process stops serving requests.
425
+ signal: AbortSignal.any([request.signal, waiting.signal]),
426
+ });
427
+ } catch (cause: unknown) {
428
+ // Nobody waits for an answer to a request the browser abandoned.
429
+ if (request.signal.aborted) throw cause;
430
+ if (outage.aborted) return backendFailure(pathname, 503);
431
+ void reportBackendFailure();
432
+ return backendFailure(pathname, waiting.signal.aborted ? 504 : 503);
433
+ } finally {
434
+ clearTimeout(deadline);
435
+ clearTimeout(healthCheck);
436
+ outage.removeEventListener("abort", failOnOutage);
437
+ }
438
+ reportBackendAnswer();
280
439
 
281
440
  const responseHeaders = new Headers();
282
441
  for (const name of relayedResponseHeaders) {
@@ -0,0 +1,13 @@
1
+ import type { RequestHandler } from "@sveltejs/kit";
2
+
3
+ // Liveness and readiness of the web server itself, for the Kubernetes probes,
4
+ // the Compose healthcheck, and the image HEALTHCHECK. It never calls the API, so
5
+ // an API outage does not take the web server out of service.
6
+ export const GET: RequestHandler = () =>
7
+ new Response("ok\n", {
8
+ headers: {
9
+ "content-type": "text/plain; charset=utf-8",
10
+ "cache-control": "no-store",
11
+ "x-robots-tag": "noindex, nofollow",
12
+ },
13
+ });
@@ -12,6 +12,10 @@ POSTGRES_USER=podokit
12
12
  POSTGRES_PASSWORD=podokit
13
13
  POSTGRES_DB=podokit
14
14
  BACKEND_INTERNAL_URL=http://api:5002
15
+ # Web: milliseconds per server-side API call, and the /api proxy's wait for
16
+ # response headers.
17
+ # BACKEND_TIMEOUT_MS=2000
18
+ # BACKEND_PROXY_TIMEOUT_MS=30000
15
19
  CORS_ORIGIN=http://{{projectName}}.localhost,http://localhost
16
20
  REDIS_HOST=redis
17
21
  REDIS_PORT=6379
@@ -35,6 +35,13 @@ POSTGRES_DB=podokit
35
35
 
36
36
  # Web -> API boundary (server-side only; not exposed to the browser)
37
37
  BACKEND_INTERNAL_URL=http://localhost:5002
38
+ # Milliseconds one server-side API call may take, and how long the /api proxy
39
+ # waits for response headers (504 BACKEND_TIMEOUT after the second value). When
40
+ # the API's GET /health does not answer within BACKEND_TIMEOUT_MS either, API
41
+ # calls fail at once until it does. Raise BACKEND_PROXY_TIMEOUT_MS together with
42
+ # the API's apiServerOptions.idleTimeout.
43
+ # BACKEND_TIMEOUT_MS=2000
44
+ # BACKEND_PROXY_TIMEOUT_MS=30000
38
45
 
39
46
  CORS_ORIGIN=http://localhost:5001
40
47
  # Web request limit; leave room for a 2 MiB multipart upload
@@ -21,10 +21,13 @@ spec:
21
21
  envFrom:
22
22
  - configMapRef: { name: app-config }
23
23
  # Without these a rolling update sends traffic to a process that has not
24
- # bound its port yet.
24
+ # bound its port yet. /healthz never calls the API, so an API outage does
25
+ # not take the web pods out of service.
25
26
  readinessProbe:
26
- httpGet: { path: /, port: 3000 }
27
+ httpGet: { path: /healthz, port: 3000 }
27
28
  initialDelaySeconds: 5
29
+ timeoutSeconds: 2
28
30
  livenessProbe:
29
- httpGet: { path: /, port: 3000 }
31
+ httpGet: { path: /healthz, port: 3000 }
30
32
  initialDelaySeconds: 15
33
+ timeoutSeconds: 2
@@ -12,3 +12,11 @@ test("GET /health/ready reports the database", async ({ request }) => {
12
12
  expect(res.status()).toBe(200);
13
13
  expect((await res.json()).status).toBe("ready");
14
14
  });
15
+
16
+ test("GET /healthz answers from the web server without caching or indexing @smoke", async ({ request }) => {
17
+ const res = await request.get("/healthz");
18
+ expect(res.status()).toBe(200);
19
+ expect(await res.text()).toBe("ok\n");
20
+ expect(res.headers()["cache-control"]).toBe("no-store");
21
+ expect(res.headers()["x-robots-tag"]).toBe("noindex, nofollow");
22
+ });
@@ -1,6 +1,7 @@
1
1
  import { ApiError } from "@podosoft/podokit-api-client";
2
2
  import type { RequestEvent } from "@sveltejs/kit";
3
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { BackendUnavailableError } from "#lib/server/api.js";
4
5
  import { handle } from "./hooks.server";
5
6
 
6
7
  type SessionResult = {
@@ -13,7 +14,8 @@ const client = vi.hoisted(() => ({
13
14
  getSession: vi.fn<() => Promise<SessionResult>>(),
14
15
  }));
15
16
 
16
- vi.mock("#lib/server/api.js", () => ({
17
+ vi.mock("#lib/server/api.js", async (importOriginal) => ({
18
+ ...(await importOriginal<typeof import("#lib/server/api.js")>()),
17
19
  serverApiClient: () => ({ get: client.get, auth: { getSession: client.getSession } }),
18
20
  }));
19
21
 
@@ -98,6 +100,16 @@ describe("handle", () => {
98
100
  });
99
101
  });
100
102
 
103
+ it("treats an API known to be unreachable as an outage", async () => {
104
+ client.get.mockRejectedValue(new BackendUnavailableError());
105
+ client.getSession.mockRejectedValue(new BackendUnavailableError());
106
+
107
+ await expect(handle({ event: requestEvent("/account"), resolve })).rejects.toMatchObject({ status: 503 });
108
+ const event = requestEvent("/");
109
+ await handle({ event, resolve });
110
+ expect(event.locals).toMatchObject({ site: null, siteUnavailable: true, authUnavailable: true, user: null });
111
+ });
112
+
101
113
  it("confirms an anonymous visitor on a 401 session response", async () => {
102
114
  client.get.mockResolvedValue({ locale: null });
103
115
  client.getSession.mockResolvedValue({ data: null, error: { status: 401 } });
@@ -121,4 +133,15 @@ describe("handle", () => {
121
133
  expect(client.get).not.toHaveBeenCalled();
122
134
  expect(client.getSession).not.toHaveBeenCalled();
123
135
  });
136
+
137
+ it("answers the health route without runtime lookups", async () => {
138
+ client.get.mockRejectedValue(new BackendUnavailableError());
139
+ client.getSession.mockRejectedValue(new BackendUnavailableError());
140
+
141
+ await handle({ event: requestEvent("/healthz"), resolve });
142
+
143
+ expect(client.get).not.toHaveBeenCalled();
144
+ expect(client.getSession).not.toHaveBeenCalled();
145
+ expect(resolve).toHaveBeenCalledTimes(1);
146
+ });
124
147
  });
@@ -61,6 +61,12 @@ describe("needsRuntimeContext", () => {
61
61
  expect(needsRuntimeContext(request("/apiary", { headers: { accept: NAVIGATION } }))).toBe(true);
62
62
  });
63
63
 
64
+ it("skips them for the web server's health route, which must answer while the API is down", () => {
65
+ expect(needsRuntimeContext(request("/healthz", { headers: { accept: "*/*" } }))).toBe(false);
66
+ expect(needsRuntimeContext(request("/healthz", { headers: { accept: NAVIGATION } }))).toBe(false);
67
+ expect(needsRuntimeContext(request("/healthz/extra", { routeId: null, headers: { accept: NAVIGATION } }))).toBe(true);
68
+ });
69
+
64
70
  it("skips them while prerendering", () => {
65
71
  expect(needsRuntimeContext(request("/pricing", { headers: { accept: NAVIGATION } }), { building: true })).toBe(false);
66
72
  });
@@ -1,5 +1,8 @@
1
1
  import { APP_ASSET_PATHS } from "./asset-paths";
2
2
 
3
+ /** The web server's own health route (`routes/healthz/+server.ts`), which never calls the API. */
4
+ const WEB_HEALTH_PATH = "/healthz";
5
+
3
6
  /** The request fields that decide whether a response can use the session and site settings. */
4
7
  export interface RuntimeContextRequest {
5
8
  url: URL;
@@ -92,6 +95,8 @@ export function needsRuntimeContext(
92
95
  const { pathname } = event.url;
93
96
  // The backend proxy forwards the cookies; the API resolves the session itself.
94
97
  if (underPrefix(pathname, "/api")) return false;
98
+ // The web server's health route answers the probes even while the API is down.
99
+ if (pathname === WEB_HEALTH_PATH) return false;
95
100
  // Remote functions run application code that may read the session.
96
101
  if (event.isRemoteRequest) return true;
97
102
  if (event.route.id === null) {