@podosoft/podokit 1.4.2 → 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 +1 -0
- package/dist/deploy-profile.js +2 -2
- package/dist/deploy-render.js +2 -0
- package/dist/deploy-schema.d.ts +7 -0
- package/dist/deploy-schema.js +40 -0
- package/dist/lockfile.js +3 -1
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +5 -2
- package/dist/templates/fullstack/AGENTS.md +18 -5
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
- 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/web/Dockerfile +4 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +291 -36
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +196 -41
- 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/dot-env.docker.example +2 -0
- package/dist/templates/fullstack/dot-env.example +17 -1
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -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/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/tests/api/account-sessions.api.spec.ts +55 -0
- 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/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 +7 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
- 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 +8 -3
- 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/package.json +2 -2
|
@@ -32,6 +32,10 @@ FROM oven/bun:1.4.0-alpine AS runtime
|
|
|
32
32
|
WORKDIR /app
|
|
33
33
|
ENV NODE_ENV=production
|
|
34
34
|
ENV BODY_SIZE_LIMIT=3M
|
|
35
|
+
# Seconds a connection may stay silent, such as while the /api proxy waits for the
|
|
36
|
+
# API: more than 4 above BACKEND_PROXY_TIMEOUT_MS (30 s), since Bun closes idle
|
|
37
|
+
# connections on a 4-second tick.
|
|
38
|
+
ENV CONNECTION_IDLE_TIMEOUT=35
|
|
35
39
|
COPY --from=build /app/apps/web/build ./apps/web/build
|
|
36
40
|
WORKDIR /app/apps/web
|
|
37
41
|
USER bun
|
|
@@ -6,13 +6,16 @@ import {
|
|
|
6
6
|
backendReachable,
|
|
7
7
|
bodyLimitFor,
|
|
8
8
|
checkBackend,
|
|
9
|
+
checkIdleTimeout,
|
|
9
10
|
envMilliseconds,
|
|
10
11
|
IN_MEMORY_RESPONSE_BYTES,
|
|
11
12
|
proxyRequest,
|
|
13
|
+
proxyTimeoutFor,
|
|
12
14
|
reportBackendAnswer,
|
|
13
15
|
rawSubpath,
|
|
14
16
|
resolveClientIp,
|
|
15
17
|
sortedBodyLimits,
|
|
18
|
+
sortedProxyTimeouts,
|
|
16
19
|
withAppHeaders,
|
|
17
20
|
} from "./backend-proxy";
|
|
18
21
|
|
|
@@ -550,53 +553,110 @@ describe("backend proxy application body limits", () => {
|
|
|
550
553
|
expect(bodyLimitFor("/other", limits)).toBeUndefined();
|
|
551
554
|
});
|
|
552
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
|
+
|
|
553
593
|
it.each<[{ pathPrefix: string; maxBytes: number }[], string]>([
|
|
554
|
-
[[{ pathPrefix: "/feedback/", maxBytes: 10 }], "must be a
|
|
555
|
-
[[{ pathPrefix: "/api/files/*", maxBytes: 10 }], "must be a
|
|
556
|
-
[[{ pathPrefix: "/api//files", maxBytes: 10 }], "must be a
|
|
557
|
-
[[{ 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/"],
|
|
558
598
|
[[{ pathPrefix: "/api/files", maxBytes: 0 }], "positive whole number"],
|
|
559
599
|
[[{ pathPrefix: "/api/files", maxBytes: 1.5 }], "positive whole number"],
|
|
560
|
-
[[{ 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
|
+
],
|
|
561
610
|
])("rejects an invalid body limit list %#", (limits, message) => {
|
|
562
611
|
expect(() => sortedBodyLimits(limits)).toThrow(message);
|
|
563
612
|
});
|
|
564
613
|
|
|
565
614
|
it("refuses to load with an invalid application body limit", async () => {
|
|
566
615
|
await expect(proxyWithBodyLimits([{ pathPrefix: "/upload", maxBytes: 10 }])).rejects.toThrow(
|
|
567
|
-
"APP_BODY_LIMITS pathPrefix must be a
|
|
616
|
+
"APP_BODY_LIMITS pathPrefix must be a path under /api/",
|
|
568
617
|
);
|
|
569
618
|
});
|
|
570
619
|
});
|
|
571
620
|
|
|
572
|
-
|
|
573
|
-
|
|
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
|
+
}
|
|
574
652
|
|
|
653
|
+
describe("backend proxy outages", () => {
|
|
575
654
|
afterEach(() => {
|
|
576
655
|
reportBackendAnswer();
|
|
577
656
|
vi.unstubAllGlobals();
|
|
578
657
|
vi.unstubAllEnvs();
|
|
579
658
|
});
|
|
580
659
|
|
|
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
660
|
async function takeApiDown(): Promise<void> {
|
|
601
661
|
stubApi(refuse, refuse);
|
|
602
662
|
await checkBackend();
|
|
@@ -645,25 +705,37 @@ describe("backend proxy outages", () => {
|
|
|
645
705
|
expect(backendReachable()).toBe(false);
|
|
646
706
|
});
|
|
647
707
|
|
|
648
|
-
it("keeps waiting for a slow endpoint while the API answers its health
|
|
708
|
+
it("keeps waiting for a slow endpoint while the API answers its health checks", async () => {
|
|
649
709
|
vi.stubEnv("BACKEND_INTERNAL_URL", "http://api:5002");
|
|
650
710
|
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
|
-
);
|
|
711
|
+
const fetch = stubApi(async () => new Response("ok"), answerAfter(100));
|
|
655
712
|
|
|
656
713
|
const response = await proxyRequest(new Request("http://app.localhost/api/report"), "http://api:5002/report");
|
|
657
714
|
|
|
658
715
|
expect(response.status).toBe(200);
|
|
659
716
|
expect(await response.json()).toEqual({ done: true });
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
]);
|
|
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"]));
|
|
664
721
|
expect(backendReachable()).toBe(true);
|
|
665
722
|
});
|
|
666
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
|
+
|
|
667
739
|
it("answers at once without contacting the API while it is unreachable", async () => {
|
|
668
740
|
await takeApiDown();
|
|
669
741
|
const fetch = stubApi(refuse, async () => new Response(null, { status: 204 }));
|
|
@@ -739,3 +811,186 @@ describe("backend proxy outages", () => {
|
|
|
739
811
|
expect(envMilliseconds("BACKEND_TIMEOUT_MS", 2_000)).toBe(1_500);
|
|
740
812
|
});
|
|
741
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",
|
|
994
|
+
);
|
|
995
|
+
});
|
|
996
|
+
});
|