@ultimat3/cli 29.4.0 → 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 +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 +1313 -0
- package/wiki/The-Eight-Primitives.md +247 -0
- package/wiki/Theming.md +362 -0
- package/wiki/Timezones-And-Dates.md +194 -0
- package/wiki/Troubleshooting.md +163 -0
- package/wiki/Tutorial-01-First-App.md +289 -0
- package/wiki/Tutorial-02-First-Feature.md +339 -0
- package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
- package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
- package/wiki/Tutorial-05-Deploy-Free.md +231 -0
- package/wiki/Tutorial-06-Growing-Up.md +158 -0
- package/wiki/UI-Components.md +156 -0
- package/wiki/Upgrading.md +3667 -0
- package/src/stored-object-headers.ts +0 -43
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// A byte-bounded, least-recently-used memo with single flight: what keeps a public image variant
|
|
2
|
+
// from being decoded twice. Concurrent first requests share one promise, so a burst of visitors to
|
|
3
|
+
// a fresh deploy is one decode, not one each — and the bound holds the process's memory flat.
|
|
4
|
+
|
|
5
|
+
export interface VariantLru<T> {
|
|
6
|
+
/** The kept answer for `key`, the one in flight, or `make()`'s — kept if `sizeOf` sizes it. */
|
|
7
|
+
get(key: string, make: () => Promise<T>): Promise<T>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `sizeOf` answering `undefined` means "answer, never keep" — a refusal is cheap to recompute and
|
|
12
|
+
* must not hold a slot a real variant could. An entry larger than the whole budget is never kept.
|
|
13
|
+
*/
|
|
14
|
+
export function variantLru<T>(
|
|
15
|
+
maxBytes: number,
|
|
16
|
+
sizeOf: (value: T) => number | undefined,
|
|
17
|
+
): VariantLru<T> {
|
|
18
|
+
const kept = new Map<string, { readonly value: T; readonly bytes: number }>();
|
|
19
|
+
const flying = new Map<string, Promise<T>>();
|
|
20
|
+
let total = 0;
|
|
21
|
+
|
|
22
|
+
const keep = (key: string, value: T): void => {
|
|
23
|
+
const bytes = sizeOf(value);
|
|
24
|
+
if (bytes === undefined || bytes > maxBytes) return;
|
|
25
|
+
kept.set(key, { value, bytes });
|
|
26
|
+
total += bytes;
|
|
27
|
+
// A Map iterates in insertion order, and a hit re-inserts: the first key is the coldest.
|
|
28
|
+
for (const [cold, entry] of kept) {
|
|
29
|
+
if (total <= maxBytes) break;
|
|
30
|
+
kept.delete(cold);
|
|
31
|
+
total -= entry.bytes;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
get(key, make) {
|
|
37
|
+
const hit = kept.get(key);
|
|
38
|
+
if (hit !== undefined) {
|
|
39
|
+
kept.delete(key);
|
|
40
|
+
kept.set(key, hit);
|
|
41
|
+
return Promise.resolve(hit.value);
|
|
42
|
+
}
|
|
43
|
+
const pending = flying.get(key);
|
|
44
|
+
if (pending !== undefined) return pending;
|
|
45
|
+
const made = make()
|
|
46
|
+
.then((value) => {
|
|
47
|
+
keep(key, value);
|
|
48
|
+
return value;
|
|
49
|
+
})
|
|
50
|
+
.finally(() => flying.delete(key));
|
|
51
|
+
flying.set(key, made);
|
|
52
|
+
return made;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The variants of a public site image, served: `responsiveImage({ src: asset('assets/hero.png') })`
|
|
2
|
+
// mints `?w=&f=` candidates, and `/assets` answered every one with the full original. This wraps the
|
|
3
|
+
// plain route with exactly the variants the markup can mint for that source, decoded once per
|
|
4
|
+
// content hash through the pipeline `/media` uses — and refuses every other query before a decode.
|
|
5
|
+
|
|
6
|
+
import { finiteCount, probeImage, sniffImageFormat } from '@ultimat3/core';
|
|
7
|
+
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
8
|
+
import { applyCacheHeaders, jsonResponse } from '@ultimat3/http';
|
|
9
|
+
import type { ImageQuery, ImageTransformDriver } from '@ultimat3/seo';
|
|
10
|
+
import {
|
|
11
|
+
builtinImageDriver,
|
|
12
|
+
DEFAULT_FORMATS,
|
|
13
|
+
DEFAULT_WIDTHS,
|
|
14
|
+
parseImageQuery,
|
|
15
|
+
usableWidths,
|
|
16
|
+
} from '@ultimat3/seo';
|
|
17
|
+
import { variantLru } from './runtime-assets-site-cache';
|
|
18
|
+
import { SITE_ASSET_CACHE, siteAssetRoutes } from './site-asset-routes';
|
|
19
|
+
import type { SiteAsset, SiteAssetTable } from './site-assets';
|
|
20
|
+
import { parseHashedAssetUrl } from './site-assets';
|
|
21
|
+
|
|
22
|
+
/** What one process holds of decoded variants. Bounded: an edited asset in `x dev` adds entries. */
|
|
23
|
+
export const SITE_VARIANT_CACHE_BYTES = 64 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
/** The sources core's pipeline decodes. An SVG, an icon or a film has no width to resize to. */
|
|
26
|
+
const RASTERS: ReadonlySet<string> = new Set(['png', 'jpeg', 'webp', 'gif']);
|
|
27
|
+
|
|
28
|
+
export interface SiteVariantOptions {
|
|
29
|
+
/** `assetRoutes`' `images`: one driver for `/media` and `/assets`. Omitted, core's pipeline. */
|
|
30
|
+
readonly images?: ImageTransformDriver | undefined;
|
|
31
|
+
/** Bytes of variants kept in memory, least recently used evicted. */
|
|
32
|
+
readonly cacheBytes?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Answer =
|
|
36
|
+
| { readonly bytes: Uint8Array; readonly contentType: string }
|
|
37
|
+
| { readonly refused: string; readonly widths: readonly number[] };
|
|
38
|
+
|
|
39
|
+
const undeclared = (asset: SiteAsset, cause: string, widths: readonly number[]): Response =>
|
|
40
|
+
jsonResponse(
|
|
41
|
+
{
|
|
42
|
+
ok: false,
|
|
43
|
+
error: {
|
|
44
|
+
code: 'X_ASSET_VARIANT_UNDECLARED',
|
|
45
|
+
cause: `${asset.url}: ${cause} — /assets answers only the variants responsiveImage() mints, so a public URL is never a resize service`,
|
|
46
|
+
fix: `responsiveImage({ src: asset('${asset.path}'), width, height, alt }) # declared here: ?w=${widths.join('|') || 'none'} with ?f=${DEFAULT_FORMATS.join('|')} or no f; an upload under /media answers any width`,
|
|
47
|
+
meta: { url: asset.url, widths: [...widths], formats: [...DEFAULT_FORMATS] },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{ status: 404 },
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Why a query names no declared variant, or `undefined` when it names one. Widths are the srcset's
|
|
55
|
+
* own (`usableWidths` over `DEFAULT_WIDTHS`, asked rather than restated), formats `DEFAULT_FORMATS`
|
|
56
|
+
* or the source's own, and never a quality: `responsiveImage` writes none.
|
|
57
|
+
*/
|
|
58
|
+
function undeclaredBy(query: ImageQuery, widths: readonly number[]): string | undefined {
|
|
59
|
+
if (query.quality !== undefined) return `?q=${String(query.quality)} is a quality`;
|
|
60
|
+
if (query.format !== undefined && !(DEFAULT_FORMATS as readonly string[]).includes(query.format))
|
|
61
|
+
return `?f=${query.format} is not an offered format`;
|
|
62
|
+
if (query.width !== undefined && !widths.includes(query.width))
|
|
63
|
+
return `?w=${String(query.width)} is not a srcset width of this source`;
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The current asset a pathname names, or `undefined` for whatever the plain route answers. */
|
|
68
|
+
function currentAsset(table: SiteAssetTable, pathname: string): SiteAsset | undefined {
|
|
69
|
+
const named = parseHashedAssetUrl(pathname);
|
|
70
|
+
if (named === undefined) return undefined;
|
|
71
|
+
try {
|
|
72
|
+
const asset = table.resolve(named.path);
|
|
73
|
+
return asset.hash === named.hash ? asset : undefined;
|
|
74
|
+
} catch {
|
|
75
|
+
// The plain route answers a missing file with its own code; this one only stands aside.
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `/assets/*file`, the plain route for every request without a transform query — and for a URL
|
|
82
|
+
* that names no current asset, so a stale hash keeps its one answer — and a declared variant else.
|
|
83
|
+
*/
|
|
84
|
+
export function siteAssetVariantRoutes(
|
|
85
|
+
table: SiteAssetTable,
|
|
86
|
+
options: SiteVariantOptions = {},
|
|
87
|
+
): readonly Route[] {
|
|
88
|
+
const cache = variantLru<Answer>(
|
|
89
|
+
finiteCount(
|
|
90
|
+
'siteAssetVariantRoutes',
|
|
91
|
+
'cacheBytes',
|
|
92
|
+
options.cacheBytes ?? SITE_VARIANT_CACHE_BYTES,
|
|
93
|
+
),
|
|
94
|
+
(answer) => ('bytes' in answer ? answer.bytes.byteLength : undefined),
|
|
95
|
+
);
|
|
96
|
+
return siteAssetRoutes(table).map((plain) => ({
|
|
97
|
+
...plain,
|
|
98
|
+
handler: async (request: UltimateRequest, ctx): Promise<Response> => {
|
|
99
|
+
const query = parseImageQuery(request.url.searchParams);
|
|
100
|
+
const asset = query === null ? undefined : currentAsset(table, request.pathname);
|
|
101
|
+
if (query === null || asset === undefined) return plain.handler(request, ctx);
|
|
102
|
+
|
|
103
|
+
const key = `${asset.hash}:${String(query.width ?? 'own')}:${query.format ?? 'own'}:${String(query.quality ?? '')}`;
|
|
104
|
+
const answer = await cache.get(key, async (): Promise<Answer> => {
|
|
105
|
+
const source = await Bun.file(asset.file).bytes();
|
|
106
|
+
const format = sniffImageFormat(source);
|
|
107
|
+
if (format === null || !RASTERS.has(format)) {
|
|
108
|
+
return { refused: 'the file is not a raster image', widths: [] };
|
|
109
|
+
}
|
|
110
|
+
// A header read: past `MAX_IMAGE_PIXELS` it is core's `X_IMAGE_TOO_LARGE`, before a decode.
|
|
111
|
+
const intrinsic = probeImage(source).width;
|
|
112
|
+
const widths = usableWidths(intrinsic, DEFAULT_WIDTHS);
|
|
113
|
+
const why = undeclaredBy(query, widths);
|
|
114
|
+
if (why !== undefined) return { refused: why, widths };
|
|
115
|
+
const driver = options.images ?? builtinImageDriver({ read: async () => source });
|
|
116
|
+
const made = await driver.transform({
|
|
117
|
+
src: asset.url,
|
|
118
|
+
width: query.width ?? intrinsic,
|
|
119
|
+
...(query.format === undefined ? {} : { format: query.format }),
|
|
120
|
+
});
|
|
121
|
+
return { bytes: made.bytes, contentType: made.contentType };
|
|
122
|
+
});
|
|
123
|
+
if ('refused' in answer) return undeclared(asset, answer.refused, answer.widths);
|
|
124
|
+
return applyCacheHeaders(
|
|
125
|
+
// Copied: a `Uint8Array` may sit on a `SharedArrayBuffer`, which `Response` refuses.
|
|
126
|
+
new Response(new Uint8Array(answer.bytes), {
|
|
127
|
+
headers: {
|
|
128
|
+
'content-type': answer.contentType,
|
|
129
|
+
'content-length': String(answer.bytes.byteLength),
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
SITE_ASSET_CACHE,
|
|
133
|
+
);
|
|
134
|
+
},
|
|
135
|
+
}));
|
|
136
|
+
}
|
package/src/runtime-assets.ts
CHANGED
|
@@ -6,12 +6,19 @@
|
|
|
6
6
|
// from `runtime-storage.ts`, because the same bytes are reachable through both.
|
|
7
7
|
|
|
8
8
|
import { probeImage } from '@ultimat3/core';
|
|
9
|
-
import type {
|
|
10
|
-
import { applyCacheHeaders } from '@ultimat3/http';
|
|
9
|
+
import type { ObjectSource, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
|
|
10
|
+
import { applyCacheHeaders, notModified, objectResponse } from '@ultimat3/http';
|
|
11
11
|
import type { ImageQuery, ImageTransformDriver } from '@ultimat3/seo';
|
|
12
12
|
import { builtinImageDriver, DEFAULT_WIDTHS, parseImageQuery, usableWidths } from '@ultimat3/seo';
|
|
13
|
-
import type { ImageTransform, Storage, VariantFormat } from '@ultimat3/storage';
|
|
14
|
-
import {
|
|
13
|
+
import type { ImageTransform, Storage, StorageObject, VariantFormat } from '@ultimat3/storage';
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_QUALITY,
|
|
16
|
+
isVariantFormat,
|
|
17
|
+
objectNotFound,
|
|
18
|
+
variantFormatOf,
|
|
19
|
+
variantKey,
|
|
20
|
+
variantRevision,
|
|
21
|
+
} from '@ultimat3/storage';
|
|
15
22
|
import { faviconRoute } from './favicon';
|
|
16
23
|
// The icon matrix's source, its base path and its renderer live in their own module so that
|
|
17
24
|
// `pwa-artifacts.ts` — which this file imports for the manifest route — can reach them without
|
|
@@ -21,15 +28,16 @@ import { iconPlan, iconRenderer } from './icon-assets';
|
|
|
21
28
|
import type { PwaArtifacts } from './pwa-artifacts';
|
|
22
29
|
import { pwaManifestRoute } from './pwa-artifacts';
|
|
23
30
|
import { revalidatedResponse } from './revalidated-response';
|
|
31
|
+
import { siteAssetVariantRoutes } from './runtime-assets-site';
|
|
24
32
|
import {
|
|
25
33
|
AUTHORIZED_OBJECT_CACHE,
|
|
26
34
|
assertReadableKey,
|
|
35
|
+
assertVariantCurrent,
|
|
27
36
|
authorizeStorageRead,
|
|
37
|
+
readStorageObject,
|
|
28
38
|
STORAGE_READ_PERMISSION,
|
|
29
39
|
} from './runtime-storage';
|
|
30
|
-
import { siteAssetRoutes } from './site-asset-routes';
|
|
31
40
|
import { siteAssetTable } from './site-assets';
|
|
32
|
-
import { storedObjectHeaders } from './stored-object-headers';
|
|
33
41
|
|
|
34
42
|
/**
|
|
35
43
|
* Storage-backed images. `responsiveImage({ src: '/media/<key>' })` mints its variants under it.
|
|
@@ -42,14 +50,6 @@ import { storedObjectHeaders } from './stored-object-headers';
|
|
|
42
50
|
*/
|
|
43
51
|
export const MEDIA_BASE_PATH = '/media';
|
|
44
52
|
|
|
45
|
-
/**
|
|
46
|
-
* A content-addressed variant answers forever with the same bytes, so the immutable hint is a fact
|
|
47
|
-
* about the key. It is NOT a fact about this route — see `mediaCache`. The generated ICONS are not
|
|
48
|
-
* content-addressed (`/icons/icon-192.png` names a size, not bytes), so they revalidate instead —
|
|
49
|
-
* see `revalidatedResponse`.
|
|
50
|
-
*/
|
|
51
|
-
const IMMUTABLE_IMAGE: CacheHint = { mode: 'immutable' };
|
|
52
|
-
|
|
53
53
|
/**
|
|
54
54
|
* Whether a variant is one the framework itself can MINT, and therefore one worth storing.
|
|
55
55
|
*
|
|
@@ -78,65 +78,105 @@ const isMintableWidth = (width: number | undefined, intrinsic: number): boolean
|
|
|
78
78
|
const isMintableQuality = (quality: number | undefined): boolean =>
|
|
79
79
|
quality === undefined || quality === DEFAULT_QUALITY;
|
|
80
80
|
|
|
81
|
-
const imageResponse = (bytes: Uint8Array, contentType: string, cache: CacheHint): Response =>
|
|
82
|
-
applyCacheHeaders(
|
|
83
|
-
// Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
|
|
84
|
-
// `SharedArrayBuffer`, which `Response` does not accept, and copying is what makes that true
|
|
85
|
-
// by construction rather than by a cast that would only silence it.
|
|
86
|
-
new Response(new Uint8Array(bytes), {
|
|
87
|
-
headers: { 'content-type': contentType, ...storedObjectHeaders(contentType) },
|
|
88
|
-
}),
|
|
89
|
-
cache,
|
|
90
|
-
);
|
|
91
|
-
|
|
92
81
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* immutable` for exactly those keys until this branch. Applied in the handler rather than declared
|
|
97
|
-
* in `meta.cache` because the posture is decided by the key, which no route declaration can see —
|
|
98
|
-
* the pipeline's `cache-headers` stage only fills a `cache-control` a handler did not set.
|
|
82
|
+
* Bytes already in hand, as `objectResponse` asks for them. Copied, not passed through: a
|
|
83
|
+
* `Uint8Array<ArrayBufferLike>` may be backed by a `SharedArrayBuffer`, which `Response` does not
|
|
84
|
+
* accept, and copying is what makes that true by construction rather than by a cast.
|
|
99
85
|
*/
|
|
100
|
-
const
|
|
101
|
-
|
|
86
|
+
const heldBytes = (bytes: Uint8Array): ObjectSource['open'] => {
|
|
87
|
+
const body = new Uint8Array(bytes);
|
|
88
|
+
return (range) => (range === undefined ? body : body.subarray(range.start, range.end + 1));
|
|
89
|
+
};
|
|
102
90
|
|
|
103
91
|
/**
|
|
104
92
|
* `exactOptionalPropertyTypes` makes an explicit `undefined` a different answer from an absent
|
|
105
93
|
* key, and `variantKey` reads presence — so a spread, never an assignment.
|
|
106
94
|
*/
|
|
107
|
-
function storageTransform(
|
|
95
|
+
function storageTransform(
|
|
96
|
+
query: ImageQuery,
|
|
97
|
+
format: VariantFormat,
|
|
98
|
+
revision: string,
|
|
99
|
+
): ImageTransform {
|
|
108
100
|
return {
|
|
109
101
|
...(query.width === undefined ? {} : { width: query.width }),
|
|
110
|
-
|
|
102
|
+
format,
|
|
111
103
|
...(query.quality === undefined ? {} : { quality: query.quality }),
|
|
104
|
+
revision,
|
|
112
105
|
};
|
|
113
106
|
}
|
|
114
107
|
|
|
115
108
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
109
|
+
* The validator of one transform of one revision of a source. Built only from the source's own
|
|
110
|
+
* etag and values this route parsed, so a `?f=` no format names never reaches a header: `undefined`
|
|
111
|
+
* there, and the driver refuses that URL. An unknowable source etag is `undefined` too.
|
|
112
|
+
*/
|
|
113
|
+
function variantEtag(
|
|
114
|
+
source: StorageObject,
|
|
115
|
+
query: ImageQuery,
|
|
116
|
+
format: VariantFormat | undefined,
|
|
117
|
+
): string | undefined {
|
|
118
|
+
const bare = source.etag.replace(/^(W\/)?"|"$/g, '');
|
|
119
|
+
if (bare === '') return undefined;
|
|
120
|
+
if (format === undefined && query.format !== undefined) return undefined;
|
|
121
|
+
const transform = [
|
|
122
|
+
query.width === undefined ? 'intrinsic' : `w${query.width}`,
|
|
123
|
+
format ?? 'source',
|
|
124
|
+
...(query.quality === undefined ? [] : [`q${query.quality}`]),
|
|
125
|
+
];
|
|
126
|
+
return `"${bare}-${transform.join('-')}"`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The source is STATED first, so a deleted one is `X_STORAGE_NOT_FOUND` on every disk before any
|
|
131
|
+
* variant is consulted, and its etag decides the key: a variant is looked up under the revision and
|
|
132
|
+
* the format it would be made in, so an overwrite misses it and `?w=` cannot share a key with
|
|
133
|
+
* `?w=&f=webp`. A key no one can state honestly (a `gif`, an unknowable etag) is served, never
|
|
134
|
+
* stored. A revalidation costs one `stat`, a hit one `exists` and one `get`, a miss a decode.
|
|
119
135
|
*/
|
|
120
136
|
async function transformedVariant(
|
|
121
137
|
storage: Storage,
|
|
138
|
+
request: UltimateRequest,
|
|
122
139
|
key: string,
|
|
123
140
|
query: ImageQuery,
|
|
124
141
|
images: ImageTransformDriver | undefined,
|
|
125
142
|
): Promise<Response> {
|
|
126
143
|
const disk = storage.disk();
|
|
144
|
+
await assertVariantCurrent(disk, storage.defaultDisk, key);
|
|
145
|
+
const object = await disk.stat(key);
|
|
146
|
+
if (object === undefined) throw objectNotFound(storage.defaultDisk, key);
|
|
147
|
+
|
|
127
148
|
// A format storage cannot name has no variant key, so it cannot be cached. The driver refuses
|
|
128
149
|
// it with core's `X_IMAGE_UNSUPPORTED`; refusing it here too would give one bad URL two codes.
|
|
150
|
+
const named = query.format;
|
|
129
151
|
const format =
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
152
|
+
named === undefined
|
|
153
|
+
? variantFormatOf(object.contentType)
|
|
154
|
+
: isVariantFormat(named)
|
|
155
|
+
? named
|
|
156
|
+
: undefined;
|
|
157
|
+
const revision = variantRevision(object.etag);
|
|
158
|
+
const cached =
|
|
159
|
+
format === undefined || revision === undefined
|
|
160
|
+
? undefined
|
|
161
|
+
: variantKey(key, storageTransform(query, format, revision));
|
|
162
|
+
|
|
163
|
+
const etag = variantEtag(object, query, format);
|
|
164
|
+
// Before anything is read: this is the request a revalidating browser makes for every image.
|
|
165
|
+
const fresh = etag === undefined ? undefined : notModified(request, etag);
|
|
166
|
+
if (fresh !== undefined) return applyCacheHeaders(fresh, AUTHORIZED_OBJECT_CACHE);
|
|
167
|
+
const answer = (contentType: string, bytes: Uint8Array): Promise<Response> =>
|
|
168
|
+
objectResponse(
|
|
169
|
+
request,
|
|
170
|
+
{
|
|
171
|
+
object: { contentType, size: bytes.byteLength, etag },
|
|
172
|
+
open: heldBytes(bytes),
|
|
173
|
+
},
|
|
174
|
+
{ cache: AUTHORIZED_OBJECT_CACHE },
|
|
175
|
+
);
|
|
176
|
+
|
|
137
177
|
if (cached !== undefined && (await disk.exists(cached))) {
|
|
138
178
|
const hit = await disk.get(cached);
|
|
139
|
-
return
|
|
179
|
+
return answer(hit.object.contentType, hit.bytes);
|
|
140
180
|
}
|
|
141
181
|
|
|
142
182
|
const source = await disk.get(key);
|
|
@@ -157,12 +197,15 @@ async function transformedVariant(
|
|
|
157
197
|
});
|
|
158
198
|
if (
|
|
159
199
|
cached !== undefined &&
|
|
200
|
+
// The bytes just decoded are the revision the key names, or an overwrite landed in between and
|
|
201
|
+
// this variant would answer for bytes it was not made from.
|
|
202
|
+
source.object.etag === object.etag &&
|
|
160
203
|
isMintableWidth(query.width, intrinsic) &&
|
|
161
204
|
isMintableQuality(query.quality)
|
|
162
205
|
) {
|
|
163
206
|
await disk.put(cached, variant.bytes, { contentType: variant.contentType });
|
|
164
207
|
}
|
|
165
|
-
return
|
|
208
|
+
return answer(variant.contentType, variant.bytes);
|
|
166
209
|
}
|
|
167
210
|
|
|
168
211
|
/**
|
|
@@ -181,17 +224,17 @@ async function mediaResponse(
|
|
|
181
224
|
authorizeStorageRead({ disk: storage.defaultDisk, key: requested }, ctx);
|
|
182
225
|
const key = assertReadableKey(storage, storage.defaultDisk, requested, ctx.actor);
|
|
183
226
|
const query = parseImageQuery(request.url.searchParams);
|
|
184
|
-
if (query !== null) return transformedVariant(storage, key, query, images);
|
|
185
|
-
// No transform asked for: the object itself,
|
|
186
|
-
const
|
|
187
|
-
return
|
|
227
|
+
if (query !== null) return transformedVariant(storage, request, key, query, images);
|
|
228
|
+
// No transform asked for: the object itself, read and answered exactly as `/_storage` answers it.
|
|
229
|
+
const served = await readStorageObject(storage, { disk: storage.defaultDisk, key }, ctx.actor);
|
|
230
|
+
return objectResponse(request, served, { cache: AUTHORIZED_OBJECT_CACHE });
|
|
188
231
|
}
|
|
189
232
|
|
|
190
233
|
export interface AssetRoutesOptions {
|
|
191
234
|
/** App root. The source icon is resolved against it; storage keys never are. */
|
|
192
235
|
readonly root: string;
|
|
193
236
|
readonly storage: Storage;
|
|
194
|
-
/** Replaces `builtinImageDriver` for `/media/*`. Omitted, core's
|
|
237
|
+
/** Replaces `builtinImageDriver` for `/media/*` and `/assets/*`. Omitted, core's pipeline. */
|
|
195
238
|
readonly images?: ImageTransformDriver;
|
|
196
239
|
/**
|
|
197
240
|
* `manifest.webmanifest`, resolved at boot by `pwa-artifacts.ts`. Absent when the app declares
|
|
@@ -230,7 +273,7 @@ export function assetRoutes(options: AssetRoutesOptions): readonly Route[] {
|
|
|
230
273
|
// whatever is on the app's only disk, which is every tenant's uploads, so it takes `/_storage`'s
|
|
231
274
|
// declaration verbatim. `enforcedBy: 'handler'` for the reason that route gives — the `authz`
|
|
232
275
|
// stage resolves a policy from `@ultimat3/render`'s page table, which this route is not in.
|
|
233
|
-
//
|
|
276
|
+
// No key's bytes are fixed and every answer is authorized for one actor, so one posture for all.
|
|
234
277
|
routes.push({
|
|
235
278
|
method: 'GET',
|
|
236
279
|
path: `${MEDIA_BASE_PATH}/*key`,
|
|
@@ -252,8 +295,9 @@ export function assetRoutes(options: AssetRoutesOptions): readonly Route[] {
|
|
|
252
295
|
routes.push(faviconRoute(options.root));
|
|
253
296
|
// And every other public file of the site: `apps/web/site/assets/**` at the hashed URLs
|
|
254
297
|
// `asset()` mints, immutable. The same table `loadApp` installed for the renderer, so the URL a
|
|
255
|
-
// document names and the URL this answers are one computation.
|
|
256
|
-
|
|
298
|
+
// document names and the URL this answers are one computation. A raster also answers the
|
|
299
|
+
// `?w=&f=` variants a served `responsiveImage` mints for it, through the same driver as `/media`.
|
|
300
|
+
routes.push(...siteAssetVariantRoutes(siteAssetTable(options.root), { images: options.images }));
|
|
257
301
|
// Same rule one asset further along: the icons above are the ones this manifest NAMES, so the
|
|
258
302
|
// two belong to one surface and cannot be mounted from two places without drifting apart.
|
|
259
303
|
if (options.pwa !== undefined) routes.push(pwaManifestRoute(options.pwa));
|
package/src/runtime-bindings.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { mkdirSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import type { RealtimeConfig } from '@ultimat3/core';
|
|
8
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
9
|
+
import { StorageUnwritableError } from './errors';
|
|
8
10
|
import { REALTIME_DEFAULTS } from './runtime-realtime';
|
|
9
11
|
import { safeUrlLabel } from './safe-url-label';
|
|
10
12
|
|
|
@@ -57,6 +59,22 @@ const s3EndpointOf = (env: Env): string | undefined => nonEmpty(env['S3_ENDPOINT
|
|
|
57
59
|
*/
|
|
58
60
|
export const storageIsExternal = (env: Env): boolean => s3EndpointOf(env) !== undefined;
|
|
59
61
|
|
|
62
|
+
/**
|
|
63
|
+
* `.x/`, coded. A read-only root filesystem (the chart's `readOnlyRootFilesystem: true`) with
|
|
64
|
+
* `DATABASE_URL` set and no `S3_ENDPOINT` threw a bare `EROFS`/`EACCES` here — every role,
|
|
65
|
+
* `ROLE=migrate` included — before `startStorage`'s own coded refusal of the same disk could run.
|
|
66
|
+
*/
|
|
67
|
+
function makeStateDir(stateDir: string): void {
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(stateDir, { recursive: true });
|
|
70
|
+
} catch (cause) {
|
|
71
|
+
throw new StorageUnwritableError(
|
|
72
|
+
`the embedded database or storage disk needs the state directory ${stateDir} and it could not be created: ${renderThrowable(cause)}`,
|
|
73
|
+
`export ULTIMATE_STATE_DIR=/tmp/ultimate-state # any writable directory or volume; or set DATABASE_URL, S3_ENDPOINT and S3_BUCKET so nothing embedded needs ${stateDir}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
60
78
|
/**
|
|
61
79
|
* Embedded Postgres is PGlite on disk under `.x/`, so a restart keeps the data and a `x db reset`
|
|
62
80
|
* is a directory delete rather than a container dance.
|
|
@@ -76,9 +94,7 @@ export function resolveServices(
|
|
|
76
94
|
// do, in-process events never touch the disk. A container whose bindings are external runs
|
|
77
95
|
// non-root over a read-only app directory, and a mkdir there is an EACCES at boot for a directory
|
|
78
96
|
// that would have stayed empty — which an unset NATS_URL alone used to trigger.
|
|
79
|
-
if (databaseUrl === undefined || s3Endpoint === undefined)
|
|
80
|
-
mkdirSync(stateDir, { recursive: true });
|
|
81
|
-
}
|
|
97
|
+
if (databaseUrl === undefined || s3Endpoint === undefined) makeStateDir(stateDir);
|
|
82
98
|
return {
|
|
83
99
|
root,
|
|
84
100
|
stateDir,
|
package/src/runtime-cache.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// and lru unconditionally, redis on `REDIS_URL` and cdn on any real purge driver, so the key was
|
|
5
5
|
// declared, validated at boot, documented, and read by nothing.
|
|
6
6
|
|
|
7
|
-
import type { CacheTier, PurgeDriver } from '@ultimat3/cache';
|
|
7
|
+
import type { CacheTier, PurgeDriver, RedisLike } from '@ultimat3/cache';
|
|
8
8
|
import {
|
|
9
9
|
CacheDriverUnavailableError,
|
|
10
10
|
cdnTier,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
logger,
|
|
29
29
|
renderThrowable,
|
|
30
30
|
} from '@ultimat3/core';
|
|
31
|
+
import { redisClient, redisUrlSource } from '@ultimat3/core/redis';
|
|
31
32
|
import type { Transport, TransportSubscription } from '@ultimat3/realtime/server';
|
|
32
33
|
import type { Env } from './runtime-bindings';
|
|
33
34
|
|
|
@@ -69,6 +70,8 @@ export interface CacheTiersOptions {
|
|
|
69
70
|
* `FIRST_DIAL_GRACE_MS`; a test of an OUTAGE passes 0.
|
|
70
71
|
*/
|
|
71
72
|
readonly firstDialGraceMs?: number | undefined;
|
|
73
|
+
/** The client a `redis` rung reads, for the URL `env` names. Default core's `redisClient`. */
|
|
74
|
+
readonly redisClient?: ((url: string) => RedisLike) | undefined;
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
/**
|
|
@@ -100,14 +103,15 @@ export const cacheTiersOf = (config: AppConfig | undefined): readonly CacheTierN
|
|
|
100
103
|
config?.cache.tiers ?? DEFAULT_CACHE_TIERS;
|
|
101
104
|
|
|
102
105
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
+
* The client for `url`, built at its first command, never at selection: `redisTier` dials nothing
|
|
107
|
+
* until it is read, and handing it a built client would dial at boot. `client` is memoised per URL
|
|
108
|
+
* (core's `redisClient`), so asking it per command is one lookup.
|
|
106
109
|
*/
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
const lazyRedis = (url: string, client: (url: string) => RedisLike): RedisLike => ({
|
|
111
|
+
get: (key) => client(url).get(key),
|
|
112
|
+
set: (key, value) => client(url).set(key, value),
|
|
113
|
+
send: (command, args) => client(url).send(command, args),
|
|
114
|
+
});
|
|
111
115
|
|
|
112
116
|
/**
|
|
113
117
|
* One rung, or a refusal. A process that cannot build what it was configured to build must not
|
|
@@ -125,16 +129,20 @@ function buildTier(name: CacheTierName, options: CacheTiersOptions): CacheTier {
|
|
|
125
129
|
return memoTier();
|
|
126
130
|
case 'lru':
|
|
127
131
|
return lruTier();
|
|
128
|
-
case 'redis':
|
|
129
|
-
|
|
132
|
+
case 'redis': {
|
|
133
|
+
// `REDIS_URL` then `VALKEY_URL`, out of the boot's env: core's own order (`redisUrlSource`),
|
|
134
|
+
// so the URL checked here is the URL the client dials.
|
|
135
|
+
const source = redisUrlSource(options.env);
|
|
136
|
+
if (source === undefined) {
|
|
130
137
|
throw new CacheDriverUnavailableError({
|
|
131
138
|
driver: 'redis',
|
|
132
139
|
cause:
|
|
133
|
-
'cache.tiers names it and REDIS_URL is unset, so this process would read a per-process ladder while the config declares a shared one',
|
|
140
|
+
'cache.tiers names it and REDIS_URL (or VALKEY_URL) is unset, so this process would read a per-process ladder while the config declares a shared one',
|
|
134
141
|
fix: 'set REDIS_URL in .env, or drop the redis tier from cache.tiers in app.config.ts',
|
|
135
142
|
});
|
|
136
143
|
}
|
|
137
|
-
return redisTier();
|
|
144
|
+
return redisTier({ client: lazyRedis(source.url, options.redisClient ?? redisClient) });
|
|
145
|
+
}
|
|
138
146
|
case 'cdn':
|
|
139
147
|
// A noop tier would put a `cdn` line in every invalidation report claiming keys an edge that
|
|
140
148
|
// does not exist had accepted — and the `/_x` cache panel renders those reports, so the lie
|
|
@@ -159,8 +167,9 @@ function buildTier(name: CacheTierName, options: CacheTiersOptions): CacheTier {
|
|
|
159
167
|
*/
|
|
160
168
|
function warnUnnamed(options: CacheTiersOptions): void {
|
|
161
169
|
const named = new Set(options.tiers);
|
|
162
|
-
|
|
163
|
-
|
|
170
|
+
const redis = redisUrlSource(options.env);
|
|
171
|
+
if (!named.has('redis') && redis !== undefined) {
|
|
172
|
+
logger.warn('cache.tier.unnamed', { tier: 'redis', source: redis.key });
|
|
164
173
|
}
|
|
165
174
|
if (!named.has('cdn') && !isNoopPurgeDriver(options.purge)) {
|
|
166
175
|
logger.warn('cache.tier.unnamed', { tier: 'cdn', source: options.purge.name });
|