@podosoft/podokit 1.4.0 → 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/README.md +1 -1
- 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 +22 -3
- package/dist/templates/fullstack/AGENTS.md +22 -3
- package/dist/templates/fullstack/README.md +10 -7
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +29 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +3 -1
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +84 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +50 -5
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +2 -2
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +118 -0
- package/dist/templates/fullstack/apps/web/Dockerfile +1 -1
- package/dist/templates/fullstack/apps/web/src/error.html +73 -0
- 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/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.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/audit-log/module.manifest.json +1 -1
- 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/modules/auth/module.manifest.json +0 -1
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +12 -6
- package/dist/templates/modules/file-upload/module.manifest.json +2 -2
- package/dist/templates/todo/AGENTS.md +10 -3
- package/dist/templates/todo/README.md +10 -8
- package/package.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="color-scheme" content="light dark" />
|
|
7
|
+
<title>%sveltekit.status% %sveltekit.error.message%</title>
|
|
8
|
+
<!-- SvelteKit serves this page when the root layout or a server hook fails,
|
|
9
|
+
so it cannot use the app's styles, scripts, assets, or components. It
|
|
10
|
+
fills in only the status and the HTML-escaped error message. -->
|
|
11
|
+
<style>
|
|
12
|
+
:root {
|
|
13
|
+
--background: oklch(1 0 0);
|
|
14
|
+
--foreground: oklch(0.141 0.005 285.823);
|
|
15
|
+
--muted-foreground: oklch(0.552 0.016 285.938);
|
|
16
|
+
--border: oklch(0.92 0.004 286.32);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@media (prefers-color-scheme: dark) {
|
|
20
|
+
:root {
|
|
21
|
+
--background: oklch(0.141 0.005 285.823);
|
|
22
|
+
--foreground: oklch(0.985 0 0);
|
|
23
|
+
--muted-foreground: oklch(0.705 0.015 286.067);
|
|
24
|
+
--border: oklch(1 0 0 / 15%);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
body {
|
|
29
|
+
box-sizing: border-box;
|
|
30
|
+
display: grid;
|
|
31
|
+
min-height: 100vh;
|
|
32
|
+
margin: 0;
|
|
33
|
+
padding: 1.5rem;
|
|
34
|
+
place-items: center;
|
|
35
|
+
background: var(--background);
|
|
36
|
+
color: var(--foreground);
|
|
37
|
+
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
38
|
+
line-height: 1.5;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
main {
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
gap: 1rem;
|
|
45
|
+
max-width: 32rem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.status {
|
|
49
|
+
margin: 0;
|
|
50
|
+
color: var(--muted-foreground);
|
|
51
|
+
font-size: 2.25rem;
|
|
52
|
+
font-variant-numeric: tabular-nums;
|
|
53
|
+
font-weight: 600;
|
|
54
|
+
line-height: 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
h1 {
|
|
58
|
+
margin: 0;
|
|
59
|
+
padding-left: 1rem;
|
|
60
|
+
border-left: 1px solid var(--border);
|
|
61
|
+
font-size: 1rem;
|
|
62
|
+
font-weight: 500;
|
|
63
|
+
overflow-wrap: anywhere;
|
|
64
|
+
}
|
|
65
|
+
</style>
|
|
66
|
+
</head>
|
|
67
|
+
<body>
|
|
68
|
+
<main>
|
|
69
|
+
<p class="status">%sveltekit.status%</p>
|
|
70
|
+
<h1>%sveltekit.error.message%</h1>
|
|
71
|
+
</main>
|
|
72
|
+
</body>
|
|
73
|
+
</html>
|
|
@@ -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
|
);
|
|
@@ -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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|