@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.
Files changed (245) hide show
  1. package/README.md +2 -1
  2. package/dist/app-proxy-timeouts.d.ts +27 -0
  3. package/dist/app-proxy-timeouts.js +180 -0
  4. package/dist/assemble.d.ts +5 -2
  5. package/dist/assemble.js +14 -4
  6. package/dist/deploy-checks.d.ts +44 -0
  7. package/dist/deploy-checks.js +267 -0
  8. package/dist/deploy-compose-profile.d.ts +6 -2
  9. package/dist/deploy-compose-profile.js +15 -6
  10. package/dist/deploy-compose-render.d.ts +12 -4
  11. package/dist/deploy-compose-render.js +47 -17
  12. package/dist/deploy-compose.d.ts +2 -5
  13. package/dist/deploy-compose.js +7 -2
  14. package/dist/deploy-profile.d.ts +6 -2
  15. package/dist/deploy-profile.js +11 -7
  16. package/dist/deploy-render.d.ts +4 -1
  17. package/dist/deploy-render.js +28 -20
  18. package/dist/deploy-schema.d.ts +61 -0
  19. package/dist/deploy-schema.js +157 -1
  20. package/dist/deploy.d.ts +2 -5
  21. package/dist/deploy.js +5 -2
  22. package/dist/dev.d.ts +21 -0
  23. package/dist/dev.js +78 -3
  24. package/dist/eject.js +2 -0
  25. package/dist/index.js +85 -5
  26. package/dist/lib.d.ts +2 -0
  27. package/dist/lib.js +5 -2
  28. package/dist/lockfile.d.ts +24 -0
  29. package/dist/lockfile.js +77 -5
  30. package/dist/provider.d.ts +1 -0
  31. package/dist/provider.js +12 -4
  32. package/dist/remove.js +1 -1
  33. package/dist/template-files.d.ts +42 -0
  34. package/dist/template-files.js +102 -0
  35. package/dist/templates/fullstack/AGENTS.md +44 -7
  36. package/dist/templates/fullstack/README.md +8 -7
  37. package/dist/templates/fullstack/apps/api/package.json +4 -4
  38. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  39. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  40. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  41. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
  42. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
  43. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  44. package/dist/templates/fullstack/apps/api/src/app.ts +47 -28
  45. package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  47. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  48. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +46 -3
  49. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  50. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  51. package/dist/templates/fullstack/apps/api/src/config/providers.ts +8 -2
  52. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  53. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  54. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  55. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
  56. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
  57. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  58. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  59. package/dist/templates/fullstack/apps/api/src/core/services.ts +43 -0
  60. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  61. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  62. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  63. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  64. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  65. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  66. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  67. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  68. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  69. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  70. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  71. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  72. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  73. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  74. package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
  75. package/dist/templates/fullstack/apps/web/package.json +1 -1
  76. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  77. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  78. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  79. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  80. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  81. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +370 -36
  82. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +250 -41
  83. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  84. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +23 -0
  85. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  86. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  87. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  88. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  89. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  90. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  91. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
  92. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  93. package/dist/templates/fullstack/dot-env.docker.example +7 -2
  94. package/dist/templates/fullstack/dot-env.example +21 -1
  95. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  96. package/dist/templates/fullstack/infra/k3s/configmap.yaml +4 -0
  97. package/dist/templates/fullstack/tests/README.md +40 -1
  98. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  99. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  100. package/dist/templates/fullstack/tests/package.json +2 -2
  101. package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
  102. package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
  103. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  104. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  106. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  107. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  108. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  109. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +73 -39
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  124. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  125. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  126. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  127. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  128. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  129. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  130. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  131. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  132. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  133. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  134. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  135. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  136. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  137. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  138. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  139. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  140. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  141. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  142. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  143. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  144. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  145. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  146. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  147. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  148. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  149. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +29 -1
  150. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  151. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  152. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +55 -0
  153. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  154. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  155. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  156. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
  157. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +62 -59
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  164. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  165. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  166. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  167. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  168. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  169. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  170. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  171. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  172. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  173. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  174. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  175. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  176. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  177. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  178. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  179. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  180. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +33 -7
  181. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
  182. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  183. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  184. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  185. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  186. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  187. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +33 -0
  188. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +20 -1
  189. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  190. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  191. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  192. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  193. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  194. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +6 -1
  195. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  196. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  197. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  198. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -8
  199. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  200. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  201. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  202. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  203. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  204. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  205. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  206. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  207. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  208. package/dist/templates/modules/auth/module.manifest.json +8 -2
  209. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
  210. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
  211. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  212. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  213. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  214. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  215. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  216. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  217. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
  218. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
  219. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  220. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  221. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  222. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  223. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
  224. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  225. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
  226. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  227. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  228. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  229. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  230. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  231. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  232. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  233. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  234. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +63 -27
  235. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  236. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  237. package/dist/templates/todo/AGENTS.md +46 -6
  238. package/dist/templates/todo/README.md +6 -4
  239. package/dist/text-diff.d.ts +12 -0
  240. package/dist/text-diff.js +111 -0
  241. package/dist/update.d.ts +61 -3
  242. package/dist/update.js +152 -27
  243. package/package.json +2 -2
  244. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  245. 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
