@ultimat3/cli 29.4.0 → 30.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +11 -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 +9 -0
- 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 +41 -81
- 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 +7 -1
- 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 +1314 -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
package/src/serve-boot.ts
CHANGED
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
// module graph holds neither.
|
|
7
7
|
|
|
8
8
|
import type { DrainConfig, LifecycleOptions, Role } from '@ultimat3/core';
|
|
9
|
-
import { configureLifecycle, logger } from '@ultimat3/core';
|
|
9
|
+
import { configureLifecycle, logger, UltimateError } from '@ultimat3/core';
|
|
10
|
+
import { loadAppConfig } from './app-config-load';
|
|
11
|
+
import { armFailureCapture } from './failure-capture-boot';
|
|
10
12
|
import type { MetricsEndpoint } from './metrics-endpoint';
|
|
11
13
|
import { startOtlpExport } from './otlp-export';
|
|
14
|
+
import type { Finding } from './output';
|
|
12
15
|
import { loadAppForRole, roleLoadFor } from './role-load';
|
|
13
16
|
import { startRoles } from './role-start';
|
|
17
|
+
import { armRowSecurity, rowSecurityDeclared } from './row-security-boot';
|
|
14
18
|
import { resolveServices } from './runtime-bindings';
|
|
15
19
|
import { busLabel } from './runtime-bus';
|
|
16
20
|
import { warnIfIdempotencyProcessScoped } from './runtime-idempotency-scope';
|
|
@@ -96,6 +100,29 @@ export async function bootServing(boot: {
|
|
|
96
100
|
return bootRoles({ ...boot, runtime });
|
|
97
101
|
}
|
|
98
102
|
|
|
103
|
+
/**
|
|
104
|
+
* A container role whose app did not load whole: `X_APP_MODULE_LOAD_FAILED`. Beside its one thrower
|
|
105
|
+
* (`errors.ts` is at its ceiling). Every environment, not only production: this boot has no watcher
|
|
106
|
+
* to load the module again after a save — that is `x dev`'s, which keeps its own log-and-reload —
|
|
107
|
+
* so a half-loaded app here stays half-loaded, answering `/readyz` without a route or a policy.
|
|
108
|
+
*/
|
|
109
|
+
export class AppModuleLoadFailedError extends UltimateError {
|
|
110
|
+
constructor(input: { role: Role; findings: readonly Finding[] }) {
|
|
111
|
+
const modules = input.findings.map((finding) => finding.at ?? '(unknown module)');
|
|
112
|
+
super({
|
|
113
|
+
code: 'X_APP_MODULE_LOAD_FAILED',
|
|
114
|
+
cause: `the ${input.role} role refused to start: ${String(modules.length)} app module(s) failed to load, and serving without them drops their routes, actions, policies and jobs — ${input.findings.map((finding, index) => `${modules[index] ?? ''} (${finding.code})`).join(', ')}`,
|
|
115
|
+
fix: 'x manifest --json # names each module that does not load and why; fix it, then restart the role',
|
|
116
|
+
meta: { role: input.role, modules },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Throws when any app module failed to load; each was already logged with its own cause. */
|
|
122
|
+
export function refuseHalfLoadedApp(role: Role, findings: readonly Finding[]): void {
|
|
123
|
+
if (findings.length > 0) throw new AppModuleLoadFailedError({ role, findings });
|
|
124
|
+
}
|
|
125
|
+
|
|
99
126
|
/** The half of `serveApp` whose every acquisition is registered for rollback. */
|
|
100
127
|
async function bootRoles(boot: {
|
|
101
128
|
readonly options: ServeOptions;
|
|
@@ -106,6 +133,15 @@ async function bootRoles(boot: {
|
|
|
106
133
|
readonly metrics: MetricsEndpoint;
|
|
107
134
|
}): Promise<ServedApp> {
|
|
108
135
|
const { options, role, runtime, acquired, metrics } = boot;
|
|
136
|
+
// Before the app's modules, because importing one may register a `close` hook, and that phase
|
|
137
|
+
// runs last-registered first: the flush has to be the hook registered earliest, or a teardown's
|
|
138
|
+
// spans are recorded into an exporter already shut down. `OTEL_EXPORTER_OTLP_ENDPOINT` is in the
|
|
139
|
+
// shipped chart and nothing read it, so every deployment that configured a collector got an empty
|
|
140
|
+
// dashboard. `x dev` keeps its own recorder — the `/_x` timeline is a different sink with a
|
|
141
|
+
// different lifetime — so this is the production boot's alone (axiom 6).
|
|
142
|
+
// At the FRONT of the unwind list, so the stop and a failed boot's rollback alike release it
|
|
143
|
+
// last: the drain's own spans and the final counter snapshot still have somewhere to go.
|
|
144
|
+
acquired.unshift(startOtlpExport(options.env));
|
|
109
145
|
// The image's compiled stylesheets, before the first page is imported — importing one IS
|
|
110
146
|
// compiling its sheet — and the count of what this boot compiles anyway.
|
|
111
147
|
adoptPrebuiltStyles(options.root);
|
|
@@ -113,23 +149,26 @@ async function bootRoles(boot: {
|
|
|
113
149
|
// Importing the app's modules IS the registration: every route, action and job below is
|
|
114
150
|
// whatever this call put in the registries — for THIS role (`role-load.ts`).
|
|
115
151
|
const loaded = await loadAppForRole(options.root, role);
|
|
116
|
-
// A module that would not import registered nothing
|
|
117
|
-
//
|
|
152
|
+
// A module that would not import registered nothing — its routes, actions, policies and jobs
|
|
153
|
+
// are simply absent — so each is logged with its own cause, and then the role refuses to serve.
|
|
118
154
|
for (const finding of loaded.findings) {
|
|
119
155
|
logger.error('ultimate app module failed to load', { role, ...finding });
|
|
120
156
|
}
|
|
157
|
+
refuseHalfLoadedApp(role, loaded.findings);
|
|
158
|
+
// After the app's modules and before any role serves: a role Postgres exempts from row security is
|
|
159
|
+
// refused here (`X_DB_ROW_SECURITY_BYPASSED`), never discovered on another tenant's first read.
|
|
160
|
+
// PGlite's one session is a superuser: there the boot serves as `x_app` instead of refusing.
|
|
161
|
+
const embedded = runtime.services.db.mode === 'embedded';
|
|
162
|
+
const appConfig = await loadAppConfig(options.root);
|
|
163
|
+
acquired.push(await armRowSecurity(rowSecurityDeclared(appConfig), { embedded }));
|
|
164
|
+
// `failureCapture.enabled`: a 5xx keeps a replayable bundle for `x repro` (off by default).
|
|
165
|
+
acquired.push(armFailureCapture(appConfig));
|
|
121
166
|
// After the app's modules: an app swaps the store at import time, after `startServices`.
|
|
122
167
|
warnIfIdempotencyProcessScoped();
|
|
123
168
|
const buildId = await buildIdFor(options, role);
|
|
124
169
|
// Before the first socket opens: everything above this line fails loudly into the container's
|
|
125
170
|
// own logs, everything below it is a served request, a claimed job or a routed frame.
|
|
126
171
|
configureReporting(options.env, buildId);
|
|
127
|
-
// Beside error reporting, and for the same reason it is here: `OTEL_EXPORTER_OTLP_ENDPOINT` is
|
|
128
|
-
// in the shipped chart and nothing read it, so every deployment that configured a collector got
|
|
129
|
-
// an empty dashboard. `x dev` keeps its own recorder — the `/_x` timeline is a different sink
|
|
130
|
-
// with a different lifetime — so this is the production boot's alone (axiom 6).
|
|
131
|
-
const stopOtlp = startOtlpExport(options.env);
|
|
132
|
-
acquired.push(stopOtlp);
|
|
133
172
|
// Only the web role serves pages, so only it builds islands and assembles a route table: a
|
|
134
173
|
// worker, scheduler, sync or replicator pod compiled every island of the app on every boot for
|
|
135
174
|
// nothing it would ever serve (plan 101, slice 12 e). The MODULE is the web role's as well —
|
|
@@ -196,18 +235,12 @@ async function bootRoles(boot: {
|
|
|
196
235
|
running,
|
|
197
236
|
runtime,
|
|
198
237
|
async stop() {
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
await releaseOrThrow([
|
|
206
|
-
stopOtlp,
|
|
207
|
-
() => runtime.stop(),
|
|
208
|
-
() => web?.release(),
|
|
209
|
-
() => running.stop(),
|
|
210
|
-
]);
|
|
238
|
+
// The boot's own unwind list, whole (as `x dev`'s is): every step runs and the first failure
|
|
239
|
+
// is rethrown (`releaseOrThrow`), newest first — the roles; the web surface, after the server
|
|
240
|
+
// drained; the failure-capture store, settled while the pool is still open; the row-security
|
|
241
|
+
// client; the services; the exporter last. A hand-listed copy dropped the two in the middle,
|
|
242
|
+
// so a bundle in flight at drain was lost and the next boot in the process inherited them.
|
|
243
|
+
await releaseOrThrow([...acquired]);
|
|
211
244
|
},
|
|
212
245
|
};
|
|
213
246
|
}
|
package/src/serve-drain.ts
CHANGED
package/src/serve-env.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
// What a container's environment decides about a boot: which role, which port, which interface,
|
|
2
2
|
// and where errors are reported. Split from `serve.ts`, which re-exports every name here.
|
|
3
3
|
|
|
4
|
-
import type { Role } from '@ultimat3/core';
|
|
5
|
-
import {
|
|
4
|
+
import type { ErrorReporter, Role } from '@ultimat3/core';
|
|
5
|
+
import {
|
|
6
|
+
chainErrorReporters,
|
|
7
|
+
configureErrorReporting,
|
|
8
|
+
errorReportersOf,
|
|
9
|
+
installedErrorReporter,
|
|
10
|
+
isRole,
|
|
11
|
+
ROLES,
|
|
12
|
+
sentryErrorReporter,
|
|
13
|
+
} from '@ultimat3/core';
|
|
6
14
|
import { PortInvalidError, RoleUnknownError } from './errors';
|
|
7
15
|
import { DEFAULT_METRICS_PORT } from './metrics-endpoint';
|
|
8
16
|
import type { WebBinding } from './role-start';
|
|
@@ -103,17 +111,27 @@ export const metricsPortFor = (env: Env, port: number, override?: number): numbe
|
|
|
103
111
|
*/
|
|
104
112
|
export const ERROR_DSN_KEY = 'SENTRY_DSN';
|
|
105
113
|
|
|
114
|
+
/** The transport the LAST `configureReporting` installed, so a second boot swaps it, never adds. */
|
|
115
|
+
let vendor: ErrorReporter | undefined;
|
|
116
|
+
|
|
106
117
|
/**
|
|
107
|
-
* Switch reporting on for this process. Unset DSN leaves
|
|
108
|
-
* laptop and a CI run pay nothing and page nobody — and the release every
|
|
109
|
-
* build id this boot already computed, never a second identity for the
|
|
118
|
+
* Switch reporting on for this process. Unset DSN leaves whatever the app installed (or core's
|
|
119
|
+
* no-op) in place, so a laptop and a CI run pay nothing and page nobody — and the release every
|
|
120
|
+
* event carries is the build id this boot already computed, never a second identity for the deploy.
|
|
121
|
+
*
|
|
122
|
+
* The transport is CHAINED beside the app's reporter, never over it: the app's modules import —
|
|
123
|
+
* and install an `errorGroupsSink`, say — before this line runs, and replacing it here would
|
|
124
|
+
* leave `/admin/errors` empty in exactly the deploys that set `SENTRY_DSN`.
|
|
110
125
|
*/
|
|
111
126
|
export function configureReporting(env: Env, buildId: string): void {
|
|
112
127
|
const dsn = env[ERROR_DSN_KEY]?.trim();
|
|
128
|
+
// A malformed DSN throws here, at boot, rather than at the first outage: a monitor that was
|
|
129
|
+
// never connected looks exactly like an app that never failed.
|
|
130
|
+
const next = dsn === undefined || dsn.length === 0 ? undefined : sentryErrorReporter({ dsn });
|
|
131
|
+
const app = errorReportersOf(installedErrorReporter()).filter((member) => member !== vendor);
|
|
132
|
+
vendor = next;
|
|
113
133
|
configureErrorReporting({
|
|
114
134
|
release: buildId,
|
|
115
|
-
|
|
116
|
-
// never connected looks exactly like an app that never failed.
|
|
117
|
-
...(dsn === undefined || dsn.length === 0 ? {} : { reporter: sentryErrorReporter({ dsn }) }),
|
|
135
|
+
reporter: chainErrorReporters(...app, ...(next === undefined ? [] : [next])),
|
|
118
136
|
});
|
|
119
137
|
}
|
package/src/serve-retire.ts
CHANGED
|
@@ -62,12 +62,18 @@ export function armWorkerRetire(options: WorkerRetireOptions = {}): WorkerRetire
|
|
|
62
62
|
const worker = await adopted;
|
|
63
63
|
try {
|
|
64
64
|
if (worker !== null) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
65
|
+
// The log line alone reads stats: a db blip here must not skip the stop, or the drain
|
|
66
|
+
// below is a plain SIGTERM drain and cuts at the budget a job meant to outlast.
|
|
67
|
+
try {
|
|
68
|
+
const before = await worker.stats();
|
|
69
|
+
logger.info('jobs.worker.retiring', {
|
|
70
|
+
signal: RETIRE_SIGNAL,
|
|
71
|
+
workerId: before.workerId,
|
|
72
|
+
inFlight: before.inFlight,
|
|
73
|
+
});
|
|
74
|
+
} catch (error) {
|
|
75
|
+
logger.warn('jobs.worker.retire-stats-failed', { error: renderThrowable(error) });
|
|
76
|
+
}
|
|
71
77
|
await worker.stop(RETIRE_SIGNAL);
|
|
72
78
|
const after = await worker.stats();
|
|
73
79
|
logger.info('jobs.worker.retired', {
|
package/src/serve-web.ts
CHANGED
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
// `await import()` for `ROLE=web` alone, and `serve-graph.test.ts` pins what every other role is
|
|
4
4
|
// spared: render's server half, the PWA, SEO, MCP and island subsystems.
|
|
5
5
|
|
|
6
|
+
import { resolveEnvironment } from '@ultimat3/core';
|
|
6
7
|
import type { Route } from '@ultimat3/http';
|
|
7
8
|
import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
8
|
-
|
|
9
9
|
import { installedVapid } from '@ultimat3/pwa';
|
|
10
10
|
import { describePages } from '@ultimat3/render';
|
|
11
11
|
import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
|
|
12
12
|
import { loadSignInPath } from './app-auth';
|
|
13
|
+
import { loadAppConfig } from './app-config-load';
|
|
13
14
|
import { mountAppMcp } from './app-mcp';
|
|
15
|
+
import { clientErrorsSurface } from './client-errors-routes';
|
|
16
|
+
import { clientScriptHeads, clientVitalsSurface } from './client-vitals-route';
|
|
14
17
|
import { errorPageStyleBodies } from './error-page-csp';
|
|
15
18
|
import { islandRoutes } from './island-routes';
|
|
16
19
|
import type { LoadedIslands } from './island-store';
|
|
@@ -18,7 +21,10 @@ import { loadOrBuildIslands } from './island-store';
|
|
|
18
21
|
import { loadNavigation, pageNavigation } from './page-navigation';
|
|
19
22
|
import { loadSpeculation, otherLocaleSegments, pageSpeculation } from './page-speculation';
|
|
20
23
|
import { pageSync } from './page-sync';
|
|
24
|
+
import { plainRoutes } from './plain-routes';
|
|
21
25
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
26
|
+
import { retainBuildAssets } from './retained-assets-boot';
|
|
27
|
+
import { withRetainedAssets } from './retained-assets-read';
|
|
22
28
|
import { adminMountRoutes } from './runtime-admin';
|
|
23
29
|
import { assetRoutes } from './runtime-assets';
|
|
24
30
|
import { attachedIsr } from './runtime-isr';
|
|
@@ -34,6 +40,7 @@ import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
|
34
40
|
import { serviceWorkerRoutes } from './sw-routes';
|
|
35
41
|
import { loadThemeMode, themeBoot } from './theme-boot';
|
|
36
42
|
import { loadThemeBrand } from './theme-brand';
|
|
43
|
+
import { zoneBoot } from './zone-boot';
|
|
37
44
|
|
|
38
45
|
/** Everything `startRoles` is handed that only a process serving documents has. */
|
|
39
46
|
export interface WebSurface {
|
|
@@ -108,6 +115,29 @@ export async function webSurface(
|
|
|
108
115
|
const mcpMount = await mountAppMcp(options.root);
|
|
109
116
|
// The app's own disks when it declared any (`defineStorage` in an app module), else this boot's.
|
|
110
117
|
const served = servedStorage(runtime.storage);
|
|
118
|
+
// `assets.retain`: this build's hashed files onto the shared disk, BEFORE the role binds — a pod
|
|
119
|
+
// takes no traffic until `/readyz`, so no document of this build reaches an old pod first.
|
|
120
|
+
const config = await loadAppConfig(options.root);
|
|
121
|
+
// `time.detect`: the zone script on `app/` documents, admitted below by its own hash.
|
|
122
|
+
const zone = zoneBoot(config?.time);
|
|
123
|
+
const scripts = [
|
|
124
|
+
...sync.scripts,
|
|
125
|
+
...(navigation.script === undefined ? [] : [navigation.script]),
|
|
126
|
+
];
|
|
127
|
+
const retained = await retainBuildAssets({
|
|
128
|
+
root: options.root,
|
|
129
|
+
config,
|
|
130
|
+
storage: served,
|
|
131
|
+
buildId,
|
|
132
|
+
environment: resolveEnvironment({ env: options.env, fallback: 'production' }),
|
|
133
|
+
islands,
|
|
134
|
+
styles: styleBundle(),
|
|
135
|
+
scripts,
|
|
136
|
+
});
|
|
137
|
+
// The same call `x dev` makes: `clientErrors.enabled` or nothing mounted and nothing named.
|
|
138
|
+
const clientErrors = clientErrorsSurface(config?.clientErrors, buildId);
|
|
139
|
+
// And real-user timings, on their own switch: the call `x dev` makes, so both name one script.
|
|
140
|
+
const vitals = clientVitalsSurface(config?.clientErrors?.vitals);
|
|
111
141
|
// Attached, so a tag bust marks this process's ISR pages stale. The store is the deployment's
|
|
112
142
|
// when it supplied one: the default is per process, so twelve replicas hold twelve of them and a
|
|
113
143
|
// purge regenerates one twelfth of the fleet while the other eleven keep the invalidated page.
|
|
@@ -122,7 +152,7 @@ export async function webSurface(
|
|
|
122
152
|
...apiMountRoutes(options.runtime?.rateLimitStore ?? runtime.rateLimitStore),
|
|
123
153
|
...mcpMount.routes,
|
|
124
154
|
// The app's own plain routes (`apps/<app>/runtime.ts` `routes`), before any page can shadow one.
|
|
125
|
-
...(options.runtime?.routes
|
|
155
|
+
...plainRoutes(options.runtime?.routes),
|
|
126
156
|
...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
|
|
127
157
|
...assetRoutes({
|
|
128
158
|
root: options.root,
|
|
@@ -131,6 +161,8 @@ export async function webSurface(
|
|
|
131
161
|
...(pwa === undefined ? {} : { pwa }),
|
|
132
162
|
}),
|
|
133
163
|
...storageRoutes({ storage: served }),
|
|
164
|
+
...(clientErrors?.routes ?? []),
|
|
165
|
+
...(vitals?.routes ?? []),
|
|
134
166
|
...islandRoutes(() => islands),
|
|
135
167
|
// The surface stylesheets the documents link. Built from the registry the `loadApp` above
|
|
136
168
|
// filled, so this process serves exactly the CSS it renders against.
|
|
@@ -169,12 +201,18 @@ export async function webSurface(
|
|
|
169
201
|
...(pwa === undefined
|
|
170
202
|
? {}
|
|
171
203
|
: { pwaHead: (locale: string) => pwa.headFor(locale) + (serviceWorker?.head ?? '') }),
|
|
204
|
+
...clientScriptHeads(clientErrors, vitals, zone),
|
|
172
205
|
isr: isr.isr,
|
|
173
206
|
}),
|
|
174
207
|
];
|
|
175
208
|
return {
|
|
176
|
-
|
|
177
|
-
|
|
209
|
+
// A miss on any asset route asks the retained builds (`assets.retain`); none declared, unchanged.
|
|
210
|
+
routes: withRetainedAssets(routes, retained?.reader),
|
|
211
|
+
inlineScripts: [
|
|
212
|
+
theme.cspSource,
|
|
213
|
+
...(speculation === undefined ? [] : [speculation.cspSource]),
|
|
214
|
+
...(zone === undefined ? [] : [zone.cspSource]),
|
|
215
|
+
],
|
|
178
216
|
// The enforced policy this process sends must admit the app's own error pages' `<style>` and
|
|
179
217
|
// the brand's; `x dev` is report-only, so only here was a missing hash an unstyled page.
|
|
180
218
|
inlineStyles: [
|
package/src/serve.ts
CHANGED
|
@@ -50,20 +50,22 @@ export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve-t
|
|
|
50
50
|
export async function runMigrations(options: ServeOptions): Promise<MigratedApp> {
|
|
51
51
|
// `'apply'`, never the stamp-first default: this IS the step that applies, and an operator
|
|
52
52
|
// re-running it is how a dropped framework table comes back.
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
options.runtime,
|
|
56
|
-
options.env,
|
|
57
|
-
'apply',
|
|
58
|
-
);
|
|
53
|
+
const services = resolveServices(options.root, options.env);
|
|
54
|
+
const queue = await startQueue(services, options.runtime, options.env, 'apply');
|
|
59
55
|
try {
|
|
60
56
|
const migrations = await readMigrations(options.root);
|
|
57
|
+
// PGlite with `database.rowSecurity`: the app's migrations run as `x_migrator` for their scope.
|
|
58
|
+
// Behind `await import()` and only embedded: a container's migrate graph never loads it.
|
|
59
|
+
const release =
|
|
60
|
+
services.db.mode === 'embedded'
|
|
61
|
+
? await (await import('./row-security-boot')).migrateAsMigrator(options.root)
|
|
62
|
+
: undefined;
|
|
61
63
|
const report = await migrate({
|
|
62
64
|
migrations,
|
|
63
65
|
...(options.env['APP_VERSION'] === undefined
|
|
64
66
|
? {}
|
|
65
67
|
: { appVersion: options.env['APP_VERSION'] }),
|
|
66
|
-
});
|
|
68
|
+
}).finally(() => release?.());
|
|
67
69
|
logger.info('ultimate migrate applied', {
|
|
68
70
|
applied: report.applied.length,
|
|
69
71
|
available: migrations.length,
|
package/src/site-asset-routes.ts
CHANGED
|
@@ -35,7 +35,13 @@ function assetResponse(asset: SiteAsset, range: string | null): Response {
|
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
37
|
if (wanted === undefined) {
|
|
38
|
-
return applyCacheHeaders(
|
|
38
|
+
return applyCacheHeaders(
|
|
39
|
+
new Response(file, {
|
|
40
|
+
// Declared, or `compress` may not read it: a `Bun.file` body states no length of its own.
|
|
41
|
+
headers: { ...headers, 'content-length': String(asset.bytes) },
|
|
42
|
+
}),
|
|
43
|
+
SITE_ASSET_CACHE,
|
|
44
|
+
);
|
|
39
45
|
}
|
|
40
46
|
return applyCacheHeaders(
|
|
41
47
|
new Response(file.slice(wanted.start, wanted.end + 1), {
|
package/src/site-config.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface SiteSettings {
|
|
|
26
26
|
* `SiteSettings` literal written before it existed still compiles.
|
|
27
27
|
*/
|
|
28
28
|
readonly favicon?: SiteFavicon | undefined;
|
|
29
|
+
/** `seo.llms` — `/llms.txt` on (the default) or off. Optional for `robotsGroups`' reason. */
|
|
30
|
+
readonly llms?: boolean | undefined;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export const NO_SITE_SETTINGS: SiteSettings = {
|
|
@@ -34,6 +36,7 @@ export const NO_SITE_SETTINGS: SiteSettings = {
|
|
|
34
36
|
robotsGroups: [],
|
|
35
37
|
sitemap: { extra: [], lastmod: 'none', sections: [], pageSize: 50_000 },
|
|
36
38
|
favicon: undefined,
|
|
39
|
+
llms: true,
|
|
37
40
|
};
|
|
38
41
|
|
|
39
42
|
const trimSlash = (origin: string): string => origin.replace(/\/+$/, '');
|
|
@@ -48,6 +51,7 @@ export async function loadSiteSettings(root: string): Promise<SiteSettings> {
|
|
|
48
51
|
robotsGroups: [...(config.seo.robots.groups ?? [])],
|
|
49
52
|
sitemap: config.seo.sitemap,
|
|
50
53
|
favicon,
|
|
54
|
+
llms: config.seo.llms !== false,
|
|
51
55
|
};
|
|
52
56
|
}
|
|
53
57
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// `/llms.txt`'s per-page reads: every page of a public `site/` route whose `meta` needs its `load`
|
|
2
|
+
// (or its params), titled by that `meta`. Collected by the static export INSIDE its API scope, so
|
|
3
|
+
// `siteLlms` never runs a page's `load` after the build stopped answering the app's own queries.
|
|
4
|
+
|
|
5
|
+
import type { Environment } from '@ultimat3/core';
|
|
6
|
+
import { ctxOf, DEFAULT_ENVIRONMENT, runWithContext, tryResolveEnvironment } from '@ultimat3/core';
|
|
7
|
+
import type { PrerenderEntry, RouteEntry } from '@ultimat3/render';
|
|
8
|
+
import { metaContextFor, routeDataFor, routeEntries } from '@ultimat3/render';
|
|
9
|
+
import { enumeratePrerenderEntries, fillPath } from '@ultimat3/render/server';
|
|
10
|
+
import type { RouteRecord } from '@ultimat3/seo';
|
|
11
|
+
import { isIndexable } from '@ultimat3/seo';
|
|
12
|
+
import type { RouteEntries } from './prerender-entries';
|
|
13
|
+
import { readSiteMeta } from './seo-meta';
|
|
14
|
+
|
|
15
|
+
/** Each route's page records, by DECLARED path (`/posts/:slug`) — what `siteLlms` reads first. */
|
|
16
|
+
export type LlmsPages = ReadonlyMap<string, readonly RouteRecord[]>;
|
|
17
|
+
|
|
18
|
+
export type Enumerate = (entry: RouteEntry) => Promise<readonly PrerenderEntry[]>;
|
|
19
|
+
|
|
20
|
+
export interface LlmsPageOptions {
|
|
21
|
+
readonly baseUrl: string;
|
|
22
|
+
/** The app's default locale: every `meta` runs in it. */
|
|
23
|
+
readonly defaultLocale: string;
|
|
24
|
+
/** The build's own enumeration (`PrerenderReport.entries`), so `prerender()` is asked once. */
|
|
25
|
+
readonly enumerate?: Enumerate | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A `site/` page anyone may fetch — `site-seo.ts`' own rule. */
|
|
29
|
+
const isPublicSite = (entry: RouteEntry): boolean =>
|
|
30
|
+
entry.surface === 'site' && entry.config.policy === undefined;
|
|
31
|
+
|
|
32
|
+
/** `llms.txt`'s routes: those whose `meta` resolves without a request, and those that need `load`. */
|
|
33
|
+
export interface LlmsRoutes {
|
|
34
|
+
readonly resolved: readonly RouteRecord[];
|
|
35
|
+
readonly loaded: readonly RouteEntry[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The public `site/` routes, split once: `resolved` answered by `readSiteMeta` (no `load`, no
|
|
40
|
+
* params), `loaded` the rest, which `pageRecords` reads page by page.
|
|
41
|
+
*/
|
|
42
|
+
export async function llmsRoutes(defaultLocale: string): Promise<LlmsRoutes> {
|
|
43
|
+
const entries = routeEntries().filter(isPublicSite);
|
|
44
|
+
const publicPaths = new Set(entries.map((entry) => entry.path));
|
|
45
|
+
const resolved = (await readSiteMeta(defaultLocale)).records.filter((record) =>
|
|
46
|
+
publicPaths.has(record.path),
|
|
47
|
+
);
|
|
48
|
+
const resolvedPaths = new Set(resolved.map((record) => record.path));
|
|
49
|
+
return { resolved, loaded: entries.filter((entry) => !resolvedPaths.has(entry.path)) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One record per page `prerender()` enumerates, with the `meta` its own `load` feeds, run in the
|
|
54
|
+
* default locale with no actor: the prerender's context.
|
|
55
|
+
*/
|
|
56
|
+
export async function pageRecords(
|
|
57
|
+
entry: RouteEntry,
|
|
58
|
+
options: LlmsPageOptions,
|
|
59
|
+
): Promise<readonly RouteRecord[]> {
|
|
60
|
+
const records: RouteRecord[] = [];
|
|
61
|
+
for (const page of await (options.enumerate ?? enumeratePrerenderEntries)(entry)) {
|
|
62
|
+
const path = fillPath(entry.pattern.source, page.params);
|
|
63
|
+
const ctx = { url: `${options.baseUrl.replace(/\/+$/, '')}${path}`, params: page.params };
|
|
64
|
+
const meta = await runWithContext(
|
|
65
|
+
ctxOf({ role: 'web', locale: options.defaultLocale }),
|
|
66
|
+
async () => entry.config.meta(metaContextFor(ctx, await routeDataFor(entry.config, ctx))),
|
|
67
|
+
);
|
|
68
|
+
records.push({ path, file: entry.file, surface: 'site', render: entry.config.render, meta });
|
|
69
|
+
}
|
|
70
|
+
return records;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CollectLlmsPagesOptions extends Omit<LlmsPageOptions, 'enumerate'> {
|
|
74
|
+
/** The build's own `prerender()` answers (`enumerateRoutes`): a route listed is never asked again. */
|
|
75
|
+
readonly entries?: RouteEntries | undefined;
|
|
76
|
+
readonly environment?: Environment | undefined;
|
|
77
|
+
/** `seo.llms`; `false` collects nothing. */
|
|
78
|
+
readonly enabled?: boolean | undefined;
|
|
79
|
+
/** The build's API scope: every `load` runs inside it, where the app's own queries answer. */
|
|
80
|
+
readonly scope: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
81
|
+
/** Before the first `load`: the build starts its database here, and only when one is needed. */
|
|
82
|
+
readonly before?: (() => Promise<void>) | undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Every page record `siteLlms` would read through a `load`, read now, inside `scope`. Nothing
|
|
87
|
+
* outside production or under `seo.llms: false` — `siteLlms` answers empty there, so a staging
|
|
88
|
+
* build reads no row for a document it throws away. A throw is the build's failure: a `load` that
|
|
89
|
+
* cannot answer is the app's bug, never a page silently left out.
|
|
90
|
+
*/
|
|
91
|
+
export async function collectLlmsPages(options: CollectLlmsPagesOptions): Promise<LlmsPages> {
|
|
92
|
+
const environment = options.environment ?? tryResolveEnvironment() ?? DEFAULT_ENVIRONMENT;
|
|
93
|
+
const pages = new Map<string, readonly RouteRecord[]>();
|
|
94
|
+
if (options.enabled === false || !isIndexable(environment)) return pages;
|
|
95
|
+
const routes = (await llmsRoutes(options.defaultLocale)).loaded;
|
|
96
|
+
if (routes.length === 0) return pages;
|
|
97
|
+
await options.before?.();
|
|
98
|
+
const enumerate: Enumerate = (route) => {
|
|
99
|
+
const listed = options.entries?.get(route.path);
|
|
100
|
+
return listed === undefined ? enumeratePrerenderEntries(route) : Promise.resolve(listed);
|
|
101
|
+
};
|
|
102
|
+
for (const entry of routes) {
|
|
103
|
+
pages.set(entry.path, await options.scope(() => pageRecords(entry, { ...options, enumerate })));
|
|
104
|
+
}
|
|
105
|
+
return pages;
|
|
106
|
+
}
|
package/src/site-llms.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// `/llms.txt`'s pages, off the route table: every public `site/` page in the default locale by the
|
|
2
|
+
// title and description its own `meta` gives it. A dynamic route (or one with a `load`) is expanded
|
|
3
|
+
// through `prerender()` and its `load`, page by page, exactly as the static export renders it — or
|
|
4
|
+
// read off what the export already collected inside its API scope (`site-llms-pages.ts`).
|
|
5
|
+
|
|
6
|
+
import type { Environment } from '@ultimat3/core';
|
|
7
|
+
import { DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
|
|
8
|
+
import { unlocalizedPath } from '@ultimat3/i18n';
|
|
9
|
+
import type { RouteRecord } from '@ultimat3/seo';
|
|
10
|
+
import { buildLlmsTxt, disallowMatcher, isDynamic, isIndexable } from '@ultimat3/seo';
|
|
11
|
+
import type { Enumerate, LlmsPages } from './site-llms-pages';
|
|
12
|
+
import { llmsRoutes, pageRecords } from './site-llms-pages';
|
|
13
|
+
|
|
14
|
+
export interface SiteLlmsOptions {
|
|
15
|
+
readonly baseUrl: string;
|
|
16
|
+
readonly environment?: Environment | undefined;
|
|
17
|
+
/** `seo.llms`; `false` answers an empty document. */
|
|
18
|
+
readonly enabled?: boolean | undefined;
|
|
19
|
+
readonly disallow?: readonly string[] | undefined;
|
|
20
|
+
/** The static export's emitted pages per route, as the sitemap reads them (`SiteSeoOptions`). */
|
|
21
|
+
readonly pagesFor?: ((routePath: string) => readonly string[]) | undefined;
|
|
22
|
+
/** The app's default locale: every `meta` runs in it, and its paths are unprefixed. */
|
|
23
|
+
readonly defaultLocale: string;
|
|
24
|
+
/** `siteSeo`'s one enumeration per route, so the sitemap and this file never call `prerender()` twice. */
|
|
25
|
+
readonly enumerate?: Enumerate | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* What the static export read inside its API scope (`collectLlmsPages`), by declared route path.
|
|
28
|
+
* A route listed here never runs its `load` again: by the time `llms.txt` is written the build's
|
|
29
|
+
* scope is gone, and a `load` reading the app's own query failed the export
|
|
30
|
+
* (`X_CLIENT_TRANSPORT_FAILED`).
|
|
31
|
+
*/
|
|
32
|
+
readonly pages?: LlmsPages | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Empty outside production and under `seo.llms: false` BEFORE any `prerender()` or `load` runs: a
|
|
37
|
+
* staging web role must not read every post to answer a document it then throws away.
|
|
38
|
+
*/
|
|
39
|
+
export async function siteLlms(options: SiteLlmsOptions): Promise<string> {
|
|
40
|
+
const environment = options.environment ?? tryResolveEnvironment() ?? DEFAULT_ENVIRONMENT;
|
|
41
|
+
if (options.enabled === false || !isIndexable(environment)) return '';
|
|
42
|
+
const disallowed = disallowMatcher(options.disallow ?? []);
|
|
43
|
+
const { resolved, loaded } = await llmsRoutes(options.defaultLocale);
|
|
44
|
+
const expanded: RouteRecord[] = [];
|
|
45
|
+
for (const entry of loaded) {
|
|
46
|
+
const records = options.pages?.get(entry.path) ?? (await pageRecords(entry, options));
|
|
47
|
+
expanded.push(...emittedOnly(entry.path, records, options));
|
|
48
|
+
}
|
|
49
|
+
return buildLlmsTxt(
|
|
50
|
+
[...resolved, ...expanded].filter((record) => !disallowed(record.path)),
|
|
51
|
+
{ baseUrl: options.baseUrl, environment },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A dynamic route's pages, less those the export did not emit — the sitemap's own rule. */
|
|
56
|
+
function emittedOnly(
|
|
57
|
+
routePath: string,
|
|
58
|
+
records: readonly RouteRecord[],
|
|
59
|
+
options: SiteLlmsOptions,
|
|
60
|
+
): readonly RouteRecord[] {
|
|
61
|
+
if (options.pagesFor === undefined || !isDynamic(routePath)) return records;
|
|
62
|
+
const emitted = new Set(options.pagesFor(routePath).map((path) => unlocalizedPath(path)));
|
|
63
|
+
return records.filter((record) => emitted.has(record.path));
|
|
64
|
+
}
|
package/src/site-seo-write.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Single responsibility: `siteSeo()`'s answer as files in a static export — `/sitemap.xml`, every
|
|
2
|
-
// part an index names, and `
|
|
2
|
+
// part an index names, `robots.txt` and `llms.txt` — so a CDN serves the bytes a running web role answers.
|
|
3
3
|
|
|
4
4
|
// why: Bun exposes no path-join primitive, and every file path is relative to the output root.
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
+
import { LLMS_PATH } from '@ultimat3/seo';
|
|
6
7
|
import { ROBOTS_PATH, type SiteSeo } from './site-seo';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -12,5 +13,6 @@ import { ROBOTS_PATH, type SiteSeo } from './site-seo';
|
|
|
12
13
|
export async function writeSiteSeo(out: string, seo: SiteSeo): Promise<readonly string[]> {
|
|
13
14
|
for (const file of seo.sitemaps) await Bun.write(join(out, file.path), file.xml);
|
|
14
15
|
await Bun.write(join(out, ROBOTS_PATH), seo.robots);
|
|
15
|
-
|
|
16
|
+
await Bun.write(join(out, LLMS_PATH), seo.llms);
|
|
17
|
+
return [...seo.sitemaps.map((file) => file.path), ROBOTS_PATH, LLMS_PATH];
|
|
16
18
|
}
|