@ultimat3/cli 29.3.2 → 30.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +64 -40
- package/README.md +61 -11
- package/package.json +42 -28
- package/src/affected-tests.ts +97 -0
- package/src/affected-unowned.ts +43 -0
- package/src/affected.ts +25 -6
- package/src/app-boundaries.ts +29 -1
- package/src/app-evals.ts +12 -4
- package/src/app-load-unresolved.ts +62 -0
- package/src/app-load.ts +30 -37
- package/src/app-mcp.ts +14 -2
- package/src/app-module-graph.ts +232 -0
- package/src/app-reload-graph.ts +2 -2
- package/src/app-root.ts +37 -1
- package/src/bin.ts +1 -0
- package/src/boundary-findings.ts +6 -0
- package/src/budgets-freshness.ts +32 -0
- package/src/budgets.ts +20 -1
- package/src/build-capture-env.ts +9 -0
- package/src/cdp-shot-a11y.ts +2 -39
- package/src/ci-tested.ts +243 -0
- package/src/client-emit-customer-runtime.ts +139 -0
- package/src/client-emit-customer.ts +108 -0
- package/src/client-emit-drift.ts +72 -0
- package/src/client-emit-header.ts +67 -0
- package/src/client-emit-schemas.ts +175 -0
- package/src/client-emit-types.ts +121 -0
- package/src/client-emit-vendor.ts +146 -0
- package/src/client-errors-routes.ts +229 -0
- package/src/client-errors-script.ts +74 -0
- package/src/client-vitals-route.ts +197 -0
- package/src/client-vitals-script.ts +56 -0
- package/src/cmd-affected.ts +23 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +35 -9
- package/src/cmd-ci-spec.ts +42 -6
- package/src/cmd-ci.ts +99 -2
- package/src/cmd-client-customer.ts +134 -0
- package/src/cmd-client-source.ts +81 -0
- package/src/cmd-client-spec.ts +48 -0
- package/src/cmd-client-write.ts +41 -0
- package/src/cmd-client.ts +111 -0
- package/src/cmd-db-recount.ts +120 -0
- package/src/cmd-db-spec.ts +36 -6
- package/src/cmd-db.ts +13 -3
- package/src/cmd-deploy-compose.ts +61 -3
- package/src/cmd-deploy-drain.ts +10 -2
- package/src/cmd-deploy-helm.ts +11 -0
- package/src/cmd-deploy-preflight.ts +118 -0
- package/src/cmd-deploy-spec.ts +58 -6
- package/src/cmd-deploy-verify.ts +380 -0
- package/src/cmd-deploy.ts +42 -9
- package/src/cmd-docs.ts +34 -4
- package/src/cmd-doctor.ts +66 -9
- package/src/cmd-drill-spec.ts +25 -0
- package/src/cmd-drill.ts +176 -0
- package/src/cmd-env-init.ts +102 -0
- package/src/cmd-env-spec.ts +10 -3
- package/src/cmd-env.ts +15 -10
- package/src/cmd-errors-recent.ts +60 -0
- package/src/cmd-errors-spec.ts +9 -1
- package/src/cmd-errors.ts +4 -0
- package/src/cmd-exercise-spec.ts +19 -0
- package/src/cmd-exercise.ts +129 -0
- package/src/cmd-generate-spec.ts +9 -1
- package/src/cmd-generate.ts +32 -27
- package/src/cmd-graph-load.ts +97 -0
- package/src/cmd-graph-spec.ts +16 -0
- package/src/cmd-graph.ts +73 -0
- package/src/cmd-i18n.ts +4 -1
- package/src/cmd-impact-spec.ts +13 -0
- package/src/cmd-impact.ts +64 -0
- package/src/cmd-jobs-resolve.ts +76 -0
- package/src/cmd-jobs-spec.ts +21 -2
- package/src/cmd-jobs.ts +10 -6
- package/src/cmd-mail-spec.ts +28 -0
- package/src/cmd-mail.ts +70 -0
- package/src/cmd-mcp-lock.ts +69 -0
- package/src/cmd-mcp-spec.ts +23 -4
- package/src/cmd-mcp.ts +17 -3
- package/src/cmd-new-spec.ts +8 -1
- package/src/cmd-new.ts +61 -9
- package/src/cmd-planned.ts +0 -6
- package/src/cmd-repro-spec.ts +31 -0
- package/src/cmd-repro.ts +174 -0
- package/src/cmd-stop-spec.ts +21 -0
- package/src/cmd-stop.ts +166 -0
- package/src/cmd-test-spec.ts +17 -4
- package/src/cmd-test.ts +70 -14
- package/src/cmd-upgrade-spec.ts +26 -0
- package/src/cmd-upgrade.ts +274 -0
- package/src/cmd-verify-spec.ts +3 -3
- package/src/db-backfill.ts +6 -0
- package/src/db-diagnostics.ts +106 -0
- package/src/db-generate.ts +28 -8
- package/src/db-reset-template.ts +1 -1
- package/src/db-rolling.ts +45 -0
- package/src/db-seed-generate.ts +188 -0
- package/src/deploy-verify-findings.ts +98 -0
- package/src/deploy-verify-flags.ts +109 -0
- package/src/deploy-verify-grade.ts +185 -0
- package/src/deploy-verify-queues.ts +53 -0
- package/src/dev-boot.ts +35 -15
- package/src/dev-child-watch.ts +16 -4
- package/src/dev-dashboard-guard.ts +8 -1
- package/src/dev-dashboard.ts +7 -0
- package/src/dev-lock-identity.ts +61 -0
- package/src/dev-lock-port-holder.ts +80 -0
- package/src/dev-lock-steal.ts +124 -0
- package/src/dev-lock.ts +92 -120
- package/src/dev-mail-templates.ts +51 -0
- package/src/dev-rebuild.ts +26 -2
- package/src/dev-route-table.ts +17 -0
- package/src/dev-supervisor.ts +5 -2
- package/src/dev-watch-tree.ts +11 -10
- package/src/dev-watch.ts +1 -1
- package/src/dispatch.ts +29 -6
- package/src/docs-section.ts +69 -0
- package/src/docs-sources.ts +100 -0
- package/src/doctor-auth.ts +11 -2
- package/src/doctor-chart-alerts.ts +55 -0
- package/src/doctor-pooler.ts +107 -0
- package/src/doctor-rolling.ts +35 -0
- package/src/doctor-row-security.ts +40 -0
- package/src/document-options.ts +13 -0
- package/src/drill-sync-lane.ts +25 -0
- package/src/drill-targets.ts +39 -0
- package/src/duplicate-packages.ts +1 -1
- package/src/env-production.ts +93 -0
- package/src/error-codes-shot.ts +59 -0
- package/src/error-codes-tooling.ts +29 -0
- package/src/error-codes.ts +100 -54
- package/src/error-contract.ts +2 -2
- package/src/errors.ts +22 -3
- package/src/exercise-database.ts +99 -0
- package/src/exercise-findings.ts +159 -0
- package/src/exercise-overrides.ts +42 -0
- package/src/exercise-registration.ts +58 -0
- package/src/exercise-static.ts +88 -0
- package/src/exercise-subject.ts +69 -0
- package/src/failure-capture-boot.ts +30 -0
- package/src/framework-env.ts +7 -1
- package/src/framework-schema.ts +72 -5
- package/src/generate-catalog-locales.ts +19 -0
- package/src/generate-chart-alerts.ts +156 -0
- package/src/generate-feature-required.ts +65 -0
- package/src/generate-feature.ts +83 -6
- package/src/generate-files.ts +24 -0
- package/src/generate-format-written.ts +32 -0
- package/src/generate-kinds.ts +6 -0
- package/src/generate-sign-in-config.ts +100 -0
- package/src/generate-sign-in-manifest.ts +28 -0
- package/src/generate-sign-in-mode.ts +101 -0
- package/src/generate-sign-in.ts +250 -0
- package/src/generate-write.ts +44 -7
- package/src/hand-rolled-socket.ts +72 -0
- package/src/i18n-app-key-shapes.ts +29 -0
- package/src/i18n-audit.ts +10 -3
- package/src/i18n-registration.ts +18 -3
- package/src/index.ts +2 -0
- package/src/island-bundle.ts +4 -3
- package/src/island-disposer.ts +116 -0
- package/src/island-realtime-state.ts +34 -6
- package/src/island-realtime.ts +6 -2
- package/src/island-routes.ts +3 -2
- package/src/isr-query-advice.ts +6 -11
- package/src/isr-query-guard.ts +4 -3
- package/src/job-budget.ts +95 -0
- package/src/jobs-json.ts +11 -0
- package/src/lint-findings.ts +104 -0
- package/src/local-cli.ts +34 -4
- package/src/loopback-host.ts +2 -2
- package/src/mail-preview-findings.ts +24 -0
- package/src/mcp-errors.ts +82 -2
- package/src/mcp-host.ts +11 -1
- package/src/mcp-lock-findings.ts +57 -0
- package/src/measure-paths.ts +12 -4
- package/src/measure-scope.ts +27 -13
- package/src/messages-client.ts +23 -0
- package/src/messages-db.ts +56 -0
- package/src/messages-gate.ts +107 -0
- package/src/messages-repro.ts +11 -0
- package/src/messages-shot.ts +51 -0
- package/src/messages-upgrade.ts +45 -0
- package/src/messages.ts +53 -178
- package/src/metrics-endpoint.ts +23 -5
- package/src/new-shapes-generate.ts +71 -0
- package/src/new-shapes.ts +63 -0
- package/src/openapi-compile-ir.ts +68 -0
- package/src/openapi-compile-schema.ts +284 -0
- package/src/openapi-compile.ts +273 -0
- package/src/openapi-refuse.ts +45 -0
- package/src/otlp-export.ts +2 -1
- package/src/output-findings.ts +123 -0
- package/src/output.ts +32 -8
- package/src/package-boundaries.ts +110 -0
- package/src/page-pins.ts +23 -0
- package/src/plain-routes.ts +13 -0
- package/src/prerender-entries.ts +59 -0
- package/src/prerender-heaviest.ts +27 -0
- package/src/prerender-preload.ts +6 -0
- package/src/prerender-report.ts +12 -0
- package/src/prerender-seo-reads.ts +38 -0
- package/src/prerender-served.ts +22 -7
- package/src/prerender-vitals.ts +42 -0
- package/src/prerender.ts +61 -32
- package/src/pwa-artifacts.ts +4 -3
- package/src/pwa-manifest-locales.ts +1 -1
- package/src/registry.ts +18 -0
- package/src/repro-bundle.ts +65 -0
- package/src/repro-errors.ts +47 -0
- package/src/repro-job.ts +72 -0
- package/src/repro-literal.ts +41 -0
- package/src/repro-request.ts +48 -0
- package/src/repro-write.ts +100 -0
- package/src/retain-sweep.ts +27 -0
- package/src/retained-assets-boot.ts +113 -0
- package/src/retained-assets-layout.ts +110 -0
- package/src/retained-assets-read.ts +142 -0
- package/src/retained-assets-sweep.ts +70 -0
- package/src/retained-assets.ts +171 -0
- package/src/revalidated-response.ts +8 -5
- package/src/role-replicator.ts +7 -1
- package/src/role-start-devices.ts +119 -0
- package/src/role-start-types.ts +6 -0
- package/src/role-start.ts +22 -5
- package/src/role-sync.ts +7 -1
- package/src/route-islands.ts +42 -0
- package/src/route-table.ts +2 -1
- package/src/row-security-boot.ts +58 -0
- package/src/row-security-wanted.ts +32 -0
- package/src/runtime-admin.ts +0 -1
- package/src/runtime-assets-site-cache.ts +55 -0
- package/src/runtime-assets-site.ts +136 -0
- package/src/runtime-assets.ts +99 -55
- package/src/runtime-bindings.ts +19 -3
- package/src/runtime-cache.ts +23 -14
- package/src/runtime-isr-outcome.ts +114 -60
- package/src/runtime-purge.ts +14 -2
- package/src/runtime-queue.ts +31 -4
- package/src/runtime-render-document.ts +204 -0
- package/src/runtime-render.ts +48 -198
- package/src/runtime-replica-position.ts +62 -0
- package/src/runtime-replica.ts +18 -3
- package/src/runtime-s3-path-style.ts +17 -0
- package/src/runtime-services.ts +19 -6
- package/src/runtime-statement-bound.ts +22 -0
- package/src/runtime-storage-upload.ts +9 -0
- package/src/runtime-storage.ts +42 -138
- package/src/schema-diff-views.ts +54 -0
- package/src/schema-diff.ts +21 -3
- package/src/schema-drift.ts +28 -9
- package/src/schema-dump-drift.ts +91 -7
- package/src/script-csp.ts +1 -1
- package/src/seo-cards.ts +26 -0
- package/src/seo-routes.ts +22 -8
- package/src/serve-boot.ts +54 -21
- package/src/serve-drain.ts +1 -0
- package/src/serve-env.ts +26 -8
- package/src/serve-retire.ts +12 -6
- package/src/serve-web.ts +42 -4
- package/src/serve.ts +9 -7
- package/src/site-asset-routes.ts +17 -27
- package/src/site-config.ts +4 -0
- package/src/site-llms-pages.ts +106 -0
- package/src/site-llms.ts +64 -0
- package/src/site-seo-write.ts +4 -2
- package/src/site-seo.ts +55 -6
- package/src/source-mtime.ts +50 -0
- package/src/static-capture-errors.ts +41 -0
- package/src/static-capture.ts +56 -0
- package/src/static-cards-serve.ts +39 -0
- package/src/static-cards.ts +93 -0
- package/src/static-document.ts +14 -13
- package/src/static-images.ts +115 -0
- package/src/stream-hole-scope.ts +55 -0
- package/src/style-bundle.ts +1 -1
- package/src/style-routes.ts +3 -2
- package/src/sw-precache-plan.ts +2 -2
- package/src/sw-routes.ts +2 -2
- package/src/templates/action-tests.ts +306 -0
- package/src/templates/action.ts +81 -269
- package/src/templates/backfill.ts +1 -1
- package/src/templates/entity.ts +21 -5
- package/src/templates/github/ci.yml.ts +8 -9
- package/src/templates/guard-bare-error.ts +2 -2
- package/src/templates/guard-error-declared.ts +263 -0
- package/src/templates/guard-native-confirm.ts +132 -0
- package/src/templates/guard-raw-fetch.ts +151 -0
- package/src/templates/guard-raw-interval.ts +121 -0
- package/src/templates/guard-repo-raw-sql.ts +71 -2
- package/src/templates/job.ts +1 -1
- package/src/templates/naming.ts +5 -0
- package/src/templates/pinned/docker-compose.yml +6 -0
- package/src/templates/pinned-images.ts +8 -0
- package/src/templates/policy.ts +85 -2
- package/src/templates/query.ts +70 -13
- package/src/templates/resource-create.ts +3 -1
- package/src/templates/resource-form-island.ts +15 -4
- package/src/templates/resource-page-db.ts +83 -0
- package/src/templates/resource-page.ts +161 -52
- package/src/templates/route.ts +4 -6
- package/src/templates/scaffold-app.ts +3 -0
- package/src/templates/scaffold-auth.ts +2 -1
- package/src/templates/scaffold-bin.ts +5 -52
- package/src/templates/scaffold-claude-agents.ts +19 -19
- package/src/templates/scaffold-claude-commands.ts +28 -31
- package/src/templates/scaffold-claude.ts +26 -23
- package/src/templates/scaffold-container-compose.ts +6 -1
- package/src/templates/scaffold-container.ts +24 -4
- package/src/templates/scaffold-dashboard-bare.ts +3 -3
- package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
- package/src/templates/scaffold-dashboard-example-view.ts +156 -0
- package/src/templates/scaffold-dashboard-example.ts +43 -131
- package/src/templates/scaffold-dashboard-shared.ts +3 -7
- package/src/templates/scaffold-db-client.ts +20 -6
- package/src/templates/scaffold-db-package.ts +5 -14
- package/src/templates/scaffold-docs.ts +73 -57
- package/src/templates/scaffold-domain-package.ts +18 -14
- package/src/templates/scaffold-entries.ts +7 -2
- package/src/templates/scaffold-exercise.ts +37 -0
- package/src/templates/scaffold-guards.ts +13 -1
- package/src/templates/scaffold-helm-alerts.ts +234 -0
- package/src/templates/scaffold-helm-templates.ts +7 -5
- package/src/templates/scaffold-helm.ts +46 -12
- package/src/templates/scaffold-i18n.ts +6 -2
- package/src/templates/scaffold-mcp-endpoint.ts +182 -0
- package/src/templates/scaffold-mcp-json.ts +31 -0
- package/src/templates/scaffold-mcp-package.ts +59 -56
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +45 -79
- package/src/templates/scaffold-test-services.ts +13 -5
- package/src/templates/shapes/api-keys.ts +170 -0
- package/src/templates/shapes/api.ts +101 -0
- package/src/templates/shapes/blog-site.ts +340 -0
- package/src/templates/shapes/blog-slice.ts +265 -0
- package/src/templates/shapes/blog.ts +114 -0
- package/src/templates/shapes/dashboard-detail.ts +242 -0
- package/src/templates/shapes/dashboard.ts +53 -0
- package/src/templates/shapes/index.ts +17 -0
- package/src/templates/shapes/overlay.ts +99 -0
- package/src/templates/shapes/shared-edits.ts +46 -0
- package/src/templates/sign-in-action-tests.ts +237 -0
- package/src/templates/sign-in-authenticator.ts +112 -0
- package/src/templates/sign-in-oidc.ts +189 -0
- package/src/templates/sign-in-pages.ts +148 -0
- package/src/templates/sign-in-password.ts +216 -0
- package/src/templates/sign-in-shared.ts +268 -0
- package/src/templates/slice-foundation.ts +70 -15
- package/src/test-batches.ts +1 -1
- package/src/test-database.ts +1 -1
- package/src/test-shards.ts +7 -1
- package/src/test-workers.ts +18 -12
- package/src/tool-gate-names.ts +34 -0
- package/src/upgrade-agents-link.ts +49 -0
- package/src/upgrade-bun-floor.ts +225 -0
- package/src/upgrade-gate.ts +228 -0
- package/src/upgrade-pins.ts +63 -0
- package/src/upgrade-plan.ts +214 -0
- package/src/upgrade-registry.ts +53 -0
- package/src/upgrade-report.ts +139 -0
- package/src/upgrade-root.ts +74 -0
- package/src/verify-budgets-build.ts +118 -0
- package/src/verify-checks.ts +67 -24
- package/src/verify-coverage-run.ts +1 -1
- package/src/verify-e2e-lane.ts +41 -0
- package/src/verify-exercise.ts +64 -0
- package/src/verify-floor.ts +41 -0
- package/src/verify-merge.ts +19 -4
- package/src/verify-role-load.ts +2 -0
- package/src/verify-run.ts +28 -8
- package/src/verify-shard.ts +12 -2
- package/src/verify-skip-ceiling.ts +87 -0
- package/src/verify-step.ts +5 -0
- package/src/verify-tests.ts +25 -5
- package/src/view-drift.ts +55 -0
- package/src/worker-bundle.ts +3 -2
- package/src/workspace-graph.ts +38 -19
- package/src/write-generated.ts +43 -0
- package/src/zone-boot.ts +20 -0
- package/wiki/Actions.md +342 -0
- package/wiki/Admin-Dashboard.md +432 -0
- package/wiki/Agents.md +958 -0
- package/wiki/Auth.md +195 -0
- package/wiki/Bare-VM.md +111 -0
- package/wiki/Batching-And-Preloading.md +136 -0
- package/wiki/Building-Your-Own-Base.md +203 -0
- package/wiki/CI-Parallel-Gate.md +220 -0
- package/wiki/CLI-Reference.md +1894 -0
- package/wiki/Caching-And-Invalidation.md +255 -0
- package/wiki/Client-Data.md +194 -0
- package/wiki/Client-Navigation.md +513 -0
- package/wiki/Compared.md +93 -0
- package/wiki/Configuration.md +868 -0
- package/wiki/Contributing.md +12 -0
- package/wiki/Deploy-Recipes.md +122 -0
- package/wiki/Deployment.md +508 -0
- package/wiki/Entities-And-Migrations.md +1116 -0
- package/wiki/FAQ.md +175 -0
- package/wiki/Feature-Flags.md +65 -0
- package/wiki/Getting-Started.md +221 -0
- package/wiki/Home.md +160 -0
- package/wiki/I18n.md +239 -0
- package/wiki/Installation.md +206 -0
- package/wiki/Interface-Rules.md +339 -0
- package/wiki/Jobs-And-Workflows.md +783 -0
- package/wiki/Known-Gaps.md +185 -0
- package/wiki/MCP-And-AI.md +753 -0
- package/wiki/Mail.md +138 -0
- package/wiki/Migrating-An-Existing-App.md +594 -0
- package/wiki/Migrating-From-Django.md +54 -0
- package/wiki/Migrating-From-Laravel.md +53 -0
- package/wiki/Migrating-From-Node-Drizzle.md +53 -0
- package/wiki/Migrating-From-Rails.md +53 -0
- package/wiki/Migrations-And-Backfills.md +552 -0
- package/wiki/Money.md +338 -0
- package/wiki/N-Plus-One-Detection.md +197 -0
- package/wiki/Notify.md +208 -0
- package/wiki/Observability.md +245 -0
- package/wiki/Outbound-HTTP.md +109 -0
- package/wiki/PWA-And-Offline.md +343 -0
- package/wiki/Policies-And-Authz.md +251 -0
- package/wiki/Project-Layout.md +150 -0
- package/wiki/Queries-And-Live-Queries.md +429 -0
- package/wiki/Realtime.md +734 -0
- package/wiki/Resource-Management.md +121 -0
- package/wiki/Routes-And-Render-Modes.md +521 -0
- package/wiki/SEO.md +288 -0
- package/wiki/Scheduled-Tasks.md +175 -0
- package/wiki/Scraping.md +493 -0
- package/wiki/Shapes.md +63 -0
- package/wiki/Stability.md +97 -0
- package/wiki/Static-Assets.md +166 -0
- package/wiki/Storage-And-Uploads.md +404 -0
- package/wiki/Testing.md +1313 -0
- package/wiki/The-Eight-Primitives.md +247 -0
- package/wiki/Theming.md +362 -0
- package/wiki/Timezones-And-Dates.md +194 -0
- package/wiki/Troubleshooting.md +163 -0
- package/wiki/Tutorial-01-First-App.md +289 -0
- package/wiki/Tutorial-02-First-Feature.md +339 -0
- package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
- package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
- package/wiki/Tutorial-05-Deploy-Free.md +231 -0
- package/wiki/Tutorial-06-Growing-Up.md +158 -0
- package/wiki/UI-Components.md +156 -0
- package/wiki/Upgrading.md +3667 -0
- package/src/stored-object-headers.ts +0 -43
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
# Client navigation
|
|
2
|
+
|
|
3
|
+
Moving between pages of an opted-in surface without a full document load: the router fetches the
|
|
4
|
+
next page **the server renders anyway** and swaps it into the same tab. The tab keeps its islands
|
|
5
|
+
that did not change, its socket, its page store and its scroll history, so an app of server pages
|
|
6
|
+
feels like an installed one.
|
|
7
|
+
|
|
8
|
+
**Not a render mode.** Every page is still rendered by its own route, in its own mode (`ssr`,
|
|
9
|
+
`stream`, `static`, `isr`), behind its own policy and cache headers. The router only decides what
|
|
10
|
+
the browser does with the answer. Without the script (scripting off, or a link marked
|
|
11
|
+
`data-x-reload`), the same markup is an ordinary multi-page app. Why this and not a client-rendered
|
|
12
|
+
mode: [docs/history/render.md](https://github.com/developerz-ai/ultimate/blob/main/docs/history/render.md).
|
|
13
|
+
|
|
14
|
+
**One rule over everything below: nothing the router sends is sent twice, and nothing it guesses
|
|
15
|
+
runs a route that did not ask for it.** The server enforces both, before any app code runs.
|
|
16
|
+
|
|
17
|
+
## Opt in: per surface, then per page
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// app.config.ts
|
|
21
|
+
export const config = defineConfig({
|
|
22
|
+
name: 'acme',
|
|
23
|
+
navigation: { client: ['app'] },
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| `navigation.client` | Effect |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `[]` (default) | every navigation is a full load; no surface ships a router byte |
|
|
30
|
+
| `['app']` | `app/` pages navigate softly; `site/` stays 0kb of JS |
|
|
31
|
+
| `['site', 'app']` | both. Crossing from one surface to the other is still a full load |
|
|
32
|
+
|
|
33
|
+
`api` and `shared` render no documents and are refused (`X_CONFIG_INVALID`).
|
|
34
|
+
|
|
35
|
+
A page may then say how the router treats it, with the route's twelfth key:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
export const config = defineRoute({ render: 'ssr', …, navigation: 'prefetch' });
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| `navigation` | The router | The server, before `load` |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| absent | swaps the page in on a click; never fetches it early | a prefetch gets an empty `204` |
|
|
44
|
+
| `'prefetch'` | also fetches it on hover/focus | a prefetch is answered like a visit |
|
|
45
|
+
| `'document'` | always a real document load; the page itself carries **no router** (`As of 22.8.1`): no script, no navigation metas, none of the router's bytes charged to its budget — a `0b` page stays `0b`, and its own links and forms are plain browser navigations | a soft visit gets `204` + `x-ultimate-location`; the browser's own load runs it once |
|
|
46
|
+
| `'modal'` | shown **over** the page the visitor is on, addressed by the hash (`/runs#/runs/new`) — [Modals](#modals-a-page-over-a-page). A full load of its own URL is still the whole page | as absent: a prefetch gets an empty `204` |
|
|
47
|
+
| `{ modal: true, prefetch: 'intent' }` | a `'modal'` that is also fetched on hover/focus/touchstart, as `'prefetch'` is (`As of 29.1.0`) | a prefetch is answered like a visit |
|
|
48
|
+
|
|
49
|
+
**Use `'document'` for every GET that records something** — a recipient opening a link, a download
|
|
50
|
+
logged as evidence, a one-time token. **Use `'prefetch'` — or, for a modal, `{ modal: true,
|
|
51
|
+
prefetch: 'intent' }` — only for a GET that does nothing but render.** A plain `'modal'` stays
|
|
52
|
+
unprefetched: a modal is often a form, and its GET may prepare one (a draft, a token, a reserved
|
|
53
|
+
slot). One spelling per meaning: the object takes exactly those two keys and values, anything else
|
|
54
|
+
is `X_ROUTE_NAVIGATION_INVALID`, and `{ modal: true }` alone is refused — that is `'modal'`.
|
|
55
|
+
Declaring any of them on a surface without client navigation refuses the boot
|
|
56
|
+
(`X_ROUTE_NAVIGATION_INVALID`).
|
|
57
|
+
|
|
58
|
+
An opted-in document (every page of the surface except a `'document'` one) carries:
|
|
59
|
+
|
|
60
|
+
| Tag | Why |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `<meta name="ultimate-navigation" content="<app>:<surface>">` | which router may swap it in. The app's `name` is part of it, so two apps on one origin (web and admin) never swap each other's pages |
|
|
63
|
+
| `<meta name="x-ultimate-build" content="…">` | the skew check |
|
|
64
|
+
| `<script src="/_x/assets/navigation/<hash>.js" defer>` | the router: one classic script, content-addressed, `immutable`, `'self'` under the default CSP. `x dev`, the container and `x build --target static` all serve or write it |
|
|
65
|
+
| `<meta name="ultimate-modals" content='{"routes":[…],"close":"…","loading":"…"}'>` | only on a surface with a `'modal'` route: the modal patterns, most specific first, with `!<pattern>` for a page that outranks one (`/runs/new/peek` over `/runs/:id/peek`), and the frame's two words in the document's locale (`ui.close`, `ui.loading` — override them in the app's catalog). What lets a click open the dialog before the answer ([Modals](#opening-on-the-click)) |
|
|
66
|
+
|
|
67
|
+
**Budget:** the router is 27,845 B minified (10,094 B gzip) `As of 29.1.0` — 24,448 B (9,028 B
|
|
68
|
+
gzip) before the modal that opens on the click and its popover opening (+3,397 B), and 19,966 B
|
|
69
|
+
before route-presented modals and navigating from code (+4,092 B, 26.1.0). It is **charged** to
|
|
70
|
+
every route on the surface, like realtime's page boot, because it is interactivity the app opted
|
|
71
|
+
into. Raise a route's `budget.js` by the measured amount (`bun run budget-raises`).
|
|
72
|
+
|
|
73
|
+
## What the server enforces
|
|
74
|
+
|
|
75
|
+
Every router request carries `x-ultimate-navigation: soft | prefetch`, the document's
|
|
76
|
+
`x-ultimate-surface: <app>:<surface>`, its principal (`x-ultimate-navigation-scope`, when the
|
|
77
|
+
document has one) and, on a GET, its `x-ultimate-build`. `@ultimat3/http` answers **before auth,
|
|
78
|
+
before any hook, before the handler**:
|
|
79
|
+
|
|
80
|
+
| Request | Route | Answer |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| prefetch | anything but a page declaring `'prefetch'` (actions, queries, assets, storage, app routes, pages) | `204`, nothing ran |
|
|
83
|
+
| soft GET | anything but a page of THIS router's `<app>:<surface>` (a download, an evidence GET, a `'document'` page, another surface or app) | `204` + `x-ultimate-location: <same url>`; the router loads it for real, and it runs once |
|
|
84
|
+
| soft GET | a page rendered for another principal than the router's document | `204` + `x-ultimate-location`, before `load` |
|
|
85
|
+
| GET | a tab on another build | `409` from the existing build check; the router loads the page for real |
|
|
86
|
+
| any router request answered with a 3xx (a form's 303, a sign-in wall, a `load`'s `setRedirect`, an OAuth or payment hop) | any | `204` + `x-ultimate-location: <target>`, cookies kept. The handler ran once; the router follows a same-origin target as its own next request, and hands another origin to the browser. A target that is not `http:`/`https:` (or will not parse) is never handed over: the header names the requested URL instead, as a path |
|
|
87
|
+
| POST | any | never gated: it is the form's submission |
|
|
88
|
+
|
|
89
|
+
A soft GET across a principal change runs auth and rate-limit **twice** — once before the `204` of
|
|
90
|
+
the principal row above, once for the real load. Expected and harmless (auth and rate-limit only;
|
|
91
|
+
the handler and `load` run once), but a tight per-actor rate limit should budget one extra request
|
|
92
|
+
per sign-in or sign-out navigation.
|
|
93
|
+
|
|
94
|
+
The router never lets `fetch` follow a redirect (`redirect: 'manual'`), so a CORS-refused hop to
|
|
95
|
+
another origin, or a target on another surface, is never fetched and then asked for again.
|
|
96
|
+
|
|
97
|
+
## What the router takes, and what it leaves to the browser
|
|
98
|
+
|
|
99
|
+
Clicks and submits are read on `window` in the bubble phase, **after** every handler on the page:
|
|
100
|
+
a handler that calls `preventDefault()` keeps the event.
|
|
101
|
+
|
|
102
|
+
| Taken (soft) | Left to the browser (native) |
|
|
103
|
+
|---|---|
|
|
104
|
+
| left click on a same-origin `<a href>` / `<area href>` | a modifier key or another button; `target` other than `_self`; `download`; `rel="external"`; `data-x-reload`; another origin or scheme |
|
|
105
|
+
| a link to another page, or another query on this page | a fragment on the page already shown, `href="#"` included: the browser scrolls, nothing is fetched. An `href` or form `action` no URL parses (`http://`): the browser decides |
|
|
106
|
+
| `<form method="get">`: navigates to its query, like the browser | `method="dialog"`; a `target`; another origin; `data-x-reload` on the form or its submitter |
|
|
107
|
+
| `<form method="post">`, url-encoded or multipart | a POST with a chosen file (the browser's own upload progress); `enctype="text/plain"` |
|
|
108
|
+
|
|
109
|
+
The submitter's `formaction`, `formmethod`, `formenctype`, `formtarget` and its own name/value are
|
|
110
|
+
honoured. A GET query and a url-encoded body carry every line break as CRLF, as a native submit does
|
|
111
|
+
(`As of 2026-10-02`): a `<textarea>` sends the same bytes with the router as without it.
|
|
112
|
+
|
|
113
|
+
### What happens to the answer
|
|
114
|
+
|
|
115
|
+
| Answer | Result |
|
|
116
|
+
|---|---|
|
|
117
|
+
| a page of this surface, build and principal | swapped in |
|
|
118
|
+
| a 4xx/5xx page to a GET, whatever rendered it (the framework's own error page carries no surface meta) | swapped in: a `load` that wrote and then threw is never run a second time. The next click is a real load |
|
|
119
|
+
| `204` + `x-ultimate-location`, same origin | followed as the router's next request (at most 5 hops), or loaded when it names the URL just asked for |
|
|
120
|
+
| `204` + `x-ultimate-location`, another origin | the browser goes there |
|
|
121
|
+
| an empty `204` | nothing: the browser stays, as it does for a `204` navigation |
|
|
122
|
+
| a 2xx that is not a page (a PDF, a zip, JSON) | handed to the browser **from the bytes already received**: saved under its `Content-Disposition` name when `attachment`, shown otherwise. Never asked for again |
|
|
123
|
+
| a 4xx/5xx that is not a page (an offline service worker's empty `503`, a JSON `404`) | a GET: a full load of the URL asked for — the one answer the router asks for again, since there is nothing to show (`As of 2026-10-05`). A POST: `ultimate:navigation-error`, never re-sent |
|
|
124
|
+
| a POST answered in place with a page | swapped in: it cannot be repeated. When it was rendered for ANOTHER principal or build (a sign-in that renders instead of redirecting), every tab's cache is emptied and this tab stops swapping: every later link and form is a real document load |
|
|
125
|
+
| a page from another surface, build or principal where no framework server answered (a static host) | a full load |
|
|
126
|
+
|
|
127
|
+
### When something fails
|
|
128
|
+
|
|
129
|
+
| Failure | Result |
|
|
130
|
+
|---|---|
|
|
131
|
+
| a GET fails on the network | the browser loads it (its own offline page) |
|
|
132
|
+
| a POST fails on the network, or its answer cannot be read | **never re-sent.** `ultimate:navigation-error` on `document` (cancelable, `detail: { url, method, reason }`); its default is a GET load of the page the visitor is on, which shows what the server actually has |
|
|
133
|
+
| the swap throws part-way, or the answer's body cannot be read | a real load of the page (GET), or `ultimate:navigation-error` (POST) — never a half-replaced page |
|
|
134
|
+
|
|
135
|
+
**A failed swap or body read loads a GET for real, so its route runs a second time.** That is why a
|
|
136
|
+
page whose GET records anything must be `navigation: 'document'`: the server then never runs it for
|
|
137
|
+
a soft request at all.
|
|
138
|
+
|
|
139
|
+
**A failed POST's default is a GET reload of the current page.** On a page where reloading has an
|
|
140
|
+
effect of its own (it is itself a `'document'` page, or its GET records something), put
|
|
141
|
+
`data-x-reload` on its forms — the browser then submits them natively — or cancel
|
|
142
|
+
`ultimate:navigation-error` and show the failure in place.
|
|
143
|
+
|
|
144
|
+
A navigation started while another is in flight aborts the older one; the slower answer is never
|
|
145
|
+
swapped in over the newer page.
|
|
146
|
+
|
|
147
|
+
## The swap
|
|
148
|
+
|
|
149
|
+
In one order, so no frame is unstyled and no island runs twice:
|
|
150
|
+
|
|
151
|
+
1. **Stylesheets** the next page links and this one has not loaded are loaded first.
|
|
152
|
+
2. **Islands** of the old body are disposed: each one's `mount` return value, when it is a
|
|
153
|
+
function, is called (Solid's `render` returns one).
|
|
154
|
+
3. **Body** replaced. Elements marked `data-x-persist="<id>"` in **both** documents are carried
|
|
155
|
+
across live (same node, same state, islands inside still mounted). What the element SAYS about
|
|
156
|
+
the page follows the incoming one: its own attributes (the ones a server document set — never one
|
|
157
|
+
a script added), and every descendant's `aria-current` with the `class` beside it, matched by `id`
|
|
158
|
+
or by position. A sidebar's active item moves.
|
|
159
|
+
4. **Head** brought level by markup: server-rendered tags only this page had go (JSON-LD
|
|
160
|
+
included), tags only the next has arrive, identical ones stay. Tags a **script** added at run
|
|
161
|
+
time (a CSS-in-JS `<style>`, analytics) are the tab's and are never removed. The previous page's
|
|
162
|
+
own stylesheets it no longer links are retired after the swap. `<html lang>` and `dir` follow.
|
|
163
|
+
5. **Scripts**: the next page's head `<script src>` and its body scripts run, in order. The inline
|
|
164
|
+
hydration runtime runs again and boots the new islands; it visits each island root once per tab,
|
|
165
|
+
so a carried island is not booted twice. A `src` this tab already ran is not run again. An
|
|
166
|
+
inline head script runs when its TEXT is new to this head (once per page that carries it, as a
|
|
167
|
+
full load would), never when the current head already has it (the theme boot, on every page);
|
|
168
|
+
one only the previous page had is removed. Every inline script, head or body, still needs its
|
|
169
|
+
CSP hash — the router re-inserts the same text, and the policy judges it as on a full load.
|
|
170
|
+
|
|
171
|
+
Inside `document.startViewTransition` when the browser has it and `prefers-reduced-motion` is not
|
|
172
|
+
`reduce`. Style the transition with the standard `::view-transition-*` pseudo-elements. While it
|
|
173
|
+
animates the browser hit-tests every press to `<html>` (Chrome does whatever `pointer-events` the
|
|
174
|
+
overlay has), so a click that lands there skips the animation and is given to the element under the
|
|
175
|
+
pointer: the visitor's first click after a swap is never lost. A bare press — a touch that scrolls,
|
|
176
|
+
a long press — never skips it, so named elements finish their glide (`As of 2026-10-05`).
|
|
177
|
+
|
|
178
|
+
## History, scroll, focus
|
|
179
|
+
|
|
180
|
+
| Concern | Behaviour |
|
|
181
|
+
|---|---|
|
|
182
|
+
| history | `pushState` per navigation, each entry naming the document it shows; `replaceState` for the same URL. Back/forward to an entry whose document is not the one on screen swaps it in; an entry an app pushed shows the document of its path |
|
|
183
|
+
| scroll | top on a new page; the fragment's element for `#id`; back AND forward restore the saved position (saved as the visitor scrolls), and so do a reload and a Back that is a full document load (`As of 2026-10-02`). Always `instant`, whatever `scroll-behavior` says |
|
|
184
|
+
| focus | moved to the new page's `<main>` (else its first `<h1>`), `tabindex="-1"` added if needed. A `refresh()` keeps it where it was ([below](#refresh-is-the-same-page)) |
|
|
185
|
+
| announcement | the new `document.title`, in a polite `aria-live` region the router owns. A `refresh()` announces nothing |
|
|
186
|
+
| progress | `data-x-navigating` on `<html>` once a navigation runs past 150 ms. No bundled UI: style it, e.g. `html[data-x-navigating] main { opacity: 0.6 }` inside `prefers-reduced-motion: no-preference` |
|
|
187
|
+
|
|
188
|
+
## Prefetch
|
|
189
|
+
|
|
190
|
+
On intent: `pointerover` that rests 65 ms, `focusin`, `touchstart`. The server answers only pages
|
|
191
|
+
that declared `navigation: 'prefetch'`; everything else is an empty `204`, remembered so a second
|
|
192
|
+
hover does not ask again. Answers wait in a per-tab **memory** cache (never storage), 30 s, at most 20
|
|
193
|
+
documents, keyed with the query sorted and without the fragment. It is the ONLY reuse layer: every
|
|
194
|
+
router fetch goes out `cache: 'no-cache'`, so the browser's HTTP cache never answers a soft
|
|
195
|
+
navigation — an anonymous page's `stale-while-revalidate` would otherwise show the document from
|
|
196
|
+
before a write, a sign-out or a deploy (`navigation-fetch.ts`; issue #693).
|
|
197
|
+
|
|
198
|
+
| A prefetched answer is never used when | |
|
|
199
|
+
|---|---|
|
|
200
|
+
| it is not a 2xx page, or it was a hand-over (a redirect, a login wall) | the click asks again |
|
|
201
|
+
| the server said `no-store` and the click came more than 5 s later | the click asks again |
|
|
202
|
+
|
|
203
|
+
| The cache is emptied by | |
|
|
204
|
+
|---|---|
|
|
205
|
+
| any POST the router sends — when it is sent, and again when it settles, landed or failed: a prefetch sent while it was in flight may have rendered the old state | |
|
|
206
|
+
| any write through the framework's client (`rpc`, a mutation, an upload — `@ultimat3/core`'s `onClientWrite`) | |
|
|
207
|
+
| a principal change: `onRescope`, a load for another principal, a POST answered for one, and every hand-over of the very URL asked for (the server's principal check answers that way, and the router cannot tell it from a `'document'` page — so it forgets either way) | |
|
|
208
|
+
| a `BroadcastChannel('ultimate:navigation')` message from another tab — each of the above posts one | |
|
|
209
|
+
| the page's return from the back/forward cache: the channel is closed on `pagehide` (an open one keeps a page out of that cache), so every message sent meanwhile was missed | |
|
|
210
|
+
|
|
211
|
+
Never prefetched at all: a link with `data-x-no-prefetch`, anything the click rules leave to the
|
|
212
|
+
browser, anything on `Save-Data` or a `2g`/`slow-2g` connection, and the page a navigation is
|
|
213
|
+
already fetching. A press or click cancels a pending hover's prefetch, and the focus a press gives a
|
|
214
|
+
link is not an intent — so a fast click sends exactly one request (`As of 22.8.1`).
|
|
215
|
+
|
|
216
|
+
## Without the router: the browser's own prefetch
|
|
217
|
+
|
|
218
|
+
`As of 2026-09`. A document that carries **no** router — every page of a surface outside
|
|
219
|
+
`navigation.client`, and every `navigation: 'document'` page — carries one
|
|
220
|
+
`<script type="speculationrules">` instead: the browser fetches a link's document before the click,
|
|
221
|
+
and the full-page load that follows paints from memory. No JavaScript ships for it; a `0kb` page
|
|
222
|
+
stays `0kb` (`budgets.ts` reads the block as data). **Prefetch only, never prerender** — a prerender
|
|
223
|
+
runs the next page's scripts for a page nobody opened.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
navigation: { speculation: { prefetch: 'moderate', exclude: ['/blog/borrador-*'] } },
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
| `speculation.prefetch` | The browser fetches |
|
|
230
|
+
|---|---|
|
|
231
|
+
| `'moderate'` (default) | on pointer rest (~200 ms) or pointer down |
|
|
232
|
+
| `'conservative'` | on pointer down only |
|
|
233
|
+
| `false` | nothing: no tag, no CSP source |
|
|
234
|
+
|
|
235
|
+
The rules are an **allow-list** from the route table — a prefetch is a real GET with the visitor's
|
|
236
|
+
cookies, so only a page the table says is a pure read is a candidate:
|
|
237
|
+
|
|
238
|
+
| A page is a candidate when | Why |
|
|
239
|
+
|---|---|
|
|
240
|
+
| its surface has no router, and it is `static` or `isr` with no policy | it was rendered at build time, for nobody |
|
|
241
|
+
| its surface has the router, and it declared `navigation: 'prefetch'` | the app's own statement that its GET is a pure read |
|
|
242
|
+
|
|
243
|
+
Never a candidate: a `navigation: 'document'` page, an `ssr` page of a surface without the router
|
|
244
|
+
(it has no way to say its GET records nothing), an `offline: 'network-only'` page, an `api/` route,
|
|
245
|
+
and anything that is not a page — `/_storage/*`, `/mcp*`, an app's plain routes. Each candidate is
|
|
246
|
+
one URL pattern covering every routed locale (`{/en}?/precios`, `{/en}?/blog/:slug`);
|
|
247
|
+
`speculation.exclude` subtracts more, as URL patterns starting with `/`.
|
|
248
|
+
|
|
249
|
+
The body is the same string on every document of the app, sorted, and `script-src` admits it by
|
|
250
|
+
sha256 hashed from that string (`page-speculation.ts`), so the enforced policy needs no
|
|
251
|
+
`'unsafe-inline'`. Browsers without Speculation Rules ignore the block.
|
|
252
|
+
|
|
253
|
+
## Opting a link or form out
|
|
254
|
+
|
|
255
|
+
| Want | Write |
|
|
256
|
+
|---|---|
|
|
257
|
+
| a full load for this link or form | `data-x-reload` |
|
|
258
|
+
| soft on click, never fetched early | `data-x-no-prefetch` |
|
|
259
|
+
| a full load for every link to a page, from anywhere | `navigation: 'document'` on its route |
|
|
260
|
+
| an element that survives navigations | `data-x-persist="<stable id>"` on it, in both pages |
|
|
261
|
+
|
|
262
|
+
## Modals: a page over a page
|
|
263
|
+
|
|
264
|
+
`As of 26.1.0`. A create form, an edit step, a confirmation: a page whose route says it is
|
|
265
|
+
presented over another.
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
// app/runs/new/page.tsx
|
|
269
|
+
import { defineRoute } from '@ultimat3/render';
|
|
270
|
+
|
|
271
|
+
export const config = defineRoute({
|
|
272
|
+
render: 'ssr',
|
|
273
|
+
offline: 'network-only',
|
|
274
|
+
meta: ({ t }) => ({ title: t('runs.new') }),
|
|
275
|
+
navigation: 'modal',
|
|
276
|
+
});
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
```tsx
|
|
280
|
+
// app/runs/page.tsx — an ordinary link: without the router it is the whole page
|
|
281
|
+
<a href="/runs/new">New run</a>
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
**Two routers, one URL.** The PATH is the page on screen, swapped as everywhere else on this page.
|
|
285
|
+
The HASH is the one modal over it, and the hash's path is itself a route of the same surface:
|
|
286
|
+
`/runs#/runs/new`, `/projects/7#/projects/7/members/invite` — any depth, with a query
|
|
287
|
+
(`#/runs/new?bank=ve`). Every other fragment (`#below`) is still a fragment. Link the page's own
|
|
288
|
+
path; the router writes the hash.
|
|
289
|
+
|
|
290
|
+
| Visit | Result |
|
|
291
|
+
|---|---|
|
|
292
|
+
| a click on a link to a `'modal'` page | the dialog opens **on the click**, busy ([below](#opening-on-the-click)); its document fetched (one soft GET), its `<main>` shown in that `<dialog>` over this page; the URL becomes `<this page>#<its path>`, pushed once the content is in |
|
|
293
|
+
| a reload, a pasted or bookmarked `/runs#/runs/new`, Forward onto it | `/runs` renders; the router opens `/runs/new` over it |
|
|
294
|
+
| a hash typed over the page | the same |
|
|
295
|
+
| a hash naming anything else — a page that is not `'modal'`, a 404, a redirect, another principal's page, `#//host` | **just the page**: the hash is dropped in place. Never followed, loaded, or shown as an error |
|
|
296
|
+
| a full load of `/runs/new` itself, scripting off, `data-x-reload` | the whole page, as before — the server renders one document for both |
|
|
297
|
+
| a link to the page on screen that is itself a `'modal'` route | that page, swapped in as usual |
|
|
298
|
+
|
|
299
|
+
**Why the route, and not the link (axiom 1).** Presentation is a property of the page: a create
|
|
300
|
+
form is a modal wherever it is reached from, and the one place that can say so for EVERY way of
|
|
301
|
+
reaching it — a link, a redirect after a POST, a pasted hash — is its route. A link attribute
|
|
302
|
+
would be a second declaration two links to one page could disagree on, and a redirect has no link
|
|
303
|
+
to carry it. The server names the page a modal in its own document
|
|
304
|
+
(`<meta name="ultimate-presentation" content="modal">`); the router never decides it.
|
|
305
|
+
|
|
306
|
+
**Why the whole document, and not a fragment.** The router asks for the page exactly as a full
|
|
307
|
+
load does — same policy, same `load`, same cache headers, no request header that varies the
|
|
308
|
+
answer — and shows its `<main>`. A second shape of one URL would need `Vary`, could not be a
|
|
309
|
+
`static` or `isr` file, and would put two answers under one key in every cache on the way.
|
|
310
|
+
|
|
311
|
+
### Opening on the click
|
|
312
|
+
|
|
313
|
+
`As of 29.1.0`. The document of every page on a surface with a `'modal'` route names those routes
|
|
314
|
+
(`ultimate-modals`). A click on a link to one — or `openModal(path)`, or a hash that addresses one —
|
|
315
|
+
opens the dialog **in the click's own task**, before the request is sent:
|
|
316
|
+
|
|
317
|
+
| Phase | The dialog |
|
|
318
|
+
|---|---|
|
|
319
|
+
| on the click | open, in the top layer, focused: its body `[data-x-modal-body]` is `aria-busy="true"` and holds a skeleton (`[data-x-modal-skeleton]`, `aria-hidden`); the dialog is named "Loading…" (`ui.loading`); the close button is there. Nothing is pushed on history yet |
|
|
320
|
+
| the answer is the modal | its `<main>` replaces the skeleton in the same dialog, `aria-busy` goes, the dialog is named by its heading, focus moves to `[autofocus]` (else the first focusable element) unless the visitor already moved it, and the hash entry is pushed — exactly as before |
|
|
321
|
+
| closed while it loads (Escape, the close button, a press outside, Back) | the request is **aborted**; the dialog goes; no entry was pushed, so nothing is left to pop |
|
|
322
|
+
| the answer is not a modal (a page, a redirect, a load, a failure, a stale hash) | the frame goes, and the answer is handled exactly as it was before this release: a page swaps in, a network failure loads the URL, a stale hash is dropped in place |
|
|
323
|
+
| a link to another modal from inside one | the dialog keeps its content, busy, until the next one replaces it |
|
|
324
|
+
|
|
325
|
+
The router never decides the presentation from the meta: it only opens early. The ANSWER's
|
|
326
|
+
`ultimate-presentation` still decides, so a document that names no modal routes (or an older
|
|
327
|
+
router) behaves as before: nothing on screen until the answer.
|
|
328
|
+
|
|
329
|
+
**Prefetch on intent.** With `navigation: { modal: true, prefetch: 'intent' }` a hover, focus or
|
|
330
|
+
touchstart fetches the modal's document, and the click fills the frame from that answer — often in
|
|
331
|
+
the same frame it opened in. An island that opens a modal from code (`openModal`) gets no hover of
|
|
332
|
+
its own; put the modal's link in the row, or let the row's own link carry the intent.
|
|
333
|
+
|
|
334
|
+
**Why not `showModal()`.** A modal `<dialog>` makes the rest of the document inert, and Chrome
|
|
335
|
+
computes inertness in style: opening one restyles **every element on the page**. The router opens
|
|
336
|
+
its dialog with `show()` + `showPopover()` — the top layer and `::backdrop`, no inert document — and
|
|
337
|
+
does what `showModal()` did by hand: `aria-modal="true"`, focus moved in, Tab kept inside, Escape
|
|
338
|
+
(where `closedby` is not supported) and a press on the backdrop closing it. Measured on a 7,011-element
|
|
339
|
+
page in headless Chrome 150, 4x CPU throttling, median of 7 cold loads:
|
|
340
|
+
|
|
341
|
+
| Opening | open + forced style/layout | to next frame | RecalcStyle |
|
|
342
|
+
|---|---|---|---|
|
|
343
|
+
| `showModal()` + `html:has(dialog:modal)` (before) | 131.3 ms | 163.9 ms | 124.1 ms |
|
|
344
|
+
| `showModal()`, no `:has()` lock | 122.3 ms | 157.8 ms | 116.4 ms |
|
|
345
|
+
| `inert` on one wrapper + popover | 117.3 ms | 153.3 ms | 111.1 ms |
|
|
346
|
+
| `show()` + `showPopover()` + `html:has(> body > dialog[data-x-modal][open])` (now) | 9.7 ms | 32.8 ms | 2.6 ms |
|
|
347
|
+
|
|
348
|
+
The cost was the inert document, not the `:has()` lock and not the attribute toggles: `inert` on a
|
|
349
|
+
single wrapper is as expensive as `showModal()`. A browser without popovers still gets `showModal()`.
|
|
350
|
+
The page beneath is out of reach behind the backdrop and for assistive technology (`aria-modal`),
|
|
351
|
+
but it is not `inert`: `:modal` no longer matches the router's dialog — select it by
|
|
352
|
+
`dialog[data-x-modal]` (and `[open]`).
|
|
353
|
+
|
|
354
|
+
### Inside the modal
|
|
355
|
+
|
|
356
|
+
| Concern | Behaviour |
|
|
357
|
+
|---|---|
|
|
358
|
+
| markup | one `<dialog data-x-modal aria-modal="true">` in the top layer (`show()` + `showPopover()`, [above](#opening-on-the-click)), `::backdrop`, focus moved in and kept in. Its content in `[data-x-modal-body]`, then the frame's close button. Named by its first `h1`/`h2` (`aria-labelledby`), else by the page's title. The tab's title is the modal's while it is open |
|
|
359
|
+
| the page beneath | untouched: its islands stay mounted, its socket and page store live, its scroll where it was |
|
|
360
|
+
| links and forms | resolved against the MODAL's URL, as on its own page: `href="?step=2"` is `/runs/new?step=2`, a form with no `action` posts to `/runs/new` |
|
|
361
|
+
| islands in the modal | booted by the page's hydration runtime; disposed when the modal closes or its content is replaced |
|
|
362
|
+
| styles | the modal page's stylesheets load first. `@ultimat3/ui`'s `global.scss` styles `dialog[data-x-modal]` as its `Dialog` (`surface-raised` over `scrim`), its close button and its skeleton, under `:where()` so an app restyles them at zero specificity; its reset locks the page's scroll behind the router's dialog (`html:has(> body > dialog[data-x-modal][open])`) and behind any `:modal` `<dialog>` |
|
|
363
|
+
| one at a time | a link to another `'modal'` page from inside replaces the content of the same dialog and pushes, so Back steps out one level (`#/runs/new` → `#/runs/new/advanced` → Back → `#/runs/new`) |
|
|
364
|
+
|
|
365
|
+
### Closing
|
|
366
|
+
|
|
367
|
+
| Gesture | Result |
|
|
368
|
+
|---|---|
|
|
369
|
+
| Escape, a press outside it (the dialog carries `closedby="any"`; a browser without it gets the same from the router, for a press that begins and ends on the backdrop), a `<form method="dialog">`, any way the browser closes the dialog | **Back**, when the entry is the one the router pushed; otherwise (a cold-loaded address — Back would leave the app) the hash is dropped in place |
|
|
370
|
+
| the browser's Back | the entry beneath has no modal: closed |
|
|
371
|
+
| a link to the page beneath (the page's own **Cancel**, which is also its no-JS way back) | as Escape — nothing is fetched |
|
|
372
|
+
| a link anywhere else | that page swaps in, in place of the modal's entry |
|
|
373
|
+
|
|
374
|
+
Focus returns to the link or button that opened it; the title returns to the page's.
|
|
375
|
+
|
|
376
|
+
The router renders **one close button** (`As of 29.1.0`): `<button type="button"
|
|
377
|
+
data-x-modal-close aria-label="…">` after the content — never a `<form>`, so a page's own
|
|
378
|
+
`dialog form` selector still finds its form — named by the framework catalog's
|
|
379
|
+
`ui.close` in the document's locale, drawn as a cross by `@ultimat3/ui`. It closes as Escape
|
|
380
|
+
does. It needs the `ultimate-modals` meta for its name, so a document without one gets none. Still
|
|
381
|
+
give the modal page a Cancel link to the page it is opened over: on its own full page that link is
|
|
382
|
+
its way back.
|
|
383
|
+
|
|
384
|
+
### Forms inside
|
|
385
|
+
|
|
386
|
+
A form inside the modal submits through the router like any other: the POST is sent once, never
|
|
387
|
+
again.
|
|
388
|
+
|
|
389
|
+
| The server answers | Result |
|
|
390
|
+
|---|---|
|
|
391
|
+
| the modal page again (a `422` re-render with its errors) | shown in the same dialog; the address stays (`replace`) |
|
|
392
|
+
| a redirect to the page beneath (`303 /runs`) | the modal closes and the page is fetched and swapped in — its content refreshed — through Back, so the spent form's entry is not left to reopen |
|
|
393
|
+
| a redirect anywhere else | the target swaps in, in place of the modal's entry |
|
|
394
|
+
| a redirect to another `'modal'` page (create, then confirm) | shown in the same dialog, in place of the form's entry |
|
|
395
|
+
| nothing (the network failed) | `ultimate:navigation-error`, never re-sent; its default reloads the tab — the page and its modal both asked for again, since a hash alone is only scrolled to |
|
|
396
|
+
| anything else (a download, another principal) | as on any page — [What happens to the answer](#what-happens-to-the-answer) |
|
|
397
|
+
|
|
398
|
+
A `'modal'` page's form on its own full page is an ordinary page form: a re-render there is a
|
|
399
|
+
page, never a modal.
|
|
400
|
+
|
|
401
|
+
## Navigating from code
|
|
402
|
+
|
|
403
|
+
`As of 2026-10`. From an island or an app script, never by clicking a hidden link — imported from
|
|
404
|
+
`@ultimat3/render/client`, the package's browser entry:
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
import { closeModal, navigate, openModal, refresh, scheduleRefresh } from '@ultimat3/render/client';
|
|
408
|
+
|
|
409
|
+
await navigate('/runs/7'); // as a click on a link to it
|
|
410
|
+
await navigate('/runs?status=failed', { replace: true }); // the current entry replaced
|
|
411
|
+
await refresh(); // the page on screen, rendered again, where it is scrolled
|
|
412
|
+
await openModal('/runs/new'); // the `navigation: 'modal'` route over this page: /runs#/runs/new
|
|
413
|
+
closeModal(); // as Escape
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
The same entry exports `disposeIslands(root)`: what the router calls on the body it swaps out, for an
|
|
417
|
+
island that drops markup holding other islands (`As of 2026-10`, [Client data](Client-Data)).
|
|
418
|
+
|
|
419
|
+
| Call | With the router | Without it (a `site/` page, a `'document'` page, the router's script blocked) |
|
|
420
|
+
|---|---|---|
|
|
421
|
+
| `navigate(url, { replace? })` | the router's own navigation: the server's gate and the answer decide swap, modal or full load. Another origin: a document load | `location.assign` / `location.replace` |
|
|
422
|
+
| `refresh()` | the page fetched again (never from the prefetch cache) and swapped in with no history entry, at the visitor's scroll — for the same visitor: [below](#refresh-is-the-same-page). Under an open modal: nothing | `location.reload()` |
|
|
423
|
+
| `scheduleRefresh()` | a `refresh()` once the visitor is idle, coalesced: [below](#scheduling-a-refresh) | nothing — a reload under a reader is the failure it prevents |
|
|
424
|
+
| `openModal(path)` | `#<path>` over the page, pushed, so Back closes it. A path that is not a modal of this tab opens nothing, as a stale hash | `location.assign(path)`: the modal route's whole page |
|
|
425
|
+
| `closeModal()` | as Escape: Back through the router's own entry, else the hash dropped in place | nothing — no modal is open |
|
|
426
|
+
|
|
427
|
+
`openModal` takes the route's own path, as its link would (`'/runs/new'`, `'/runs/new?bank=ve'`); a
|
|
428
|
+
full URL, `//host` or a relative path is a rejected promise, `X_NAVIGATION_MODAL_PATH_INVALID` —
|
|
429
|
+
never a synchronous throw, so `.catch` sees it. On the server
|
|
430
|
+
every call does nothing. The functions import the router's TYPE only: an island that navigates
|
|
431
|
+
ships none of the router's bytes.
|
|
432
|
+
|
|
433
|
+
Import them from `/client`, never from `@ultimat3/render`: the barrel keeps render's whole error
|
|
434
|
+
table in any chunk that reaches it. An island whose body is `refresh()` is 216 B minified from
|
|
435
|
+
`/client` and was 9,323 B from the barrel (`packages/render/src/client-bundle.test.ts`, `As of
|
|
436
|
+
2026-10-08`). The barrel still exports them, for compatibility. The rejection is
|
|
437
|
+
`NavigationModalPathInvalidError`, one class from either entry, so `instanceof` holds whichever an
|
|
438
|
+
app imports; read `.code` when the two halves of an app may disagree on where they import from.
|
|
439
|
+
|
|
440
|
+
### `refresh()` is the same page
|
|
441
|
+
|
|
442
|
+
`As of 30.0.0`. Same page, same visitor, so none of a new page's arrival applies:
|
|
443
|
+
|
|
444
|
+
| | A navigation | `refresh()` |
|
|
445
|
+
|---|---|---|
|
|
446
|
+
| view transition | yes, unless reduced motion | none |
|
|
447
|
+
| focus | `<main>` | the control the visitor was in: by `id`, else `name` in the same form, else its row (`[data-row]`) + tag + place in the row. Restored after the page's scripts ran, so an island's control is there. Gone from the new page: `<main>`. Nothing focused: untouched |
|
|
448
|
+
| announcer | the new title | not written |
|
|
449
|
+
| a navigation already in flight | aborted by the newer one | kept: the refresh resolves and does nothing, since that navigation fetches fresh anyway |
|
|
450
|
+
| an open modal | — | nothing is fetched; the modal and the page beneath stay |
|
|
451
|
+
|
|
452
|
+
Proof: `packages/render/src/navigation-refresh.test.ts`, and in Chrome
|
|
453
|
+
`packages/cli/e2e/client-navigation-refresh.e2e.test.ts`.
|
|
454
|
+
|
|
455
|
+
### Scheduling a refresh
|
|
456
|
+
|
|
457
|
+
`scheduleRefresh()` is "a channel said the page changed", not "re-read now". One runs, one is owed;
|
|
458
|
+
any number of calls while one runs owe exactly one more. It is held — owed, paid later — while:
|
|
459
|
+
|
|
460
|
+
| Held while | Paid |
|
|
461
|
+
|---|---|
|
|
462
|
+
| the tab is hidden | on show |
|
|
463
|
+
| a `dialog[open]` or an open popover is on the page | once it closes (asked again every second) |
|
|
464
|
+
| focus is in an `input`, `textarea` or `[contenteditable]`, or text is selected | once it leaves |
|
|
465
|
+
| a navigation runs (`data-x-navigating`) | after it |
|
|
466
|
+
| within `QUIET_MS` (1 s) of a scroll, wheel, key or press | 1 s after the last one |
|
|
467
|
+
| a press is down | on release, or 10 s after it (a lost `pointerup`) |
|
|
468
|
+
|
|
469
|
+
After a lost session (`sessionIsLost()`, [Client data](Client-Data)) it does nothing, ever. Without
|
|
470
|
+
the router it does nothing — the difference from `refresh()`. Its state is on `window` under
|
|
471
|
+
`Symbol.for('ultimate.refresh-idle')`, so every island chunk shares one. The channel-to-refresh
|
|
472
|
+
idiom is in [Realtime](Realtime#a-channel-event-re-reads-the-page).
|
|
473
|
+
|
|
474
|
+
## Events
|
|
475
|
+
|
|
476
|
+
Dispatched on `document`:
|
|
477
|
+
|
|
478
|
+
| Event | When | `detail` |
|
|
479
|
+
|---|---|---|
|
|
480
|
+
| `ultimate:navigate` | before the fetch. **Cancelable**: `preventDefault()` keeps the tab where it is | `{ url, method }` |
|
|
481
|
+
| `ultimate:navigated` | after the swap, the scroll and the new scripts — and after a modal opens, its `url` the modal's | `{ url }` |
|
|
482
|
+
| `ultimate:navigation-error` | a POST that failed or could not be shown, a swap that failed on a POST. **Cancelable**: the default is a GET of the current page | `{ url, method, reason }` |
|
|
483
|
+
|
|
484
|
+
The three event names are on `@ultimat3/render/client` too — what an island listens with. The event
|
|
485
|
+
names and attributes are exported from `@ultimat3/render` (`NAVIGATE_EVENT`,
|
|
486
|
+
`NAVIGATED_EVENT`, `NAVIGATION_ERROR_EVENT`, `NAVIGATION_RELOAD_ATTRIBUTE`,
|
|
487
|
+
`NAVIGATION_NO_PREFETCH_ATTRIBUTE`, `NAVIGATION_PERSIST_ATTRIBUTE`, `NAVIGATING_ATTRIBUTE`,
|
|
488
|
+
`NAVIGATION_PRESENTATION_META`, `NAVIGATION_MODAL_ATTRIBUTE`, `NAVIGATION_MODALS_META`,
|
|
489
|
+
`NAVIGATION_MODAL_BODY_ATTRIBUTE`, `NAVIGATION_MODAL_CLOSE_ATTRIBUTE`,
|
|
490
|
+
`NAVIGATION_MODAL_SKELETON_ATTRIBUTE`), with the pure rules the router runs
|
|
491
|
+
(`linkVerdict`, `formVerdict`, `responseVerdict`, `reusable`, `mayPrefetch`, `modalAddress`,
|
|
492
|
+
`presentation`, `modalHistory`, `leaveModal`, `declaresModal`, `parseModalRoutes`), the server's
|
|
493
|
+
`modalRouteList`, and the declaration's readers (`isRouteNavigation`, `navigationModeOf`,
|
|
494
|
+
`navigationPrefetches`). The headers are `@ultimat3/core`'s, imported from there only
|
|
495
|
+
(`CLIENT_NAVIGATION_HEADER`, `CLIENT_NAVIGATION_LOCATION_HEADER`, `CLIENT_NAVIGATION_SCOPE_HEADER`,
|
|
496
|
+
`CLIENT_NAVIGATION_SURFACE_HEADER`) — render's `NAVIGATION_*_HEADER` aliases were deleted in 25.0.0. The server half is
|
|
497
|
+
`@ultimat3/http`'s `navigationGate`, `redirectForRouter` and `relocate`.
|
|
498
|
+
|
|
499
|
+
## Tested where
|
|
500
|
+
|
|
501
|
+
| What | Where |
|
|
502
|
+
|---|---|
|
|
503
|
+
| every client rule, as pure functions | `packages/render/src/navigation-rules.test.ts`, `navigation-cache.test.ts`, `navigation-dom.test.ts`, `route-navigation.test.ts`, `navigation-modal-rules.test.ts` |
|
|
504
|
+
| a `'modal'` page: never prefetched, one document for a soft and a full load, the meta it carries; `{ modal: true, prefetch: 'intent' }` prefetched; the `ultimate-modals` meta, its shadows and its words | `packages/cli/src/page-navigation.test.ts`, `packages/render/src/navigation-tags.test.ts` |
|
|
505
|
+
| the frame: open on the click, filled by the answer, aborted on close, taken down by a non-modal answer, opened without `showModal()` | `packages/render/src/navigation-modal-frame.test.ts` |
|
|
506
|
+
| modals in a real Chrome: open over a live page, the frame in the click's own task, Escape while it loads, a prefetched modal, Escape/Back/Cancel, reload and pasted hash, stale hash, nesting, a refused and a redirected submit, a confirmation, `openModal`/`closeModal`/`refresh` from code, scripting off | `packages/cli/e2e/client-navigation-modal.e2e.test.ts` |
|
|
507
|
+
| navigating from code: through the router, the browser's fallbacks, the server, `openModal`'s refusal | `packages/render/src/navigation-api.test.ts` |
|
|
508
|
+
| the server gate and the redirect hand-over, counting handler runs | `packages/http/src/navigation.test.ts` |
|
|
509
|
+
| speculation rules: which pages are candidates, the tag on a router-less document only, the CSP hash of the served bytes | `packages/cli/src/page-speculation.test.ts`, `packages/render/src/speculation-rules.test.ts` |
|
|
510
|
+
| real pages: prefetch and `'document'` run no `load`, another principal or app is refused, a misplaced key refuses the boot | `packages/cli/src/page-navigation.test.ts` |
|
|
511
|
+
| writes announced after they settle | `packages/core/src/client-writes.test.ts` |
|
|
512
|
+
| in a real Chrome, through the real pipeline under an enforced CSP: every rule above, counted in route executions | `packages/cli/e2e/client-navigation-*.e2e.test.ts` |
|
|
513
|
+
| the reference app, opted in: real pages, sign-out, a like after a round trip, the editor as a modal over the feed, scripting off | `examples/dummy/apps/web/e2e/client-navigation.e2e.test.ts` |
|
package/wiki/Compared.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Compared
|
|
2
|
+
|
|
3
|
+
Each stack this framework gets compared to, **"pick it instead when" first**. A row in this
|
|
4
|
+
framework's favour cites a command, a file or a page you can check; a claim about another stack
|
|
5
|
+
links that stack's own documentation. `As of 2026-11`. No head-to-head chart: a vendor's numbers
|
|
6
|
+
against its rivals convince nobody, so this page publishes its own figures and the harness, and you
|
|
7
|
+
point the harness at yours.
|
|
8
|
+
|
|
9
|
+
## What you give up here, whichever stack you come from
|
|
10
|
+
|
|
11
|
+
| You give up | Where it is stated |
|
|
12
|
+
|---|---|
|
|
13
|
+
| any runtime but Bun | `non_goals` in `.dz/maintainer/maintainer.yml`; [FAQ](FAQ) |
|
|
14
|
+
| any database but Postgres | the same list; [Home](Home) |
|
|
15
|
+
| any renderer but SolidJS | the same list; [Routes and render modes](Routes-And-Render-Modes) |
|
|
16
|
+
| a long track record: released in 2026, no outside production adopter claimed | [FAQ](FAQ) |
|
|
17
|
+
| a slow major cadence: from v30, one major per 90 days | [Stability](Stability) |
|
|
18
|
+
| a third-party security audit: there has been none | [`SECURITY.md`](https://github.com/developerz-ai/ultimate/blob/main/SECURITY.md) |
|
|
19
|
+
| a plugin API: an app wraps a primitive instead | [Home](Home) |
|
|
20
|
+
| a serverless or edge target: containers only | [Deployment](Deployment) |
|
|
21
|
+
| everything on the open list | [Known gaps](Known-Gaps) |
|
|
22
|
+
|
|
23
|
+
## Measured, and how to reproduce it
|
|
24
|
+
|
|
25
|
+
One local run on an 8-core laptop, `As of 2026-11-06`, not yet a CI figure; the method, the box and
|
|
26
|
+
the caveats are [`scripts/bench/results/README.md`](https://github.com/developerz-ai/ultimate/blob/main/scripts/bench/results/README.md),
|
|
27
|
+
and every number below is held to the committed JSON by `bun run scripts/bench-claims.ts`
|
|
28
|
+
(`X_BENCH_CLAIM_STALE`).
|
|
29
|
+
|
|
30
|
+
| Figure | Measured |
|
|
31
|
+
|---|---|
|
|
32
|
+
| scaffold, published `create-ultimate` (29.4.0) | 4.7 s |
|
|
33
|
+
| `bun run setup`, cold Bun cache | 43.3 s |
|
|
34
|
+
| `node_modules` after setup | 165 MB |
|
|
35
|
+
| first `bun run verify` | **red on 29.4.0**: `budgets` was `X_BUDGET_UNMEASURED` until something built; the step builds first on `main` |
|
|
36
|
+
| `/` under `x dev`, one client, concurrency 16 | median 267 req/s, p99 181.85 ms |
|
|
37
|
+
| a typed read, `/_x/query/post-list` | median 183 req/s, p99 258.26 ms |
|
|
38
|
+
| cold start to the first 2xx (`x dev`) | 4.8 s |
|
|
39
|
+
|
|
40
|
+
`x dev` is the development server — every role and an embedded Postgres in one process — so these
|
|
41
|
+
are a laptop's first-hour numbers, not a production capacity claim.
|
|
42
|
+
|
|
43
|
+
Reproduce on any machine with Bun and a clone of this repository:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
bun run scripts/bench/first-hour.ts --runs 5 --json
|
|
47
|
+
bun run scripts/bench/http-hello.ts --url http://localhost:3000/ --seconds 30 --runs 5 --start 'bunx x dev' --cwd <the work directory first-hour printed>/firsthour
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`http-hello.ts --url` loads any URL, so point it at your own app on any stack and compare like with
|
|
51
|
+
like. It refuses a run under 10 s (`X_BENCH_RUN_TOO_SHORT`), and a non-2xx answer or a server that
|
|
52
|
+
dies mid-run is `X_BENCH_RUN_FAILED`, never a figure. `.github/workflows/bench.yml` re-measures weekly
|
|
53
|
+
and on every release, on a free `ubuntu-latest` runner, and opens a pull request with the JSON.
|
|
54
|
+
|
|
55
|
+
## Rails
|
|
56
|
+
|
|
57
|
+
| | |
|
|
58
|
+
|---|---|
|
|
59
|
+
| **Pick Rails instead when** | your team writes Ruby; you need MySQL or SQLite as well as Postgres ([Active Record](https://guides.rubyonrails.org/active_record_basics.html)); you want two decades of gems, hosts and hiring; you want server-rendered HTML with Hotwire and no client build |
|
|
60
|
+
| **Pick this when** | the primary author is an agent: one declaration projects HTTP, OpenAPI, a typed client and an MCP tool ([Actions](Actions)), and `x verify` is one green-or-red answer over 21 steps (`VERIFY_STEP_NAMES`, `packages/cli/src/verify-step.ts`); you want types end to end with no codegen step ([Client data](Client-Data)) |
|
|
61
|
+
| **What you give up** | the table above, and Rails' ecosystem. The concept map: [Migrating from Rails](Migrating-From-Rails) |
|
|
62
|
+
|
|
63
|
+
## Next.js
|
|
64
|
+
|
|
65
|
+
| | |
|
|
66
|
+
|---|---|
|
|
67
|
+
| **Pick Next.js instead when** | your team and component library are React; you deploy to a serverless or edge platform ([Next.js deploying](https://nextjs.org/docs/app/getting-started/deploying)); you want the largest ecosystem of the five |
|
|
68
|
+
| **Pick this when** | you want jobs, realtime, authz, mail, storage and an admin in the box rather than chosen per project ([Jobs and workflows](Jobs-And-Workflows), [Realtime](Realtime), [Policies and authz](Policies-And-Authz), [Admin dashboard](Admin-Dashboard)); you want a page's JavaScript budget to fail the build (`budget: { js: '0kb' }`, [Routes and render modes](Routes-And-Render-Modes)) |
|
|
69
|
+
| **What you give up** | React, and the platform features this framework will not use. The concept map for a Node app: [Migrating from Node and Drizzle](Migrating-From-Node-Drizzle) |
|
|
70
|
+
|
|
71
|
+
## Meteor
|
|
72
|
+
|
|
73
|
+
| | |
|
|
74
|
+
|---|---|
|
|
75
|
+
| **Pick Meteor instead when** | your data is in MongoDB, Meteor's primary database ([Meteor docs](https://docs.meteor.com/)); you already run a Meteor app |
|
|
76
|
+
| **Pick this when** | you want realtime that reuses the one authz system per delivered row: a live query is a `query` with its `policy` ([Queries and live queries](Queries-And-Live-Queries)); you want Postgres and SQL you can read (`postgresDriver()`, `packages/entity/src/pg-driver.ts`) |
|
|
77
|
+
| **What you give up** | MongoDB, and Meteor's package ecosystem |
|
|
78
|
+
|
|
79
|
+
## Phoenix
|
|
80
|
+
|
|
81
|
+
| | |
|
|
82
|
+
|---|---|
|
|
83
|
+
| **Pick Phoenix instead when** | you want the BEAM: lightweight processes, supervision and clustering ([Phoenix docs](https://hexdocs.pm/phoenix/overview.html)); you want LiveView's server-held state over a socket |
|
|
84
|
+
| **Pick this when** | you want TypeScript on both sides with one schema per declaration ([Actions](Actions)); you want durable step workflows as a primitive rather than a library choice ([Jobs and workflows](Jobs-And-Workflows)) |
|
|
85
|
+
| **What you give up** | the BEAM's concurrency model and a long-proven runtime. Realtime capacity here is measured on one node: `scripts/bench/results/README.md` |
|
|
86
|
+
|
|
87
|
+
## Inngest and Zero
|
|
88
|
+
|
|
89
|
+
| | |
|
|
90
|
+
|---|---|
|
|
91
|
+
| **Pick them instead when** | you keep the framework you have and add durable functions ([Inngest docs](https://www.inngest.com/docs)) or a sync engine ([Zero docs](https://zero.rocicorp.dev/docs/introduction)) to it |
|
|
92
|
+
| **Pick this when** | you want their shapes — a `job` with steps, a `mutator` — inside one runtime under one authz system, with nothing to operate beside Postgres ([The eight primitives](The-Eight-Primitives)) |
|
|
93
|
+
| **What you give up** | keeping your current framework |
|