@ultimat3/cli 29.4.0 → 30.1.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 (448) hide show
  1. package/CLAUDE.md +64 -40
  2. package/README.md +61 -11
  3. package/package.json +42 -28
  4. package/src/affected-tests.ts +97 -0
  5. package/src/affected-unowned.ts +43 -0
  6. package/src/affected.ts +25 -6
  7. package/src/app-boundaries.ts +29 -1
  8. package/src/app-evals.ts +12 -4
  9. package/src/app-load-unresolved.ts +62 -0
  10. package/src/app-load.ts +30 -37
  11. package/src/app-mcp.ts +14 -2
  12. package/src/app-module-graph.ts +232 -0
  13. package/src/app-reload-graph.ts +2 -2
  14. package/src/app-root.ts +37 -1
  15. package/src/bin.ts +1 -0
  16. package/src/boundary-findings.ts +6 -0
  17. package/src/budgets-freshness.ts +32 -0
  18. package/src/budgets.ts +20 -1
  19. package/src/build-capture-env.ts +9 -0
  20. package/src/cdp-shot-a11y.ts +2 -39
  21. package/src/ci-tested.ts +243 -0
  22. package/src/client-emit-customer-runtime.ts +139 -0
  23. package/src/client-emit-customer.ts +108 -0
  24. package/src/client-emit-drift.ts +72 -0
  25. package/src/client-emit-header.ts +67 -0
  26. package/src/client-emit-schemas.ts +175 -0
  27. package/src/client-emit-types.ts +121 -0
  28. package/src/client-emit-vendor.ts +146 -0
  29. package/src/client-errors-routes.ts +229 -0
  30. package/src/client-errors-script.ts +74 -0
  31. package/src/client-vitals-route.ts +197 -0
  32. package/src/client-vitals-script.ts +56 -0
  33. package/src/cmd-affected.ts +23 -0
  34. package/src/cmd-build-spec.ts +7 -1
  35. package/src/cmd-build.ts +35 -9
  36. package/src/cmd-ci-spec.ts +42 -6
  37. package/src/cmd-ci.ts +99 -2
  38. package/src/cmd-client-customer.ts +134 -0
  39. package/src/cmd-client-source.ts +81 -0
  40. package/src/cmd-client-spec.ts +48 -0
  41. package/src/cmd-client-write.ts +41 -0
  42. package/src/cmd-client.ts +111 -0
  43. package/src/cmd-db-recount.ts +120 -0
  44. package/src/cmd-db-spec.ts +36 -6
  45. package/src/cmd-db.ts +11 -3
  46. package/src/cmd-deploy-compose.ts +61 -3
  47. package/src/cmd-deploy-drain.ts +10 -2
  48. package/src/cmd-deploy-helm.ts +11 -0
  49. package/src/cmd-deploy-preflight.ts +118 -0
  50. package/src/cmd-deploy-spec.ts +58 -6
  51. package/src/cmd-deploy-verify.ts +380 -0
  52. package/src/cmd-deploy.ts +42 -9
  53. package/src/cmd-docs.ts +34 -4
  54. package/src/cmd-doctor.ts +66 -9
  55. package/src/cmd-drill-spec.ts +25 -0
  56. package/src/cmd-drill.ts +176 -0
  57. package/src/cmd-env-init.ts +102 -0
  58. package/src/cmd-env-spec.ts +10 -3
  59. package/src/cmd-env.ts +15 -10
  60. package/src/cmd-errors-recent.ts +60 -0
  61. package/src/cmd-errors-spec.ts +9 -1
  62. package/src/cmd-errors.ts +4 -0
  63. package/src/cmd-exercise-spec.ts +19 -0
  64. package/src/cmd-exercise.ts +129 -0
  65. package/src/cmd-generate-spec.ts +9 -1
  66. package/src/cmd-generate.ts +32 -27
  67. package/src/cmd-graph-load.ts +97 -0
  68. package/src/cmd-graph-spec.ts +16 -0
  69. package/src/cmd-graph.ts +73 -0
  70. package/src/cmd-i18n.ts +4 -1
  71. package/src/cmd-impact-spec.ts +13 -0
  72. package/src/cmd-impact.ts +64 -0
  73. package/src/cmd-jobs-resolve.ts +76 -0
  74. package/src/cmd-jobs-spec.ts +21 -2
  75. package/src/cmd-jobs.ts +10 -6
  76. package/src/cmd-mail-spec.ts +28 -0
  77. package/src/cmd-mail.ts +70 -0
  78. package/src/cmd-mcp-lock.ts +69 -0
  79. package/src/cmd-mcp-spec.ts +23 -4
  80. package/src/cmd-mcp.ts +17 -3
  81. package/src/cmd-new-spec.ts +8 -1
  82. package/src/cmd-new.ts +61 -9
  83. package/src/cmd-planned.ts +0 -6
  84. package/src/cmd-repro-spec.ts +31 -0
  85. package/src/cmd-repro.ts +174 -0
  86. package/src/cmd-stop-spec.ts +21 -0
  87. package/src/cmd-stop.ts +166 -0
  88. package/src/cmd-test-spec.ts +17 -4
  89. package/src/cmd-test.ts +70 -14
  90. package/src/cmd-upgrade-spec.ts +26 -0
  91. package/src/cmd-upgrade.ts +274 -0
  92. package/src/cmd-verify-spec.ts +3 -3
  93. package/src/db-backfill.ts +6 -0
  94. package/src/db-diagnostics.ts +106 -0
  95. package/src/db-generate.ts +9 -0
  96. package/src/db-reset-template.ts +1 -1
  97. package/src/db-rolling.ts +45 -0
  98. package/src/db-seed-generate.ts +188 -0
  99. package/src/deploy-verify-findings.ts +98 -0
  100. package/src/deploy-verify-flags.ts +109 -0
  101. package/src/deploy-verify-grade.ts +185 -0
  102. package/src/deploy-verify-queues.ts +53 -0
  103. package/src/dev-boot.ts +35 -15
  104. package/src/dev-child-watch.ts +16 -4
  105. package/src/dev-dashboard-guard.ts +8 -1
  106. package/src/dev-dashboard.ts +7 -0
  107. package/src/dev-lock-identity.ts +61 -0
  108. package/src/dev-lock-port-holder.ts +80 -0
  109. package/src/dev-lock-steal.ts +124 -0
  110. package/src/dev-lock.ts +92 -120
  111. package/src/dev-mail-templates.ts +51 -0
  112. package/src/dev-rebuild.ts +26 -2
  113. package/src/dev-route-table.ts +17 -0
  114. package/src/dev-supervisor.ts +5 -2
  115. package/src/dev-watch-tree.ts +11 -10
  116. package/src/dev-watch.ts +1 -1
  117. package/src/dispatch.ts +29 -6
  118. package/src/docs-section.ts +69 -0
  119. package/src/docs-sources.ts +100 -0
  120. package/src/doctor-auth.ts +11 -2
  121. package/src/doctor-chart-alerts.ts +55 -0
  122. package/src/doctor-pooler.ts +107 -0
  123. package/src/doctor-rolling.ts +35 -0
  124. package/src/doctor-row-security.ts +40 -0
  125. package/src/document-options.ts +13 -0
  126. package/src/drill-sync-lane.ts +25 -0
  127. package/src/drill-targets.ts +39 -0
  128. package/src/duplicate-packages.ts +1 -1
  129. package/src/env-production.ts +93 -0
  130. package/src/error-codes-shot.ts +59 -0
  131. package/src/error-codes-tooling.ts +29 -0
  132. package/src/error-codes.ts +100 -54
  133. package/src/error-contract.ts +2 -2
  134. package/src/errors.ts +22 -3
  135. package/src/exercise-database.ts +99 -0
  136. package/src/exercise-findings.ts +159 -0
  137. package/src/exercise-overrides.ts +42 -0
  138. package/src/exercise-registration.ts +58 -0
  139. package/src/exercise-static.ts +88 -0
  140. package/src/exercise-subject.ts +69 -0
  141. package/src/failure-capture-boot.ts +30 -0
  142. package/src/framework-env.ts +7 -1
  143. package/src/framework-schema.ts +72 -5
  144. package/src/generate-catalog-locales.ts +19 -0
  145. package/src/generate-chart-alerts.ts +156 -0
  146. package/src/generate-feature-required.ts +65 -0
  147. package/src/generate-feature.ts +83 -6
  148. package/src/generate-files.ts +24 -0
  149. package/src/generate-format-written.ts +32 -0
  150. package/src/generate-kinds.ts +6 -0
  151. package/src/generate-sign-in-config.ts +100 -0
  152. package/src/generate-sign-in-manifest.ts +28 -0
  153. package/src/generate-sign-in-mode.ts +101 -0
  154. package/src/generate-sign-in.ts +250 -0
  155. package/src/generate-write.ts +44 -7
  156. package/src/hand-rolled-socket.ts +72 -0
  157. package/src/i18n-app-key-shapes.ts +29 -0
  158. package/src/i18n-audit.ts +10 -3
  159. package/src/i18n-registration.ts +18 -3
  160. package/src/index.ts +2 -0
  161. package/src/island-bundle.ts +4 -3
  162. package/src/island-disposer.ts +116 -0
  163. package/src/island-realtime-state.ts +34 -6
  164. package/src/island-realtime.ts +6 -2
  165. package/src/island-routes.ts +3 -2
  166. package/src/isr-query-advice.ts +6 -11
  167. package/src/isr-query-guard.ts +4 -3
  168. package/src/job-budget.ts +95 -0
  169. package/src/jobs-json.ts +11 -0
  170. package/src/lint-findings.ts +104 -0
  171. package/src/local-cli.ts +34 -4
  172. package/src/loopback-host.ts +2 -2
  173. package/src/mail-preview-findings.ts +24 -0
  174. package/src/mcp-errors.ts +82 -2
  175. package/src/mcp-host.ts +11 -1
  176. package/src/mcp-lock-findings.ts +57 -0
  177. package/src/measure-paths.ts +12 -4
  178. package/src/measure-scope.ts +27 -13
  179. package/src/messages-client.ts +23 -0
  180. package/src/messages-db.ts +56 -0
  181. package/src/messages-gate.ts +107 -0
  182. package/src/messages-repro.ts +11 -0
  183. package/src/messages-shot.ts +51 -0
  184. package/src/messages-upgrade.ts +45 -0
  185. package/src/messages.ts +53 -178
  186. package/src/metrics-endpoint.ts +23 -5
  187. package/src/new-shapes-generate.ts +71 -0
  188. package/src/new-shapes.ts +63 -0
  189. package/src/openapi-compile-ir.ts +68 -0
  190. package/src/openapi-compile-schema.ts +284 -0
  191. package/src/openapi-compile.ts +273 -0
  192. package/src/openapi-refuse.ts +45 -0
  193. package/src/otlp-export.ts +2 -1
  194. package/src/output-findings.ts +123 -0
  195. package/src/output.ts +32 -8
  196. package/src/package-boundaries.ts +110 -0
  197. package/src/page-pins.ts +23 -0
  198. package/src/plain-routes.ts +13 -0
  199. package/src/prerender-entries.ts +59 -0
  200. package/src/prerender-heaviest.ts +27 -0
  201. package/src/prerender-preload.ts +6 -0
  202. package/src/prerender-report.ts +12 -0
  203. package/src/prerender-seo-reads.ts +38 -0
  204. package/src/prerender-served.ts +22 -7
  205. package/src/prerender-vitals.ts +42 -0
  206. package/src/prerender.ts +61 -32
  207. package/src/pwa-artifacts.ts +4 -3
  208. package/src/pwa-manifest-locales.ts +1 -1
  209. package/src/registry.ts +18 -0
  210. package/src/repro-bundle.ts +65 -0
  211. package/src/repro-errors.ts +47 -0
  212. package/src/repro-job.ts +72 -0
  213. package/src/repro-literal.ts +41 -0
  214. package/src/repro-request.ts +48 -0
  215. package/src/repro-write.ts +100 -0
  216. package/src/retain-sweep.ts +27 -0
  217. package/src/retained-assets-boot.ts +113 -0
  218. package/src/retained-assets-layout.ts +110 -0
  219. package/src/retained-assets-read.ts +142 -0
  220. package/src/retained-assets-sweep.ts +70 -0
  221. package/src/retained-assets.ts +171 -0
  222. package/src/revalidated-response.ts +8 -5
  223. package/src/role-replicator.ts +7 -1
  224. package/src/role-start-devices.ts +119 -0
  225. package/src/role-start-types.ts +6 -0
  226. package/src/role-start.ts +22 -5
  227. package/src/role-sync.ts +7 -1
  228. package/src/route-islands.ts +42 -0
  229. package/src/route-table.ts +2 -1
  230. package/src/row-security-boot.ts +58 -0
  231. package/src/row-security-wanted.ts +32 -0
  232. package/src/runtime-admin.ts +0 -1
  233. package/src/runtime-assets-site-cache.ts +55 -0
  234. package/src/runtime-assets-site.ts +136 -0
  235. package/src/runtime-assets.ts +99 -55
  236. package/src/runtime-bindings.ts +19 -3
  237. package/src/runtime-cache.ts +23 -14
  238. package/src/runtime-isr-outcome.ts +114 -60
  239. package/src/runtime-purge.ts +14 -2
  240. package/src/runtime-queue.ts +31 -4
  241. package/src/runtime-render-document.ts +204 -0
  242. package/src/runtime-render.ts +48 -198
  243. package/src/runtime-replica-position.ts +62 -0
  244. package/src/runtime-replica.ts +18 -3
  245. package/src/runtime-s3-path-style.ts +17 -0
  246. package/src/runtime-services.ts +19 -6
  247. package/src/runtime-statement-bound.ts +22 -0
  248. package/src/runtime-storage-upload.ts +9 -0
  249. package/src/runtime-storage.ts +41 -81
  250. package/src/schema-diff-views.ts +54 -0
  251. package/src/schema-diff.ts +21 -3
  252. package/src/schema-drift.ts +28 -9
  253. package/src/schema-dump-drift.ts +91 -7
  254. package/src/script-csp.ts +1 -1
  255. package/src/seo-cards.ts +26 -0
  256. package/src/seo-routes.ts +22 -8
  257. package/src/serve-boot.ts +54 -21
  258. package/src/serve-drain.ts +1 -0
  259. package/src/serve-env.ts +26 -8
  260. package/src/serve-retire.ts +12 -6
  261. package/src/serve-web.ts +42 -4
  262. package/src/serve.ts +9 -7
  263. package/src/site-asset-routes.ts +7 -1
  264. package/src/site-config.ts +4 -0
  265. package/src/site-llms-pages.ts +106 -0
  266. package/src/site-llms.ts +64 -0
  267. package/src/site-seo-write.ts +4 -2
  268. package/src/site-seo.ts +55 -6
  269. package/src/source-mtime.ts +50 -0
  270. package/src/static-capture-errors.ts +41 -0
  271. package/src/static-capture.ts +56 -0
  272. package/src/static-cards-serve.ts +39 -0
  273. package/src/static-cards.ts +93 -0
  274. package/src/static-document.ts +14 -13
  275. package/src/static-images.ts +115 -0
  276. package/src/stream-hole-scope.ts +55 -0
  277. package/src/style-bundle.ts +1 -1
  278. package/src/style-routes.ts +3 -2
  279. package/src/sw-precache-plan.ts +2 -2
  280. package/src/sw-routes.ts +2 -2
  281. package/src/templates/action-tests.ts +306 -0
  282. package/src/templates/action.ts +81 -269
  283. package/src/templates/backfill.ts +1 -1
  284. package/src/templates/entity.ts +21 -5
  285. package/src/templates/github/ci.yml.ts +8 -9
  286. package/src/templates/guard-bare-error.ts +2 -2
  287. package/src/templates/guard-error-declared.ts +263 -0
  288. package/src/templates/guard-native-confirm.ts +132 -0
  289. package/src/templates/guard-raw-fetch.ts +151 -0
  290. package/src/templates/guard-raw-interval.ts +121 -0
  291. package/src/templates/guard-repo-raw-sql.ts +71 -2
  292. package/src/templates/job.ts +1 -1
  293. package/src/templates/naming.ts +5 -0
  294. package/src/templates/pinned/docker-compose.yml +6 -0
  295. package/src/templates/pinned-images.ts +8 -0
  296. package/src/templates/policy.ts +85 -2
  297. package/src/templates/query.ts +70 -13
  298. package/src/templates/resource-create.ts +3 -1
  299. package/src/templates/resource-form-island.ts +15 -4
  300. package/src/templates/resource-page-db.ts +83 -0
  301. package/src/templates/resource-page.ts +161 -52
  302. package/src/templates/route.ts +4 -6
  303. package/src/templates/scaffold-app.ts +3 -0
  304. package/src/templates/scaffold-auth.ts +2 -1
  305. package/src/templates/scaffold-bin.ts +5 -52
  306. package/src/templates/scaffold-claude-agents.ts +19 -19
  307. package/src/templates/scaffold-claude-commands.ts +28 -31
  308. package/src/templates/scaffold-claude.ts +26 -23
  309. package/src/templates/scaffold-container-compose.ts +6 -1
  310. package/src/templates/scaffold-container.ts +24 -4
  311. package/src/templates/scaffold-dashboard-bare.ts +3 -3
  312. package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
  313. package/src/templates/scaffold-dashboard-example-view.ts +156 -0
  314. package/src/templates/scaffold-dashboard-example.ts +43 -131
  315. package/src/templates/scaffold-dashboard-shared.ts +3 -7
  316. package/src/templates/scaffold-db-client.ts +20 -6
  317. package/src/templates/scaffold-db-package.ts +5 -14
  318. package/src/templates/scaffold-docs.ts +73 -57
  319. package/src/templates/scaffold-domain-package.ts +18 -14
  320. package/src/templates/scaffold-entries.ts +7 -2
  321. package/src/templates/scaffold-exercise.ts +37 -0
  322. package/src/templates/scaffold-guards.ts +13 -1
  323. package/src/templates/scaffold-helm-alerts.ts +234 -0
  324. package/src/templates/scaffold-helm-templates.ts +7 -5
  325. package/src/templates/scaffold-helm.ts +46 -12
  326. package/src/templates/scaffold-i18n.ts +6 -2
  327. package/src/templates/scaffold-mcp-endpoint.ts +182 -0
  328. package/src/templates/scaffold-mcp-json.ts +31 -0
  329. package/src/templates/scaffold-mcp-package.ts +59 -56
  330. package/src/templates/scaffold-repo.ts +7 -2
  331. package/src/templates/scaffold-shell.ts +45 -79
  332. package/src/templates/scaffold-test-services.ts +13 -5
  333. package/src/templates/shapes/api-keys.ts +170 -0
  334. package/src/templates/shapes/api.ts +101 -0
  335. package/src/templates/shapes/blog-site.ts +340 -0
  336. package/src/templates/shapes/blog-slice.ts +265 -0
  337. package/src/templates/shapes/blog.ts +114 -0
  338. package/src/templates/shapes/dashboard-detail.ts +242 -0
  339. package/src/templates/shapes/dashboard.ts +53 -0
  340. package/src/templates/shapes/index.ts +17 -0
  341. package/src/templates/shapes/overlay.ts +99 -0
  342. package/src/templates/shapes/shared-edits.ts +46 -0
  343. package/src/templates/sign-in-action-tests.ts +237 -0
  344. package/src/templates/sign-in-authenticator.ts +112 -0
  345. package/src/templates/sign-in-oidc.ts +189 -0
  346. package/src/templates/sign-in-pages.ts +148 -0
  347. package/src/templates/sign-in-password.ts +216 -0
  348. package/src/templates/sign-in-shared.ts +268 -0
  349. package/src/templates/slice-foundation.ts +70 -15
  350. package/src/test-batches.ts +1 -1
  351. package/src/test-database.ts +1 -1
  352. package/src/test-shards.ts +7 -1
  353. package/src/test-workers.ts +18 -12
  354. package/src/tool-gate-names.ts +34 -0
  355. package/src/upgrade-agents-link.ts +49 -0
  356. package/src/upgrade-bun-floor.ts +225 -0
  357. package/src/upgrade-gate.ts +228 -0
  358. package/src/upgrade-pins.ts +63 -0
  359. package/src/upgrade-plan.ts +214 -0
  360. package/src/upgrade-registry.ts +53 -0
  361. package/src/upgrade-report.ts +139 -0
  362. package/src/upgrade-root.ts +74 -0
  363. package/src/verify-budgets-build.ts +118 -0
  364. package/src/verify-checks.ts +67 -24
  365. package/src/verify-coverage-run.ts +1 -1
  366. package/src/verify-e2e-lane.ts +41 -0
  367. package/src/verify-exercise.ts +64 -0
  368. package/src/verify-floor.ts +41 -0
  369. package/src/verify-merge.ts +19 -4
  370. package/src/verify-role-load.ts +2 -0
  371. package/src/verify-run.ts +28 -8
  372. package/src/verify-shard.ts +12 -2
  373. package/src/verify-skip-ceiling.ts +87 -0
  374. package/src/verify-step.ts +5 -0
  375. package/src/verify-tests.ts +25 -5
  376. package/src/view-drift.ts +55 -0
  377. package/src/worker-bundle.ts +3 -2
  378. package/src/workspace-graph.ts +38 -19
  379. package/src/write-generated.ts +43 -0
  380. package/src/zone-boot.ts +20 -0
  381. package/wiki/Actions.md +342 -0
  382. package/wiki/Admin-Dashboard.md +432 -0
  383. package/wiki/Agents.md +958 -0
  384. package/wiki/Auth.md +195 -0
  385. package/wiki/Bare-VM.md +111 -0
  386. package/wiki/Batching-And-Preloading.md +136 -0
  387. package/wiki/Building-Your-Own-Base.md +203 -0
  388. package/wiki/CI-Parallel-Gate.md +220 -0
  389. package/wiki/CLI-Reference.md +1894 -0
  390. package/wiki/Caching-And-Invalidation.md +255 -0
  391. package/wiki/Client-Data.md +194 -0
  392. package/wiki/Client-Navigation.md +513 -0
  393. package/wiki/Compared.md +93 -0
  394. package/wiki/Configuration.md +868 -0
  395. package/wiki/Contributing.md +12 -0
  396. package/wiki/Deploy-Recipes.md +122 -0
  397. package/wiki/Deployment.md +508 -0
  398. package/wiki/Entities-And-Migrations.md +1116 -0
  399. package/wiki/FAQ.md +175 -0
  400. package/wiki/Feature-Flags.md +65 -0
  401. package/wiki/Getting-Started.md +221 -0
  402. package/wiki/Home.md +160 -0
  403. package/wiki/I18n.md +239 -0
  404. package/wiki/Installation.md +206 -0
  405. package/wiki/Interface-Rules.md +339 -0
  406. package/wiki/Jobs-And-Workflows.md +783 -0
  407. package/wiki/Known-Gaps.md +185 -0
  408. package/wiki/MCP-And-AI.md +753 -0
  409. package/wiki/Mail.md +138 -0
  410. package/wiki/Migrating-An-Existing-App.md +594 -0
  411. package/wiki/Migrating-From-Django.md +54 -0
  412. package/wiki/Migrating-From-Laravel.md +53 -0
  413. package/wiki/Migrating-From-Node-Drizzle.md +53 -0
  414. package/wiki/Migrating-From-Rails.md +53 -0
  415. package/wiki/Migrations-And-Backfills.md +552 -0
  416. package/wiki/Money.md +338 -0
  417. package/wiki/N-Plus-One-Detection.md +197 -0
  418. package/wiki/Notify.md +208 -0
  419. package/wiki/Observability.md +245 -0
  420. package/wiki/Outbound-HTTP.md +109 -0
  421. package/wiki/PWA-And-Offline.md +343 -0
  422. package/wiki/Policies-And-Authz.md +251 -0
  423. package/wiki/Project-Layout.md +150 -0
  424. package/wiki/Queries-And-Live-Queries.md +429 -0
  425. package/wiki/Realtime.md +734 -0
  426. package/wiki/Resource-Management.md +121 -0
  427. package/wiki/Routes-And-Render-Modes.md +521 -0
  428. package/wiki/SEO.md +288 -0
  429. package/wiki/Scheduled-Tasks.md +175 -0
  430. package/wiki/Scraping.md +493 -0
  431. package/wiki/Shapes.md +63 -0
  432. package/wiki/Stability.md +97 -0
  433. package/wiki/Static-Assets.md +166 -0
  434. package/wiki/Storage-And-Uploads.md +404 -0
  435. package/wiki/Testing.md +1314 -0
  436. package/wiki/The-Eight-Primitives.md +247 -0
  437. package/wiki/Theming.md +362 -0
  438. package/wiki/Timezones-And-Dates.md +194 -0
  439. package/wiki/Troubleshooting.md +163 -0
  440. package/wiki/Tutorial-01-First-App.md +289 -0
  441. package/wiki/Tutorial-02-First-Feature.md +339 -0
  442. package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
  443. package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
  444. package/wiki/Tutorial-05-Deploy-Free.md +231 -0
  445. package/wiki/Tutorial-06-Growing-Up.md +158 -0
  446. package/wiki/UI-Components.md +156 -0
  447. package/wiki/Upgrading.md +3667 -0
  448. package/src/stored-object-headers.ts +0 -43
