@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
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
import { logger } from '@ultimat3/core';
|
|
6
6
|
import type { RedirectIntent, RequestContext, RouteParams } from '@ultimat3/http';
|
|
7
|
-
import { asCtx, requestAddress, takeRedirect } from '@ultimat3/http';
|
|
7
|
+
import { asCtx, requestAddress, takeRedirect, withAnonymousRequest } from '@ultimat3/http';
|
|
8
8
|
import type { RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
|
|
9
9
|
import { describePages, routeDataFor, routeNoStoreOf, routeStatusOf } from '@ultimat3/render';
|
|
10
10
|
import type { IsrController, IsrEntry } from '@ultimat3/render/server';
|
|
11
|
-
import { isrKey, isrRequestUrl, isrRouteFor
|
|
11
|
+
import { isrKey, isrRequestUrl, isrRouteFor } from '@ultimat3/render/server';
|
|
12
12
|
|
|
13
13
|
export type IsrOutcome =
|
|
14
14
|
| { readonly kind: 'page'; readonly result: RenderResult }
|
|
@@ -23,47 +23,83 @@ export interface IsrRequest {
|
|
|
23
23
|
readonly data: IsrData;
|
|
24
24
|
readonly ctx: RequestContext;
|
|
25
25
|
readonly isr: IsrController;
|
|
26
|
+
/** The app's declared public origin (`publicOrigin()`), or nothing: then the request's is keyed. */
|
|
27
|
+
readonly origin: string | undefined;
|
|
26
28
|
/**
|
|
27
|
-
* The route's whole document for what `load` returned (`runtime-render.ts`'s `documentFrom`),
|
|
29
|
+
* The route's whole document for what `load` returned (`runtime-render-document.ts`'s `documentFrom`),
|
|
28
30
|
* rendered AT the data this file hands it — the narrowed URL, never the request's own.
|
|
29
31
|
*/
|
|
30
32
|
readonly document: (loaded: RouteData, at: IsrData) => Promise<string>;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
/** Route files already told, once, about a request whose query they never declared. */
|
|
34
|
-
const toldUndeclared = new Set<string>();
|
|
35
|
-
|
|
36
|
-
/** Names are the visitor's: a few, cut short, is all a log line needs to name the edit. */
|
|
37
|
-
const NAMED_PARAMS = 5;
|
|
38
|
-
const NAMED_PARAM_CHARS = 40;
|
|
39
|
-
|
|
40
35
|
/**
|
|
41
36
|
* The request narrowed to what the route declared it varies on (`revalidate.query`), AT the one
|
|
42
37
|
* spelling of its address (`pathname`). ONE narrowing feeds the store key, `load`, `meta` and the
|
|
43
38
|
* page, so neither a parameter outside the key nor the way a visitor happened to spell the path
|
|
44
39
|
* (`/blog//a/`, `/blog/%61`) can reach the document stored under it.
|
|
45
40
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
41
|
+
* The origin is the same narrowing: a declared one replaces the request's before the key, so `load`
|
|
42
|
+
* and the page are handed the address the document's own canonical names, whatever `Host` arrived.
|
|
48
43
|
*/
|
|
49
|
-
function keyedData(
|
|
44
|
+
function keyedData(
|
|
45
|
+
entry: RouteEntry,
|
|
46
|
+
data: IsrData,
|
|
47
|
+
pathname: string | undefined,
|
|
48
|
+
origin: URL | undefined,
|
|
49
|
+
): IsrData {
|
|
50
50
|
const url = new URL(data.url);
|
|
51
51
|
if (pathname !== undefined) url.pathname = pathname;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
route: entry.file,
|
|
59
|
-
params: minted.slice(0, NAMED_PARAMS).map((name) => name.slice(0, NAMED_PARAM_CHARS)),
|
|
60
|
-
cause:
|
|
61
|
-
'the route declares no revalidate.query, so every distinct query string is rendered and stored as its own page',
|
|
62
|
-
fix: `revalidate: { query: [] } // in ${entry.file}; or query: ['<name>'] for each parameter the page varies on`,
|
|
63
|
-
});
|
|
64
|
-
}
|
|
52
|
+
if (origin !== undefined) {
|
|
53
|
+
url.protocol = origin.protocol;
|
|
54
|
+
url.hostname = origin.hostname;
|
|
55
|
+
// `port` and not `host`: a `host` with no port leaves the request's, so `evil.test:8443` named
|
|
56
|
+
// itself in the document after all.
|
|
57
|
+
url.port = origin.port;
|
|
65
58
|
}
|
|
66
|
-
return { ...data, url: isrRequestUrl(url,
|
|
59
|
+
return { ...data, url: isrRequestUrl(url, entry.config.revalidate?.query ?? null).href };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The declared origin, when it is one: http or https. `APP_URL` is an operator's string and nothing
|
|
64
|
+
* has read it as a URL by here. One that does not parse would be a 500 on every `isr` page, and
|
|
65
|
+
* `localhost:3000` parses with `localhost:` as its scheme, where URL setters do nothing and every
|
|
66
|
+
* host would share one key. Both are keyed as undeclared: against the request, as the head falls back.
|
|
67
|
+
*/
|
|
68
|
+
function declaredOrigin(origin: string | undefined): URL | undefined {
|
|
69
|
+
if (origin === undefined || !URL.canParse(origin)) return undefined;
|
|
70
|
+
const parsed = new URL(origin);
|
|
71
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* How many request origins one controller stores pages for when the app declares none. A `Host` is
|
|
76
|
+
* the visitor's to choose, so each new one would be a render and an entry pushing a real page out
|
|
77
|
+
* of the bounded store. Past the cap a NEW origin is refused rather than anything evicted, as
|
|
78
|
+
* `static-document.ts`'s memo does: answered, its own canonical, never stored.
|
|
79
|
+
*/
|
|
80
|
+
export const ISR_MAX_REQUEST_ORIGINS = 16;
|
|
81
|
+
|
|
82
|
+
/** Per controller, so a `stop` that drops the store drops what it had admitted. */
|
|
83
|
+
const admittedOrigins = new WeakMap<IsrController, Set<string>>();
|
|
84
|
+
|
|
85
|
+
function admitsOrigin(isr: IsrController, origin: string, route: string): boolean {
|
|
86
|
+
const admitted = admittedOrigins.get(isr) ?? new Set<string>();
|
|
87
|
+
admittedOrigins.set(isr, admitted);
|
|
88
|
+
if (admitted.has(origin)) return true;
|
|
89
|
+
if (admitted.size < ISR_MAX_REQUEST_ORIGINS) {
|
|
90
|
+
admitted.add(origin);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
if (admitted.size === ISR_MAX_REQUEST_ORIGINS) {
|
|
94
|
+
// Told once: a sentinel past the cap, which nothing else ever reads as an origin.
|
|
95
|
+
admitted.add('');
|
|
96
|
+
logger.warn('isr.origin.over_cap', {
|
|
97
|
+
route,
|
|
98
|
+
cause: `this process has stored pages for ${ISR_MAX_REQUEST_ORIGINS} request origins and the app declares none, so a page for any other Host is answered uncached`,
|
|
99
|
+
fix: 'APP_URL=https://your.domain # or site: { origin } in app.config.ts',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
67
103
|
}
|
|
68
104
|
|
|
69
105
|
/**
|
|
@@ -94,50 +130,61 @@ function dropPage(isr: IsrController, key: string): void {
|
|
|
94
130
|
* inside the producer, which runs on a miss and on a stale page's regeneration.
|
|
95
131
|
*
|
|
96
132
|
* Concurrent misses on one key share ONE producer run, and only a PAGE may be shared. A redirect is
|
|
97
|
-
*
|
|
133
|
+
* never stored, so it stays the decision of the render that made it: a request that joined a run whose load
|
|
98
134
|
* redirected decides for itself, exactly as the static single-flight in `appRoutes` does.
|
|
135
|
+
*
|
|
136
|
+
* `load` and the document run as nobody, on every path (`withAnonymousRequest`): what is stored is
|
|
137
|
+
* answered to every later visitor, so it may not hold what this one's identity could read.
|
|
99
138
|
*/
|
|
100
139
|
export async function isrOutcome(arrived: IsrRequest): Promise<IsrOutcome> {
|
|
101
140
|
// Where the router MATCHED this request, never where the visitor wrote it: the router answers
|
|
102
141
|
// many spellings with one page, and a key built from the raw pathname stored each of them.
|
|
103
142
|
const address = requestAddress(arrived.ctx);
|
|
143
|
+
const origin = declaredOrigin(arrived.origin);
|
|
104
144
|
const request: IsrRequest = {
|
|
105
145
|
...arrived,
|
|
106
|
-
data: keyedData(arrived.entry, arrived.data, address?.pathname),
|
|
146
|
+
data: keyedData(arrived.entry, arrived.data, address?.pathname, origin),
|
|
107
147
|
};
|
|
108
148
|
const { entry, data, ctx, isr } = request;
|
|
109
|
-
// `isrKey(url, locale)`, never `url.pathname`: the DECLARED query is part of what was rendered —
|
|
149
|
+
// `isrKey(url, locale, origin)`, never `url.pathname`: the DECLARED query is part of what was rendered —
|
|
110
150
|
// `meta` reads `data.url` — so two URLs differing in it are two documents (#171). The locale is
|
|
111
151
|
// `ctx.locale`, the one the `locale` stage negotiated for THIS request. The key's path carries
|
|
112
152
|
// NO base path: the route table has never heard of the mount, so a key that kept it matched no
|
|
113
153
|
// route — and every `isr` page of an app under a `basePath` was stored with no ttl and no tags.
|
|
114
154
|
const keyed = new URL(data.url);
|
|
115
155
|
if (address !== undefined) keyed.pathname = address.path;
|
|
116
|
-
|
|
156
|
+
// No origin declared: the document's canonical is absolute against the request's, so a foreign
|
|
157
|
+
// `Host` is its own entry and never names itself in the page the next visitor gets.
|
|
158
|
+
const key = isrKey(keyed, asCtx(ctx).locale, origin === undefined ? keyed.origin : undefined);
|
|
117
159
|
// The key has to read back as THIS route, or its ttl, its tags and what a bust does to it are
|
|
118
160
|
// another route's or nobody's. It always does for an address the framework's URL scheme can
|
|
119
161
|
// spell; what is left is a path the router matched that another reading claims (`//en/x` is the
|
|
120
162
|
// `/:lang/x` route with no locale, and `/en/x` is the `/x` route in English). Answered, uncached.
|
|
121
163
|
if (isrRouteFor(describePages(), key)?.file !== entry.file) return ownOutcome(request);
|
|
164
|
+
if (origin === undefined && !admitsOrigin(isr, keyed.origin, entry.file)) {
|
|
165
|
+
return ownOutcome(request);
|
|
166
|
+
}
|
|
122
167
|
let decided: RedirectIntent | undefined;
|
|
123
168
|
// `{ html, status }`, never the bare string: the entry stores the status beside the HTML and
|
|
124
169
|
// serves it on every hit, so a 404 under `isr` is a 404 for its whole TTL.
|
|
125
|
-
const served = await isr.serve(key,
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
170
|
+
const served = await isr.serve(key, () =>
|
|
171
|
+
withAnonymousRequest(ctx, new URL(data.url), async (scope) => {
|
|
172
|
+
const loaded = await routeDataFor(entry.config, data);
|
|
173
|
+
const to = takeRedirect(scope);
|
|
174
|
+
if (to === undefined) {
|
|
175
|
+
return {
|
|
176
|
+
html: await request.document(loaded, data),
|
|
177
|
+
status: routeStatusOf(loaded),
|
|
178
|
+
noStore: routeNoStoreOf(loaded),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
// Set by THIS request's producer only. On a stale page it runs behind the answer, and when it
|
|
182
|
+
// has already decided by the time `serve` returns, the redirect is still this request's own.
|
|
183
|
+
decided = to;
|
|
184
|
+
dropPage(isr, key);
|
|
185
|
+
return { html: '', status: to.status };
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
141
188
|
if (decided !== undefined) return { kind: 'redirect', to: decided };
|
|
142
189
|
if (!isRedirectMarker(served.entry)) return { kind: 'page', result: served.result };
|
|
143
190
|
return ownOutcome(request);
|
|
@@ -146,19 +193,26 @@ export async function isrOutcome(arrived: IsrRequest): Promise<IsrOutcome> {
|
|
|
146
193
|
/**
|
|
147
194
|
* A request answered for itself alone: a joiner whose leader's load redirected, or one whose key
|
|
148
195
|
* is not its own route's. Its own `load`; a page here is NOT stored — the store was just told the
|
|
149
|
-
* key has none, or the key is another page's — so it is `private, no-store`.
|
|
196
|
+
* key has none, or the key is another page's — so it is `private, no-store`. Still as nobody: not
|
|
197
|
+
* to protect the store, but because one route renders one way, whichever path a request took.
|
|
150
198
|
*/
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
199
|
+
function ownOutcome(request: IsrRequest): Promise<IsrOutcome> {
|
|
200
|
+
return withAnonymousRequest(
|
|
201
|
+
request.ctx,
|
|
202
|
+
new URL(request.data.url),
|
|
203
|
+
async (scope): Promise<IsrOutcome> => {
|
|
204
|
+
const loaded = await routeDataFor(request.entry.config, request.data);
|
|
205
|
+
const to = takeRedirect(scope);
|
|
206
|
+
if (to !== undefined) return { kind: 'redirect', to };
|
|
207
|
+
const body = await request.document(loaded, request.data);
|
|
208
|
+
return {
|
|
209
|
+
kind: 'page',
|
|
210
|
+
result: {
|
|
211
|
+
status: routeStatusOf(loaded),
|
|
212
|
+
headers: { 'cache-control': 'private, no-store' },
|
|
213
|
+
body,
|
|
214
|
+
},
|
|
215
|
+
};
|
|
162
216
|
},
|
|
163
|
-
|
|
217
|
+
);
|
|
164
218
|
}
|
package/src/runtime-purge.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The retention sweep this boot owns: the framework tables that grow with traffic, the `purge()`
|
|
2
2
|
// job that empties them and the `task` that fires it hourly.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
// table, `x_notify_deliveries`, `x_notify_inbox`, `x_notify_digests`, `x_job_events`, `x_outbox`
|
|
4
|
+
// TEN TARGETS, `As of 2026-11-02` — `x_idempotency`, `x_rate_limit`, the `x_auth` limiter's
|
|
5
|
+
// table, `x_totp_steps`, `x_notify_deliveries`, `x_notify_inbox`, `x_notify_digests`, `x_job_events`, `x_outbox`
|
|
6
6
|
// (published rows) and `x_jobs` (finished rows, with their `x_job_steps`). Counted nowhere in prose but here: this header said
|
|
7
7
|
// "three" for two releases after the notify tables joined the boot's DDL, which is how
|
|
8
8
|
// `x_notify_inbox` became the one framework table nothing swept — and `x_jobs` was the next one.
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// per source address forever.
|
|
20
20
|
|
|
21
21
|
import type { PostgresIdempotencyStore } from '@ultimat3/action';
|
|
22
|
+
import type { PostgresTotpReplayGuard } from '@ultimat3/auth';
|
|
22
23
|
import { purgeAuthLimits } from '@ultimat3/auth';
|
|
23
24
|
import type { PostgresRateLimitStore } from '@ultimat3/http';
|
|
24
25
|
import type { EventBus, JobHandle, PurgeInput, PurgeTarget } from '@ultimat3/jobs';
|
|
@@ -52,6 +53,12 @@ export const PURGE_TASK_NAME = 'x.purge.hourly';
|
|
|
52
53
|
export interface RetentionStores {
|
|
53
54
|
readonly idempotency: PostgresIdempotencyStore;
|
|
54
55
|
readonly rateLimit: PostgresRateLimitStore;
|
|
56
|
+
/**
|
|
57
|
+
* The guard this boot installed through `configureTotpReplay`. Unlike the limiter it takes
|
|
58
|
+
* nothing from the app, so it exists here. REQUIRED, or `x_totp_steps` keeps a row per second
|
|
59
|
+
* factor for the life of the deployment.
|
|
60
|
+
*/
|
|
61
|
+
readonly totpReplay: PostgresTotpReplayGuard;
|
|
55
62
|
/**
|
|
56
63
|
* The bus this boot installed for `step.waitForEvent`. REQUIRED: `x_job_events` takes a row per
|
|
57
64
|
* publish and `find` only filters an expired one, so a boot that left it out is the table
|
|
@@ -149,6 +156,11 @@ function retentionTargets(stores: RetentionStores): readonly PurgeTarget[] {
|
|
|
149
156
|
purgeExpired: (nowMs: number): Promise<number> => stores.rateLimit.purgeExpired(nowMs),
|
|
150
157
|
},
|
|
151
158
|
authTarget,
|
|
159
|
+
{
|
|
160
|
+
name: 'x_totp_steps',
|
|
161
|
+
// The job's clock: `verifyTotp` derived every step in that table from a process's clock.
|
|
162
|
+
purgeExpired: (nowMs: number): Promise<number> => stores.totpReplay.purgeExpired(nowMs),
|
|
163
|
+
},
|
|
152
164
|
...notifyTargets(stores.inboxRetention ?? NO_INBOX_RETENTION),
|
|
153
165
|
// Its own clock, and so no `nowMs`: every instant in that table is the database's.
|
|
154
166
|
eventsPurgeTarget(stores.events),
|
package/src/runtime-queue.ts
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
import {
|
|
8
8
|
type PostgresIdempotencyStore,
|
|
9
9
|
postgresIdempotencyStore,
|
|
10
|
+
postgresMeterStore,
|
|
10
11
|
requestDeadlineMs,
|
|
11
12
|
resetIdempotency,
|
|
13
|
+
resetMeterStore,
|
|
12
14
|
setIdempotencyStore,
|
|
15
|
+
setMeterStore,
|
|
13
16
|
} from '@ultimat3/action';
|
|
14
17
|
import type { PgliteClient, PostgresClient } from '@ultimat3/db';
|
|
15
18
|
import {
|
|
@@ -18,6 +21,8 @@ import {
|
|
|
18
21
|
dbExecutor,
|
|
19
22
|
pgliteClient,
|
|
20
23
|
pgliteDataDir,
|
|
24
|
+
poolerFromEnv,
|
|
25
|
+
poolerSessionRequired,
|
|
21
26
|
poolMaxFromEnv,
|
|
22
27
|
postgresClient,
|
|
23
28
|
setDbClient,
|
|
@@ -111,7 +116,7 @@ export function startDb(services: DevServices, env: ReplicaEnv): StartedDb {
|
|
|
111
116
|
dataDir: pgliteDataDir(binding.url),
|
|
112
117
|
extensions: () => appExtensions(services.root),
|
|
113
118
|
})
|
|
114
|
-
: postgresClient({ url: binding.url, profile });
|
|
119
|
+
: postgresClient({ url: binding.url, profile, env });
|
|
115
120
|
const attached = attachReplica(client, replicaUrlFor(binding, env), profile);
|
|
116
121
|
setDbClient(attached.client);
|
|
117
122
|
return { client, replica: attached.replica };
|
|
@@ -182,8 +187,8 @@ async function startJobs(
|
|
|
182
187
|
schema: SchemaMode,
|
|
183
188
|
): Promise<RunningQueue> {
|
|
184
189
|
await applySchema(client, schema);
|
|
185
|
-
// Bound to `client`, never the ambient `db()`: the queue
|
|
186
|
-
//
|
|
190
|
+
// Bound to `client`, never the ambient `db()`: the queue and the idempotency reservations stay
|
|
191
|
+
// on the boot's pool whatever transaction is open.
|
|
187
192
|
const executor = dbExecutor(() => client);
|
|
188
193
|
const driver = overrides?.jobs ?? postgresJobDriver({ executor });
|
|
189
194
|
setJobDriver(driver);
|
|
@@ -195,7 +200,16 @@ async function startJobs(
|
|
|
195
200
|
setJobsFacade(
|
|
196
201
|
outboxJobsFacade({ store: outbox, driver }, () => currentTx() as unknown as Tx | undefined),
|
|
197
202
|
);
|
|
198
|
-
|
|
203
|
+
// The open transaction's connection, as the outbox beside it: an answer belongs to the unit of
|
|
204
|
+
// work that gave it, so a refusal that rolls back never resumes the run waiting on it.
|
|
205
|
+
//
|
|
206
|
+
// `wakes` only when the queue in hand is the boot's own `postgresJobDriver`: the wake is an update
|
|
207
|
+
// of `x_jobs` on this database, and an override driver (Redis) keeps no rows here — its waiters
|
|
208
|
+
// keep the 30 s poll. Waking, a parked wait's backstop is 15 min instead.
|
|
209
|
+
const events = postgresEventBus({
|
|
210
|
+
executor: dbExecutor(() => currentTx() ?? client),
|
|
211
|
+
wakes: overrides?.jobs === undefined,
|
|
212
|
+
});
|
|
199
213
|
setEventBus(events);
|
|
200
214
|
const idempotency = postgresIdempotencyStore({
|
|
201
215
|
executor,
|
|
@@ -206,6 +220,9 @@ async function startJobs(
|
|
|
206
220
|
reclaimAfterMs: requestDeadlineMs,
|
|
207
221
|
});
|
|
208
222
|
setIdempotencyStore(idempotency);
|
|
223
|
+
// `meter:` counters on the boot's pool, never the open transaction: a draw is the caller's
|
|
224
|
+
// whether or not the business write commits, and one tenant's day is one row on every replica.
|
|
225
|
+
setMeterStore(postgresMeterStore({ executor }));
|
|
209
226
|
return {
|
|
210
227
|
db: client,
|
|
211
228
|
jobs: driver,
|
|
@@ -234,6 +251,7 @@ async function releaseQueue(
|
|
|
234
251
|
// The idempotency store goes back to the memory default for the same reason the facade does: it
|
|
235
252
|
// holds this client, and the next command in this process would reserve keys over a closed one.
|
|
236
253
|
resetIdempotency();
|
|
254
|
+
resetMeterStore();
|
|
237
255
|
resetJobsFacade();
|
|
238
256
|
resetEventBus();
|
|
239
257
|
resetJobDriver();
|
|
@@ -266,6 +284,15 @@ export async function startQueue(
|
|
|
266
284
|
*/
|
|
267
285
|
schema: SchemaMode = 'ensure',
|
|
268
286
|
): Promise<RunningQueue> {
|
|
287
|
+
// `x db migrate` runs as whatever ROLE the shell has, so `postgresClient`'s role check cannot see
|
|
288
|
+
// it: what needs the session is the step that takes the migration lock, whichever role asked.
|
|
289
|
+
if (
|
|
290
|
+
schema === 'apply' &&
|
|
291
|
+
services.db.mode !== 'embedded' &&
|
|
292
|
+
poolerFromEnv(env) === 'transaction'
|
|
293
|
+
) {
|
|
294
|
+
throw poolerSessionRequired('a step that applies migrations');
|
|
295
|
+
}
|
|
269
296
|
const { client: db, replica } = startDb(services, env);
|
|
270
297
|
try {
|
|
271
298
|
// Pay the Postgres boot here, so the first request is not the slow one and a broken database
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// One route's document: the head a route and the app decide together, the body inside the
|
|
2
|
+
// hydration root, and the assembly of the two. Split from `runtime-render.ts`, which owns picking a
|
|
3
|
+
// mode and answering with what it rendered; every mode's document is built here, once.
|
|
4
|
+
|
|
5
|
+
import { actionPathStyle } from '@ultimat3/action';
|
|
6
|
+
import { escapeHtml } from '@ultimat3/core';
|
|
7
|
+
import type { RouteParams } from '@ultimat3/http';
|
|
8
|
+
import { currentLocale } from '@ultimat3/i18n';
|
|
9
|
+
import type { IslandCollector, RouteData, RouteEntry } from '@ultimat3/render';
|
|
10
|
+
import {
|
|
11
|
+
clientPathStyleTags,
|
|
12
|
+
clientPersistTags,
|
|
13
|
+
clientPinTags,
|
|
14
|
+
clientScopeTag,
|
|
15
|
+
clientSyncTags,
|
|
16
|
+
headFromMeta,
|
|
17
|
+
hydrateRuntime,
|
|
18
|
+
metaContextFor,
|
|
19
|
+
renderHead,
|
|
20
|
+
seoRenderers,
|
|
21
|
+
} from '@ultimat3/render';
|
|
22
|
+
import type { HoleSink } from '@ultimat3/render/server';
|
|
23
|
+
import { ROOT_ELEMENT_ID, renderComponent } from '@ultimat3/render/server';
|
|
24
|
+
import { loadAppConfig } from './app-config-load';
|
|
25
|
+
import type { DocumentOptions } from './document-options';
|
|
26
|
+
import { routeQuery } from './isr-query-guard';
|
|
27
|
+
import { navigationTagsOf } from './page-navigation';
|
|
28
|
+
import { servedPins } from './page-pins';
|
|
29
|
+
import { bootScript, collectorFor } from './route-islands';
|
|
30
|
+
import { siteAssetTable } from './site-assets';
|
|
31
|
+
import { styleBundle } from './style-bundle';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The app-wide favicon head, from `site.favicon`: the content-hashed URL `asset('assets/….svg')`
|
|
35
|
+
* returns and `/assets/*` serves, never the raw path. Per render, never frozen at boot.
|
|
36
|
+
*/
|
|
37
|
+
export async function appFaviconHead(root: string): Promise<string | undefined> {
|
|
38
|
+
const favicon = (await loadAppConfig(root))?.site.favicon;
|
|
39
|
+
if (favicon === undefined) return undefined;
|
|
40
|
+
const url = siteAssetTable(root).resolve(favicon).url;
|
|
41
|
+
return `<link rel="icon" type="image/svg+xml" href="${escapeHtml(url)}">`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `DocumentOptions`, plus the app root the app's own favicon is resolved against per render. */
|
|
45
|
+
export type RenderOptions = DocumentOptions & { readonly root?: string };
|
|
46
|
+
|
|
47
|
+
/** What a route's `meta(data)` is given. `url` is a string because that is what `ld.*` embeds. */
|
|
48
|
+
export interface DevRouteData extends Record<string, unknown> {
|
|
49
|
+
readonly url: string;
|
|
50
|
+
readonly params: RouteParams;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* `<html lang>` is the request's own locale, never a constant: the `locale` stage negotiated it
|
|
54
|
+
* one stage before this handler and published it on the context, so a hardcoded `'en'` shipped
|
|
55
|
+
* every document mislabelled — wrong for a screen reader, wrong for `hreflang`. Outside a request
|
|
56
|
+
* (`x build`'s prerender) it is the app's own configured fallback.
|
|
57
|
+
*/
|
|
58
|
+
export const lang = (): string => currentLocale();
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `scope` is present only on a PRIVATE document (a gated `ssr` page, every `stream`): the page's
|
|
62
|
+
* client scope (`@ultimat3/auth`'s `clientScopeOf`), which core's `pageClient()` reads to fence its
|
|
63
|
+
* one store per principal. A shareable document carries NO scope tag — absent means "not rendered
|
|
64
|
+
* for anyone", a different answer from `''`, the anonymous page.
|
|
65
|
+
*/
|
|
66
|
+
export const headFor = async (
|
|
67
|
+
entry: RouteEntry,
|
|
68
|
+
ctx: DevRouteData,
|
|
69
|
+
data: RouteData,
|
|
70
|
+
options: RenderOptions,
|
|
71
|
+
scope?: string,
|
|
72
|
+
): Promise<string> => {
|
|
73
|
+
const meta = metaContextFor(ctx, data);
|
|
74
|
+
const url = new URL(ctx.url);
|
|
75
|
+
const router = navigationTagsOf(options.navigation, entry, meta.t);
|
|
76
|
+
return (
|
|
77
|
+
renderHead(
|
|
78
|
+
headFromMeta(
|
|
79
|
+
await entry.config.meta(meta),
|
|
80
|
+
seoRenderers({
|
|
81
|
+
path: url.pathname,
|
|
82
|
+
baseUrl: options.origin ?? url.origin,
|
|
83
|
+
// The default is the cluster's own head (`alternates` is default-first by contract), so
|
|
84
|
+
// `x-default` comes from the list the hreflang tags do — never a second locale read.
|
|
85
|
+
localization: {
|
|
86
|
+
locale: meta.locale,
|
|
87
|
+
defaultLocale: meta.alternates[0]?.locale ?? meta.locale,
|
|
88
|
+
alternates: meta.alternates,
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
[
|
|
92
|
+
...(options.sync === undefined ? [] : clientSyncTags(options.sync)),
|
|
93
|
+
// Every document, shareable ones included: the style is the app's, not a visitor's, and
|
|
94
|
+
// it is the only way an island's `rpc()` learns it. Read per render — `defineApi` may run
|
|
95
|
+
// after these routes are built. `'resource'`, the default, is no tag.
|
|
96
|
+
...clientPathStyleTags(actionPathStyle()),
|
|
97
|
+
// Same reason, same read: a pinned action or query is reached by NAME from the browser
|
|
98
|
+
// (`rpc`, `useQuery`, `useMutation`) only through this. Nothing pinned is no tag.
|
|
99
|
+
...clientPinTags(servedPins()),
|
|
100
|
+
...router,
|
|
101
|
+
// The page boot rides the scope tag: its whole job — restoring a principal's persisted
|
|
102
|
+
// records and replaying its queued writes — is per principal, and a shareable document
|
|
103
|
+
// (no scope tag) has neither.
|
|
104
|
+
...(scope === undefined
|
|
105
|
+
? []
|
|
106
|
+
: [clientScopeTag(scope), ...clientPersistTags(options.persisted?.() ?? [])]),
|
|
107
|
+
],
|
|
108
|
+
),
|
|
109
|
+
) +
|
|
110
|
+
(options.themeHead ?? '') +
|
|
111
|
+
// One mechanism per document: the router prefetches for itself, the browser for the rest.
|
|
112
|
+
(router.length === 0 ? (options.speculationHead ?? '') : '') +
|
|
113
|
+
// The app's own favicon: one link, every document, the URL the table resolves NOW.
|
|
114
|
+
((options.root === undefined ? options.faviconHead : await appFaviconHead(options.root)) ??
|
|
115
|
+
'') +
|
|
116
|
+
// Per locale: each links its own locale's manifest (`PwaArtifacts.headFor`).
|
|
117
|
+
(typeof options.pwaHead === 'function'
|
|
118
|
+
? options.pwaHead(meta.locale)
|
|
119
|
+
: (options.pwaHead ?? '')) +
|
|
120
|
+
(entry.surface === 'app' ? (options.appHead ?? '') : (options.siteHead ?? ''))
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `<link rel="stylesheet">` for the surface's own stylesheets, or nothing at all when the surface
|
|
126
|
+
* imports none.
|
|
127
|
+
*
|
|
128
|
+
* In `<head>`, which is what keeps this a swap and not a regression: a `<link rel="stylesheet">`
|
|
129
|
+
* there is render-blocking in every browser, exactly as the inline block was, so there is no window
|
|
130
|
+
* in which the document paints unstyled. Moving it to the body, or deferring it, would introduce a
|
|
131
|
+
* flash — never do that here.
|
|
132
|
+
*
|
|
133
|
+
* Read through `styleBundle()` rather than passed in through `DocumentOptions`: the registry it
|
|
134
|
+
* derives from is process-global (importing the app IS what fills it), so a caller that forgot to
|
|
135
|
+
* thread a resolver would serve a document with no CSS at all. One reader, and it is the same one
|
|
136
|
+
* `styleRoutes` serves from.
|
|
137
|
+
*/
|
|
138
|
+
const styleTag = (entry: RouteEntry): string => {
|
|
139
|
+
const href = styleBundle().hrefFor(entry.surface);
|
|
140
|
+
return href === undefined ? '' : `<link rel="stylesheet" href="${href}">`;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** The surface stylesheet, then the app's brand — the order that lets the brand win the cascade. */
|
|
144
|
+
export const stylesFor = (entry: RouteEntry, options: DocumentOptions): string =>
|
|
145
|
+
styleTag(entry) + (options.brandHead ?? '');
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The route's rendered body, inside the hydration root. Every mode goes through here — an empty
|
|
149
|
+
* root is what a module exporting no component renders, and nothing else: `spa` was the one mode
|
|
150
|
+
* that never reached this function, so every `spa` route ever declared served an empty root.
|
|
151
|
+
*/
|
|
152
|
+
export async function routeBody(
|
|
153
|
+
entry: RouteEntry,
|
|
154
|
+
ctx: DevRouteData,
|
|
155
|
+
data: RouteData,
|
|
156
|
+
islands: IslandCollector,
|
|
157
|
+
// Only the `stream` branch passes one. Absent, a `<Hole>` is its children, inline.
|
|
158
|
+
holes?: HoleSink,
|
|
159
|
+
): Promise<string> {
|
|
160
|
+
if (entry.component === undefined) return `<div id="${ROOT_ELEMENT_ID}"></div>`;
|
|
161
|
+
const url = new URL(ctx.url);
|
|
162
|
+
const html = await renderComponent(
|
|
163
|
+
entry.component,
|
|
164
|
+
// `data` is the route's own `load` result and is what `meta` was just given — the same object,
|
|
165
|
+
// never a second resolution. `query` is supplied because a page that reads `props.query.x`
|
|
166
|
+
// otherwise dereferences undefined and takes the whole render down.
|
|
167
|
+
{
|
|
168
|
+
data,
|
|
169
|
+
params: ctx.params,
|
|
170
|
+
url: ctx.url,
|
|
171
|
+
query: routeQuery(entry, url),
|
|
172
|
+
},
|
|
173
|
+
entry.file,
|
|
174
|
+
holes === undefined ? { islands } : { islands, holes },
|
|
175
|
+
);
|
|
176
|
+
return `<div id="${ROOT_ELEMENT_ID}">${html}</div>`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The document from data ALREADY resolved. Split from `routeDocument` so one request resolves
|
|
181
|
+
* `load` exactly once: `stream` renders head and body separately, and resolving in each would let
|
|
182
|
+
* a `<title>` describe content the body does not contain.
|
|
183
|
+
*
|
|
184
|
+
* The hydration runtime is appended after the body and only after it: what strategies a page needs
|
|
185
|
+
* is a fact about the islands the walk just recorded, so emitting it earlier would guess or ship
|
|
186
|
+
* the whole runtime to a page with no island.
|
|
187
|
+
*/
|
|
188
|
+
export async function documentFrom(
|
|
189
|
+
entry: RouteEntry,
|
|
190
|
+
ctx: DevRouteData,
|
|
191
|
+
data: RouteData,
|
|
192
|
+
options: RenderOptions,
|
|
193
|
+
scope?: string,
|
|
194
|
+
): Promise<string> {
|
|
195
|
+
const islands = collectorFor(entry, options, scope);
|
|
196
|
+
const [head, body] = await Promise.all([
|
|
197
|
+
headFor(entry, ctx, data, options, scope),
|
|
198
|
+
routeBody(entry, ctx, data, islands),
|
|
199
|
+
]);
|
|
200
|
+
return (
|
|
201
|
+
`<!doctype html><html lang="${lang()}"><head>${head}${stylesFor(entry, options)}</head>` +
|
|
202
|
+
`<body>${body}${bootScript(entry, islands, options, scope)}${hydrateRuntime(islands.directives)}</body></html>`
|
|
203
|
+
);
|
|
204
|
+
}
|