@podosoft/podokit 1.4.1 → 1.5.0
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-profile.js +2 -2
- package/dist/deploy-compose-render.js +3 -1
- package/dist/deploy-profile.js +2 -2
- package/dist/deploy-render.js +16 -8
- package/dist/deploy-schema.d.ts +7 -0
- package/dist/deploy-schema.js +40 -0
- package/dist/lockfile.d.ts +8 -1
- package/dist/lockfile.js +23 -4
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +5 -2
- package/dist/templates/fullstack/AGENTS.md +27 -2
- package/dist/templates/fullstack/README.md +1 -1
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -2
- package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
- package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
- package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
- 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 +5 -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 +436 -6
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +352 -38
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
- package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
- package/dist/templates/fullstack/dot-env.docker.example +6 -0
- package/dist/templates/fullstack/dot-env.example +23 -0
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -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/account-sessions/account-sessions.routes.spec.ts +152 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -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/account-sessions.test.ts +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -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/account-sessions.api.spec.ts +55 -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/api/users-admin.api.spec.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +54 -47
- 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/table-toolbar.ui.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +19 -8
- 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/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
- package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
- package/dist/templates/todo/AGENTS.md +41 -5
- package/dist/templates/todo/README.md +1 -1
- package/package.json +2 -2
|
@@ -3,12 +3,19 @@ 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
|
+
checkIdleTimeout,
|
|
10
|
+
envMilliseconds,
|
|
7
11
|
IN_MEMORY_RESPONSE_BYTES,
|
|
8
12
|
proxyRequest,
|
|
13
|
+
proxyTimeoutFor,
|
|
14
|
+
reportBackendAnswer,
|
|
9
15
|
rawSubpath,
|
|
10
16
|
resolveClientIp,
|
|
11
17
|
sortedBodyLimits,
|
|
18
|
+
sortedProxyTimeouts,
|
|
12
19
|
withAppHeaders,
|
|
13
20
|
} from "./backend-proxy";
|
|
14
21
|
|
|
@@ -546,21 +553,444 @@ describe("backend proxy application body limits", () => {
|
|
|
546
553
|
expect(bodyLimitFor("/other", limits)).toBeUndefined();
|
|
547
554
|
});
|
|
548
555
|
|
|
556
|
+
it("matches :name parameters against one non-empty segment, most specific rule first", () => {
|
|
557
|
+
const limits = sortedBodyLimits([
|
|
558
|
+
{ pathPrefix: "/api/hosts", maxBytes: 100 },
|
|
559
|
+
{ pathPrefix: "/api/hosts/:hostId/file-transfers", maxBytes: 1_048_576 },
|
|
560
|
+
{ pathPrefix: "/api/hosts/local/:section", maxBytes: 10 },
|
|
561
|
+
{ pathPrefix: "/api/hosts/:hostId/:section/:item", maxBytes: 20 },
|
|
562
|
+
]);
|
|
563
|
+
|
|
564
|
+
expect(bodyLimitFor("/api/hosts/h1/file-transfers", limits)).toBe(1_048_576);
|
|
565
|
+
expect(bodyLimitFor("/api/hosts/h%31/file-transfers", limits)).toBe(1_048_576);
|
|
566
|
+
// More segments win, then a literal segment over a parameter from the left.
|
|
567
|
+
expect(bodyLimitFor("/api/hosts/h1/file-transfers/t1/chunk", limits)).toBe(20);
|
|
568
|
+
expect(bodyLimitFor("/api/hosts/local/file-transfers", limits)).toBe(10);
|
|
569
|
+
expect(bodyLimitFor("/api/hosts/h1/file-transfers-old", limits)).toBe(100);
|
|
570
|
+
expect(bodyLimitFor("/api/hosts/file-transfers", limits)).toBe(100);
|
|
571
|
+
expect(bodyLimitFor("/api/hosts//file-transfers", limits)).toBe(100);
|
|
572
|
+
expect(bodyLimitFor("/api/other", limits)).toBeUndefined();
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("caps the body of a route with a path parameter", async () => {
|
|
576
|
+
const proxy = await proxyWithBodyLimits([{ pathPrefix: "/api/hosts/:hostId/file-transfers", maxBytes: 8 }]);
|
|
577
|
+
const record = recordingFetch();
|
|
578
|
+
|
|
579
|
+
const capped = await proxy(
|
|
580
|
+
new Request("http://app.localhost/api/hosts/h1/file-transfers/t1/chunk", { method: "POST", body: "0123456789" }),
|
|
581
|
+
"http://api:5002/hosts/h1/file-transfers/t1/chunk",
|
|
582
|
+
);
|
|
583
|
+
const other = await proxy(
|
|
584
|
+
new Request("http://app.localhost/api/hosts/h1/notes", { method: "POST", body: "0123456789" }),
|
|
585
|
+
"http://api:5002/hosts/h1/notes",
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
expect(capped.status).toBe(413);
|
|
589
|
+
expect(other.status).toBe(204);
|
|
590
|
+
expect(record.bodies.map((body) => body.byteLength)).toEqual([10]);
|
|
591
|
+
});
|
|
592
|
+
|
|
549
593
|
it.each<[{ pathPrefix: string; maxBytes: number }[], string]>([
|
|
550
|
-
[[{ pathPrefix: "/feedback/", maxBytes: 10 }], "must be a
|
|
551
|
-
[[{ pathPrefix: "/api/files/*", maxBytes: 10 }], "must be a
|
|
552
|
-
[[{ pathPrefix: "/api//files", maxBytes: 10 }], "must be a
|
|
553
|
-
[[{ pathPrefix: "/api/files/../admin", maxBytes: 10 }], "must be a
|
|
594
|
+
[[{ pathPrefix: "/feedback/", maxBytes: 10 }], "must be a path under /api/"],
|
|
595
|
+
[[{ pathPrefix: "/api/files/*", maxBytes: 10 }], "must be a path under /api/"],
|
|
596
|
+
[[{ pathPrefix: "/api//files", maxBytes: 10 }], "must be a path under /api/"],
|
|
597
|
+
[[{ pathPrefix: "/api/files/../admin", maxBytes: 10 }], "must be a path under /api/"],
|
|
554
598
|
[[{ pathPrefix: "/api/files", maxBytes: 0 }], "positive whole number"],
|
|
555
599
|
[[{ pathPrefix: "/api/files", maxBytes: 1.5 }], "positive whole number"],
|
|
556
|
-
[[{ pathPrefix: "/
|
|
600
|
+
[[{ pathPrefix: "/apix/files", maxBytes: 10 }], "must be a path under /api/"],
|
|
601
|
+
[[{ pathPrefix: "/api/files/:", maxBytes: 10 }], "must be a path under /api/"],
|
|
602
|
+
[[{ pathPrefix: "/api/files/:1st", maxBytes: 10 }], "must be a path under /api/"],
|
|
603
|
+
[[{ pathPrefix: "/api/files/:file-id", maxBytes: 10 }], "must be a path under /api/"],
|
|
604
|
+
[[{ pathPrefix: "/api/files/:a:b", maxBytes: 10 }], "must be a path under /api/"],
|
|
605
|
+
[[{ pathPrefix: "/api/files", maxBytes: 10 }, { pathPrefix: "/api/files/", maxBytes: 20 }], "lists the same paths twice"],
|
|
606
|
+
[
|
|
607
|
+
[{ pathPrefix: "/api/hosts/:hostId", maxBytes: 10 }, { pathPrefix: "/api/hosts/:id/", maxBytes: 20 }],
|
|
608
|
+
'lists the same paths twice: "/api/hosts/:hostId" and "/api/hosts/:id/"',
|
|
609
|
+
],
|
|
557
610
|
])("rejects an invalid body limit list %#", (limits, message) => {
|
|
558
611
|
expect(() => sortedBodyLimits(limits)).toThrow(message);
|
|
559
612
|
});
|
|
560
613
|
|
|
561
614
|
it("refuses to load with an invalid application body limit", async () => {
|
|
562
615
|
await expect(proxyWithBodyLimits([{ pathPrefix: "/upload", maxBytes: 10 }])).rejects.toThrow(
|
|
563
|
-
"APP_BODY_LIMITS pathPrefix must be a
|
|
616
|
+
"APP_BODY_LIMITS pathPrefix must be a path under /api/",
|
|
617
|
+
);
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
622
|
+
|
|
623
|
+
// Never answers on its own, like a blackholed connection or a hung API, and
|
|
624
|
+
// rejects when its signal aborts.
|
|
625
|
+
const hang: Fetch = (_input, init) =>
|
|
626
|
+
new Promise((_settle, reject) => {
|
|
627
|
+
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason));
|
|
628
|
+
});
|
|
629
|
+
const refuse: Fetch = async () => {
|
|
630
|
+
throw new TypeError("Unable to connect. Is the computer able to access the url?");
|
|
631
|
+
};
|
|
632
|
+
// Answers after a delay unless its signal aborts first, like a slow endpoint.
|
|
633
|
+
const answerAfter =
|
|
634
|
+
(ms: number): Fetch =>
|
|
635
|
+
(_input, init) =>
|
|
636
|
+
new Promise((settle, reject) => {
|
|
637
|
+
const timer = setTimeout(() => settle(Response.json({ done: true })), ms);
|
|
638
|
+
init?.signal?.addEventListener("abort", () => {
|
|
639
|
+
clearTimeout(timer);
|
|
640
|
+
reject(init.signal?.reason);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// Routes the background GET /health check and the proxied request separately.
|
|
645
|
+
function stubApi(health: Fetch, upstream: Fetch): ReturnType<typeof vi.fn<Fetch>> {
|
|
646
|
+
const fetch = vi.fn<Fetch>((input, init) =>
|
|
647
|
+
String(input).endsWith("/health") ? health(input, init) : upstream(input, init),
|
|
648
|
+
);
|
|
649
|
+
vi.stubGlobal("fetch", fetch);
|
|
650
|
+
return fetch;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
describe("backend proxy outages", () => {
|
|
654
|
+
afterEach(() => {
|
|
655
|
+
reportBackendAnswer();
|
|
656
|
+
vi.unstubAllGlobals();
|
|
657
|
+
vi.unstubAllEnvs();
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
async function takeApiDown(): Promise<void> {
|
|
661
|
+
stubApi(refuse, refuse);
|
|
662
|
+
await checkBackend();
|
|
663
|
+
expect(backendReachable()).toBe(false);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
it("answers 503 with the error envelope when the API cannot be reached", async () => {
|
|
667
|
+
stubApi(refuse, refuse);
|
|
668
|
+
|
|
669
|
+
const response = await proxyRequest(new Request("http://app.localhost/api/blog"), "http://api:5002/blog");
|
|
670
|
+
|
|
671
|
+
expect(response.status).toBe(503);
|
|
672
|
+
expect(response.headers.get("retry-after")).toBe("2");
|
|
673
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
674
|
+
expect(await response.json()).toMatchObject({
|
|
675
|
+
success: false,
|
|
676
|
+
error: { code: "BACKEND_UNAVAILABLE", statusCode: 503, path: "/api/blog" },
|
|
677
|
+
});
|
|
678
|
+
await checkBackend();
|
|
679
|
+
expect(backendReachable()).toBe(false);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("answers 504 when the API sends no response headers before the deadline", async () => {
|
|
683
|
+
vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "20");
|
|
684
|
+
stubApi(async () => new Response("ok"), hang);
|
|
685
|
+
|
|
686
|
+
const response = await proxyRequest(new Request("http://app.localhost/api/report"), "http://api:5002/report");
|
|
687
|
+
|
|
688
|
+
expect(response.status).toBe(504);
|
|
689
|
+
expect(response.headers.get("retry-after")).toBeNull();
|
|
690
|
+
expect(await response.json()).toMatchObject({ error: { code: "BACKEND_TIMEOUT", statusCode: 504 } });
|
|
691
|
+
await checkBackend();
|
|
692
|
+
expect(backendReachable()).toBe(true);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("fails requests still waiting for headers once the API is found unreachable", async () => {
|
|
696
|
+
vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
|
|
697
|
+
stubApi(hang, hang);
|
|
698
|
+
|
|
699
|
+
const started = Date.now();
|
|
700
|
+
const response = await proxyRequest(new Request("http://app.localhost/api/blog"), "http://api:5002/blog");
|
|
701
|
+
|
|
702
|
+
expect(Date.now() - started).toBeLessThan(5_000);
|
|
703
|
+
expect(response.status).toBe(503);
|
|
704
|
+
expect(await response.json()).toMatchObject({ error: { code: "BACKEND_UNAVAILABLE" } });
|
|
705
|
+
expect(backendReachable()).toBe(false);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it("keeps waiting for a slow endpoint while the API answers its health checks", async () => {
|
|
709
|
+
vi.stubEnv("BACKEND_INTERNAL_URL", "http://api:5002");
|
|
710
|
+
vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
|
|
711
|
+
const fetch = stubApi(async () => new Response("ok"), answerAfter(100));
|
|
712
|
+
|
|
713
|
+
const response = await proxyRequest(new Request("http://app.localhost/api/report"), "http://api:5002/report");
|
|
714
|
+
|
|
715
|
+
expect(response.status).toBe(200);
|
|
716
|
+
expect(await response.json()).toEqual({ done: true });
|
|
717
|
+
const [first, ...checks] = fetch.mock.calls.map(([input]) => String(input));
|
|
718
|
+
expect(first).toBe("http://api:5002/report");
|
|
719
|
+
expect(checks.length).toBeGreaterThanOrEqual(2);
|
|
720
|
+
expect(new Set(checks)).toEqual(new Set(["http://api:5002/health"]));
|
|
721
|
+
expect(backendReachable()).toBe(true);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("fails a waiting request when the API stops answering after an earlier check", async () => {
|
|
725
|
+
vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
|
|
726
|
+
vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "10000");
|
|
727
|
+
let checks = 0;
|
|
728
|
+
stubApi((input, init) => (++checks <= 2 ? Promise.resolve(new Response("ok")) : hang(input, init)), hang);
|
|
729
|
+
|
|
730
|
+
const started = Date.now();
|
|
731
|
+
const response = await proxyRequest(new Request("http://app.localhost/api/report"), "http://api:5002/report");
|
|
732
|
+
|
|
733
|
+
expect(response.status).toBe(503);
|
|
734
|
+
expect(await response.json()).toMatchObject({ error: { code: "BACKEND_UNAVAILABLE" } });
|
|
735
|
+
expect(checks).toBeGreaterThanOrEqual(3);
|
|
736
|
+
expect(Date.now() - started).toBeLessThan(5_000);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it("answers at once without contacting the API while it is unreachable", async () => {
|
|
740
|
+
await takeApiDown();
|
|
741
|
+
const fetch = stubApi(refuse, async () => new Response(null, { status: 204 }));
|
|
742
|
+
|
|
743
|
+
const response = await proxyRequest(
|
|
744
|
+
new Request("http://app.localhost/api/blog", { method: "POST", body: "{}" }),
|
|
745
|
+
"http://api:5002/blog",
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
expect(response.status).toBe(503);
|
|
749
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it("checks the API again after the retry interval and recovers on any HTTP answer", async () => {
|
|
753
|
+
await takeApiDown();
|
|
754
|
+
const fetch = stubApi(async () => new Response("unavailable", { status: 500 }), refuse);
|
|
755
|
+
|
|
756
|
+
expect(backendReachable(Date.now() + 2_000)).toBe(false);
|
|
757
|
+
await checkBackend();
|
|
758
|
+
|
|
759
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
760
|
+
expect(backendReachable()).toBe(true);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it("relays an error status without checking the API", async () => {
|
|
764
|
+
const fetch = stubApi(refuse, async () => Response.json({ success: false }, { status: 500 }));
|
|
765
|
+
|
|
766
|
+
const response = await proxyRequest(new Request("http://app.localhost/api/blog"), "http://api:5002/blog");
|
|
767
|
+
|
|
768
|
+
expect(response.status).toBe(500);
|
|
769
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
770
|
+
expect(backendReachable()).toBe(true);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it("does not report a browser that disconnected as an outage", async () => {
|
|
774
|
+
const fetch = stubApi(refuse, hang);
|
|
775
|
+
const client = new AbortController();
|
|
776
|
+
const pending = proxyRequest(
|
|
777
|
+
new Request("http://app.localhost/api/events", { signal: client.signal }),
|
|
778
|
+
"http://api:5002/events",
|
|
779
|
+
);
|
|
780
|
+
client.abort();
|
|
781
|
+
|
|
782
|
+
await expect(pending).rejects.toBeDefined();
|
|
783
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
784
|
+
expect(backendReachable()).toBe(true);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it("stops the deadline once response headers arrive, so a stream keeps flowing", async () => {
|
|
788
|
+
vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "20");
|
|
789
|
+
let upstreamSignal: AbortSignal | undefined;
|
|
790
|
+
stubApi(refuse, async (_input, init) => {
|
|
791
|
+
upstreamSignal = init?.signal ?? undefined;
|
|
792
|
+
return new Response(new ReadableStream(), { headers: { "content-type": "text/event-stream" } });
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
const response = await proxyRequest(new Request("http://app.localhost/api/events"), "http://api:5002/events");
|
|
796
|
+
await new Promise((settle) => setTimeout(settle, 60));
|
|
797
|
+
|
|
798
|
+
expect(response.status).toBe(200);
|
|
799
|
+
expect(upstreamSignal?.aborted).toBe(false);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it.each(["0", "-1", "1.5", "soon", "2147483648"])("rejects the invalid timeout %s", (value) => {
|
|
803
|
+
vi.stubEnv("BACKEND_TIMEOUT_MS", value);
|
|
804
|
+
expect(() => envMilliseconds("BACKEND_TIMEOUT_MS", 2_000)).toThrow("whole number of milliseconds");
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it("uses the default timeout when the variable is unset or empty", () => {
|
|
808
|
+
vi.stubEnv("BACKEND_TIMEOUT_MS", "");
|
|
809
|
+
expect(envMilliseconds("BACKEND_TIMEOUT_MS", 2_000)).toBe(2_000);
|
|
810
|
+
vi.stubEnv("BACKEND_TIMEOUT_MS", "1500");
|
|
811
|
+
expect(envMilliseconds("BACKEND_TIMEOUT_MS", 2_000)).toBe(1_500);
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
describe("backend proxy application timeouts", () => {
|
|
816
|
+
afterEach(() => {
|
|
817
|
+
vi.unstubAllGlobals();
|
|
818
|
+
vi.unstubAllEnvs();
|
|
819
|
+
vi.doUnmock("./proxy-timeouts");
|
|
820
|
+
vi.resetModules();
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
async function proxyWithTimeouts(
|
|
824
|
+
timeouts: ReadonlyArray<{ pathPrefix: string; timeoutMs: number | null }>,
|
|
825
|
+
): Promise<typeof proxyRequest> {
|
|
826
|
+
vi.resetModules();
|
|
827
|
+
vi.doMock("./proxy-timeouts", () => ({ APP_PROXY_TIMEOUTS: timeouts }));
|
|
828
|
+
return (await import("./backend-proxy")).proxyRequest;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
it("waits longer on a route with a longer deadline", async () => {
|
|
832
|
+
vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "20");
|
|
833
|
+
const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/exports/", timeoutMs: 2_000 }]);
|
|
834
|
+
stubApi(async () => new Response("ok"), answerAfter(80));
|
|
835
|
+
|
|
836
|
+
const listed = await proxy(new Request("http://app.localhost/api/exports/42"), "http://api:5002/exports/42");
|
|
837
|
+
const other = await proxy(new Request("http://app.localhost/api/report"), "http://api:5002/report");
|
|
838
|
+
|
|
839
|
+
expect(listed.status).toBe(200);
|
|
840
|
+
expect(await listed.json()).toEqual({ done: true });
|
|
841
|
+
expect(other.status).toBe(504);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it("answers 504 at a route's shorter deadline", async () => {
|
|
845
|
+
const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/quick", timeoutMs: 20 }]);
|
|
846
|
+
stubApi(async () => new Response("ok"), hang);
|
|
847
|
+
|
|
848
|
+
const started = Date.now();
|
|
849
|
+
const response = await proxy(new Request("http://app.localhost/api/quick/check"), "http://api:5002/quick/check");
|
|
850
|
+
|
|
851
|
+
expect(response.status).toBe(504);
|
|
852
|
+
expect(await response.json()).toMatchObject({ error: { code: "BACKEND_TIMEOUT", path: "/api/quick/check" } });
|
|
853
|
+
expect(Date.now() - started).toBeLessThan(5_000);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it("waits without a deadline on a null route until the API answers", async () => {
|
|
857
|
+
vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
|
|
858
|
+
vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "20");
|
|
859
|
+
const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/file-transfers/", timeoutMs: null }]);
|
|
860
|
+
stubApi(async () => new Response("ok"), answerAfter(150));
|
|
861
|
+
|
|
862
|
+
const response = await proxy(
|
|
863
|
+
new Request("http://app.localhost/api/file-transfers/7/commit", { method: "POST", body: "{}" }),
|
|
864
|
+
"http://api:5002/file-transfers/7/commit",
|
|
865
|
+
);
|
|
866
|
+
|
|
867
|
+
expect(response.status).toBe(200);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it("still answers 503 on a null route once the API is found unreachable", async () => {
|
|
871
|
+
vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
|
|
872
|
+
const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/file-transfers/", timeoutMs: null }]);
|
|
873
|
+
let checks = 0;
|
|
874
|
+
stubApi((input, init) => (++checks <= 2 ? Promise.resolve(new Response("ok")) : hang(input, init)), hang);
|
|
875
|
+
|
|
876
|
+
const response = await proxy(
|
|
877
|
+
new Request("http://app.localhost/api/file-transfers/7/commit", { method: "POST", body: "{}" }),
|
|
878
|
+
"http://api:5002/file-transfers/7/commit",
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
expect(response.status).toBe(503);
|
|
882
|
+
expect(response.headers.get("retry-after")).toBe("2");
|
|
883
|
+
expect(await response.json()).toMatchObject({
|
|
884
|
+
error: { code: "BACKEND_UNAVAILABLE", path: "/api/file-transfers/7/commit" },
|
|
885
|
+
});
|
|
886
|
+
expect(checks).toBeGreaterThanOrEqual(3);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
it("waits without a deadline on a route with a path parameter", async () => {
|
|
890
|
+
vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "20");
|
|
891
|
+
const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/hosts/:hostId/file-transfers", timeoutMs: null }]);
|
|
892
|
+
stubApi(async () => new Response("ok"), answerAfter(80));
|
|
893
|
+
|
|
894
|
+
const commit = await proxy(
|
|
895
|
+
new Request("http://app.localhost/api/hosts/h1/file-transfers/t1/commit", { method: "POST", body: "{}" }),
|
|
896
|
+
"http://api:5002/hosts/h1/file-transfers/t1/commit",
|
|
897
|
+
);
|
|
898
|
+
const other = await proxy(new Request("http://app.localhost/api/hosts/h1/notes"), "http://api:5002/hosts/h1/notes");
|
|
899
|
+
|
|
900
|
+
expect(commit.status).toBe(200);
|
|
901
|
+
expect(other.status).toBe(504);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
it("applies the longest matching prefix on the decoded path and the default elsewhere", () => {
|
|
905
|
+
vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "1234");
|
|
906
|
+
const timeouts = sortedProxyTimeouts([
|
|
907
|
+
{ pathPrefix: "/api/", timeoutMs: 5_000 },
|
|
908
|
+
{ pathPrefix: "/api/file-transfers/", timeoutMs: null },
|
|
909
|
+
{ pathPrefix: "/api/file-transfers/status", timeoutMs: 100 },
|
|
910
|
+
]);
|
|
911
|
+
|
|
912
|
+
expect(proxyTimeoutFor("/api/file-transfers/7/commit", timeouts)).toBeNull();
|
|
913
|
+
expect(proxyTimeoutFor("/api/file-transfers", timeouts)).toBeNull();
|
|
914
|
+
expect(proxyTimeoutFor("/api/file-transfers/status/7", timeouts)).toBe(100);
|
|
915
|
+
expect(proxyTimeoutFor("/api/%66ile-transfers/7", timeouts)).toBeNull();
|
|
916
|
+
expect(proxyTimeoutFor("/api/file-transfers-old/7", timeouts)).toBe(5_000);
|
|
917
|
+
expect(proxyTimeoutFor("/api/auth/get-session", sortedProxyTimeouts([]))).toBe(1234);
|
|
918
|
+
expect(proxyTimeoutFor("/other", timeouts)).toBe(1234);
|
|
919
|
+
|
|
920
|
+
const byHost = sortedProxyTimeouts([
|
|
921
|
+
{ pathPrefix: "/api/hosts/:hostId/file-transfers", timeoutMs: null },
|
|
922
|
+
{ pathPrefix: "/api/hosts/local/file-transfers", timeoutMs: 60_000 },
|
|
923
|
+
]);
|
|
924
|
+
expect(proxyTimeoutFor("/api/hosts/h1/file-transfers/t1/commit", byHost)).toBeNull();
|
|
925
|
+
expect(proxyTimeoutFor("/api/hosts/local/file-transfers/t1/commit", byHost)).toBe(60_000);
|
|
926
|
+
expect(proxyTimeoutFor("/api/hosts/file-transfers", byHost)).toBe(1234);
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
it.each<[{ pathPrefix: string; timeoutMs: number | null }[], string]>([
|
|
930
|
+
[[{ pathPrefix: "/exports/", timeoutMs: 10 }], "must be a path under /api/"],
|
|
931
|
+
[[{ pathPrefix: "/api/exports/*", timeoutMs: 10 }], "must be a path under /api/"],
|
|
932
|
+
[[{ pathPrefix: "/api/exports/../auth", timeoutMs: 10 }], "must be a path under /api/"],
|
|
933
|
+
[[{ pathPrefix: "/api/exports", timeoutMs: 0 }], "must be null or a whole number of milliseconds"],
|
|
934
|
+
[[{ pathPrefix: "/api/exports", timeoutMs: 1.5 }], "must be null or a whole number of milliseconds"],
|
|
935
|
+
[[{ pathPrefix: "/api/exports", timeoutMs: 2_147_483_648 }], "must be null or a whole number of milliseconds"],
|
|
936
|
+
[[{ pathPrefix: "/api/exports/:", timeoutMs: 10 }], "must be a path under /api/"],
|
|
937
|
+
[[{ pathPrefix: "/api/exports", timeoutMs: null }, { pathPrefix: "/api/exports/", timeoutMs: 10 }], "lists the same paths twice"],
|
|
938
|
+
[[{ pathPrefix: "/api/:a/x", timeoutMs: null }, { pathPrefix: "/api/:b/x", timeoutMs: 10 }], "lists the same paths twice"],
|
|
939
|
+
])("rejects an invalid timeout list %#", (timeouts, message) => {
|
|
940
|
+
expect(() => sortedProxyTimeouts(timeouts)).toThrow(message);
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it.each([undefined, "0", "35", "soon"])("accepts CONNECTION_IDLE_TIMEOUT=%s with the default deadline", (idle) => {
|
|
944
|
+
expect(() => checkIdleTimeout(idle, 30_000, [])).not.toThrow();
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
it("requires the idle timeout to outlast the longest deadline by Bun's check interval", () => {
|
|
948
|
+
expect(() => checkIdleTimeout("34", 30_000, [])).toThrow(
|
|
949
|
+
"CONNECTION_IDLE_TIMEOUT=34 lets the web server close a silent connection before the /api proxy stops " +
|
|
950
|
+
"waiting for response headers (30000 ms, BACKEND_PROXY_TIMEOUT_MS)",
|
|
951
|
+
);
|
|
952
|
+
expect(() => checkIdleTimeout("10", 30_000, [])).toThrow("Set CONNECTION_IDLE_TIMEOUT to at least 35, or to 0");
|
|
953
|
+
expect(() => checkIdleTimeout("35", 30_500, [])).not.toThrow();
|
|
954
|
+
expect(() => checkIdleTimeout("35", 31_000, [])).toThrow("at least 36");
|
|
955
|
+
|
|
956
|
+
const routes = sortedProxyTimeouts([
|
|
957
|
+
{ pathPrefix: "/api/quick/", timeoutMs: 5_000 },
|
|
958
|
+
{ pathPrefix: "/api/exports/", timeoutMs: 120_000 },
|
|
959
|
+
]);
|
|
960
|
+
expect(() => checkIdleTimeout("35", 30_000, routes)).toThrow(
|
|
961
|
+
"(120000 ms, APP_PROXY_TIMEOUTS for /api/exports); Bun closes idle connections up to 4 seconds early. " +
|
|
962
|
+
"Set CONNECTION_IDLE_TIMEOUT to at least 125, or to 0 to turn the idle timeout off.",
|
|
963
|
+
);
|
|
964
|
+
expect(() => checkIdleTimeout("125", 30_000, routes)).not.toThrow();
|
|
965
|
+
expect(() => checkIdleTimeout("0", 30_000, routes)).not.toThrow();
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
it("requires the idle timeout off for a route without a deadline or beyond Bun's limit", () => {
|
|
969
|
+
const unbounded = sortedProxyTimeouts([
|
|
970
|
+
{ pathPrefix: "/api/exports/", timeoutMs: 120_000 },
|
|
971
|
+
{ pathPrefix: "/api/file-transfers/", timeoutMs: null },
|
|
972
|
+
]);
|
|
973
|
+
expect(() => checkIdleTimeout("255", 30_000, unbounded)).toThrow(
|
|
974
|
+
"(no deadline, APP_PROXY_TIMEOUTS for /api/file-transfers); Bun closes idle connections up to 4 seconds " +
|
|
975
|
+
"early. Set CONNECTION_IDLE_TIMEOUT to 0 to turn the idle timeout off, since Bun allows at most 255 seconds.",
|
|
976
|
+
);
|
|
977
|
+
expect(() => checkIdleTimeout("0", 30_000, unbounded)).not.toThrow();
|
|
978
|
+
expect(() => checkIdleTimeout("255", 250_999, [])).not.toThrow();
|
|
979
|
+
expect(() => checkIdleTimeout("255", 251_000, [])).toThrow("Set CONNECTION_IDLE_TIMEOUT to 0");
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it("refuses to load when CONNECTION_IDLE_TIMEOUT cannot cover a route's deadline", async () => {
|
|
983
|
+
vi.stubEnv("CONNECTION_IDLE_TIMEOUT", "35");
|
|
984
|
+
await expect(proxyWithTimeouts([{ pathPrefix: "/api/file-transfers/", timeoutMs: null }])).rejects.toThrow(
|
|
985
|
+
"CONNECTION_IDLE_TIMEOUT=35 lets the web server close a silent connection",
|
|
986
|
+
);
|
|
987
|
+
vi.stubEnv("CONNECTION_IDLE_TIMEOUT", "0");
|
|
988
|
+
await expect(proxyWithTimeouts([{ pathPrefix: "/api/file-transfers/", timeoutMs: null }])).resolves.toBeDefined();
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
it("refuses to load with an invalid application timeout", async () => {
|
|
992
|
+
await expect(proxyWithTimeouts([{ pathPrefix: "/api/exports", timeoutMs: -1 }])).rejects.toThrow(
|
|
993
|
+
"APP_PROXY_TIMEOUTS timeoutMs for /api/exports must be null",
|
|
564
994
|
);
|
|
565
995
|
});
|
|
566
996
|
});
|