@@ -0,0 +1,175 @@
1
+ // The vendor client's `schemas.ts`: every type the operations reach as a TypeScript type AND a `t`
2
+ // schema of the same name, so a vendor's answer is parsed, never cast. Components are declared in
3
+ // dependency order; only a reference back into a cycle is `lazy`, which is how recursion survives.
4
+
5
+ import type { DocumentNames } from './client-emit-types';
6
+ import { documentNames, parametersType, propertyKey, typeText } from './client-emit-types';
7
+ import type { ClientOperation, ClientType, CompiledDocument } from './openapi-compile-ir';
8
+
9
+ /** Helpers a document needs only when it has a cycle or a value that is only ever null. */
10
+ const LAZY = `/** A schema read through a getter, so a recursive type can name itself. */
11
+ const lazy = <T>(get: () => Schema<unknown, T>): Schema<unknown, T> =>
12
+ makeSchema<unknown, T>({ kind: 'unknown' }, (value, path) => {
13
+ const result = get().safeParse(value);
14
+ if ('value' in result) return pass(result.value as T);
15
+ return failWith(result.issues.map((issue) => ({ ...issue, path: [...path, ...(issue.path ?? [])] })));
16
+ });`;
17
+ const NULL_ONLY = `/** The one value \`null\`. */
18
+ const nullValue: Schema<unknown, null> = makeSchema<unknown, null>({ kind: 'unknown' }, (value, path) =>
19
+ value === null ? pass(null) : fail(path, 'expected null'),
20
+ );`;
21
+
22
+ interface Printer {
23
+ readonly declared: Set<string>;
24
+ readonly names: DocumentNames;
25
+ usesLazy: boolean;
26
+ usesNull: boolean;
27
+ }
28
+
29
+ /** `t` text for one type. A component not yet declared is reached through `lazy`. */
30
+ export function schemaText(type: ClientType, printer: Printer): string {
31
+ switch (type.kind) {
32
+ case 'unknown':
33
+ return 't.json()';
34
+ case 'string':
35
+ return 't.string';
36
+ case 'number':
37
+ return type.integer ? 't.number.int()' : 't.number';
38
+ case 'boolean':
39
+ return 't.boolean';
40
+ case 'null':
41
+ printer.usesNull = true;
42
+ return 'nullValue';
43
+ case 'literal':
44
+ return `t.literal(${JSON.stringify(type.value)})`;
45
+ case 'array':
46
+ return `t.array(${schemaText(type.items, printer)})`;
47
+ case 'record':
48
+ return `t.record(${schemaText(type.values, printer)})`;
49
+ case 'object':
50
+ return `t.object({ ${type.fields
51
+ .map((field) => {
52
+ const inner = schemaText(field.type, printer);
53
+ return `${propertyKey(field.name)}: ${field.required ? inner : `t.optional(${inner})`}`;
54
+ })
55
+ .join(', ')} })`;
56
+ case 'ref': {
57
+ const name = printer.names.component(type.name);
58
+ if (printer.declared.has(type.name)) return name;
59
+ printer.usesLazy = true;
60
+ return `lazy(() => ${name})`;
61
+ }
62
+ case 'union': {
63
+ const rest = type.members.filter((member) => member.kind !== 'null');
64
+ const nullable = rest.length < type.members.length;
65
+ const strings = rest.every((m) => m.kind === 'literal' && typeof m.value === 'string');
66
+ const inner =
67
+ rest.length === 0
68
+ ? undefined
69
+ : rest.length === 1
70
+ ? schemaText(rest[0] as ClientType, printer)
71
+ : strings
72
+ ? `t.enumerated(${rest.map((m) => (m.kind === 'literal' ? JSON.stringify(m.value) : '')).join(', ')})`
73
+ : `t.union(${rest.map((member) => schemaText(member, printer)).join(', ')})`;
74
+ if (inner === undefined) return schemaText({ kind: 'null' }, printer);
75
+ return nullable ? `t.nullable(${inner})` : inner;
76
+ }
77
+ }
78
+ }
79
+
80
+ /** Component names in an order where each is declared after what it names, cycles aside. */
81
+ function dependencyOrder(types: ReadonlyMap<string, ClientType>): readonly string[] {
82
+ const order: string[] = [];
83
+ const state = new Map<string, 'open' | 'done'>();
84
+ const refs = (type: ClientType): readonly string[] => {
85
+ switch (type.kind) {
86
+ case 'ref':
87
+ return [type.name];
88
+ case 'array':
89
+ return refs(type.items);
90
+ case 'record':
91
+ return refs(type.values);
92
+ case 'union':
93
+ return type.members.flatMap(refs);
94
+ case 'object':
95
+ return type.fields.flatMap((field) => refs(field.type));
96
+ default:
97
+ return [];
98
+ }
99
+ };
100
+ const visit = (name: string): void => {
101
+ if (state.has(name)) return;
102
+ state.set(name, 'open');
103
+ const type = types.get(name);
104
+ for (const next of type === undefined ? [] : refs(type)) visit(next);
105
+ state.set(name, 'done');
106
+ order.push(name);
107
+ };
108
+ for (const name of types.keys()) visit(name);
109
+ return order;
110
+ }
111
+
112
+ const opSchema = (op: ClientOperation, part: 'Params' | 'Body' | 'Response'): string =>
113
+ `${op.name}${part}`;
114
+
115
+ /** `schemas.ts`, unstamped. */
116
+ export function emitSchemas(document: CompiledDocument): string {
117
+ const names = documentNames(document);
118
+ const printer: Printer = { declared: new Set(), names, usesLazy: false, usesNull: false };
119
+ const body: string[] = [];
120
+ for (const name of dependencyOrder(document.types)) {
121
+ const type = document.types.get(name) as ClientType;
122
+ const id = names.component(name);
123
+ body.push(`export type ${id} = ${typeText(type, names.component)};`);
124
+ body.push(`export const ${id}: Schema<unknown, ${id}> = ${schemaText(type, printer)};`);
125
+ printer.declared.add(name);
126
+ }
127
+ for (const op of document.operations) {
128
+ const types = names.operation.get(op.name);
129
+ if (types?.request?.text !== undefined) {
130
+ body.push(`export type ${types.request.name} = ${types.request.text};`);
131
+ }
132
+ const params = parametersType(op);
133
+ if (params !== undefined) {
134
+ body.push(`export const ${opSchema(op, 'Params')} = ${schemaText(params, printer)};`);
135
+ }
136
+ if (op.body !== undefined && op.body.type.kind !== 'ref') {
137
+ body.push(`export const ${opSchema(op, 'Body')} = ${schemaText(op.body.type, printer)};`);
138
+ }
139
+ if (types?.response?.text !== undefined && op.response !== undefined) {
140
+ const id = types.response.name;
141
+ body.push(`export type ${id} = ${types.response.text};`);
142
+ body.push(
143
+ `export const ${opSchema(op, 'Response')}: Schema<unknown, ${id}> = ${schemaText(op.response, printer)};`,
144
+ );
145
+ }
146
+ }
147
+ const builders = [
148
+ ...(printer.usesNull ? ['fail'] : []),
149
+ ...(printer.usesLazy ? ['failWith'] : []),
150
+ ...(printer.usesLazy || printer.usesNull ? ['makeSchema', 'pass'] : []),
151
+ 't',
152
+ ].sort();
153
+ return [
154
+ `// The shapes of ${document.title} ${document.version}: each a type and a schema of one name.`,
155
+ "import type { Schema } from '@ultimat3/schema';",
156
+ `import { ${builders.join(', ')} } from '@ultimat3/schema';`,
157
+ '',
158
+ ...(printer.usesLazy ? [LAZY, ''] : []),
159
+ ...(printer.usesNull ? [NULL_ONLY, ''] : []),
160
+ ...body,
161
+ '',
162
+ ].join('\n');
163
+ }
164
+
165
+ /** The schema expression `client.ts` parses one operation's part with, through `S.`. */
166
+ export function opSchemaRef(
167
+ op: ClientOperation,
168
+ part: 'Params' | 'Body' | 'Response',
169
+ names: DocumentNames,
170
+ ): string | undefined {
171
+ if (part === 'Params') return op.parameters.length === 0 ? undefined : `S.${opSchema(op, part)}`;
172
+ const type = part === 'Body' ? op.body?.type : op.response;
173
+ if (type === undefined) return undefined;
174
+ return type.kind === 'ref' ? `S.${names.component(type.name)}` : `S.${opSchema(op, part)}`;
175
+ }
@@ -0,0 +1,121 @@
1
+ // `ClientType` to TypeScript type text, and the names both emitters give a document's types —
2
+ // components and the per-operation request and response. One printer for both clients, so the
3
+ // customer's `types.ts` and the vendor's `schemas.ts` cannot describe one document two ways.
4
+
5
+ import type { ClientOperation, ClientType, CompiledDocument } from './openapi-compile-ir';
6
+ import { typeName } from './openapi-compile-schema';
7
+
8
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
9
+
10
+ /** A property key as TypeScript accepts it: bare when it can be, quoted otherwise. */
11
+ export const propertyKey = (name: string): string =>
12
+ IDENTIFIER.test(name) ? name : JSON.stringify(name);
13
+
14
+ /** Type text for one value, `ref` naming each component the way its file declares it. */
15
+ export function typeText(type: ClientType, ref: (component: string) => string): string {
16
+ switch (type.kind) {
17
+ case 'unknown':
18
+ case 'string':
19
+ case 'boolean':
20
+ case 'null':
21
+ return type.kind;
22
+ case 'number':
23
+ return 'number';
24
+ case 'literal':
25
+ return JSON.stringify(type.value);
26
+ case 'array': {
27
+ const items = typeText(type.items, ref);
28
+ return type.items.kind === 'union' ? `(${items})[]` : `${items}[]`;
29
+ }
30
+ case 'record':
31
+ return `Record<string, ${typeText(type.values, ref)}>`;
32
+ case 'union':
33
+ return type.members.map((member) => typeText(member, ref)).join(' | ');
34
+ case 'ref':
35
+ return ref(type.name);
36
+ case 'object':
37
+ if (type.fields.length === 0) return 'Record<string, never>';
38
+ return `{ ${type.fields
39
+ .map((field) =>
40
+ field.required
41
+ ? `${propertyKey(field.name)}: ${typeText(field.type, ref)}`
42
+ : `${propertyKey(field.name)}?: ${typeText(field.type, ref)} | undefined`,
43
+ )
44
+ .join('; ')} }`;
45
+ }
46
+ }
47
+
48
+ /** What an operation's caller passes and gets back, as type text over the document's names. */
49
+ export interface OperationTypes {
50
+ /** The input's type text; `undefined` when the operation takes nothing. */
51
+ readonly request?: { readonly name: string; readonly text?: string | undefined } | undefined;
52
+ /** The answer's type name; `undefined` when a success has no body. */
53
+ readonly response?: { readonly name: string; readonly text?: string | undefined } | undefined;
54
+ }
55
+
56
+ export interface DocumentNames {
57
+ /** Component name → the identifier its type and schema are declared under. */
58
+ readonly component: (name: string) => string;
59
+ readonly operation: ReadonlyMap<string, OperationTypes>;
60
+ }
61
+
62
+ /** The parameters as one object type, or `undefined` when there are none. */
63
+ export function parametersType(operation: ClientOperation): ClientType | undefined {
64
+ if (operation.parameters.length === 0) return undefined;
65
+ return {
66
+ kind: 'object',
67
+ fields: operation.parameters.map((p) => ({ name: p.name, type: p.type, required: p.required })),
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Every name the document's types take. An operation's request is the body's own name when it is
73
+ * a component and nothing else rides beside it; otherwise `<Op>Request`, suffixed `Op` until it
74
+ * clashes with no component — a vendor's `CreateInvoiceRequest` component keeps its name.
75
+ */
76
+ export function documentNames(document: CompiledDocument): DocumentNames {
77
+ const taken = new Set([...document.types.keys()].map(typeName));
78
+ const fresh = (base: string): string => {
79
+ let name = base;
80
+ while (taken.has(name)) name = `${name}Op`;
81
+ taken.add(name);
82
+ return name;
83
+ };
84
+ const component = (name: string): string => typeName(name);
85
+ const operation = new Map<string, OperationTypes>();
86
+ for (const op of document.operations) {
87
+ const pascal = typeName(op.name);
88
+ const params = parametersType(op);
89
+ const paramsText = params === undefined ? undefined : typeText(params, component);
90
+ const body = op.body;
91
+ let request: OperationTypes['request'];
92
+ if (body === undefined) {
93
+ request =
94
+ paramsText === undefined
95
+ ? undefined
96
+ : { name: fresh(`${pascal}Request`), text: paramsText };
97
+ } else if (body.mode === 'value') {
98
+ const value = `{ body${body.required ? '' : '?'}: ${typeText(body.type, component)}${body.required ? '' : ' | undefined'} }`;
99
+ request = {
100
+ name: fresh(`${pascal}Request`),
101
+ text: paramsText === undefined ? value : `${paramsText} & ${value}`,
102
+ };
103
+ } else if (paramsText === undefined && body.type.kind === 'ref') {
104
+ request = { name: component(body.type.name) };
105
+ } else {
106
+ const bodyText = typeText(body.type, component);
107
+ request = {
108
+ name: fresh(`${pascal}Request`),
109
+ text: paramsText === undefined ? bodyText : `${paramsText} & ${bodyText}`,
110
+ };
111
+ }
112
+ const response =
113
+ op.response === undefined
114
+ ? undefined
115
+ : op.response.kind === 'ref'
116
+ ? { name: component(op.response.name) }
117
+ : { name: fresh(`${pascal}Response`), text: typeText(op.response, component) };
118
+ operation.set(op.name, { request, response });
119
+ }
120
+ return { component, operation };
121
+ }
@@ -0,0 +1,146 @@
1
+ // The vendor client: one async method per operation of a third party's document, each a single
2
+ // `outbound()` call — the caller's wrapper decides `dial` and the deadline. Input is parsed with
3
+ // `t` before a byte leaves; the answer through `answer.json(schema)`, so a renamed field is
4
+ // `X_OUTBOUND_BODY_INVALID` naming it, and a documented non-2xx is outbound's `X_OUTBOUND_REFUSED`.
5
+
6
+ import { emitSchemas, opSchemaRef } from './client-emit-schemas';
7
+ import type { DocumentNames } from './client-emit-types';
8
+ import { documentNames, propertyKey } from './client-emit-types';
9
+ import type { ClientOperation, CompiledDocument } from './openapi-compile-ir';
10
+ import { typeName } from './openapi-compile-schema';
11
+
12
+ export interface VendorFiles {
13
+ readonly 'client.ts': string;
14
+ readonly 'schemas.ts': string;
15
+ }
16
+
17
+ const RUNTIME = (
18
+ pascal: string,
19
+ vendor: string,
20
+ ): string => `/** How this vendor is reached: decided by the app's own wrapper, never by the document. */
21
+ export interface ${pascal}ClientOptions {
22
+ readonly baseUrl: string;
23
+ /** \`'public'\` for an internet vendor; \`'named'\` for a host the app names in config. */
24
+ readonly dial: 'named' | 'public';
25
+ /** The whole call, retries included. */
26
+ readonly deadlineMs: number;
27
+ /** Called before every request: credentials are the app's to store. */
28
+ readonly headers?:
29
+ | (() => Readonly<Record<string, string>> | Promise<Readonly<Record<string, string>>>)
30
+ | undefined;
31
+ readonly deps?: OutboundDeps | undefined;
32
+ }
33
+
34
+ export interface CallOptions {
35
+ readonly signal?: AbortSignal | undefined;
36
+ }
37
+
38
+ interface CallSpec {
39
+ readonly method: OutboundMethod;
40
+ readonly path: string;
41
+ readonly params: object;
42
+ readonly pathParams: readonly string[];
43
+ readonly query: readonly string[];
44
+ readonly headers: readonly string[];
45
+ readonly body?: unknown;
46
+ }
47
+
48
+ const read = (input: object, key: string): unknown =>
49
+ Object.hasOwn(input, key) ? (input as Readonly<Record<string, unknown>>)[key] : undefined;
50
+
51
+ function sender(options: ${pascal}ClientOptions) {
52
+ return async (spec: CallSpec, call: CallOptions) => {
53
+ let path = spec.path;
54
+ for (const name of spec.pathParams) {
55
+ path = path.replace(\`{\${name}}\`, encodeURIComponent(String(read(spec.params, name))));
56
+ }
57
+ const url = new URL(\`\${options.baseUrl.replace(/\\/$/, '')}\${path}\`);
58
+ for (const name of spec.query) {
59
+ const value = read(spec.params, name);
60
+ if (value === undefined) continue;
61
+ for (const item of Array.isArray(value) ? value : [value]) url.searchParams.append(name, String(item));
62
+ }
63
+ const headers: Record<string, string> = { accept: 'application/json', ...(await options.headers?.()) };
64
+ for (const name of spec.headers) {
65
+ const value = read(spec.params, name);
66
+ if (value !== undefined) headers[name] = String(value);
67
+ }
68
+ if (spec.body !== undefined) headers['content-type'] = 'application/json';
69
+ return outbound(
70
+ {
71
+ url,
72
+ vendor: ${JSON.stringify(vendor)},
73
+ dial: options.dial,
74
+ deadlineMs: options.deadlineMs,
75
+ method: spec.method,
76
+ headers,
77
+ ...(spec.body === undefined ? {} : { body: JSON.stringify(spec.body) }),
78
+ ...(call.signal === undefined ? {} : { signal: call.signal }),
79
+ },
80
+ options.deps,
81
+ );
82
+ };
83
+ }
84
+ `;
85
+
86
+ function operationMethod(op: ClientOperation, names: DocumentNames): string {
87
+ const types = names.operation.get(op.name);
88
+ const request = types?.request === undefined ? undefined : `S.${types.request.name}`;
89
+ const response = types?.response === undefined ? 'void' : `S.${types.response.name}`;
90
+ const params = opSchemaRef(op, 'Params', names);
91
+ const bodySchema = opSchemaRef(op, 'Body', names);
92
+ const responseSchema = opSchemaRef(op, 'Response', names);
93
+ const where = (place: 'path' | 'query' | 'header'): string =>
94
+ JSON.stringify(op.parameters.filter((p) => p.in === place).map((p) => p.name));
95
+ const bodyValue =
96
+ op.body === undefined || bodySchema === undefined
97
+ ? undefined
98
+ : op.body.mode === 'value'
99
+ ? `${bodySchema}.parse(input.body)`
100
+ : `${bodySchema}.parse(input)`;
101
+ const optionalBody = op.body?.required === false && op.body.mode === 'value';
102
+ const body =
103
+ bodyValue === undefined
104
+ ? ''
105
+ : optionalBody
106
+ ? `\n body: input.body === undefined ? undefined : ${bodyValue},`
107
+ : `\n body: ${bodyValue},`;
108
+ const doc = op.summary === undefined ? '' : ` /** ${op.summary.replace(/\*\//g, '* /')} */\n`;
109
+ const input = request === undefined ? 'input: Record<string, never> = {}' : `input: ${request}`;
110
+ return `${doc} async ${propertyKey(op.name)}(${input}, call: CallOptions = {}): Promise<${response}> {
111
+ const answer = await send({
112
+ method: ${JSON.stringify(op.method)},
113
+ path: ${JSON.stringify(op.path)},
114
+ params: ${params === undefined ? '{}' : `${params}.parse(input)`},
115
+ pathParams: ${where('path')},
116
+ query: ${where('query')},
117
+ headers: ${where('header')},${body}
118
+ }, call);
119
+ ${responseSchema === undefined ? 'void answer;' : `return answer.json(${responseSchema});`}
120
+ },`;
121
+ }
122
+
123
+ /** Both files' text, unstamped. `name` is the vendor label every log line and cause carries. */
124
+ export function emitVendor(name: string, document: CompiledDocument): VendorFiles {
125
+ const names = documentNames(document);
126
+ const pascal = typeName(name);
127
+ const client = [
128
+ `// The ${name} client, compiled from ${document.title} ${document.version}: one outbound() call per operation.`,
129
+ "import type { OutboundDeps, OutboundMethod } from '@ultimat3/core/outbound';",
130
+ "import { outbound } from '@ultimat3/core/outbound';",
131
+ "import * as S from './schemas';",
132
+ '',
133
+ RUNTIME(pascal, name),
134
+ `/** One method per operation of ${document.title}, named by its \`operationId\`. */`,
135
+ `export function create${pascal}Client(options: ${pascal}ClientOptions) {`,
136
+ ' const send = sender(options);',
137
+ ' return {',
138
+ ...document.operations.map((op) => operationMethod(op, names)),
139
+ ' };',
140
+ '}',
141
+ '',
142
+ `export type ${pascal}Client = ReturnType<typeof create${pascal}Client>;`,
143
+ '',
144
+ ].join('\n');
145
+ return { 'client.ts': client, 'schemas.ts': emitSchemas(document) };
146
+ }
@@ -0,0 +1,229 @@
1
+ // Single responsibility: the server half of client error reporting — `GET /x-client-errors.js`
2
+ // and `POST /_client-errors`, which turns one browser report into one `reportError` call with
3
+ // `source: 'browser'`. A framework route like `/_storage`, never an action: it must not appear in
4
+ // OpenAPI or as an MCP tool. Mounted by `x dev` and the container alike, only when enabled.
5
+
6
+ import type { ClientErrorsConfig } from '@ultimat3/core';
7
+ import {
8
+ escapeHtml,
9
+ isJsonObject,
10
+ proveSameOrigin,
11
+ readWithinLimit,
12
+ renderFixShellArg,
13
+ reportError,
14
+ UltimateError,
15
+ } from '@ultimat3/core';
16
+ import type { RequestContext, Route, UltimateRequest } from '@ultimat3/http';
17
+ import { HttpError, NO_STORE, selfOrigin } from '@ultimat3/http';
18
+ import type { RouteEntry } from '@ultimat3/render';
19
+ import { routeEntries } from '@ultimat3/render';
20
+ import { t } from '@ultimat3/schema';
21
+ import {
22
+ CLIENT_ERRORS_LIMITS,
23
+ CLIENT_ERRORS_PATH,
24
+ CLIENT_ERRORS_SCRIPT_PATH,
25
+ clientErrorsScript,
26
+ } from './client-errors-script';
27
+ import { revalidatedResponse } from './revalidated-response';
28
+
29
+ /** A report is five short strings; 32 KiB is every cap below with room, and nothing more. */
30
+ export const CLIENT_ERRORS_MAX_BODY_BYTES = 32 * 1024;
31
+
32
+ const KINDS = ['error', 'rejection', 'island'] as const;
33
+
34
+ /**
35
+ * A path, never a URL and never a query: a query string is where a token or an email rides. No C0
36
+ * control or DEL either: the path lands in `scope.extra`, and a monitor or a terminal renders it.
37
+ */
38
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: the class REFUSES them, by design.
39
+ const PATH = /^\/[^?#\s\x00-\x1f\x7f]*$/;
40
+
41
+ /** An island's id is `islandModuleId`'s `[a-z0-9-]`; anything else did not come from a page. */
42
+ const ISLAND = /^[a-z0-9-]+$/;
43
+
44
+ const REPORT = t.object({
45
+ kind: t.enumerated(...KINDS),
46
+ message: t.string.max(CLIENT_ERRORS_LIMITS.message),
47
+ path: t.string.max(CLIENT_ERRORS_LIMITS.path).pattern(PATH),
48
+ build: t.string.max(CLIENT_ERRORS_LIMITS.build),
49
+ stack: t.string.max(CLIENT_ERRORS_LIMITS.stack).optional(),
50
+ island: t.string.min(1).max(CLIENT_ERRORS_LIMITS.island).pattern(ISLAND).optional(),
51
+ });
52
+
53
+ /** CLOSED: the schema drops an unknown key, and a report shape that grows quietly is not closed. */
54
+ const KEYS: ReadonlySet<string> = new Set(Object.keys(REPORT.shape));
55
+
56
+ /** The page facts the operation is resolved from — `routeEntries()`, or a test's list. */
57
+ export type ClientErrorPage = Pick<RouteEntry, 'path' | 'surface' | 'pattern' | 'islands'>;
58
+
59
+ export interface ClientErrorsRouteOptions {
60
+ readonly pages?: () => readonly ClientErrorPage[];
61
+ }
62
+
63
+ const refused = (pathname: string, why: string): HttpError =>
64
+ new HttpError({
65
+ code: 'X_BODY_INVALID',
66
+ cause: `${pathname} refused a client error report: ${why}`,
67
+ fix: "reportError(error, { source: 'http' }) from @ultimat3/core for a server-side failure — this route takes only the shape /x-client-errors.js sends from an app/ page",
68
+ });
69
+
70
+ /**
71
+ * Same-origin PROVEN, stricter than the pipeline's `csrf` stage: that one lets an anonymous request
72
+ * with no browser evidence through as a webhook, and this route has no webhook caller. A cross-site
73
+ * page must not file through a signed-in browser, and a script with no headers is not a browser.
74
+ */
75
+ function assertSameOrigin(request: UltimateRequest, ctx: RequestContext): void {
76
+ const verdict = proveSameOrigin({
77
+ selfOrigins: [selfOrigin(ctx.url, ctx.https)],
78
+ origin: request.header('origin'),
79
+ secFetchSite: request.header('sec-fetch-site') || null,
80
+ listed: () => false,
81
+ listName: "the app's own origin",
82
+ });
83
+ if (verdict.ok) return;
84
+ throw new HttpError({
85
+ code: 'X_CSRF_BLOCKED',
86
+ cause: `${ctx.url.pathname} refused a client error report it could not show came from this app: ${verdict.reason}`,
87
+ fix: "fetch('/_client-errors', { method: 'POST', credentials: 'same-origin' }) from a page served on this same origin — /x-client-errors.js on an app/ document does exactly that; a request with neither sec-fetch-site nor Origin is refused",
88
+ });
89
+ }
90
+
91
+ /** The parsed report, or the refusal naming what was wrong — never the body echoed back. */
92
+ async function reportOf(request: UltimateRequest, pathname: string) {
93
+ const read = await readWithinLimit(request.raw.body, CLIENT_ERRORS_MAX_BODY_BYTES);
94
+ if ('over' in read) {
95
+ throw refused(pathname, `the body is over ${CLIENT_ERRORS_MAX_BODY_BYTES} bytes`);
96
+ }
97
+ let body: unknown;
98
+ try {
99
+ body = JSON.parse(new TextDecoder().decode(read.bytes));
100
+ } catch {
101
+ throw refused(pathname, 'the body is not JSON');
102
+ }
103
+ if (!isJsonObject(body)) throw refused(pathname, 'the body is not a JSON object');
104
+ const unknown = Object.keys(body).filter((key) => !KEYS.has(key));
105
+ if (unknown.length > 0) {
106
+ throw refused(pathname, `${unknown.length} key(s) outside ${[...KEYS].join(', ')}`);
107
+ }
108
+ const parsed = REPORT.safeParse(body);
109
+ if (parsed.issues !== undefined) {
110
+ const where = parsed.issues.map((issue) => issue.path?.map(String).join('.') ?? '').join(', ');
111
+ throw refused(pathname, `field(s) out of shape: ${where}`);
112
+ }
113
+ return parsed.value;
114
+ }
115
+
116
+ /**
117
+ * The grouping facet: the page PATTERN, plus the island id only when this build serves it. The id
118
+ * is the browser's to choose, and an invented one per report would be a new fingerprint group per
119
+ * report — so a report naming an island no page declares is filed (`meta.island`) with NO
120
+ * operation, one group for every such report.
121
+ */
122
+ function operationOf(
123
+ pages: readonly ClientErrorPage[],
124
+ path: string,
125
+ island: string | undefined,
126
+ ): string | undefined {
127
+ const pattern = patternOf(pages, path);
128
+ if (pattern === undefined || island === undefined) return pattern;
129
+ return pages.some((page) => page.islands.includes(island)) ? `${pattern} ${island}` : undefined;
130
+ }
131
+
132
+ /** The most specific `app/` page the path names — the PATTERN, the one bounded grouping facet. */
133
+ function patternOf(pages: readonly ClientErrorPage[], path: string): string | undefined {
134
+ let best: ClientErrorPage | undefined;
135
+ for (const page of pages) {
136
+ if (page.surface !== 'app' || !page.pattern.regex.test(path)) continue;
137
+ if (best === undefined || page.pattern.specificity > best.pattern.specificity) best = page;
138
+ }
139
+ return best?.path;
140
+ }
141
+
142
+ export function clientErrorsRoutes(
143
+ config: ClientErrorsConfig,
144
+ options: ClientErrorsRouteOptions = {},
145
+ ): readonly Route[] {
146
+ const pages = options.pages ?? routeEntries;
147
+ const source = clientErrorsScript();
148
+ return [
149
+ {
150
+ method: 'GET',
151
+ path: CLIENT_ERRORS_SCRIPT_PATH,
152
+ meta: { name: 'clientErrors.script', auth: 'public' },
153
+ // No content hash in the URL — every `app/` document names it — so revalidated, as
154
+ // `/x-sw-register.js` is: a changed script reaches a returning tab on its next load.
155
+ handler: (request) => revalidatedResponse(request, source, 'text/javascript; charset=utf-8'),
156
+ },
157
+ {
158
+ method: 'POST',
159
+ path: CLIENT_ERRORS_PATH,
160
+ meta: {
161
+ name: 'clientErrors.report',
162
+ // `required` unless the app opened the door: the pipeline answers an anonymous caller 401.
163
+ auth: config.anonymous ? 'public' : 'required',
164
+ cache: NO_STORE,
165
+ // The pipeline's `rate-limit` stage spends it, per actor or (anonymous) per address, on the
166
+ // app's own store — fleet-wide when the app declared `scope: 'shared'`, never a second one.
167
+ rateLimit: 'clientErrors.report',
168
+ rateLimitBucket: { capacity: config.perMinute, refillPerSecond: config.perMinute / 60 },
169
+ },
170
+ handler: async (request, ctx) => {
171
+ assertSameOrigin(request, ctx);
172
+ const report = await reportOf(request, ctx.url.pathname);
173
+ const operation = operationOf(pages(), report.path, report.island);
174
+ const error = new UltimateError({
175
+ code: 'X_CLIENT_ERROR',
176
+ cause: report.message === '' ? `a ${report.kind} with no message` : report.message,
177
+ fix: `x shot ${renderFixShellArg(report.path, '<route>')} --json`,
178
+ meta: {
179
+ clientBuild: report.build,
180
+ kind: report.kind,
181
+ ...(report.island === undefined ? {} : { island: report.island }),
182
+ },
183
+ });
184
+ // The BROWSER's stack is the report's; the one this line just captured is the server's.
185
+ Object.defineProperty(error, 'stack', { value: report.stack, configurable: true });
186
+ reportError(error, {
187
+ source: 'browser',
188
+ scope: {
189
+ // Pattern plus island: `errorFingerprint` groups on it, so one island's fault is one
190
+ // group on every `/posts/:id`, and two islands on one page are two.
191
+ ...(operation === undefined ? {} : { operation }),
192
+ extra: { path: report.path },
193
+ },
194
+ });
195
+ return new Response(null, { status: 204 });
196
+ },
197
+ },
198
+ ];
199
+ }
200
+
201
+ /** The tag an `app/` document carries: the build it was rendered BY, so a stale tab says so. */
202
+ export const clientErrorsHead = (buildId: string): string =>
203
+ `<script src="${CLIENT_ERRORS_SCRIPT_PATH}" data-build="${escapeHtml(buildId)}" defer></script>`;
204
+
205
+ export interface ClientErrorsSurface {
206
+ readonly routes: readonly Route[];
207
+ /** For `DocumentOptions.appHead`: `app/` documents only — `site/` never pays for it. */
208
+ readonly head: string;
209
+ }
210
+
211
+ /** What a boot mounts for `app.config.ts`'s `clientErrors`: nothing at all unless enabled. */
212
+ export function clientErrorsSurface(
213
+ config: ClientErrorsConfig | undefined,
214
+ buildId: string,
215
+ ): ClientErrorsSurface | undefined {
216
+ if (config?.enabled !== true) return undefined;
217
+ return { routes: clientErrorsRoutes(config), head: clientErrorsHead(buildId) };
218
+ }
219
+
220
+ /**
221
+ * A static export runs no route table, so there is nothing to receive a report: no document names
222
+ * the script, and the build SAYS so — `originWarning`'s shape, into the report's `warnings`.
223
+ */
224
+ export function clientErrorsStaticWarning(config: ClientErrorsConfig | undefined): string[] {
225
+ if (config?.enabled !== true) return [];
226
+ return [
227
+ 'clientErrors.enabled is true and a static export serves no /_client-errors route, so no exported document names /x-client-errors.js — serve the app (x dev, or the container) for browser reports',
228
+ ];
229
+ }