- const BODY_LIMIT_PREFIX = /^\/api(?:\/[A-Za-z0-9._~!$&'()+,;=:@-]+)*\/?$/;
122
+ /** A literal path segment, or null for a `:name` parameter. */
123
+ type PathSegment = string | null;
122
124
 
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
- ) {
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
- `APP_BODY_LIMITS pathPrefix must be a static path under /api/, such as "/api/feedback/": ${JSON.stringify(pathPrefix)}`,
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
- if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
137
- throw new Error(`APP_BODY_LIMITS maxBytes for ${pathPrefix} must be a positive whole number of bytes.`);
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
- 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 };
190
+ seen.set(key, pathPrefix);
191
+ return { ...entry, pathPrefix: pathPrefix.endsWith("/") ? pathPrefix.slice(0, -1) : pathPrefix, segments };
143
192
  });
144
- return sorted.sort((left, right) => right.pathPrefix.length - left.pathPrefix.length);
193
+ return rules.sort((left, right) => compareSpecificity(left.segments, right.segments));
145
194
  }
146
195
 
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 {
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
- return limits.find(
159
- ({ pathPrefix }) => path === pathPrefix || path.startsWith(`${pathPrefix}/`),
160
- )?.maxBytes;
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 long the proxy waits for response headers before it checks whether the
224
- * API still answers at all.
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, matching the
229
- * API's default idle timeout. Streamed bodies are not cut off once they start.
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, or
264
- // when the proxy waits longer than that for response headers, so one slow
265
- // endpoint does not fail every request in the process. Once the API is found
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 } = new URL(request.url);
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 check, and the outage signal cover only the wait
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 deadline = setTimeout(
408
- () => waiting.abort(new DOMException("The API did not respond in time", "TimeoutError")),
409
- backendProxyTimeoutMs(),
410
- );
411
- const healthCheck = setTimeout(() => void checkBackend(), backendTimeoutMs());
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
- clearTimeout(healthCheck);
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
- * and the longest matching prefix wins. Above the limit the proxy stops reading
7
- * and answers 413 PAYLOAD_TOO_LARGE without contacting the API.
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 }> = [];
@@ -6,5 +6,5 @@
6
6
  "esModuleInterop": true,
7
7
  "skipLibCheck": true
8
8
  },
9
- "include": ["src", "vite.config.ts"]
9
+ "include": ["src", "vite.config.ts", "vite.extensions.ts"]
10
10
  }
@@ -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
- export default defineConfig({
14
- plugins: [
15
- tailwindcss(),
16
- sveltekit({
17
- adapter: adapter(),
18
- preprocess: vitePreprocess(),
19
- ...(publicOrigin ? { paths: { origin: publicOrigin } } : {}),
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
- server: {
23
- ...(inDocker ? { host: true, port: 5001, allowedHosts: true } : {}),
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
- - -c
145
+ - -ec
143
146
  - |
144
147
  until mc alias set loc http://minio:9000 podokit podokitsecret; do sleep 1; done
145
- mc mb -p loc/podokit || true
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 failure without printing secret values.
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, the migration warning, and the plan hash.
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. The bind address defaults to `127.0.0.1`, because the expected
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