@podosoft/podokit 1.4.1 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/deploy-compose-render.js +2 -1
- package/dist/deploy-render.js +14 -8
- package/dist/lockfile.d.ts +8 -1
- package/dist/lockfile.js +20 -3
- package/dist/templates/fullstack/AGENTS.md +12 -0
- package/dist/templates/fullstack/README.md +1 -1
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +3 -1
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +66 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +34 -11
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +54 -6
- package/dist/templates/fullstack/apps/web/Dockerfile +1 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +175 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +170 -11
- package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
- package/dist/templates/fullstack/dot-env.docker.example +4 -0
- package/dist/templates/fullstack/dot-env.example +7 -0
- package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
- package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +47 -43
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +11 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
- package/dist/templates/todo/README.md +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
@@ -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:
|
|
27
|
+
httpGet: { path: /healthz, port: 3000 }
|
|
27
28
|
initialDelaySeconds: 5
|
|
29
|
+
timeoutSeconds: 2
|
|
28
30
|
livenessProbe:
|
|
29
|
-
httpGet: { path:
|
|
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
|
});
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts
CHANGED
|
@@ -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
|
});
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts
CHANGED
|
@@ -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) {
|