@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { APP_BODY_LIMITS } from "./body-limits";
|
|
2
2
|
import { APP_FORWARDED_REQUEST_HEADERS, APP_RELAYED_RESPONSE_HEADERS } from "./proxy-headers";
|
|
3
|
+
import { APP_PROXY_TIMEOUTS } from "./proxy-timeouts";
|
|
3
4
|
|
|
4
5
|
// Server-side proxy boundary. The browser never talks to the API directly:
|
|
5
6
|
// requests go through SvelteKit server routes, which forward an allowlist of
|
|
@@ -118,46 +119,115 @@ export interface BodyLimit {
|
|
|
118
119
|
maxBytes: number;
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
export
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
122
|
+
/** A literal path segment, or null for a `:name` parameter. */
|
|
123
|
+
type PathSegment = string | null;
|
|
124
|
+
|
|
125
|
+
/** An application path rule with its prefix split into segments. */
|
|
126
|
+
export type PathRule<T extends { pathPrefix: string }> = T & { readonly segments: readonly PathSegment[] };
|
|
127
|
+
|
|
128
|
+
const LITERAL_SEGMENT = /^[A-Za-z0-9._~!$&'()+,;=:@-]+$/;
|
|
129
|
+
const PARAMETER_SEGMENT = /^:[A-Za-z_][A-Za-z0-9_]*$/;
|
|
130
|
+
|
|
131
|
+
/** The segments of a prefix under /api/, or undefined when it is not one. */
|
|
132
|
+
function parsePathPrefix(pathPrefix: string): PathSegment[] | undefined {
|
|
133
|
+
const base = pathPrefix.endsWith("/") ? pathPrefix.slice(0, -1) : pathPrefix;
|
|
134
|
+
const [root, ...segments] = base.split("/");
|
|
135
|
+
if (root !== "" || segments[0] !== "api") return undefined;
|
|
136
|
+
const parsed: PathSegment[] = [];
|
|
137
|
+
for (const segment of segments) {
|
|
138
|
+
if (PARAMETER_SEGMENT.test(segment)) {
|
|
139
|
+
parsed.push(null);
|
|
140
|
+
} else if (LITERAL_SEGMENT.test(segment) && !segment.startsWith(":") && segment !== "." && segment !== "..") {
|
|
141
|
+
parsed.push(segment);
|
|
142
|
+
} else {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return parsed;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Below zero when `a` is more specific: more segments, then, from the left, a
|
|
151
|
+
* literal segment before a parameter.
|
|
152
|
+
*/
|
|
153
|
+
function compareSpecificity(a: readonly PathSegment[], b: readonly PathSegment[]): number {
|
|
154
|
+
if (a.length !== b.length) return b.length - a.length;
|
|
155
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
156
|
+
const literal = a[index] !== null;
|
|
157
|
+
if (literal !== (b[index] !== null)) return literal ? -1 : 1;
|
|
158
|
+
}
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Validate an application list of /api path prefixes and order it so the
|
|
163
|
+
* most specific prefix matches first. Prefixes are stored without a trailing
|
|
164
|
+
* slash. */
|
|
165
|
+
function sortedByPrefix<T extends { pathPrefix: string }>(
|
|
166
|
+
entries: ReadonlyArray<T>,
|
|
167
|
+
listName: string,
|
|
168
|
+
example: string,
|
|
169
|
+
check: (entry: T) => void,
|
|
170
|
+
): PathRule<T>[] {
|
|
171
|
+
const seen = new Map<string, string>();
|
|
172
|
+
const rules = entries.map((entry) => {
|
|
173
|
+
const { pathPrefix } = entry;
|
|
174
|
+
const segments = parsePathPrefix(pathPrefix);
|
|
175
|
+
if (!segments) {
|
|
132
176
|
throw new Error(
|
|
133
|
-
|
|
177
|
+
`${listName} pathPrefix must be a path under /api/ made of literal segments and ":name" parameters, ` +
|
|
178
|
+
`such as "${example}": ${JSON.stringify(pathPrefix)}`,
|
|
134
179
|
);
|
|
135
180
|
}
|
|
136
|
-
|
|
137
|
-
|
|
181
|
+
check(entry);
|
|
182
|
+
// Parameter names do not change which paths a prefix covers.
|
|
183
|
+
const key = segments.map((segment) => segment ?? ":").join("/");
|
|
184
|
+
const listed = seen.get(key);
|
|
185
|
+
if (listed !== undefined) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`${listName} lists the same paths twice: ${JSON.stringify(listed)} and ${JSON.stringify(pathPrefix)}.`,
|
|
188
|
+
);
|
|
138
189
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
seen.add(base);
|
|
142
|
-
return { pathPrefix: base, maxBytes };
|
|
190
|
+
seen.set(key, pathPrefix);
|
|
191
|
+
return { ...entry, pathPrefix: pathPrefix.endsWith("/") ? pathPrefix.slice(0, -1) : pathPrefix, segments };
|
|
143
192
|
});
|
|
144
|
-
return
|
|
193
|
+
return rules.sort((left, right) => compareSpecificity(left.segments, right.segments));
|
|
145
194
|
}
|
|
146
195
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
196
|
+
/** The most specific rule for a request path. Prefixes match whole segments of
|
|
197
|
+
* the decoded path, so an encoded segment cannot step around them, and a
|
|
198
|
+
* parameter matches exactly one non-empty segment. */
|
|
199
|
+
function entryFor<T extends { pathPrefix: string }>(
|
|
200
|
+
pathname: string,
|
|
201
|
+
rules: readonly PathRule<T>[],
|
|
202
|
+
): PathRule<T> | undefined {
|
|
152
203
|
let path = pathname;
|
|
153
204
|
try {
|
|
154
205
|
path = decodeURIComponent(pathname);
|
|
155
206
|
} catch {
|
|
156
207
|
// Keep the raw path when it holds a malformed escape.
|
|
157
208
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
209
|
+
const segments = path.split("/").slice(1);
|
|
210
|
+
return rules.find(
|
|
211
|
+
(rule) =>
|
|
212
|
+
rule.segments.length <= segments.length &&
|
|
213
|
+
rule.segments.every((segment, index) =>
|
|
214
|
+
segment === null ? segments[index] !== "" : segment === segments[index],
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function sortedBodyLimits(limits: ReadonlyArray<BodyLimit>): PathRule<BodyLimit>[] {
|
|
220
|
+
return sortedByPrefix(limits, "APP_BODY_LIMITS", "/api/feedback/", ({ pathPrefix, maxBytes }) => {
|
|
221
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
222
|
+
throw new Error(`APP_BODY_LIMITS maxBytes for ${pathPrefix} must be a positive whole number of bytes.`);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const bodyLimits = sortedBodyLimits(APP_BODY_LIMITS);
|
|
228
|
+
|
|
229
|
+
export function bodyLimitFor(pathname: string, limits: readonly PathRule<BodyLimit>[] = bodyLimits): number | undefined {
|
|
230
|
+
return entryFor(pathname, limits)?.maxBytes;
|
|
161
231
|
}
|
|
162
232
|
|
|
163
233
|
/** Read a request body, or return null as soon as it exceeds maxBytes. The rest
|
|
@@ -218,6 +288,216 @@ export function backendBaseUrl(): string {
|
|
|
218
288
|
return process.env.BACKEND_INTERNAL_URL ?? "http://localhost:5002";
|
|
219
289
|
}
|
|
220
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Milliseconds one server-side API call may take, response body included, and
|
|
293
|
+
* how often the proxy checks whether the API still answers at all while it waits
|
|
294
|
+
* for response headers.
|
|
295
|
+
*/
|
|
296
|
+
const DEFAULT_BACKEND_TIMEOUT_MS = 2_000;
|
|
297
|
+
/**
|
|
298
|
+
* Milliseconds the proxy waits for the API's response headers, unless
|
|
299
|
+
* APP_PROXY_TIMEOUTS lists the path. The API's default idle timeout of 35
|
|
300
|
+
* seconds outlasts it.
|
|
301
|
+
* Streamed bodies are not cut off once they start.
|
|
302
|
+
*/
|
|
303
|
+
const DEFAULT_BACKEND_PROXY_TIMEOUT_MS = 30_000;
|
|
304
|
+
/** Seconds between checks while the API is unreachable, also sent as Retry-After. */
|
|
305
|
+
const BACKEND_RETRY_AFTER_SECONDS = 2;
|
|
306
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
307
|
+
|
|
308
|
+
/** A whole number of milliseconds from the environment, or the default when unset or empty. */
|
|
309
|
+
export function envMilliseconds(name: string, fallback: number): number {
|
|
310
|
+
const raw = process.env[name]?.trim();
|
|
311
|
+
if (!raw) return fallback;
|
|
312
|
+
const value = Number(raw);
|
|
313
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_TIMER_MS) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`${name} must be a whole number of milliseconds from 1 to ${MAX_TIMER_MS}: ${JSON.stringify(raw)}`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return value;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function backendTimeoutMs(): number {
|
|
322
|
+
return envMilliseconds("BACKEND_TIMEOUT_MS", DEFAULT_BACKEND_TIMEOUT_MS);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function backendProxyTimeoutMs(): number {
|
|
326
|
+
return envMilliseconds("BACKEND_PROXY_TIMEOUT_MS", DEFAULT_BACKEND_PROXY_TIMEOUT_MS);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// An invalid value stops the server when this module loads instead of failing
|
|
330
|
+
// every request that reads it.
|
|
331
|
+
backendTimeoutMs();
|
|
332
|
+
backendProxyTimeoutMs();
|
|
333
|
+
|
|
334
|
+
export interface ProxyTimeout {
|
|
335
|
+
pathPrefix: string;
|
|
336
|
+
/** Milliseconds to wait for response headers, or null for no deadline. */
|
|
337
|
+
timeoutMs: number | null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function sortedProxyTimeouts(timeouts: ReadonlyArray<ProxyTimeout>): PathRule<ProxyTimeout>[] {
|
|
341
|
+
return sortedByPrefix(timeouts, "APP_PROXY_TIMEOUTS", "/api/exports/", ({ pathPrefix, timeoutMs }) => {
|
|
342
|
+
if (timeoutMs !== null && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMER_MS)) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`APP_PROXY_TIMEOUTS timeoutMs for ${pathPrefix} must be null or a whole number of milliseconds from 1 to ${MAX_TIMER_MS}.`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const proxyTimeouts = sortedProxyTimeouts(APP_PROXY_TIMEOUTS);
|
|
351
|
+
|
|
352
|
+
/** How long the proxy waits for response headers on a request path, or null for no deadline. */
|
|
353
|
+
export function proxyTimeoutFor(
|
|
354
|
+
pathname: string,
|
|
355
|
+
timeouts: readonly PathRule<ProxyTimeout>[] = proxyTimeouts,
|
|
356
|
+
): number | null {
|
|
357
|
+
const entry = entryFor(pathname, timeouts);
|
|
358
|
+
return entry ? entry.timeoutMs : backendProxyTimeoutMs();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Bun closes silent connections on a 4-second tick, up to 4 seconds before their idle timeout. */
|
|
362
|
+
const IDLE_TICK_SECONDS = 4;
|
|
363
|
+
/** The longest idle timeout Bun accepts; 0 turns it off. */
|
|
364
|
+
const MAX_IDLE_TIMEOUT_SECONDS = 255;
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Throw unless the web server keeps a silent connection open longer than the
|
|
368
|
+
* proxy waits for response headers. adapter-bun takes its idle timeout from
|
|
369
|
+
* CONNECTION_IDLE_TIMEOUT (Bun's 10 seconds when unset) and closes a connection
|
|
370
|
+
* that sends nothing for that long without a response, so neither the API's late
|
|
371
|
+
* answer nor the proxy's 504 would reach the browser.
|
|
372
|
+
*/
|
|
373
|
+
export function checkIdleTimeout(
|
|
374
|
+
idleTimeout: string | undefined,
|
|
375
|
+
defaultTimeoutMs: number,
|
|
376
|
+
timeouts: readonly ProxyTimeout[],
|
|
377
|
+
): void {
|
|
378
|
+
// adapter-bun rejects a malformed value itself when it starts.
|
|
379
|
+
if (idleTimeout === undefined || !/^\d+$/.test(idleTimeout) || Number(idleTimeout) === 0) return;
|
|
380
|
+
let longest: { timeoutMs: number | null; source: string } = {
|
|
381
|
+
timeoutMs: defaultTimeoutMs,
|
|
382
|
+
source: "BACKEND_PROXY_TIMEOUT_MS",
|
|
383
|
+
};
|
|
384
|
+
for (const { pathPrefix, timeoutMs } of timeouts) {
|
|
385
|
+
if (longest.timeoutMs !== null && (timeoutMs === null || timeoutMs > longest.timeoutMs)) {
|
|
386
|
+
longest = { timeoutMs, source: `APP_PROXY_TIMEOUTS for ${pathPrefix}` };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const seconds = Number(idleTimeout);
|
|
390
|
+
if (longest.timeoutMs !== null && (seconds - IDLE_TICK_SECONDS) * 1000 > longest.timeoutMs) return;
|
|
391
|
+
const minimum =
|
|
392
|
+
longest.timeoutMs === null ? Infinity : Math.floor(longest.timeoutMs / 1000) + IDLE_TICK_SECONDS + 1;
|
|
393
|
+
const wait = longest.timeoutMs === null ? "no deadline" : `${longest.timeoutMs} ms`;
|
|
394
|
+
const fix =
|
|
395
|
+
minimum <= MAX_IDLE_TIMEOUT_SECONDS
|
|
396
|
+
? `at least ${minimum}, or to 0 to turn the idle timeout off`
|
|
397
|
+
: `0 to turn the idle timeout off, since Bun allows at most ${MAX_IDLE_TIMEOUT_SECONDS} seconds`;
|
|
398
|
+
throw new Error(
|
|
399
|
+
`CONNECTION_IDLE_TIMEOUT=${seconds} lets the web server close a silent connection before the /api proxy ` +
|
|
400
|
+
`stops waiting for response headers (${wait}, ${longest.source}); Bun closes idle connections up to ` +
|
|
401
|
+
`${IDLE_TICK_SECONDS} seconds early. Set CONNECTION_IDLE_TIMEOUT to ${fix}.`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
checkIdleTimeout(process.env.CONNECTION_IDLE_TIMEOUT, backendProxyTimeoutMs(), proxyTimeouts);
|
|
406
|
+
|
|
407
|
+
// The API counts as unreachable only when GET /health gets no HTTP answer
|
|
408
|
+
// within BACKEND_TIMEOUT_MS. It is checked when a request gets no answer, and
|
|
409
|
+
// every BACKEND_TIMEOUT_MS while the proxy waits for response headers, so one
|
|
410
|
+
// slow endpoint does not fail every request in the process, while an API that
|
|
411
|
+
// stops answering still ends a long or unbounded wait. Once the API is found
|
|
412
|
+
// unreachable, requests still waiting for it fail, new ones fail at once instead
|
|
413
|
+
// of each waiting for a refused connection, a DNS lookup, or a hung server, and
|
|
414
|
+
// a background check runs at most every BACKEND_RETRY_AFTER_SECONDS. Any HTTP
|
|
415
|
+
// answer, including an error status, means the API is reachable.
|
|
416
|
+
let unreachable = false;
|
|
417
|
+
let answers = 0;
|
|
418
|
+
let lastCheckEnded = 0;
|
|
419
|
+
let pendingCheck: Promise<void> | undefined;
|
|
420
|
+
let outage = new AbortController();
|
|
421
|
+
|
|
422
|
+
/** Aborts when the API is found unreachable, failing the requests that still wait for it. */
|
|
423
|
+
export function backendOutageSignal(): AbortSignal {
|
|
424
|
+
return outage.signal;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Check whether the API answers at all. Concurrent callers share one check. */
|
|
428
|
+
export function checkBackend(): Promise<void> {
|
|
429
|
+
if (!pendingCheck) {
|
|
430
|
+
const answersBefore = answers;
|
|
431
|
+
pendingCheck = fetch(`${backendBaseUrl()}/health`, { signal: AbortSignal.timeout(backendTimeoutMs()) })
|
|
432
|
+
.then(
|
|
433
|
+
(response) => {
|
|
434
|
+
reportBackendAnswer();
|
|
435
|
+
return response.body?.cancel().catch(() => undefined);
|
|
436
|
+
},
|
|
437
|
+
() => {
|
|
438
|
+
if (answers !== answersBefore) return;
|
|
439
|
+
unreachable = true;
|
|
440
|
+
outage.abort(new DOMException("The API is unavailable", "AbortError"));
|
|
441
|
+
outage = new AbortController();
|
|
442
|
+
},
|
|
443
|
+
)
|
|
444
|
+
.finally(() => {
|
|
445
|
+
lastCheckEnded = Date.now();
|
|
446
|
+
pendingCheck = undefined;
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
return pendingCheck;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Check the API unless a check is running or one ended within BACKEND_TIMEOUT_MS. */
|
|
453
|
+
function recheckBackend(now = Date.now()): void {
|
|
454
|
+
if (!pendingCheck && now - lastCheckEnded >= backendTimeoutMs()) void checkBackend();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Record an HTTP answer from the API, whatever its status. */
|
|
458
|
+
export function reportBackendAnswer(): void {
|
|
459
|
+
answers += 1;
|
|
460
|
+
unreachable = false;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Record a request that got no HTTP answer, and check the API unless it is already known to be down. */
|
|
464
|
+
export function reportBackendFailure(): Promise<void> {
|
|
465
|
+
return unreachable ? Promise.resolve() : checkBackend();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* False while the API is known to be unreachable. Such a call also starts a
|
|
470
|
+
* background check when none ran in the last BACKEND_RETRY_AFTER_SECONDS.
|
|
471
|
+
*/
|
|
472
|
+
export function backendReachable(now = Date.now()): boolean {
|
|
473
|
+
if (!unreachable) return true;
|
|
474
|
+
if (!pendingCheck && now - lastCheckEnded >= BACKEND_RETRY_AFTER_SECONDS * 1000) void checkBackend();
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function backendFailure(path: string, statusCode: 503 | 504): Response {
|
|
479
|
+
const timedOut = statusCode === 504;
|
|
480
|
+
return Response.json(
|
|
481
|
+
{
|
|
482
|
+
success: false,
|
|
483
|
+
error: {
|
|
484
|
+
code: timedOut ? "BACKEND_TIMEOUT" : "BACKEND_UNAVAILABLE",
|
|
485
|
+
message: timedOut ? "The API did not respond in time" : "The API is unavailable",
|
|
486
|
+
statusCode,
|
|
487
|
+
path,
|
|
488
|
+
timestamp: new Date().toISOString(),
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
status: statusCode,
|
|
493
|
+
headers: {
|
|
494
|
+
"cache-control": "no-store",
|
|
495
|
+
...(timedOut ? {} : { "retry-after": String(BACKEND_RETRY_AFTER_SECONDS) }),
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
221
501
|
// Normalize the resolved client IP for the forwarded header. better-auth stores
|
|
222
502
|
// IPv4 as-is but anonymizes IPv6 (masks the host bits), so an IPv6 loopback would
|
|
223
503
|
// be recorded as "::" — map loopback to 127.0.0.1 for a clean local value, and
|
|
@@ -252,12 +532,16 @@ export async function proxyRequest(
|
|
|
252
532
|
const forwardedIp = normalizeClientIp(clientAddress);
|
|
253
533
|
if (forwardedIp) headers.set("x-forwarded-for", forwardedIp);
|
|
254
534
|
|
|
535
|
+
const { pathname } = new URL(request.url);
|
|
536
|
+
if (!backendReachable()) {
|
|
537
|
+
request.body?.cancel().catch(() => undefined);
|
|
538
|
+
return backendFailure(pathname, 503);
|
|
539
|
+
}
|
|
255
540
|
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
256
541
|
let body: ArrayBuffer | undefined;
|
|
257
542
|
if (hasBody) {
|
|
258
543
|
// Preserve multipart uploads and other binary bodies byte-for-byte. Reading
|
|
259
544
|
// them as text corrupts bytes that are not valid UTF-8 before forwarding.
|
|
260
|
-
const { pathname } = new URL(request.url);
|
|
261
545
|
const maxBytes = bodyLimitFor(pathname);
|
|
262
546
|
if (maxBytes === undefined) {
|
|
263
547
|
body = await request.arrayBuffer();
|
|
@@ -267,16 +551,46 @@ export async function proxyRequest(
|
|
|
267
551
|
body = limited;
|
|
268
552
|
}
|
|
269
553
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
554
|
+
// The deadline, the health checks, and the outage signal cover only the wait
|
|
555
|
+
// for response headers, so streamed bodies such as server-sent events are not
|
|
556
|
+
// cut off.
|
|
557
|
+
const waiting = new AbortController();
|
|
558
|
+
const timeoutMs = proxyTimeoutFor(pathname);
|
|
559
|
+
const deadline =
|
|
560
|
+
timeoutMs === null
|
|
561
|
+
? undefined
|
|
562
|
+
: setTimeout(
|
|
563
|
+
() => waiting.abort(new DOMException("The API did not respond in time", "TimeoutError")),
|
|
564
|
+
timeoutMs,
|
|
565
|
+
);
|
|
566
|
+
const healthChecks = setInterval(() => recheckBackend(), backendTimeoutMs());
|
|
567
|
+
const outage = backendOutageSignal();
|
|
568
|
+
const failOnOutage = (): void => waiting.abort(outage.reason);
|
|
569
|
+
outage.addEventListener("abort", failOnOutage);
|
|
570
|
+
let upstream: Response;
|
|
571
|
+
try {
|
|
572
|
+
upstream = await fetch(targetUrl, {
|
|
573
|
+
method: request.method,
|
|
574
|
+
headers,
|
|
575
|
+
body,
|
|
576
|
+
redirect: "manual",
|
|
577
|
+
// Abort the upstream request when the browser disconnects. Without this, every
|
|
578
|
+
// closed EventSource or aborted request keeps its API socket open until Bun's
|
|
579
|
+
// fetch pool is exhausted and the web process stops serving requests.
|
|
580
|
+
signal: AbortSignal.any([request.signal, waiting.signal]),
|
|
581
|
+
});
|
|
582
|
+
} catch (cause: unknown) {
|
|
583
|
+
// Nobody waits for an answer to a request the browser abandoned.
|
|
584
|
+
if (request.signal.aborted) throw cause;
|
|
585
|
+
if (outage.aborted) return backendFailure(pathname, 503);
|
|
586
|
+
void reportBackendFailure();
|
|
587
|
+
return backendFailure(pathname, waiting.signal.aborted ? 504 : 503);
|
|
588
|
+
} finally {
|
|
589
|
+
clearTimeout(deadline);
|
|
590
|
+
clearInterval(healthChecks);
|
|
591
|
+
outage.removeEventListener("abort", failOnOutage);
|
|
592
|
+
}
|
|
593
|
+
reportBackendAnswer();
|
|
280
594
|
|
|
281
595
|
const responseHeaders = new Headers();
|
|
282
596
|
for (const name of relayedResponseHeaders) {
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
* proxies to the API. An entry caps the body of every request whose path is
|
|
4
4
|
* pathPrefix or below it, for example
|
|
5
5
|
* { pathPrefix: "/api/feedback/", maxBytes: 64 * 1024 }
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* { pathPrefix: "/api/hosts/:hostId/uploads", maxBytes: 1024 * 1024 }
|
|
7
|
+
* where a :name segment matches exactly one non-empty path segment. The most
|
|
8
|
+
* specific entry wins: more segments, then a literal segment over a parameter
|
|
9
|
+
* from the left. Above the limit the proxy stops reading and answers 413
|
|
10
|
+
* PAYLOAD_TOO_LARGE without contacting the API.
|
|
8
11
|
*
|
|
9
12
|
* BODY_SIZE_LIMIT remains the ceiling for every web route: Bun rejects a larger
|
|
10
13
|
* body before this code runs, so a maxBytes above it has no effect. Routes that
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-owned deadlines for /api requests that the web server proxies to
|
|
3
|
+
* the API. An entry replaces BACKEND_PROXY_TIMEOUT_MS for every request whose
|
|
4
|
+
* path is pathPrefix or below it, for example
|
|
5
|
+
* { pathPrefix: "/api/exports/", timeoutMs: 120_000 }
|
|
6
|
+
* { pathPrefix: "/api/hosts/:hostId/file-transfers", timeoutMs: null }
|
|
7
|
+
* where a :name segment matches exactly one non-empty path segment. The most
|
|
8
|
+
* specific entry wins: more segments, then a literal segment over a parameter
|
|
9
|
+
* from the left. timeoutMs is how long the proxy waits for the API's response
|
|
10
|
+
* headers before it answers 504 BACKEND_TIMEOUT; null waits as long as the API
|
|
11
|
+
* takes. Either way the proxy answers 503 BACKEND_UNAVAILABLE once the API's
|
|
12
|
+
* GET /health stops answering, and the deadline ends when the response headers
|
|
13
|
+
* arrive.
|
|
14
|
+
*
|
|
15
|
+
* Bun closes a silent connection up to 4 seconds before its idle timeout, so a
|
|
16
|
+
* longer wait also needs the API's API_IDLE_TIMEOUT and the web server's
|
|
17
|
+
* CONNECTION_IDLE_TIMEOUT (35 seconds each by default) more than 4 seconds above
|
|
18
|
+
* the deadline, or 0 for null or anything above 250 seconds. The proxy refuses
|
|
19
|
+
* to load when CONNECTION_IDLE_TIMEOUT cannot cover an entry here.
|
|
20
|
+
* PodoKit creates this file once and never overwrites it.
|
|
21
|
+
*/
|
|
22
|
+
export const APP_PROXY_TIMEOUTS: ReadonlyArray<{ pathPrefix: string; timeoutMs: number | null }> = [];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RequestHandler } from "@sveltejs/kit";
|
|
2
|
+
|
|
3
|
+
// Liveness and readiness of the web server itself, for the Kubernetes probes,
|
|
4
|
+
// the Compose healthcheck, and the image HEALTHCHECK. It never calls the API, so
|
|
5
|
+
// an API outage does not take the web server out of service.
|
|
6
|
+
export const GET: RequestHandler = () =>
|
|
7
|
+
new Response("ok\n", {
|
|
8
|
+
headers: {
|
|
9
|
+
"content-type": "text/plain; charset=utf-8",
|
|
10
|
+
"cache-control": "no-store",
|
|
11
|
+
"x-robots-tag": "noindex, nofollow",
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -12,6 +12,12 @@ POSTGRES_USER=podokit
|
|
|
12
12
|
POSTGRES_PASSWORD=podokit
|
|
13
13
|
POSTGRES_DB=podokit
|
|
14
14
|
BACKEND_INTERNAL_URL=http://api:5002
|
|
15
|
+
# API: the web container and the gateway are the one proxy in front of it.
|
|
16
|
+
TRUSTED_PROXY_HOPS=1
|
|
17
|
+
# Web: milliseconds per server-side API call, and the /api proxy's wait for
|
|
18
|
+
# response headers.
|
|
19
|
+
# BACKEND_TIMEOUT_MS=2000
|
|
20
|
+
# BACKEND_PROXY_TIMEOUT_MS=30000
|
|
15
21
|
CORS_ORIGIN=http://{{projectName}}.localhost,http://localhost
|
|
16
22
|
REDIS_HOST=redis
|
|
17
23
|
REDIS_PORT=6379
|
|
@@ -27,6 +27,18 @@ POSTGRES_DB=podokit
|
|
|
27
27
|
# Largest API request body in bytes (Bun's default is 128 MiB). Requests through
|
|
28
28
|
# the web app's /api proxy are also bounded by BODY_SIZE_LIMIT below.
|
|
29
29
|
# API_MAX_REQUEST_BODY_SIZE=134217728
|
|
30
|
+
# Seconds the API keeps a silent connection open (0 to 255, 0 turns it off). Keep
|
|
31
|
+
# it, like CONNECTION_IDLE_TIMEOUT below, more than 4 above the longest /api proxy
|
|
32
|
+
# deadline in seconds.
|
|
33
|
+
# API_IDLE_TIMEOUT=35
|
|
34
|
+
|
|
35
|
+
# Reverse proxies in front of the API whose X-Forwarded-For names the caller. The
|
|
36
|
+
# web app's /api proxy is one, so 1 records the browser's address for sessions,
|
|
37
|
+
# audit entries, and rate limits; 0 uses the socket peer. Count only proxies that
|
|
38
|
+
# every request passes: with 1, a caller that reaches the API directly chooses
|
|
39
|
+
# its own recorded address.
|
|
40
|
+
TRUSTED_PROXY_HOPS=1
|
|
41
|
+
# TRUSTED_PROXY_HEADER=x-forwarded-for
|
|
30
42
|
|
|
31
43
|
# Readiness (GET /health/ready): the longest one dependency check may run, and how
|
|
32
44
|
# long a result is reused across probes (0 checks on every request).
|
|
@@ -35,10 +47,21 @@ POSTGRES_DB=podokit
|
|
|
35
47
|
|
|
36
48
|
# Web -> API boundary (server-side only; not exposed to the browser)
|
|
37
49
|
BACKEND_INTERNAL_URL=http://localhost:5002
|
|
50
|
+
# Milliseconds one server-side API call may take, and how long the /api proxy
|
|
51
|
+
# waits for response headers (504 BACKEND_TIMEOUT after the second value). When
|
|
52
|
+
# the API's GET /health does not answer within BACKEND_TIMEOUT_MS either, API
|
|
53
|
+
# calls fail at once until it does. Raise BACKEND_PROXY_TIMEOUT_MS together with
|
|
54
|
+
# API_IDLE_TIMEOUT above and CONNECTION_IDLE_TIMEOUT below.
|
|
55
|
+
# BACKEND_TIMEOUT_MS=2000
|
|
56
|
+
# BACKEND_PROXY_TIMEOUT_MS=30000
|
|
38
57
|
|
|
39
58
|
CORS_ORIGIN=http://localhost:5001
|
|
40
59
|
# Web request limit; leave room for a 2 MiB multipart upload
|
|
41
60
|
BODY_SIZE_LIMIT=3M
|
|
61
|
+
# Seconds the built web server keeps a silent connection open (0 to 255, 0 turns
|
|
62
|
+
# it off). Keep it more than 4 above the longest /api proxy deadline in seconds,
|
|
63
|
+
# BACKEND_PROXY_TIMEOUT_MS or APP_PROXY_TIMEOUTS; the proxy refuses to load otherwise.
|
|
64
|
+
CONNECTION_IDLE_TIMEOUT=35
|
|
42
65
|
|
|
43
66
|
# ── Optional / development ───────────────────────────────────────────
|
|
44
67
|
# Redis — only needed by cache/queue modules (redis, bullmq, rate-limit,
|
|
@@ -9,6 +9,9 @@ data:
|
|
|
9
9
|
CORS_ORIGIN: "https://example.com"
|
|
10
10
|
BACKEND_INTERNAL_URL: "http://api:3000"
|
|
11
11
|
BODY_SIZE_LIMIT: "3M"
|
|
12
|
+
CONNECTION_IDLE_TIMEOUT: "35"
|
|
13
|
+
API_IDLE_TIMEOUT: "35"
|
|
14
|
+
TRUSTED_PROXY_HOPS: "1"
|
|
12
15
|
PROTOCOL_HEADER: "x-forwarded-proto"
|
|
13
16
|
HOST_HEADER: "x-forwarded-host"
|
|
14
17
|
ADDRESS_HEADER: "x-forwarded-for"
|
|
@@ -21,10 +21,13 @@ spec:
|
|
|
21
21
|
envFrom:
|
|
22
22
|
- configMapRef: { name: app-config }
|
|
23
23
|
# Without these a rolling update sends traffic to a process that has not
|
|
24
|
-
# bound its port yet.
|
|
24
|
+
# bound its port yet. /healthz never calls the API, so an API outage does
|
|
25
|
+
# not take the web pods out of service.
|
|
25
26
|
readinessProbe:
|
|
26
|
-
httpGet: { path:
|
|
27
|
+
httpGet: { path: /healthz, port: 3000 }
|
|
27
28
|
initialDelaySeconds: 5
|
|
29
|
+
timeoutSeconds: 2
|
|
28
30
|
livenessProbe:
|
|
29
|
-
httpGet: { path:
|
|
31
|
+
httpGet: { path: /healthz, port: 3000 }
|
|
30
32
|
initialDelaySeconds: 15
|
|
33
|
+
timeoutSeconds: 2
|
|
@@ -12,3 +12,11 @@ test("GET /health/ready reports the database", async ({ request }) => {
|
|
|
12
12
|
expect(res.status()).toBe(200);
|
|
13
13
|
expect((await res.json()).status).toBe("ready");
|
|
14
14
|
});
|
|
15
|
+
|
|
16
|
+
test("GET /healthz answers from the web server without caching or indexing @smoke", async ({ request }) => {
|
|
17
|
+
const res = await request.get("/healthz");
|
|
18
|
+
expect(res.status()).toBe(200);
|
|
19
|
+
expect(await res.text()).toBe("ok\n");
|
|
20
|
+
expect(res.headers()["cache-control"]).toBe("no-store");
|
|
21
|
+
expect(res.headers()["x-robots-tag"]).toBe("noindex, nofollow");
|
|
22
|
+
});
|