@podosoft/podokit 1.4.2 → 1.6.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/README.md +2 -1
- package/dist/app-proxy-timeouts.d.ts +27 -0
- package/dist/app-proxy-timeouts.js +180 -0
- package/dist/assemble.d.ts +5 -2
- package/dist/assemble.js +14 -4
- package/dist/deploy-checks.d.ts +44 -0
- package/dist/deploy-checks.js +267 -0
- package/dist/deploy-compose-profile.d.ts +6 -2
- package/dist/deploy-compose-profile.js +15 -6
- package/dist/deploy-compose-render.d.ts +12 -4
- package/dist/deploy-compose-render.js +47 -17
- package/dist/deploy-compose.d.ts +2 -5
- package/dist/deploy-compose.js +7 -2
- package/dist/deploy-profile.d.ts +6 -2
- package/dist/deploy-profile.js +11 -7
- package/dist/deploy-render.d.ts +4 -1
- package/dist/deploy-render.js +28 -20
- package/dist/deploy-schema.d.ts +61 -0
- package/dist/deploy-schema.js +157 -1
- package/dist/deploy.d.ts +2 -5
- package/dist/deploy.js +5 -2
- package/dist/dev.d.ts +21 -0
- package/dist/dev.js +78 -3
- package/dist/eject.js +2 -0
- package/dist/index.js +85 -5
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +5 -2
- package/dist/lockfile.d.ts +24 -0
- package/dist/lockfile.js +77 -5
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +12 -4
- package/dist/remove.js +1 -1
- package/dist/template-files.d.ts +42 -0
- package/dist/template-files.js +102 -0
- package/dist/templates/fullstack/AGENTS.md +44 -7
- package/dist/templates/fullstack/README.md +8 -7
- package/dist/templates/fullstack/apps/api/package.json +4 -4
- package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
- package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +47 -28
- package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
- package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +46 -3
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +8 -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/api-contract.extensions.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
- 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 +43 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
- package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
- package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
- package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
- package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/app.css +3 -0
- package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +370 -36
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +250 -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 +23 -0
- package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
- package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
- package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
- package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
- package/dist/templates/fullstack/compose.dev.yaml +7 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
- package/dist/templates/fullstack/dot-env.docker.example +7 -2
- package/dist/templates/fullstack/dot-env.example +21 -1
- package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +4 -0
- package/dist/templates/fullstack/tests/README.md +40 -1
- package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
- package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
- package/dist/templates/fullstack/tests/package.json +2 -2
- package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
- package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
- package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
- 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 +37 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
- 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/admin/app-flag-settings.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +73 -39
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
- 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/i18n/catalogs/admin-dashboard/en.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -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/audit.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +29 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
- 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 +62 -59
- package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +33 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
- package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
- 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.module.ts +5 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +33 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +20 -1
- package/dist/templates/modules/audit-log/module.manifest.json +15 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +6 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
- 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 +15 -8
- 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/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
- package/dist/templates/modules/auth/module.manifest.json +8 -2
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
- package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
- package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
- 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 +63 -27
- package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
- package/dist/templates/todo/AGENTS.md +46 -6
- package/dist/templates/todo/README.md +6 -4
- package/dist/text-diff.d.ts +12 -0
- package/dist/text-diff.js +111 -0
- package/dist/update.d.ts +61 -3
- package/dist/update.js +152 -27
- package/package.json +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
|
@@ -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
|
+
/** A literal path segment, or null for a `:name` parameter. */
|
|
123
|
+
type PathSegment = string | null;
|
|
122
124
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
@@ -220,13 +290,15 @@ export function backendBaseUrl(): string {
|
|
|
220
290
|
|
|
221
291
|
/**
|
|
222
292
|
* Milliseconds one server-side API call may take, response body included, and
|
|
223
|
-
* how
|
|
224
|
-
*
|
|
293
|
+
* how often the proxy checks whether the API still answers at all while it waits
|
|
294
|
+
* for response headers.
|
|
225
295
|
*/
|
|
226
296
|
const DEFAULT_BACKEND_TIMEOUT_MS = 2_000;
|
|
227
297
|
/**
|
|
228
|
-
* Milliseconds the proxy waits for the API's response headers,
|
|
229
|
-
* API's default idle timeout
|
|
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.
|
|
230
302
|
*/
|
|
231
303
|
const DEFAULT_BACKEND_PROXY_TIMEOUT_MS = 30_000;
|
|
232
304
|
/** Seconds between checks while the API is unreachable, also sent as Retry-After. */
|
|
@@ -259,10 +331,101 @@ function backendProxyTimeoutMs(): number {
|
|
|
259
331
|
backendTimeoutMs();
|
|
260
332
|
backendProxyTimeoutMs();
|
|
261
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
|
+
/**
|
|
362
|
+
* Bun's fetch fails a request whose connection sends nothing for its idle
|
|
363
|
+
* timeout, 300 seconds unless BUN_CONFIG_HTTP_IDLE_TIMEOUT sets another, even
|
|
364
|
+
* while the proxy still waits for response headers.
|
|
365
|
+
*/
|
|
366
|
+
const FETCH_IDLE_TIMEOUT_MS = 300_000;
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Bun's fetch options for a request that waits up to timeoutMs for response
|
|
370
|
+
* headers. A wait without a deadline, or one Bun's idle timeout would cut
|
|
371
|
+
* first, turns that timeout off, so only the deadline, the health checks, and
|
|
372
|
+
* the browser end it. Shorter waits keep Bun's default.
|
|
373
|
+
*/
|
|
374
|
+
export function upstreamFetchOptions(timeoutMs: number | null): { timeout?: false } {
|
|
375
|
+
return timeoutMs === null || timeoutMs >= FETCH_IDLE_TIMEOUT_MS ? { timeout: false } : {};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Bun closes silent connections on a 4-second tick, up to 4 seconds before their idle timeout. */
|
|
379
|
+
const IDLE_TICK_SECONDS = 4;
|
|
380
|
+
/** The longest idle timeout Bun accepts; 0 turns it off. */
|
|
381
|
+
const MAX_IDLE_TIMEOUT_SECONDS = 255;
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Throw unless the web server keeps a silent connection open longer than the
|
|
385
|
+
* proxy waits for response headers. adapter-bun takes its idle timeout from
|
|
386
|
+
* CONNECTION_IDLE_TIMEOUT (Bun's 10 seconds when unset) and closes a connection
|
|
387
|
+
* that sends nothing for that long without a response, so neither the API's late
|
|
388
|
+
* answer nor the proxy's 504 would reach the browser.
|
|
389
|
+
*/
|
|
390
|
+
export function checkIdleTimeout(
|
|
391
|
+
idleTimeout: string | undefined,
|
|
392
|
+
defaultTimeoutMs: number,
|
|
393
|
+
timeouts: readonly ProxyTimeout[],
|
|
394
|
+
): void {
|
|
395
|
+
// adapter-bun rejects a malformed value itself when it starts.
|
|
396
|
+
if (idleTimeout === undefined || !/^\d+$/.test(idleTimeout) || Number(idleTimeout) === 0) return;
|
|
397
|
+
let longest: { timeoutMs: number | null; source: string } = {
|
|
398
|
+
timeoutMs: defaultTimeoutMs,
|
|
399
|
+
source: "BACKEND_PROXY_TIMEOUT_MS",
|
|
400
|
+
};
|
|
401
|
+
for (const { pathPrefix, timeoutMs } of timeouts) {
|
|
402
|
+
if (longest.timeoutMs !== null && (timeoutMs === null || timeoutMs > longest.timeoutMs)) {
|
|
403
|
+
longest = { timeoutMs, source: `APP_PROXY_TIMEOUTS for ${pathPrefix}` };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const seconds = Number(idleTimeout);
|
|
407
|
+
if (longest.timeoutMs !== null && (seconds - IDLE_TICK_SECONDS) * 1000 > longest.timeoutMs) return;
|
|
408
|
+
const minimum =
|
|
409
|
+
longest.timeoutMs === null ? Infinity : Math.floor(longest.timeoutMs / 1000) + IDLE_TICK_SECONDS + 1;
|
|
410
|
+
const wait = longest.timeoutMs === null ? "no deadline" : `${longest.timeoutMs} ms`;
|
|
411
|
+
const fix =
|
|
412
|
+
minimum <= MAX_IDLE_TIMEOUT_SECONDS
|
|
413
|
+
? `at least ${minimum}, or to 0 to turn the idle timeout off`
|
|
414
|
+
: `0 to turn the idle timeout off, since Bun allows at most ${MAX_IDLE_TIMEOUT_SECONDS} seconds`;
|
|
415
|
+
throw new Error(
|
|
416
|
+
`CONNECTION_IDLE_TIMEOUT=${seconds} lets the web server close a silent connection before the /api proxy ` +
|
|
417
|
+
`stops waiting for response headers (${wait}, ${longest.source}); Bun closes idle connections up to ` +
|
|
418
|
+
`${IDLE_TICK_SECONDS} seconds early. Set CONNECTION_IDLE_TIMEOUT to ${fix}.`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
checkIdleTimeout(process.env.CONNECTION_IDLE_TIMEOUT, backendProxyTimeoutMs(), proxyTimeouts);
|
|
423
|
+
|
|
262
424
|
// The API counts as unreachable only when GET /health gets no HTTP answer
|
|
263
|
-
// within BACKEND_TIMEOUT_MS. It is checked when a request gets no answer,
|
|
264
|
-
//
|
|
265
|
-
// endpoint does not fail every request in the process
|
|
425
|
+
// within BACKEND_TIMEOUT_MS. It is checked when a request gets no answer, and
|
|
426
|
+
// every BACKEND_TIMEOUT_MS while the proxy waits for response headers, so one
|
|
427
|
+
// slow endpoint does not fail every request in the process, while an API that
|
|
428
|
+
// stops answering still ends a long or unbounded wait. Once the API is found
|
|
266
429
|
// unreachable, requests still waiting for it fail, new ones fail at once instead
|
|
267
430
|
// of each waiting for a refused connection, a DNS lookup, or a hung server, and
|
|
268
431
|
// a background check runs at most every BACKEND_RETRY_AFTER_SECONDS. Any HTTP
|
|
@@ -303,6 +466,11 @@ export function checkBackend(): Promise<void> {
|
|
|
303
466
|
return pendingCheck;
|
|
304
467
|
}
|
|
305
468
|
|
|
469
|
+
/** Check the API unless a check is running or one ended within BACKEND_TIMEOUT_MS. */
|
|
470
|
+
function recheckBackend(now = Date.now()): void {
|
|
471
|
+
if (!pendingCheck && now - lastCheckEnded >= backendTimeoutMs()) void checkBackend();
|
|
472
|
+
}
|
|
473
|
+
|
|
306
474
|
/** Record an HTTP answer from the API, whatever its status. */
|
|
307
475
|
export function reportBackendAnswer(): void {
|
|
308
476
|
answers += 1;
|
|
@@ -358,6 +526,40 @@ export function normalizeClientIp(address: string | undefined): string | undefin
|
|
|
358
526
|
return address;
|
|
359
527
|
}
|
|
360
528
|
|
|
529
|
+
let publicOriginsSource: string | undefined;
|
|
530
|
+
let publicOrigins: ReadonlySet<string> = new Set();
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The public web origins listed in CORS_ORIGIN. Wildcard patterns name no single
|
|
534
|
+
* origin and are skipped, as are entries that are not absolute URLs.
|
|
535
|
+
*/
|
|
536
|
+
export function configuredPublicOrigins(value = process.env.CORS_ORIGIN): ReadonlySet<string> {
|
|
537
|
+
if (value !== publicOriginsSource) {
|
|
538
|
+
publicOriginsSource = value;
|
|
539
|
+
publicOrigins = new Set(
|
|
540
|
+
(value ?? "")
|
|
541
|
+
.split(",")
|
|
542
|
+
.map((entry) => entry.trim())
|
|
543
|
+
.filter((entry) => !entry.includes("*") && URL.canParse(entry))
|
|
544
|
+
.map((entry) => new URL(entry).origin),
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
return publicOrigins;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Tell the API which public origin a request came in on. SvelteKit resolves the
|
|
552
|
+
* request URL from the Host header, or from the gateway's forwarded headers that
|
|
553
|
+
* HOST_HEADER and PROTOCOL_HEADER name, so the value is sent only when that origin
|
|
554
|
+
* is listed in CORS_ORIGIN. The API can then build links for the origin a browser
|
|
555
|
+
* used without trusting a header a caller wrote.
|
|
556
|
+
*/
|
|
557
|
+
export function setForwardedOrigin(headers: Headers, url: URL): void {
|
|
558
|
+
if (!configuredPublicOrigins().has(url.origin)) return;
|
|
559
|
+
headers.set("x-forwarded-host", url.host);
|
|
560
|
+
headers.set("x-forwarded-proto", url.protocol.slice(0, -1));
|
|
561
|
+
}
|
|
562
|
+
|
|
361
563
|
export function resolveClientIp(getClientAddress: () => string): string | undefined {
|
|
362
564
|
try {
|
|
363
565
|
return normalizeClientIp(getClientAddress());
|
|
@@ -380,8 +582,10 @@ export async function proxyRequest(
|
|
|
380
582
|
// Forward the resolved client IP so the API (better-auth) can record it.
|
|
381
583
|
const forwardedIp = normalizeClientIp(clientAddress);
|
|
382
584
|
if (forwardedIp) headers.set("x-forwarded-for", forwardedIp);
|
|
585
|
+
const requestUrl = new URL(request.url);
|
|
586
|
+
setForwardedOrigin(headers, requestUrl);
|
|
383
587
|
|
|
384
|
-
const { pathname } =
|
|
588
|
+
const { pathname } = requestUrl;
|
|
385
589
|
if (!backendReachable()) {
|
|
386
590
|
request.body?.cancel().catch(() => undefined);
|
|
387
591
|
return backendFailure(pathname, 503);
|
|
@@ -400,15 +604,19 @@ export async function proxyRequest(
|
|
|
400
604
|
body = limited;
|
|
401
605
|
}
|
|
402
606
|
}
|
|
403
|
-
// The deadline, the health
|
|
607
|
+
// The deadline, the health checks, and the outage signal cover only the wait
|
|
404
608
|
// for response headers, so streamed bodies such as server-sent events are not
|
|
405
609
|
// cut off.
|
|
406
610
|
const waiting = new AbortController();
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
611
|
+
const timeoutMs = proxyTimeoutFor(pathname);
|
|
612
|
+
const deadline =
|
|
613
|
+
timeoutMs === null
|
|
614
|
+
? undefined
|
|
615
|
+
: setTimeout(
|
|
616
|
+
() => waiting.abort(new DOMException("The API did not respond in time", "TimeoutError")),
|
|
617
|
+
timeoutMs,
|
|
618
|
+
);
|
|
619
|
+
const healthChecks = setInterval(() => recheckBackend(), backendTimeoutMs());
|
|
412
620
|
const outage = backendOutageSignal();
|
|
413
621
|
const failOnOutage = (): void => waiting.abort(outage.reason);
|
|
414
622
|
outage.addEventListener("abort", failOnOutage);
|
|
@@ -423,6 +631,7 @@ export async function proxyRequest(
|
|
|
423
631
|
// closed EventSource or aborted request keeps its API socket open until Bun's
|
|
424
632
|
// fetch pool is exhausted and the web process stops serving requests.
|
|
425
633
|
signal: AbortSignal.any([request.signal, waiting.signal]),
|
|
634
|
+
...upstreamFetchOptions(timeoutMs),
|
|
426
635
|
});
|
|
427
636
|
} catch (cause: unknown) {
|
|
428
637
|
// Nobody waits for an answer to a request the browser abandoned.
|
|
@@ -432,7 +641,7 @@ export async function proxyRequest(
|
|
|
432
641
|
return backendFailure(pathname, waiting.signal.aborted ? 504 : 503);
|
|
433
642
|
} finally {
|
|
434
643
|
clearTimeout(deadline);
|
|
435
|
-
|
|
644
|
+
clearInterval(healthChecks);
|
|
436
645
|
outage.removeEventListener("abort", failOnOutage);
|
|
437
646
|
}
|
|
438
647
|
reportBackendAnswer();
|
|
@@ -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,23 @@
|
|
|
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. For null or 300 seconds and more, the proxy also turns off Bun's fetch
|
|
14
|
+
* idle timeout, which would otherwise end a silent wait after 300 seconds.
|
|
15
|
+
*
|
|
16
|
+
* Bun closes a silent connection up to 4 seconds before its idle timeout, so a
|
|
17
|
+
* longer wait also needs the API's API_IDLE_TIMEOUT and the web server's
|
|
18
|
+
* CONNECTION_IDLE_TIMEOUT (35 seconds each by default) more than 4 seconds above
|
|
19
|
+
* the deadline, or 0 for null or anything above 250 seconds. The proxy refuses
|
|
20
|
+
* to load when CONNECTION_IDLE_TIMEOUT cannot cover an entry here.
|
|
21
|
+
* PodoKit creates this file once and never overwrites it.
|
|
22
|
+
*/
|
|
23
|
+
export const APP_PROXY_TIMEOUTS: ReadonlyArray<{ pathPrefix: string; timeoutMs: number | null }> = [];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { webSocketProxy } from "./vite.config.ts";
|
|
3
|
+
|
|
4
|
+
describe("dev-server WebSocket proxy", () => {
|
|
5
|
+
it("sends each exact path in .podokit/dev.json to the API", () => {
|
|
6
|
+
const proxy = webSocketProxy(
|
|
7
|
+
JSON.stringify({ hostname: "app.localhost", webSocketPaths: ["/events/ws", "/agent.v1/ws"] }),
|
|
8
|
+
"http://api:5002",
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
expect(proxy).toEqual({
|
|
12
|
+
"^/events/ws(?:\\?.*)?$": { target: "http://api:5002", ws: true, xfwd: true },
|
|
13
|
+
"^/agent\\.v1/ws(?:\\?.*)?$": { target: "http://api:5002", ws: true, xfwd: true },
|
|
14
|
+
});
|
|
15
|
+
const [events] = Object.keys(proxy).map((key) => new RegExp(key));
|
|
16
|
+
expect(events?.test("/events/ws")).toBe(true);
|
|
17
|
+
expect(events?.test("/events/ws?token=1")).toBe(true);
|
|
18
|
+
expect(events?.test("/events/ws/other")).toBe(false);
|
|
19
|
+
expect(events?.test("/events/wsx")).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("proxies nothing without the file or its paths", () => {
|
|
23
|
+
expect(webSocketProxy(null)).toEqual({});
|
|
24
|
+
expect(webSocketProxy(JSON.stringify({ hostname: "app.localhost" }))).toEqual({});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rejects a path the gateway would reject", () => {
|
|
28
|
+
expect(() => webSocketProxy(JSON.stringify({ webSocketPaths: ["events/ws"] }))).toThrow(
|
|
29
|
+
'invalid path: "events/ws"',
|
|
30
|
+
);
|
|
31
|
+
expect(() => webSocketProxy(JSON.stringify({ webSocketPaths: "/events/ws" }))).toThrow("must be an array");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import adapter from "@sveltejs/adapter-bun";
|
|
2
3
|
import { sveltekit } from "@sveltejs/kit/vite";
|
|
3
4
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
|
4
5
|
import tailwindcss from "@tailwindcss/vite";
|
|
5
|
-
import { defineConfig } from "vite";
|
|
6
|
+
import { defineConfig, mergeConfig, type ProxyOptions } from "vite";
|
|
7
|
+
import { appSvelteKitConfig, appViteConfig } from "./vite.extensions.ts";
|
|
6
8
|
|
|
7
9
|
// Inside the dev container, bind to all interfaces and accept local gateway or
|
|
8
10
|
// HTTPS tunnel hostnames. Vite derives HMR's protocol, host, and port from the
|
|
@@ -10,16 +12,56 @@ import { defineConfig } from "vite";
|
|
|
10
12
|
const inDocker = process.env.VITE_DOCKER === "1";
|
|
11
13
|
const publicOrigin = process.env.PODOKIT_BUILD_ORIGIN;
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Dev-server proxies for the exact WebSocket paths in `.podokit/dev.json`. A
|
|
17
|
+
* SvelteKit route cannot answer an upgrade, so `bun run dev` sends these paths to
|
|
18
|
+
* the API the way the `podo dev` gateway and a deployment's edge do. The API must
|
|
19
|
+
* authenticate each upgrade itself.
|
|
20
|
+
*/
|
|
21
|
+
export function webSocketProxy(
|
|
22
|
+
devConfig: string | null,
|
|
23
|
+
target = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:5002",
|
|
24
|
+
): Record<string, ProxyOptions> {
|
|
25
|
+
if (devConfig === null) return {};
|
|
26
|
+
const { webSocketPaths = [] } = JSON.parse(devConfig) as { webSocketPaths?: unknown };
|
|
27
|
+
if (!Array.isArray(webSocketPaths)) throw new Error(".podokit/dev.json webSocketPaths must be an array of paths");
|
|
28
|
+
return Object.fromEntries(
|
|
29
|
+
webSocketPaths.map((path: unknown) => {
|
|
30
|
+
if (typeof path !== "string" || !/^\/[A-Za-z0-9._~!$&'()+,;=:@/-]+$/.test(path)) {
|
|
31
|
+
throw new Error(`.podokit/dev.json webSocketPaths has an invalid path: ${JSON.stringify(path)}`);
|
|
32
|
+
}
|
|
33
|
+
const exact = `^${path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\?.*)?$`;
|
|
34
|
+
return [exact, { target, ws: true, xfwd: true }];
|
|
20
35
|
}),
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readDevConfig(): string | null {
|
|
40
|
+
try {
|
|
41
|
+
return readFileSync(new URL("../../.podokit/dev.json", import.meta.url), "utf8");
|
|
42
|
+
} catch (cause) {
|
|
43
|
+
if ((cause as { code?: unknown }).code === "ENOENT") return null;
|
|
44
|
+
throw cause;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default defineConfig((env) =>
|
|
49
|
+
mergeConfig(
|
|
50
|
+
{
|
|
51
|
+
plugins: [
|
|
52
|
+
tailwindcss(),
|
|
53
|
+
sveltekit({
|
|
54
|
+
adapter: adapter(),
|
|
55
|
+
preprocess: vitePreprocess(),
|
|
56
|
+
...appSvelteKitConfig,
|
|
57
|
+
...(publicOrigin ? { paths: { ...appSvelteKitConfig.paths, origin: publicOrigin } } : {}),
|
|
58
|
+
}),
|
|
59
|
+
],
|
|
60
|
+
server: {
|
|
61
|
+
...(inDocker ? { host: true, port: 5001, allowedHosts: true } : {}),
|
|
62
|
+
...(env.command === "serve" ? { proxy: webSocketProxy(readDevConfig()) } : {}),
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
typeof appViteConfig === "function" ? appViteConfig(env) : appViteConfig,
|
|
66
|
+
),
|
|
67
|
+
);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Config as SvelteKitConfig } from "@sveltejs/kit/vite";
|
|
2
|
+
import type { UserConfig, UserConfigFnObject } from "vite";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Application-owned additions to the managed vite.config.ts, combined with Vite's
|
|
6
|
+
* `mergeConfig`: lists such as `plugins` are appended and objects are merged key
|
|
7
|
+
* by key. Use it for dev-server warmup, dependency optimization, `define`
|
|
8
|
+
* constants, extra plugins, or extra allowed hosts instead of editing
|
|
9
|
+
* vite.config.ts. A function receives Vite's `{ command, mode }`. WebSocket paths
|
|
10
|
+
* listed in `.podokit/dev.json` already reach the API through the dev server.
|
|
11
|
+
* PodoKit creates this file once and never overwrites it.
|
|
12
|
+
*/
|
|
13
|
+
export const appViteConfig: UserConfig | UserConfigFnObject = {
|
|
14
|
+
// Transform the pages most sessions open first as soon as the dev server starts:
|
|
15
|
+
// server: { warmup: { clientFiles: ["./src/routes/+layout.svelte", "./src/routes/+page.svelte"] } },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Application-owned SvelteKit options, such as `alias`, `csrf`, or `prerender`.
|
|
20
|
+
* PodoKit sets `adapter` and `preprocess`, and `paths.origin` from
|
|
21
|
+
* PODOKIT_BUILD_ORIGIN at build time.
|
|
22
|
+
*/
|
|
23
|
+
export const appSvelteKitConfig: SvelteKitConfig = {};
|
|
@@ -48,6 +48,7 @@ services:
|
|
|
48
48
|
- { action: sync, path: ./apps/web/src, target: /app/apps/web/src }
|
|
49
49
|
- { action: sync, path: ./apps/web/static, target: /app/apps/web/static }
|
|
50
50
|
- { action: sync+restart, path: ./apps/web/vite.config.ts, target: /app/apps/web/vite.config.ts }
|
|
51
|
+
- { action: sync+restart, path: ./apps/web/vite.extensions.ts, target: /app/apps/web/vite.extensions.ts }
|
|
51
52
|
- { action: rebuild, path: ./apps/web/package.json }
|
|
52
53
|
|
|
53
54
|
api:
|
|
@@ -121,7 +122,7 @@ services:
|
|
|
121
122
|
mem_limit: ${MINIO_MEM_LIMIT:-512m}
|
|
122
123
|
memswap_limit: ${MINIO_MEM_LIMIT:-512m}
|
|
123
124
|
profiles: [storage]
|
|
124
|
-
image: pgsty/silo:RELEASE.2026-09-16T00-00-00Z
|
|
125
|
+
image: pgsty/silo:RELEASE.2026-09-16T00-00-00Z@sha256:635197cb9f36d01bee221d34d1c7d7960f6a95c48b0b6c01d99cd13bdae51a46
|
|
125
126
|
command: server /data --console-address ":9001"
|
|
126
127
|
environment:
|
|
127
128
|
MINIO_ROOT_USER: podokit
|
|
@@ -134,15 +135,17 @@ services:
|
|
|
134
135
|
mem_limit: ${MINIO_INIT_MEM_LIMIT:-128m}
|
|
135
136
|
memswap_limit: ${MINIO_INIT_MEM_LIMIT:-128m}
|
|
136
137
|
profiles: [storage]
|
|
137
|
-
image: pgsty/mc:RELEASE.2026-09-16T00-00-00Z
|
|
138
|
+
image: pgsty/mc:RELEASE.2026-09-16T00-00-00Z@sha256:cfc83108c3abb371f8fb84d99c1fdc88f8c237e022409b0081fb7c0a3be634dd
|
|
138
139
|
depends_on:
|
|
139
140
|
minio: { condition: service_started }
|
|
141
|
+
# -e stops at the first failing command, so a bucket that cannot be
|
|
142
|
+
# created fails this service instead of leaving uploads to fail later.
|
|
140
143
|
entrypoint:
|
|
141
144
|
- sh
|
|
142
|
-
- -
|
|
145
|
+
- -ec
|
|
143
146
|
- |
|
|
144
147
|
until mc alias set loc http://minio:9000 podokit podokitsecret; do sleep 1; done
|
|
145
|
-
mc mb -
|
|
148
|
+
mc mb --ignore-existing loc/podokit
|
|
146
149
|
networks: [podokit]
|
|
147
150
|
|
|
148
151
|
networks:
|
|
@@ -16,9 +16,9 @@ Read the profile's `driver` first and say which one you are operating.
|
|
|
16
16
|
1. Run `{{packageExecutor}} @podosoft/podokit status`, `{{packageExecutor}} @podosoft/podokit diff`, and `{{packageExecutor}} @podosoft/podokit doctor`. Stop if managed-file conflicts or unsupported framework versions could make the image unreliable.
|
|
17
17
|
2. Read [references/profile.md](references/profile.md) before creating or changing a deployment profile.
|
|
18
18
|
3. Make sure the images exist for that tag. Read [references/release.md](references/release.md): tagging `vX.Y.Z` runs the release workflow, which builds and pushes both images and stops there. Never apply a release whose images were not published.
|
|
19
|
-
4. Run `{{packageExecutor}} @podosoft/podokit deploy doctor --profile <name>`. Resolve every
|
|
19
|
+
4. Run `{{packageExecutor}} @podosoft/podokit deploy doctor --profile <name>`. Resolve every `FAIL` line and show the user every `warn` line, without printing secret values.
|
|
20
20
|
5. Run `{{packageExecutor}} @podosoft/podokit deploy plan --profile <name> --release <stable-semver> --json`.
|
|
21
|
-
6. Show the user the driver, the target (context plus namespace and Helm release, or Docker context plus Compose project), the fingerprint result, the exact image digests, the current revision, the actions,
|
|
21
|
+
6. Show the user the driver, the target (context plus namespace and Helm release, or Docker context plus Compose project), the fingerprint result, the exact image digests, the current revision, the actions, every plan warning, and the plan hash.
|
|
22
22
|
7. Do not apply until the user explicitly approves that exact plan.
|
|
23
23
|
8. Apply with `{{packageExecutor}} @podosoft/podokit deploy apply --profile <name> --release <stable-semver> --confirm <plan-hash>`.
|
|
24
24
|
9. Run `{{packageExecutor}} @podosoft/podokit deploy status --profile <name> --json` and `{{packageExecutor}} @podosoft/podokit deploy verify --profile <name> --json`.
|
|
@@ -24,9 +24,16 @@ Review these fields before planning:
|
|
|
24
24
|
`stripPrefix` Middleware sends them to API without `/api`. It needs a
|
|
25
25
|
Traefik-served IngressClass, and `/api/auth` is rejected. `podo deploy init`
|
|
26
26
|
and `podo add` fill in the paths installed modules declare (file-upload:
|
|
27
|
-
`/api/files/uploads/`); keep them.
|
|
27
|
+
`/api/files/uploads/`); keep them. `edgeProxies` counts the reverse proxies in
|
|
28
|
+
front of the Ingress controller or NodePort (default 0); the web server then
|
|
29
|
+
trusts that many plus the controller in `X-Forwarded-For`. Never set
|
|
30
|
+
`XFF_DEPTH`, `ADDRESS_HEADER`, `TRUSTED_PROXY_HOPS`, or `TRUSTED_PROXY_HEADER`
|
|
31
|
+
in `runtimeConfig`; the profile refuses them.
|
|
28
32
|
- `workloads`: API/web replicas and resources plus an optional worker
|
|
29
|
-
- `dependencies`: PostgreSQL, Redis, and object storage mode, image, Secret name, and storage
|
|
33
|
+
- `dependencies`: PostgreSQL, Redis, and object storage mode, image, Secret name, and storage.
|
|
34
|
+
PostgreSQL also takes `maxConnections` (default 100) and `appPools` for pools the
|
|
35
|
+
application opens itself; the doctor and the plan check the connection budget
|
|
36
|
+
against them, so lower `DB_POOL_MAX`/`DB_AUTH_POOL_MAX` or replicas when they warn.
|
|
30
37
|
- `secrets`: API/optional web Secret names and required key names, plus image-pull Secret
|
|
31
38
|
- `runtimeConfig`: non-sensitive environment values only
|
|
32
39
|
- `verification`: HTTPS origin and checks with exact statuses and optional JSON fields
|
|
@@ -72,7 +79,9 @@ The fields that differ from the Kubernetes driver:
|
|
|
72
79
|
it, sends exact WebSocket paths to API, sends `apiPaths` prefixes to API with
|
|
73
80
|
`/api` removed, and sends every other request to web. Forwarding headers are trusted
|
|
74
81
|
only from the direct proxy CIDRs in `trustedProxyCidrs`; leave it empty without an
|
|
75
|
-
external proxy.
|
|
82
|
+
external proxy. `edgeProxies` counts the reverse proxies in front of the published
|
|
83
|
+
port (default 1); with the gateway it must match `trustedProxyCidrs`, which the
|
|
84
|
+
doctor checks. The bind address defaults to `127.0.0.1`, because the expected
|
|
76
85
|
front door is a trusted reverse proxy — binding every interface publishes the app
|
|
77
86
|
before TLS exists.
|
|
78
87
|
- `workloads`: replicas plus Compose `cpus` and `memory` limits
|