@podosoft/podokit 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/add.d.ts +10 -1
- package/dist/add.js +20 -7
- package/dist/api-paths.d.ts +34 -0
- package/dist/api-paths.js +103 -0
- package/dist/deploy-compose-profile.d.ts +4 -0
- package/dist/deploy-compose-profile.js +13 -2
- package/dist/deploy-compose-render.d.ts +1 -1
- package/dist/deploy-compose-render.js +23 -7
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.d.ts +3 -0
- package/dist/deploy-profile.js +12 -4
- package/dist/deploy-render.js +54 -8
- package/dist/deploy.js +21 -1
- package/dist/dev.d.ts +14 -0
- package/dist/dev.js +152 -10
- package/dist/index.js +15 -2
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +4 -2
- package/dist/lockfile.d.ts +3 -0
- package/dist/lockfile.js +11 -0
- package/dist/module-api-paths.d.ts +52 -0
- package/dist/module-api-paths.js +189 -0
- package/dist/remove.d.ts +3 -0
- package/dist/remove.js +12 -3
- package/dist/templates/fullstack/AGENTS.md +27 -5
- package/dist/templates/fullstack/README.md +28 -11
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +25 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
- package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +26 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +52 -11
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +70 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
- package/dist/templates/fullstack/apps/web/components.json +3 -1
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/error.html +73 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
- package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
- package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
- package/dist/templates/fullstack/dot-env.example +10 -0
- package/dist/templates/fullstack/dot-gitignore +1 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
- package/dist/templates/fullstack/infra/k3s/README.md +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
- package/dist/templates/modules/auth/module.manifest.json +0 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
- package/dist/templates/modules/events-redis/module.manifest.json +4 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +153 -0
- package/dist/templates/modules/file-upload/module.manifest.json +34 -6
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
- package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
- package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
- package/dist/templates/modules/redis/module.manifest.json +4 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
- package/dist/templates/todo/AGENTS.md +19 -4
- package/dist/templates/todo/README.md +27 -12
- package/dist/update.d.ts +5 -0
- package/dist/update.js +35 -0
- package/package.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="color-scheme" content="light dark" />
|
|
7
|
+
<title>%sveltekit.status% %sveltekit.error.message%</title>
|
|
8
|
+
<!-- SvelteKit serves this page when the root layout or a server hook fails,
|
|
9
|
+
so it cannot use the app's styles, scripts, assets, or components. It
|
|
10
|
+
fills in only the status and the HTML-escaped error message. -->
|
|
11
|
+
<style>
|
|
12
|
+
:root {
|
|
13
|
+
--background: oklch(1 0 0);
|
|
14
|
+
--foreground: oklch(0.141 0.005 285.823);
|
|
15
|
+
--muted-foreground: oklch(0.552 0.016 285.938);
|
|
16
|
+
--border: oklch(0.92 0.004 286.32);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@media (prefers-color-scheme: dark) {
|
|
20
|
+
:root {
|
|
21
|
+
--background: oklch(0.141 0.005 285.823);
|
|
22
|
+
--foreground: oklch(0.985 0 0);
|
|
23
|
+
--muted-foreground: oklch(0.705 0.015 286.067);
|
|
24
|
+
--border: oklch(1 0 0 / 15%);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
body {
|
|
29
|
+
box-sizing: border-box;
|
|
30
|
+
display: grid;
|
|
31
|
+
min-height: 100vh;
|
|
32
|
+
margin: 0;
|
|
33
|
+
padding: 1.5rem;
|
|
34
|
+
place-items: center;
|
|
35
|
+
background: var(--background);
|
|
36
|
+
color: var(--foreground);
|
|
37
|
+
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
38
|
+
line-height: 1.5;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
main {
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
gap: 1rem;
|
|
45
|
+
max-width: 32rem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.status {
|
|
49
|
+
margin: 0;
|
|
50
|
+
color: var(--muted-foreground);
|
|
51
|
+
font-size: 2.25rem;
|
|
52
|
+
font-variant-numeric: tabular-nums;
|
|
53
|
+
font-weight: 600;
|
|
54
|
+
line-height: 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
h1 {
|
|
58
|
+
margin: 0;
|
|
59
|
+
padding-left: 1rem;
|
|
60
|
+
border-left: 1px solid var(--border);
|
|
61
|
+
font-size: 1rem;
|
|
62
|
+
font-weight: 500;
|
|
63
|
+
overflow-wrap: anywhere;
|
|
64
|
+
}
|
|
65
|
+
</style>
|
|
66
|
+
</head>
|
|
67
|
+
<body>
|
|
68
|
+
<main>
|
|
69
|
+
<p class="status">%sveltekit.status%</p>
|
|
70
|
+
<h1>%sveltekit.error.message%</h1>
|
|
71
|
+
</main>
|
|
72
|
+
</body>
|
|
73
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn, type WithElementRef, type WithoutChildren } from "#lib/utils.js";
|
|
3
|
+
import type { HTMLTextareaAttributes } from "svelte/elements";
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
ref = $bindable(null),
|
|
7
|
+
value = $bindable(),
|
|
8
|
+
class: className,
|
|
9
|
+
"data-slot": dataSlot = "textarea",
|
|
10
|
+
...restProps
|
|
11
|
+
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<textarea
|
|
15
|
+
bind:this={ref}
|
|
16
|
+
data-slot={dataSlot}
|
|
17
|
+
class={cn(
|
|
18
|
+
"border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-lg border bg-transparent px-2.5 py-2 text-base transition-colors focus-visible:ring-3 aria-invalid:ring-3 md:text-sm flex field-sizing-content min-h-16 w-full outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
|
19
|
+
className
|
|
20
|
+
)}
|
|
21
|
+
bind:value
|
|
22
|
+
{...restProps}></textarea>
|
|
@@ -2,7 +2,15 @@ import { createServer } from "node:http";
|
|
|
2
2
|
import type { AddressInfo } from "node:net";
|
|
3
3
|
import { gzipSync } from "node:zlib";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
bodyLimitFor,
|
|
7
|
+
IN_MEMORY_RESPONSE_BYTES,
|
|
8
|
+
proxyRequest,
|
|
9
|
+
rawSubpath,
|
|
10
|
+
resolveClientIp,
|
|
11
|
+
sortedBodyLimits,
|
|
12
|
+
withAppHeaders,
|
|
13
|
+
} from "./backend-proxy";
|
|
6
14
|
|
|
7
15
|
describe("backend proxy client address handling", () => {
|
|
8
16
|
afterEach(() => {
|
|
@@ -173,6 +181,47 @@ describe("backend proxy client address handling", () => {
|
|
|
173
181
|
expect(await response.text()).toBe("0123456789");
|
|
174
182
|
});
|
|
175
183
|
|
|
184
|
+
it("reads a body of known, modest size before relaying it, so Bun keeps its length", async () => {
|
|
185
|
+
let pulls = 0;
|
|
186
|
+
vi.stubGlobal("fetch", async () => new Response(new ReadableStream<Uint8Array>({
|
|
187
|
+
pull(controller): void {
|
|
188
|
+
pulls += 1;
|
|
189
|
+
controller.enqueue(new TextEncoder().encode("0123456789"));
|
|
190
|
+
controller.close();
|
|
191
|
+
},
|
|
192
|
+
}, { highWaterMark: 0 }), { headers: { "content-length": "10" } }));
|
|
193
|
+
|
|
194
|
+
const response = await proxyRequest(
|
|
195
|
+
new Request("http://app.localhost/api/profile-images/a.png"),
|
|
196
|
+
"http://api:5002/profile-images/a.png",
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
expect(pulls).toBe(1);
|
|
200
|
+
expect(response.headers.get("content-length")).toBe("10");
|
|
201
|
+
expect(await response.text()).toBe("0123456789");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("streams a larger body through without reading it first", async () => {
|
|
205
|
+
const size = IN_MEMORY_RESPONSE_BYTES + 1;
|
|
206
|
+
let pulls = 0;
|
|
207
|
+
vi.stubGlobal("fetch", async () => new Response(new ReadableStream<Uint8Array>({
|
|
208
|
+
pull(controller): void {
|
|
209
|
+
pulls += 1;
|
|
210
|
+
controller.enqueue(new Uint8Array(size));
|
|
211
|
+
controller.close();
|
|
212
|
+
},
|
|
213
|
+
}, { highWaterMark: 0 }), { headers: { "content-length": String(size) } }));
|
|
214
|
+
|
|
215
|
+
const response = await proxyRequest(
|
|
216
|
+
new Request("http://app.localhost/api/storage/large"),
|
|
217
|
+
"http://api:5002/storage/large",
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
expect(pulls).toBe(0);
|
|
221
|
+
expect(response.headers.get("content-length")).toBe(String(size));
|
|
222
|
+
expect((await response.arrayBuffer()).byteLength).toBe(size);
|
|
223
|
+
});
|
|
224
|
+
|
|
176
225
|
it("drops content-length when the upstream body was compressed", async () => {
|
|
177
226
|
vi.stubGlobal("fetch", async () => new Response("decoded body", {
|
|
178
227
|
headers: { "content-encoding": "gzip", "content-length": "4" },
|
|
@@ -289,3 +338,229 @@ describe("backend proxy raw path", () => {
|
|
|
289
338
|
expect(rawSubpath(new URL("http://localhost/other"), "/api/")).toBe("/other");
|
|
290
339
|
});
|
|
291
340
|
});
|
|
341
|
+
|
|
342
|
+
describe("backend proxy application header lists", () => {
|
|
343
|
+
afterEach(() => {
|
|
344
|
+
vi.unstubAllGlobals();
|
|
345
|
+
vi.doUnmock("./proxy-headers");
|
|
346
|
+
vi.resetModules();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
async function proxyWithAppHeaders(request: string[], response: string[]): Promise<typeof proxyRequest> {
|
|
350
|
+
vi.resetModules();
|
|
351
|
+
vi.doMock("./proxy-headers", () => ({
|
|
352
|
+
APP_FORWARDED_REQUEST_HEADERS: request,
|
|
353
|
+
APP_RELAYED_RESPONSE_HEADERS: response,
|
|
354
|
+
}));
|
|
355
|
+
return (await import("./backend-proxy")).proxyRequest;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
it("forwards and relays the headers an application lists", async () => {
|
|
359
|
+
const proxy = await proxyWithAppHeaders(["X-API-Key", "mcp-session-id"], ["X-Checksum-SHA256", "www-authenticate"]);
|
|
360
|
+
let upstreamHeaders: Headers | undefined;
|
|
361
|
+
vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
362
|
+
upstreamHeaders = new Headers(init?.headers);
|
|
363
|
+
return new Response(null, {
|
|
364
|
+
status: 401,
|
|
365
|
+
headers: {
|
|
366
|
+
"x-checksum-sha256": "abc123",
|
|
367
|
+
"www-authenticate": "Bearer",
|
|
368
|
+
"x-internal-trace": "hidden",
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const response = await proxy(
|
|
374
|
+
new Request("http://app.localhost/api/documents/1", {
|
|
375
|
+
headers: { "x-api-key": "key", "mcp-session-id": "s1", "x-unlisted": "dropped" },
|
|
376
|
+
}),
|
|
377
|
+
"http://api:5002/documents/1",
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
expect(upstreamHeaders?.get("x-api-key")).toBe("key");
|
|
381
|
+
expect(upstreamHeaders?.get("mcp-session-id")).toBe("s1");
|
|
382
|
+
expect(upstreamHeaders?.get("x-unlisted")).toBeNull();
|
|
383
|
+
expect(response.headers.get("x-checksum-sha256")).toBe("abc123");
|
|
384
|
+
expect(response.headers.get("www-authenticate")).toBe("Bearer");
|
|
385
|
+
expect(response.headers.get("x-internal-trace")).toBeNull();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("drops application headers that no list names", async () => {
|
|
389
|
+
const proxy = await proxyWithAppHeaders([], []);
|
|
390
|
+
let upstreamHeaders: Headers | undefined;
|
|
391
|
+
vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
392
|
+
upstreamHeaders = new Headers(init?.headers);
|
|
393
|
+
return new Response(null, { status: 204, headers: { "x-checksum-sha256": "abc123" } });
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const response = await proxy(
|
|
397
|
+
new Request("http://app.localhost/api/documents/1", { headers: { "x-api-key": "key" } }),
|
|
398
|
+
"http://api:5002/documents/1",
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
expect(upstreamHeaders?.get("x-api-key")).toBeNull();
|
|
402
|
+
expect(response.headers.get("x-checksum-sha256")).toBeNull();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("refuses to load with a reserved or invalid application header", async () => {
|
|
406
|
+
await expect(proxyWithAppHeaders(["x-forwarded-for"], [])).rejects.toThrow(
|
|
407
|
+
'APP_FORWARDED_REQUEST_HEADERS cannot contain "x-forwarded-for"',
|
|
408
|
+
);
|
|
409
|
+
await expect(proxyWithAppHeaders([], ["Set-Cookie"])).rejects.toThrow(
|
|
410
|
+
'APP_RELAYED_RESPONSE_HEADERS cannot contain "set-cookie"',
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("lowercases, deduplicates, and validates application names", () => {
|
|
415
|
+
const reserved = new Set(["host", "transfer-encoding"]);
|
|
416
|
+
expect(withAppHeaders(["accept", "etag"], ["ETag", "X-Trace", "x-trace"], reserved, "LIST")).toEqual([
|
|
417
|
+
"accept",
|
|
418
|
+
"etag",
|
|
419
|
+
"x-trace",
|
|
420
|
+
]);
|
|
421
|
+
expect(() => withAppHeaders([], ["Transfer-Encoding"], reserved, "LIST")).toThrow(
|
|
422
|
+
'LIST cannot contain "transfer-encoding"',
|
|
423
|
+
);
|
|
424
|
+
expect(() => withAppHeaders([], ["x trace"], reserved, "LIST")).toThrow(
|
|
425
|
+
'LIST contains an invalid header name: "x trace"',
|
|
426
|
+
);
|
|
427
|
+
expect(() => withAppHeaders([], [""], reserved, "LIST")).toThrow("invalid header name");
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
describe("backend proxy application body limits", () => {
|
|
432
|
+
afterEach(() => {
|
|
433
|
+
vi.unstubAllGlobals();
|
|
434
|
+
vi.doUnmock("./body-limits");
|
|
435
|
+
vi.resetModules();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
async function proxyWithBodyLimits(
|
|
439
|
+
limits: ReadonlyArray<{ pathPrefix: string; maxBytes: number }>,
|
|
440
|
+
): Promise<typeof proxyRequest> {
|
|
441
|
+
vi.resetModules();
|
|
442
|
+
vi.doMock("./body-limits", () => ({ APP_BODY_LIMITS: limits }));
|
|
443
|
+
return (await import("./backend-proxy")).proxyRequest;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function recordingFetch(): { bodies: Uint8Array[] } {
|
|
447
|
+
const record = { bodies: [] as Uint8Array[] };
|
|
448
|
+
vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
449
|
+
record.bodies.push(new Uint8Array(await new Response(init?.body).arrayBuffer()));
|
|
450
|
+
return new Response(null, { status: 204 });
|
|
451
|
+
});
|
|
452
|
+
return record;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// A body without content-length, as a chunked upload arrives.
|
|
456
|
+
function streamedRequest(url: string, chunks: number, chunkSize: number): { request: Request; pulled: () => number } {
|
|
457
|
+
let pulled = 0;
|
|
458
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
459
|
+
pull(controller) {
|
|
460
|
+
if (pulled === chunks) {
|
|
461
|
+
controller.close();
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
pulled += 1;
|
|
465
|
+
controller.enqueue(new Uint8Array(chunkSize).fill(pulled));
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
const init = { method: "POST", body: stream, duplex: "half" } as RequestInit;
|
|
469
|
+
return { request: new Request(url, init), pulled: () => pulled };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
it("rejects a declared body above the route limit before contacting the API", async () => {
|
|
473
|
+
const proxy = await proxyWithBodyLimits([{ pathPrefix: "/api/feedback/", maxBytes: 8 }]);
|
|
474
|
+
const record = recordingFetch();
|
|
475
|
+
|
|
476
|
+
const response = await proxy(
|
|
477
|
+
new Request("http://app.localhost/api/feedback/new", { method: "POST", body: "0123456789" }),
|
|
478
|
+
"http://api:5002/feedback/new",
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
expect(response.status).toBe(413);
|
|
482
|
+
const envelope = (await response.json()) as {
|
|
483
|
+
success: boolean;
|
|
484
|
+
error: { code: string; statusCode: number; path: string; details: { maxBytes: number } };
|
|
485
|
+
};
|
|
486
|
+
expect(envelope.success).toBe(false);
|
|
487
|
+
expect(envelope.error).toMatchObject({
|
|
488
|
+
code: "PAYLOAD_TOO_LARGE",
|
|
489
|
+
statusCode: 413,
|
|
490
|
+
path: "/api/feedback/new",
|
|
491
|
+
details: { maxBytes: 8 },
|
|
492
|
+
});
|
|
493
|
+
expect(record.bodies).toEqual([]);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("stops reading a streamed body once it passes the limit", async () => {
|
|
497
|
+
const proxy = await proxyWithBodyLimits([{ pathPrefix: "/api/feedback", maxBytes: 10 }]);
|
|
498
|
+
const record = recordingFetch();
|
|
499
|
+
const { request, pulled } = streamedRequest("http://app.localhost/api/feedback", 100, 4);
|
|
500
|
+
|
|
501
|
+
const response = await proxy(request, "http://api:5002/feedback");
|
|
502
|
+
|
|
503
|
+
expect(response.status).toBe(413);
|
|
504
|
+
expect(record.bodies).toEqual([]);
|
|
505
|
+
expect(pulled()).toBeLessThan(10);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("forwards a body within the limit byte-for-byte", async () => {
|
|
509
|
+
const proxy = await proxyWithBodyLimits([{ pathPrefix: "/api/feedback/", maxBytes: 12 }]);
|
|
510
|
+
const record = recordingFetch();
|
|
511
|
+
const { request } = streamedRequest("http://app.localhost/api/feedback/new", 3, 4);
|
|
512
|
+
|
|
513
|
+
const response = await proxy(request, "http://api:5002/feedback/new");
|
|
514
|
+
|
|
515
|
+
expect(response.status).toBe(204);
|
|
516
|
+
expect([...(record.bodies[0] ?? [])]).toEqual([1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3]);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("leaves routes without a limit to the server-wide ceiling", async () => {
|
|
520
|
+
const proxy = await proxyWithBodyLimits([{ pathPrefix: "/api/feedback/", maxBytes: 1 }]);
|
|
521
|
+
const record = recordingFetch();
|
|
522
|
+
|
|
523
|
+
const response = await proxy(
|
|
524
|
+
new Request("http://app.localhost/api/feedbacks", { method: "POST", body: "0123456789" }),
|
|
525
|
+
"http://api:5002/feedbacks",
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
expect(response.status).toBe(204);
|
|
529
|
+
expect(record.bodies[0]?.byteLength).toBe(10);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("applies the longest matching prefix on the decoded path", () => {
|
|
533
|
+
const limits = sortedBodyLimits([
|
|
534
|
+
{ pathPrefix: "/api/", maxBytes: 100 },
|
|
535
|
+
{ pathPrefix: "/api/files/uploads/", maxBytes: 10 },
|
|
536
|
+
{ pathPrefix: "/api/files", maxBytes: 50 },
|
|
537
|
+
]);
|
|
538
|
+
|
|
539
|
+
expect(bodyLimitFor("/api/files/uploads/u1/parts/2", limits)).toBe(10);
|
|
540
|
+
expect(bodyLimitFor("/api/files/uploads", limits)).toBe(10);
|
|
541
|
+
expect(bodyLimitFor("/api/files/u1", limits)).toBe(50);
|
|
542
|
+
expect(bodyLimitFor("/api/filesystem", limits)).toBe(100);
|
|
543
|
+
expect(bodyLimitFor("/api/%66iles/uploads/u1", limits)).toBe(10);
|
|
544
|
+
expect(bodyLimitFor("/api/files%2Fuploads/u1", limits)).toBe(10);
|
|
545
|
+
expect(bodyLimitFor("/api/files/%E0%A4%A", limits)).toBe(50);
|
|
546
|
+
expect(bodyLimitFor("/other", limits)).toBeUndefined();
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it.each<[{ pathPrefix: string; maxBytes: number }[], string]>([
|
|
550
|
+
[[{ pathPrefix: "/feedback/", maxBytes: 10 }], "must be a static path under /api/"],
|
|
551
|
+
[[{ pathPrefix: "/api/files/*", maxBytes: 10 }], "must be a static path under /api/"],
|
|
552
|
+
[[{ pathPrefix: "/api//files", maxBytes: 10 }], "must be a static path under /api/"],
|
|
553
|
+
[[{ pathPrefix: "/api/files/../admin", maxBytes: 10 }], "must be a static path under /api/"],
|
|
554
|
+
[[{ pathPrefix: "/api/files", maxBytes: 0 }], "positive whole number"],
|
|
555
|
+
[[{ pathPrefix: "/api/files", maxBytes: 1.5 }], "positive whole number"],
|
|
556
|
+
[[{ pathPrefix: "/api/files", maxBytes: 10 }, { pathPrefix: "/api/files/", maxBytes: 20 }], "more than once"],
|
|
557
|
+
])("rejects an invalid body limit list %#", (limits, message) => {
|
|
558
|
+
expect(() => sortedBodyLimits(limits)).toThrow(message);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("refuses to load with an invalid application body limit", async () => {
|
|
562
|
+
await expect(proxyWithBodyLimits([{ pathPrefix: "/upload", maxBytes: 10 }])).rejects.toThrow(
|
|
563
|
+
"APP_BODY_LIMITS pathPrefix must be a static path under /api/",
|
|
564
|
+
);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { APP_BODY_LIMITS } from "./body-limits";
|
|
2
|
+
import { APP_FORWARDED_REQUEST_HEADERS, APP_RELAYED_RESPONSE_HEADERS } from "./proxy-headers";
|
|
3
|
+
|
|
1
4
|
// Server-side proxy boundary. The browser never talks to the API directly:
|
|
2
5
|
// requests go through SvelteKit server routes, which forward an allowlist of
|
|
3
6
|
// headers to BACKEND_INTERNAL_URL and relay the response (including Set-Cookie).
|
|
7
|
+
// Applications add their own header names in the app-owned proxy-headers.ts.
|
|
4
8
|
//
|
|
5
9
|
// `user-agent` is on the list for the same reason the client IP is forwarded
|
|
6
10
|
// below: better-auth records it on the session and the account page shows it as
|
|
@@ -39,6 +43,171 @@ const RELAYED_RESPONSE_HEADERS = [
|
|
|
39
43
|
"x-next-cursor",
|
|
40
44
|
];
|
|
41
45
|
|
|
46
|
+
// Hop-by-hop headers describe a single connection, and the proxy sets framing,
|
|
47
|
+
// cookies, and the client address itself, so application lists cannot add these.
|
|
48
|
+
const HOP_BY_HOP_HEADERS = [
|
|
49
|
+
"connection",
|
|
50
|
+
"keep-alive",
|
|
51
|
+
"proxy-authenticate",
|
|
52
|
+
"proxy-authorization",
|
|
53
|
+
"proxy-connection",
|
|
54
|
+
"te",
|
|
55
|
+
"trailer",
|
|
56
|
+
"transfer-encoding",
|
|
57
|
+
"upgrade",
|
|
58
|
+
];
|
|
59
|
+
const RESERVED_REQUEST_HEADERS = new Set([
|
|
60
|
+
...HOP_BY_HOP_HEADERS,
|
|
61
|
+
"host",
|
|
62
|
+
"content-length",
|
|
63
|
+
"forwarded",
|
|
64
|
+
"x-forwarded-for",
|
|
65
|
+
"x-forwarded-host",
|
|
66
|
+
"x-forwarded-port",
|
|
67
|
+
"x-forwarded-proto",
|
|
68
|
+
"x-real-ip",
|
|
69
|
+
]);
|
|
70
|
+
const RESERVED_RESPONSE_HEADERS = new Set([
|
|
71
|
+
...HOP_BY_HOP_HEADERS,
|
|
72
|
+
"content-length",
|
|
73
|
+
"content-encoding",
|
|
74
|
+
"set-cookie",
|
|
75
|
+
]);
|
|
76
|
+
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9a-z-]+$/;
|
|
77
|
+
/** Larger response bodies stream through; smaller ones are relayed with their length. */
|
|
78
|
+
export const IN_MEMORY_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
79
|
+
|
|
80
|
+
/** Append an application's header names to a built-in list, lowercased and
|
|
81
|
+
* without duplicates. Throws on an invalid or reserved name so a mistake fails
|
|
82
|
+
* the build instead of silently dropping the header. */
|
|
83
|
+
export function withAppHeaders(
|
|
84
|
+
builtIn: readonly string[],
|
|
85
|
+
app: readonly string[],
|
|
86
|
+
reserved: ReadonlySet<string>,
|
|
87
|
+
listName: string,
|
|
88
|
+
): string[] {
|
|
89
|
+
const names = new Set(builtIn);
|
|
90
|
+
for (const entry of app) {
|
|
91
|
+
const name = entry.toLowerCase();
|
|
92
|
+
if (!HEADER_NAME.test(name)) {
|
|
93
|
+
throw new Error(`${listName} contains an invalid header name: ${JSON.stringify(entry)}`);
|
|
94
|
+
}
|
|
95
|
+
if (reserved.has(name)) {
|
|
96
|
+
throw new Error(`${listName} cannot contain "${name}": the proxy handles that header itself.`);
|
|
97
|
+
}
|
|
98
|
+
names.add(name);
|
|
99
|
+
}
|
|
100
|
+
return [...names];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const forwardedHeaders = withAppHeaders(
|
|
104
|
+
FORWARDED_HEADERS,
|
|
105
|
+
APP_FORWARDED_REQUEST_HEADERS,
|
|
106
|
+
RESERVED_REQUEST_HEADERS,
|
|
107
|
+
"APP_FORWARDED_REQUEST_HEADERS",
|
|
108
|
+
);
|
|
109
|
+
const relayedResponseHeaders = withAppHeaders(
|
|
110
|
+
RELAYED_RESPONSE_HEADERS,
|
|
111
|
+
APP_RELAYED_RESPONSE_HEADERS,
|
|
112
|
+
RESERVED_RESPONSE_HEADERS,
|
|
113
|
+
"APP_RELAYED_RESPONSE_HEADERS",
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
export interface BodyLimit {
|
|
117
|
+
pathPrefix: string;
|
|
118
|
+
maxBytes: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const BODY_LIMIT_PREFIX = /^\/api(?:\/[A-Za-z0-9._~!$&'()+,;=:@-]+)*\/?$/;
|
|
122
|
+
|
|
123
|
+
/** Validate application body limits and order them so the longest prefix
|
|
124
|
+
* matches first. Prefixes are stored without a trailing slash. */
|
|
125
|
+
export function sortedBodyLimits(limits: ReadonlyArray<BodyLimit>): BodyLimit[] {
|
|
126
|
+
const seen = new Set<string>();
|
|
127
|
+
const sorted = limits.map(({ pathPrefix, maxBytes }) => {
|
|
128
|
+
if (
|
|
129
|
+
!BODY_LIMIT_PREFIX.test(pathPrefix) ||
|
|
130
|
+
pathPrefix.split("/").some((segment) => segment === "." || segment === "..")
|
|
131
|
+
) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`APP_BODY_LIMITS pathPrefix must be a static path under /api/, such as "/api/feedback/": ${JSON.stringify(pathPrefix)}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
137
|
+
throw new Error(`APP_BODY_LIMITS maxBytes for ${pathPrefix} must be a positive whole number of bytes.`);
|
|
138
|
+
}
|
|
139
|
+
const base = pathPrefix.endsWith("/") ? pathPrefix.slice(0, -1) : pathPrefix;
|
|
140
|
+
if (seen.has(base)) throw new Error(`APP_BODY_LIMITS lists ${pathPrefix} more than once.`);
|
|
141
|
+
seen.add(base);
|
|
142
|
+
return { pathPrefix: base, maxBytes };
|
|
143
|
+
});
|
|
144
|
+
return sorted.sort((left, right) => right.pathPrefix.length - left.pathPrefix.length);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const bodyLimits = sortedBodyLimits(APP_BODY_LIMITS);
|
|
148
|
+
|
|
149
|
+
/** The body limit for a request path, matched on the decoded path so an encoded
|
|
150
|
+
* segment cannot step around a prefix. */
|
|
151
|
+
export function bodyLimitFor(pathname: string, limits: readonly BodyLimit[] = bodyLimits): number | undefined {
|
|
152
|
+
let path = pathname;
|
|
153
|
+
try {
|
|
154
|
+
path = decodeURIComponent(pathname);
|
|
155
|
+
} catch {
|
|
156
|
+
// Keep the raw path when it holds a malformed escape.
|
|
157
|
+
}
|
|
158
|
+
return limits.find(
|
|
159
|
+
({ pathPrefix }) => path === pathPrefix || path.startsWith(`${pathPrefix}/`),
|
|
160
|
+
)?.maxBytes;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Read a request body, or return null as soon as it exceeds maxBytes. The rest
|
|
164
|
+
* of an oversized body is never buffered. */
|
|
165
|
+
async function readLimitedBody(request: Request, maxBytes: number): Promise<ArrayBuffer | null> {
|
|
166
|
+
const declared = request.headers.get("content-length");
|
|
167
|
+
if (declared !== null && Number(declared) > maxBytes) {
|
|
168
|
+
request.body?.cancel().catch(() => undefined);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
if (!request.body) return new ArrayBuffer(0);
|
|
172
|
+
const reader = request.body.getReader();
|
|
173
|
+
const chunks: Uint8Array[] = [];
|
|
174
|
+
let size = 0;
|
|
175
|
+
for (;;) {
|
|
176
|
+
const { done, value } = await reader.read();
|
|
177
|
+
if (done) break;
|
|
178
|
+
size += value.byteLength;
|
|
179
|
+
if (size > maxBytes) {
|
|
180
|
+
reader.cancel().catch(() => undefined);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
chunks.push(value);
|
|
184
|
+
}
|
|
185
|
+
const body = new Uint8Array(size);
|
|
186
|
+
let offset = 0;
|
|
187
|
+
for (const chunk of chunks) {
|
|
188
|
+
body.set(chunk, offset);
|
|
189
|
+
offset += chunk.byteLength;
|
|
190
|
+
}
|
|
191
|
+
return body.buffer;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function payloadTooLarge(path: string, maxBytes: number): Response {
|
|
195
|
+
return Response.json(
|
|
196
|
+
{
|
|
197
|
+
success: false,
|
|
198
|
+
error: {
|
|
199
|
+
code: "PAYLOAD_TOO_LARGE",
|
|
200
|
+
message: `Request body exceeds the ${maxBytes}-byte limit for this route`,
|
|
201
|
+
statusCode: 413,
|
|
202
|
+
path,
|
|
203
|
+
timestamp: new Date().toISOString(),
|
|
204
|
+
details: { maxBytes },
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{ status: 413 },
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
42
211
|
// SvelteKit decodes rest params, so an encoded "%2F" or "%3F" inside a path
|
|
43
212
|
// segment would reach the API as "/" or "?". Forward the raw pathname instead.
|
|
44
213
|
export function rawSubpath(url: URL, prefix: string): string {
|
|
@@ -75,7 +244,7 @@ export async function proxyRequest(
|
|
|
75
244
|
clientAddress?: string,
|
|
76
245
|
): Promise<Response> {
|
|
77
246
|
const headers = new Headers();
|
|
78
|
-
for (const name of
|
|
247
|
+
for (const name of forwardedHeaders) {
|
|
79
248
|
const value = request.headers.get(name);
|
|
80
249
|
if (value) headers.set(name, value);
|
|
81
250
|
}
|
|
@@ -84,12 +253,24 @@ export async function proxyRequest(
|
|
|
84
253
|
if (forwardedIp) headers.set("x-forwarded-for", forwardedIp);
|
|
85
254
|
|
|
86
255
|
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
256
|
+
let body: ArrayBuffer | undefined;
|
|
257
|
+
if (hasBody) {
|
|
258
|
+
// Preserve multipart uploads and other binary bodies byte-for-byte. Reading
|
|
259
|
+
// them as text corrupts bytes that are not valid UTF-8 before forwarding.
|
|
260
|
+
const { pathname } = new URL(request.url);
|
|
261
|
+
const maxBytes = bodyLimitFor(pathname);
|
|
262
|
+
if (maxBytes === undefined) {
|
|
263
|
+
body = await request.arrayBuffer();
|
|
264
|
+
} else {
|
|
265
|
+
const limited = await readLimitedBody(request, maxBytes);
|
|
266
|
+
if (limited === null) return payloadTooLarge(pathname, maxBytes);
|
|
267
|
+
body = limited;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
87
270
|
const upstream = await fetch(targetUrl, {
|
|
88
271
|
method: request.method,
|
|
89
272
|
headers,
|
|
90
|
-
|
|
91
|
-
// them as text corrupts bytes that are not valid UTF-8 before forwarding.
|
|
92
|
-
body: hasBody ? await request.arrayBuffer() : undefined,
|
|
273
|
+
body,
|
|
93
274
|
redirect: "manual",
|
|
94
275
|
// Abort the upstream request when the browser disconnects. Without this, every
|
|
95
276
|
// closed EventSource or aborted request keeps its API socket open until Bun's
|
|
@@ -98,20 +279,24 @@ export async function proxyRequest(
|
|
|
98
279
|
});
|
|
99
280
|
|
|
100
281
|
const responseHeaders = new Headers();
|
|
101
|
-
for (const name of
|
|
282
|
+
for (const name of relayedResponseHeaders) {
|
|
102
283
|
const value = upstream.headers.get(name);
|
|
103
284
|
if (value) responseHeaders.set(name, value);
|
|
104
285
|
}
|
|
105
286
|
// Bun's fetch decodes a compressed body but keeps the upstream content-encoding
|
|
106
287
|
// and compressed content-length, which would truncate the decoded body.
|
|
107
288
|
const contentLength = upstream.headers.get("content-length");
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
289
|
+
const relayedLength = contentLength && !upstream.headers.has("content-encoding") ? contentLength : null;
|
|
290
|
+
if (relayedLength) responseHeaders.set("content-length", relayedLength);
|
|
111
291
|
// Relay Set-Cookie so auth session cookies reach the browser.
|
|
112
292
|
for (const cookie of upstream.headers.getSetCookie()) {
|
|
113
293
|
responseHeaders.append("set-cookie", cookie);
|
|
114
294
|
}
|
|
115
295
|
|
|
116
|
-
|
|
296
|
+
// Bun sends a streamed body with chunked encoding and drops its content-length
|
|
297
|
+
// unless the body is in memory, so a body of modest, known size is read first.
|
|
298
|
+
const relayedBody = relayedLength && upstream.body && Number(relayedLength) <= IN_MEMORY_RESPONSE_BYTES
|
|
299
|
+
? await upstream.arrayBuffer()
|
|
300
|
+
: upstream.body;
|
|
301
|
+
return new Response(relayedBody, { status: upstream.status, headers: responseHeaders });
|
|
117
302
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-owned request body limits for /api requests that the web server
|
|
3
|
+
* proxies to the API. An entry caps the body of every request whose path is
|
|
4
|
+
* pathPrefix or below it, for example
|
|
5
|
+
* { pathPrefix: "/api/feedback/", maxBytes: 64 * 1024 }
|
|
6
|
+
* and the longest matching prefix wins. Above the limit the proxy stops reading
|
|
7
|
+
* and answers 413 PAYLOAD_TOO_LARGE without contacting the API.
|
|
8
|
+
*
|
|
9
|
+
* BODY_SIZE_LIMIT remains the ceiling for every web route: Bun rejects a larger
|
|
10
|
+
* body before this code runs, so a maxBytes above it has no effect. Routes that
|
|
11
|
+
* need more belong in the gateway's direct API paths (apiPaths in
|
|
12
|
+
* .podokit/dev.json and the deployment profile), which bypass the web server.
|
|
13
|
+
* PodoKit creates this file once and never overwrites it.
|
|
14
|
+
*/
|
|
15
|
+
export const APP_BODY_LIMITS: ReadonlyArray<{ pathPrefix: string; maxBytes: number }> = [];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-owned header names that the managed /api proxy (backend-proxy.ts)
|
|
3
|
+
* passes through in addition to its built-in lists. Names are case-insensitive.
|
|
4
|
+
* Request headers listed here are forwarded from the browser to the API, such as
|
|
5
|
+
* "x-api-key" for machine clients; response headers are relayed from the API to
|
|
6
|
+
* the browser, such as "x-checksum-sha256". The proxy handles hop-by-hop headers,
|
|
7
|
+
* host, content-length, content-encoding, set-cookie, and the forwarding headers
|
|
8
|
+
* that carry the client address itself, so a listed one fails the build.
|
|
9
|
+
* PodoKit creates this file once and never overwrites it.
|
|
10
|
+
*/
|
|
11
|
+
export const APP_FORWARDED_REQUEST_HEADERS: readonly string[] = [];
|
|
12
|
+
export const APP_RELAYED_RESPONSE_HEADERS: readonly string[] = [];
|