@michaelthielemann/kestrel 3.0.1 → 4.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/README.md +35 -110
- package/kestrel.config.ts +1 -1
- package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
- package/layers/access/server/middleware/access-guard.ts +25 -23
- package/layers/access/server/types.d.ts +1 -2
- package/layers/admin/app/components/AdminAccount.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +1 -2
- package/layers/admin/app/components/BlockPreview.vue +17 -5
- package/layers/admin/app/components/BlocksBody.vue +1 -1
- package/layers/admin/app/components/CollectionEditor.vue +35 -16
- package/layers/admin/app/components/CollectionList.vue +34 -3
- package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
- package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
- package/layers/admin/app/components/CollectionListPager.vue +1 -1
- package/layers/admin/app/components/CollectionListTable.vue +41 -2
- package/layers/admin/app/components/EditorStatus.vue +8 -7
- package/layers/admin/app/components/LocaleBar.vue +3 -1
- package/layers/admin/app/components/PageFields.vue +2 -4
- package/layers/admin/app/components/SeoFields.vue +2 -4
- package/layers/admin/app/components/SingletonEditor.vue +3 -2
- package/layers/admin/app/composables/useAuth.ts +3 -3
- package/layers/admin/app/composables/useBlockTree.ts +1 -1
- package/layers/admin/app/composables/useBlocks.ts +1 -1
- package/layers/admin/app/composables/useCollectionOps.ts +45 -8
- package/layers/admin/app/composables/useCollections.ts +1 -2
- package/layers/admin/app/composables/useEditForm.ts +35 -17
- package/layers/admin/app/composables/useLinkResolver.ts +1 -1
- package/layers/admin/app/composables/useListBatchActions.ts +20 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
- package/layers/admin/app/composables/useListRows.ts +7 -2
- package/layers/admin/app/composables/useListUrlState.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +2 -2
- package/layers/admin/app/layouts/admin.vue +0 -1
- package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
- package/layers/admin/app/pages/admin/index.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +2 -2
- package/layers/admin/app/utils/block-tree.ts +1 -1
- package/layers/admin/app/utils/collection-ops.ts +28 -3
- package/layers/admin/app/utils/edit-form.ts +34 -2
- package/layers/admin/app/utils/editor-expose.ts +2 -0
- package/layers/admin/app/utils/editor-form-context.ts +1 -2
- package/layers/admin/app/utils/field-keys.ts +1 -1
- package/layers/admin/app/utils/list-columns.ts +3 -2
- package/layers/admin/app/utils/list-query.ts +2 -2
- package/layers/admin/app/utils/populate-blocks.ts +2 -2
- package/layers/admin/app/utils/reauth.ts +5 -6
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
- package/layers/collections/server/plugins/01.register.ts +9 -12
- package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
- package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
- package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
- package/layers/core/modules/auto-discovery/index.ts +68 -11
- package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
- package/layers/core/modules/auto-discovery/scan.ts +77 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
- package/layers/core/modules/component-namespace/index.ts +24 -0
- package/layers/core/modules/kestrel/index.ts +6 -1
- package/layers/core/modules/plugin-order/index.ts +69 -0
- package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
- package/layers/core/nuxt.config.ts +1 -0
- package/layers/core/server/api/[...path].ts +98 -0
- package/layers/core/server/plugins/00.config.ts +12 -0
- package/layers/core/server/plugins/00.migrate.ts +1 -0
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
- package/layers/core/server/plugins/02.schema-sync.ts +2 -7
- package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
- package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
- package/layers/core/server/tasks/db/migrate-module.ts +83 -0
- package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
- package/layers/core/server/tasks/db/migrate.ts +2 -6
- package/layers/core/server/utils/server-config.ts +66 -3
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
- package/layers/media/app/components/KestrelImg.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +2 -2
- package/layers/media/app/components/field/Media.vue +1 -1
- package/layers/media/app/composables/useMediaResolver.ts +1 -1
- package/layers/media/app/composables/useMediaUpload.ts +1 -1
- package/layers/media/app/composables/useMediaVariant.ts +2 -2
- package/layers/media/app/utils/ai-disclosure.ts +1 -1
- package/layers/media/app/utils/library.ts +1 -1
- package/layers/media/app/utils/ops.ts +1 -3
- package/layers/media/app/utils/picture-model.ts +2 -2
- package/layers/media/app/utils/variant-name.ts +1 -1
- package/layers/media/server/middleware/ondemand-variants.ts +6 -5
- package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
- package/layers/media/server/plugins/02.register-media.ts +10 -7
- package/layers/media/server/plugins/04.variant-capture.ts +12 -8
- package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
- package/layers/media/server/tasks/media/backfill.ts +3 -4
- package/layers/public/app/components/KestrelLink.vue +1 -1
- package/layers/public/app/pages/[...slug].vue +1 -1
- package/layers/public/app/pages/__kestrel/preview.vue +2 -2
- package/layers/public/app/utils/json-ld.ts +1 -1
- package/layers/public/app/utils/link-href.ts +1 -1
- package/layers/public/app/utils/page-head.ts +1 -1
- package/layers/public/modules/deploy-output/index.ts +4 -6
- package/layers/public/modules/prerender-routes/index.ts +4 -4
- package/layers/public/modules/prerender-routes/routes.ts +1 -1
- package/layers/public/modules/prune-media/index.ts +2 -2
- package/layers/public/server/delivery-live/serve.ts +111 -0
- package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
- package/layers/public/server/pipelines/index.ts +23 -0
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
- package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
- package/layers/public/server/plugins/02.register-links.ts +2 -3
- package/layers/public/server/plugins/03.redirects.ts +41 -23
- package/layers/public/server/plugins/05.plan-publish.ts +5 -0
- package/layers/public/server/plugins/zz.publish.ts +79 -24
- package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
- package/layers/public/server/routes/llms.txt.get.ts +4 -2
- package/layers/public/server/routes/redirects.json.get.ts +2 -2
- package/layers/public/server/routes/robots.txt.get.ts +2 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
- package/layers/public/server/tasks/publish/run.ts +9 -4
- package/layers/public/server/utils/publish/render-live.ts +26 -0
- package/layers/ui/app/components/field/Choice.vue +1 -1
- package/layers/ui/app/components/field/Datetime.vue +1 -1
- package/layers/ui/app/components/field/Json.vue +1 -2
- package/layers/ui/app/components/field/Layout.vue +2 -4
- package/layers/ui/app/components/field/Link.vue +1 -1
- package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
- package/layers/ui/app/components/field/Number.vue +2 -2
- package/layers/ui/app/components/field/Relation.vue +1 -1
- package/layers/ui/app/components/field/Repeater.vue +1 -3
- package/layers/ui/app/components/field/Slug.vue +2 -3
- package/layers/ui/app/components/field/Text.vue +1 -1
- package/layers/ui/app/components/ui/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
- package/layers/ui/app/composables/useLinkField.ts +1 -1
- package/layers/ui/app/composables/useRepeater.ts +1 -1
- package/layers/ui/app/i18n/de.ts +7 -7
- package/layers/ui/app/i18n/en.ts +7 -39
- package/layers/ui/app/utils/field-component.ts +1 -1
- package/layers/ui/app/utils/field-registry.ts +1 -1
- package/layers/ui/app/utils/icons.ts +2 -6
- package/layers/ui/app/utils/localized.ts +1 -1
- package/nuxt.config.ts +1 -1
- package/package.json +53 -19
- package/scripts/consumer-template-ci.mjs +157 -0
- package/scripts/coverage-all.mjs +163 -0
- package/scripts/dashboard.mjs +89 -0
- package/scripts/hash-password.mjs +1 -1
- package/scripts/lib/coverage-model.mjs +51 -0
- package/scripts/port-graph.mjs +350 -0
- package/scripts/typecheck.mjs +11 -0
- package/templates/starter/app/blocks/Prose.vue +1 -1
- package/layers/access/server/utils/csrf.ts +0 -39
- package/layers/access/server/utils/grant-registry.ts +0 -36
- package/layers/access/server/utils/guard.ts +0 -44
- package/layers/access/server/utils/ip-allowlist.ts +0 -79
- package/layers/access/server/utils/policy.ts +0 -98
- package/layers/access/server/utils/public-resources.ts +0 -11
- package/layers/access/server/utils/render-context.ts +0 -41
- package/layers/access/server/utils/require-admin.ts +0 -15
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
- package/layers/auth/server/api/auth/login.post.ts +0 -38
- package/layers/auth/server/api/auth/logout.post.ts +0 -9
- package/layers/auth/server/api/auth/session.get.ts +0 -1
- package/layers/auth/server/utils/admin-credential.ts +0 -42
- package/layers/auth/server/utils/client-ip.ts +0 -88
- package/layers/auth/server/utils/login-throttle.ts +0 -126
- package/layers/auth/server/utils/password.ts +0 -52
- package/layers/auth/server/utils/session-cookie.ts +0 -44
- package/layers/auth/server/utils/session-epoch.ts +0 -96
- package/layers/auth/server/utils/session.ts +0 -88
- package/layers/collections/server/collections/pages.ts +0 -19
- package/layers/collections/server/utils/populate-relations.ts +0 -79
- package/layers/core/app/utils/bulk-actions.ts +0 -9
- package/layers/core/app/utils/filter-ops.ts +0 -98
- package/layers/core/app/utils/list-limits.ts +0 -27
- package/layers/core/app/utils/locale-path.ts +0 -12
- package/layers/core/app/utils/slugify.ts +0 -16
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
- package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
- package/layers/core/server/api/[collection]/[id].get.ts +0 -11
- package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
- package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
- package/layers/core/server/api/[collection]/index.get.ts +0 -24
- package/layers/core/server/api/[collection]/index.post.ts +0 -8
- package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
- package/layers/core/server/api/[collection]/index.put.ts +0 -24
- package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
- package/layers/core/server/api/[collection]/options.get.ts +0 -18
- package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
- package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
- package/layers/core/server/api/[collection]/translations.get.ts +0 -25
- package/layers/core/server/api/collections/[name].get.ts +0 -4
- package/layers/core/server/api/collections/index.get.ts +0 -3
- package/layers/core/server/api/references/broken.get.ts +0 -9
- package/layers/core/server/api/references/referrers.get.ts +0 -24
- package/layers/core/server/database/record-refs.ts +0 -20
- package/layers/core/server/plugins/03.record-refs.ts +0 -12
- package/layers/core/server/schema/bootstrap.ts +0 -41
- package/layers/core/server/schema/desired.ts +0 -99
- package/layers/core/server/schema/dev-sync.ts +0 -23
- package/layers/core/server/schema/dialect.ts +0 -61
- package/layers/core/server/schema/diff.ts +0 -110
- package/layers/core/server/schema/introspect.ts +0 -68
- package/layers/core/server/schema/model.ts +0 -61
- package/layers/core/server/schema/render-sqlite.ts +0 -89
- package/layers/core/server/schema/sync.ts +0 -201
- package/layers/core/server/utils/blocks.ts +0 -6
- package/layers/core/server/utils/collection-types.ts +0 -28
- package/layers/core/server/utils/crud.ts +0 -578
- package/layers/core/server/utils/db.ts +0 -26
- package/layers/core/server/utils/defineCollection.ts +0 -213
- package/layers/core/server/utils/duplicate.ts +0 -162
- package/layers/core/server/utils/field-layout.ts +0 -125
- package/layers/core/server/utils/filter-predicate.ts +0 -101
- package/layers/core/server/utils/http.ts +0 -50
- package/layers/core/server/utils/if-unmodified.ts +0 -13
- package/layers/core/server/utils/kestrel-config.ts +0 -451
- package/layers/core/server/utils/key-lock.ts +0 -33
- package/layers/core/server/utils/locale.ts +0 -53
- package/layers/core/server/utils/page-route.ts +0 -58
- package/layers/core/server/utils/page-slug.ts +0 -127
- package/layers/core/server/utils/picker.ts +0 -82
- package/layers/core/server/utils/populate.ts +0 -50
- package/layers/core/server/utils/prerender-memo.ts +0 -23
- package/layers/core/server/utils/read-capture.ts +0 -37
- package/layers/core/server/utils/record-ref-index.ts +0 -200
- package/layers/core/server/utils/registry.ts +0 -24
- package/layers/core/server/utils/resolve-scope.ts +0 -79
- package/layers/core/server/utils/seo.ts +0 -42
- package/layers/core/server/utils/serialize-collection.ts +0 -147
- package/layers/core/server/utils/sql.ts +0 -2
- package/layers/core/server/utils/storage.local.ts +0 -117
- package/layers/core/server/utils/storage.s3.ts +0 -197
- package/layers/core/server/utils/storage.ts +0 -90
- package/layers/core/server/utils/write-effects.ts +0 -40
- package/layers/core/server/utils/write-events.ts +0 -31
- package/layers/fields/app/utils/condition.ts +0 -71
- package/layers/fields/app/utils/define-block.ts +0 -13
- package/layers/fields/app/utils/field-constraints.ts +0 -17
- package/layers/fields/app/utils/field-meta.ts +0 -33
- package/layers/fields/app/utils/field-validate.ts +0 -75
- package/layers/fields/app/utils/field-value.ts +0 -7
- package/layers/fields/app/utils/richtext-links.ts +0 -48
- package/layers/fields/server/api/blocks/index.get.ts +0 -9
- package/layers/fields/server/field-registry/index.ts +0 -249
- package/layers/fields/server/field-registry/naming.ts +0 -10
- package/layers/fields/server/field-registry/sanitize.ts +0 -43
- package/layers/fields/server/field-registry/types.ts +0 -19
- package/layers/fields/server/utils/block-ids.ts +0 -43
- package/layers/fields/server/utils/block-populate.ts +0 -41
- package/layers/fields/server/utils/buildCollection.ts +0 -90
- package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
- package/layers/fields/server/utils/buildTable.ts +0 -113
- package/layers/fields/server/utils/conditional-required.ts +0 -25
- package/layers/fields/server/utils/defineBlock.ts +0 -86
- package/layers/fields/server/utils/defineFieldType.ts +0 -33
- package/layers/fields/server/utils/extract-refs.ts +0 -152
- package/layers/fields/server/utils/field-populate.ts +0 -68
- package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
- package/layers/media/server/api/media/[id].delete.ts +0 -18
- package/layers/media/server/api/media/[id].get.ts +0 -10
- package/layers/media/server/api/media/[id].patch.test.ts +0 -66
- package/layers/media/server/api/media/[id].patch.ts +0 -65
- package/layers/media/server/api/media/backfill.post.ts +0 -18
- package/layers/media/server/api/media/copy.post.ts +0 -7
- package/layers/media/server/api/media/delete.post.ts +0 -13
- package/layers/media/server/api/media/folders.post.ts +0 -18
- package/layers/media/server/api/media/index.get.ts +0 -15
- package/layers/media/server/api/media/index.post.ts +0 -173
- package/layers/media/server/api/media/library.get.ts +0 -17
- package/layers/media/server/api/media/move.post.ts +0 -7
- package/layers/media/server/api/media/rename.post.ts +0 -7
- package/layers/media/server/api/media/resolve.get.ts +0 -13
- package/layers/media/server/collections/media-settings.ts +0 -25
- package/layers/media/server/collections/media.ts +0 -43
- package/layers/media/server/database/folders.ts +0 -10
- package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
- package/layers/media/server/schema-tables/folders.ts +0 -4
- package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
- package/layers/media/server/utils/ai-signal-detect.ts +0 -155
- package/layers/media/server/utils/backfill.ts +0 -144
- package/layers/media/server/utils/derive.ts +0 -59
- package/layers/media/server/utils/folder-paths.ts +0 -42
- package/layers/media/server/utils/folders.ts +0 -12
- package/layers/media/server/utils/library.ts +0 -91
- package/layers/media/server/utils/media-cleanup.ts +0 -27
- package/layers/media/server/utils/media-enabled.ts +0 -23
- package/layers/media/server/utils/media-ops.ts +0 -118
- package/layers/media/server/utils/media-write.ts +0 -13
- package/layers/media/server/utils/naming.ts +0 -52
- package/layers/media/server/utils/ondemand.ts +0 -69
- package/layers/media/server/utils/persist-upload.ts +0 -62
- package/layers/media/server/utils/populate.ts +0 -47
- package/layers/media/server/utils/record.ts +0 -71
- package/layers/media/server/utils/relocate-ops.ts +0 -341
- package/layers/media/server/utils/relocate-plan.ts +0 -63
- package/layers/media/server/utils/resolve.ts +0 -98
- package/layers/media/server/utils/sanitize-svg.ts +0 -12
- package/layers/media/server/utils/sniff.ts +0 -68
- package/layers/media/server/utils/storage-relocate.ts +0 -139
- package/layers/media/server/utils/translations.ts +0 -19
- package/layers/media/server/utils/usages.ts +0 -66
- package/layers/media/server/utils/variant-capture.ts +0 -70
- package/layers/media/server/utils/variants.ts +0 -123
- package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
- package/layers/public/server/api/links/resolve.get.ts +0 -19
- package/layers/public/server/api/preview.get.ts +0 -28
- package/layers/public/server/api/preview.post.ts +0 -93
- package/layers/public/server/api/publish-status.get.ts +0 -58
- package/layers/public/server/api/publish.post.ts +0 -84
- package/layers/public/server/api/route.get.ts +0 -54
- package/layers/public/server/collections/redirects.ts +0 -75
- package/layers/public/server/collections/site.ts +0 -44
- package/layers/public/server/database/publish-deps.ts +0 -12
- package/layers/public/server/database/publish-status.ts +0 -19
- package/layers/public/server/schema-tables/publish-deps.ts +0 -4
- package/layers/public/server/schema-tables/publish-status.ts +0 -4
- package/layers/public/server/utils/link-resolve.ts +0 -38
- package/layers/public/server/utils/llms-full.ts +0 -125
- package/layers/public/server/utils/llms.ts +0 -52
- package/layers/public/server/utils/page-resolve.ts +0 -208
- package/layers/public/server/utils/populate-links.ts +0 -66
- package/layers/public/server/utils/preview-token.ts +0 -109
- package/layers/public/server/utils/publish/coalesce.ts +0 -22
- package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
- package/layers/public/server/utils/publish/deps.ts +0 -87
- package/layers/public/server/utils/publish/invalidation.ts +0 -225
- package/layers/public/server/utils/publish/pending.ts +0 -74
- package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
- package/layers/public/server/utils/publish/publish-status.ts +0 -72
- package/layers/public/server/utils/publish/publisher.ts +0 -362
- package/layers/public/server/utils/publish/queue.ts +0 -67
- package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
- package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
- package/layers/public/server/utils/publish/route-for-record.ts +0 -16
- package/layers/public/server/utils/publish/route-keys.ts +0 -13
- package/layers/public/server/utils/richtext-markdown.ts +0 -260
- package/layers/public/server/utils/site-url.ts +0 -40
- package/layers/public/server/utils/sitemap.ts +0 -99
|
@@ -1,362 +0,0 @@
|
|
|
1
|
-
import { readdir, readFile } from 'node:fs/promises'
|
|
2
|
-
import { resolve } from 'node:path'
|
|
3
|
-
import { getTableColumns } from 'drizzle-orm'
|
|
4
|
-
import type { StorageDriver } from '../../../../core/server/utils/storage'
|
|
5
|
-
import { createLocalDriver } from '../../../../core/server/utils/storage.local'
|
|
6
|
-
import { createS3Driver } from '../../../../core/server/utils/storage.s3'
|
|
7
|
-
import { contentTypeFor, cacheControlFor, precompressedEncoding, META_KEYS, isMetaKey } from '../../../modules/deploy-output/deploy-output'
|
|
8
|
-
import { localePath } from '../../../../core/app/utils/locale-path'
|
|
9
|
-
import { pageRowHref } from '../../../../core/server/utils/page-route'
|
|
10
|
-
import { withReadCapture } from '../../../../core/server/utils/read-capture'
|
|
11
|
-
import { withResolveScope } from '../../../../core/server/utils/resolve-scope'
|
|
12
|
-
import { runAsRenderer } from '../../../../access/server/utils/render-context'
|
|
13
|
-
import { htmlKeyForRoute } from './route-keys'
|
|
14
|
-
import { staleRoutes, type DepsStore } from './deps'
|
|
15
|
-
import { recordPublishStatus, clearPublishStatus, renderOutcome, lastPublishedAt } from './publish-status'
|
|
16
|
-
import { routesToPrune, type Invalidation } from './invalidation'
|
|
17
|
-
import { pendingRoutes, heldRoutes } from './pending'
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* The runtime static publisher: renders public routes from the LIVE server (`localFetch`, the same
|
|
21
|
-
* handler `nuxt generate` uses) and writes them — plus the built `_nuxt` bundle + assets — through the
|
|
22
|
-
* configured output StorageDriver (local dir or S3). This is the engine the manual `publish:run` task
|
|
23
|
-
* and (later) the auto-trigger queue both drive. NOT unit-tested (needs a running build); the pure
|
|
24
|
-
* pieces it composes (route-keys, invalidation, deps, queue) are.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
interface OutputRc {
|
|
28
|
-
driver: 'local' | 's3'
|
|
29
|
-
dir: string
|
|
30
|
-
publicDir: string
|
|
31
|
-
auto: boolean
|
|
32
|
-
publishOnSave: boolean
|
|
33
|
-
reconcileMinutes: number
|
|
34
|
-
verbose: boolean
|
|
35
|
-
s3: { bucket: string; region: string; endpoint: string; prefix: string; accessKeyId: string; secretAccessKey: string; sessionToken: string }
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function outputConfig(): OutputRc {
|
|
39
|
-
return (useRuntimeConfig().kestrel as { output: OutputRc }).output
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Build the output driver from runtimeConfig (local dir or S3 bucket) — separate from the media driver. */
|
|
43
|
-
export function outputDriver(cfg: OutputRc = outputConfig()): StorageDriver {
|
|
44
|
-
if (cfg.driver === 's3') {
|
|
45
|
-
return createS3Driver({
|
|
46
|
-
bucket: cfg.s3.bucket, region: cfg.s3.region, endpoint: cfg.s3.endpoint || undefined, prefix: cfg.s3.prefix,
|
|
47
|
-
publicBaseUrl: '', accessKeyId: cfg.s3.accessKeyId, secretAccessKey: cfg.s3.secretAccessKey, sessionToken: cfg.s3.sessionToken || undefined,
|
|
48
|
-
})
|
|
49
|
-
}
|
|
50
|
-
return createLocalDriver({ dir: cfg.dir, baseUrl: '/' })
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Render a public route via the in-process server, returning its HTML buffer (200) or a null body with the
|
|
54
|
-
* HTTP status, so the caller can tell a broken page (5xx — a server-error RESPONSE, not a throw) from an
|
|
55
|
-
* expected non-200 (a draft / unpublish race → 404 / redirect). */
|
|
56
|
-
export async function renderRoute(route: string): Promise<{ body: Buffer | null; status: number }> {
|
|
57
|
-
// Run as the renderer principal so the nested $fetch('/api/route') passes the access guard (it is not a
|
|
58
|
-
// public endpoint). The ALS context propagates through localFetch → the page render → the nested fetch.
|
|
59
|
-
// useNitroApp is imported LAZILY (not via the Nitro auto-import) on purpose: a static import gives this
|
|
60
|
-
// module an edge to nitropack's app.mjs, which both defines useNitroApp AND loads the plugin registry
|
|
61
|
-
// that reaches this file (via zz.publish) — a build-time circular dependency. A dynamic import is a chunk
|
|
62
|
-
// boundary, so that cycle disappears from the build graph; it's the same nitroApp singleton either way
|
|
63
|
-
// (Rollup bundles the literal specifier; useNitroApp is only CALLED here at publish time, long after boot).
|
|
64
|
-
const { useNitroApp } = await import('nitropack/runtime')
|
|
65
|
-
const res = await runAsRenderer(() => useNitroApp().localFetch(route, { method: 'GET', headers: { 'x-kestrel-publish': '1' } }))
|
|
66
|
-
if (res.status !== 200) return { body: null, status: res.status }
|
|
67
|
-
return { body: Buffer.from(await res.arrayBuffer()), status: res.status }
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** The enumerated published routes plus the collections whose query failed. `failed` is non-empty ⇒ the
|
|
71
|
-
* route set is INCOMPLETE, so it must never be used as the authority for what to delete. */
|
|
72
|
-
export interface PublishedRoutes {
|
|
73
|
-
routes: string[]
|
|
74
|
-
/** Each route's record `updatedAt` in ms — the "last saved" half of the saved-vs-published comparison. */
|
|
75
|
-
savedAt: Map<string, number>
|
|
76
|
-
/** Each route's owning record as its deps tag (`<coll>:<id>`) — route strings move on a rename, records
|
|
77
|
-
* do not, so withholding a renamed page needs the identity behind the route. */
|
|
78
|
-
recordTag: Map<string, string>
|
|
79
|
-
/** Names of collections whose route query threw (drifted schema, locked DB) — routes are missing. */
|
|
80
|
-
failed: string[]
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Every published, publicly-readable page-like route (across collections) + the site root — the
|
|
84
|
-
* runtime analogue of the prerender/sitemap route enumeration. noindex pages ARE published (real
|
|
85
|
-
* reachable pages); only the sitemap omits them. */
|
|
86
|
-
export function allPublishedRoutes(): PublishedRoutes {
|
|
87
|
-
const db = useDb()
|
|
88
|
-
const primary = primaryLocale()
|
|
89
|
-
const prefixPrimary = prefixPrimaryLocale()
|
|
90
|
-
const pub = publicReadableResources()
|
|
91
|
-
const routes = new Set<string>([localePath('/', primary, primary, prefixPrimary)]) // `/` or `/<primary>`
|
|
92
|
-
const savedAt = new Map<string, number>()
|
|
93
|
-
const recordTag = new Map<string, string>()
|
|
94
|
-
const failed: string[] = []
|
|
95
|
-
for (const c of allCollections()) {
|
|
96
|
-
if (!c.def.pageLike || !isPubliclyReadable(c.def.name, pub)) continue
|
|
97
|
-
// Project ONLY the columns the route rule + status gate need — never SELECT *, which would pull every
|
|
98
|
-
// row's block-content JSON (and every other field) into memory on each full publish just to read `path`.
|
|
99
|
-
// `locale` exists ONLY on a translatable collection (buildTable adds no locale column otherwise); a bare
|
|
100
|
-
// `cols.locale` would be undefined and make the select throw, dropping every one of this NON-translatable
|
|
101
|
-
// collection's live pages (then pruning them). Guard it exactly like findRouteConflict does.
|
|
102
|
-
const cols = getTableColumns(c.table) as Record<string, never>
|
|
103
|
-
const proj: Record<string, unknown> = { path: cols.path }
|
|
104
|
-
if (c.def.translatable) proj.locale = cols.locale
|
|
105
|
-
if (c.def.status) proj.status = cols.status
|
|
106
|
-
// `updatedAt` is a system column on every built collection, but this projection also runs against
|
|
107
|
-
// hand-rolled tables in tests — guard it exactly like `locale`, or its absence throws the select.
|
|
108
|
-
if (Object.hasOwn(cols, 'updatedAt')) proj.updatedAt = cols.updatedAt
|
|
109
|
-
if (Object.hasOwn(cols, 'id')) proj.id = cols.id
|
|
110
|
-
let rows: Record<string, unknown>[]
|
|
111
|
-
try { rows = db.select(proj as never).from(c.table).all() as Record<string, unknown>[] }
|
|
112
|
-
catch (error) {
|
|
113
|
-
// Report the gap instead of contributing zero routes: an unreadable collection is NOT an empty one,
|
|
114
|
-
// and a caller that can't tell them apart would treat every live page of it as stale.
|
|
115
|
-
failed.push(c.def.name)
|
|
116
|
-
console.error(`[kestrel] allPublishedRoutes: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
|
|
117
|
-
continue
|
|
118
|
-
}
|
|
119
|
-
for (const row of rows) {
|
|
120
|
-
if (c.def.status && row.status !== 'published') continue
|
|
121
|
-
const route = pageRowHref(row, primary, prefixPrimary) // the shared (path, locale) → route rule
|
|
122
|
-
if (!route) continue
|
|
123
|
-
routes.add(route)
|
|
124
|
-
const saved = row.updatedAt
|
|
125
|
-
if (saved instanceof Date) savedAt.set(route, saved.getTime())
|
|
126
|
-
else if (typeof saved === 'number') savedAt.set(route, saved)
|
|
127
|
-
// The same tag the publisher records against a rendered route, so a route can be traced back to its
|
|
128
|
-
// record even after a rename moved the route string.
|
|
129
|
-
if (typeof row.id === 'number') recordTag.set(route, `${c.def.name}:${row.id}`)
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return { routes: [...routes], savedAt, recordTag, failed }
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Render + write the given routes (skips non-200). When `deps` is given, each render is wrapped in a
|
|
136
|
-
* read-capture and its data tags recorded, so a later write maps back to the routes it affects. */
|
|
137
|
-
export async function publishRoutes(routes: string[], driver: StorageDriver, deps?: DepsStore): Promise<string[]> {
|
|
138
|
-
// One UNBUDGETED resolve scope for the whole run: a media/link/relation shared across pages resolves
|
|
139
|
-
// once per publish instead of once per embedding page (the runtime analogue of the generate build's
|
|
140
|
-
// memoDuringPrerender). Memo hits replay their read-tags, so each page's dep capture stays complete.
|
|
141
|
-
return withResolveScope(() => publishRoutesInScope(routes, driver, deps))
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async function publishRoutesInScope(routes: string[], driver: StorageDriver, deps?: DepsStore): Promise<string[]> {
|
|
145
|
-
const written: string[] = []
|
|
146
|
-
const db = useDb()
|
|
147
|
-
const target = outputConfig().driver
|
|
148
|
-
for (const route of routes) {
|
|
149
|
-
try {
|
|
150
|
-
const { result, tags } = await withReadCapture(() => renderRoute(route))
|
|
151
|
-
const outcome = renderOutcome(result.status, !!result.body)
|
|
152
|
-
if (outcome === 'success') {
|
|
153
|
-
deps?.record(route, tags)
|
|
154
|
-
const key = htmlKeyForRoute(route)
|
|
155
|
-
await driver.put(key, result.body!, contentTypeFor(key), { cacheControl: cacheControlFor(key) })
|
|
156
|
-
written.push(route)
|
|
157
|
-
recordPublishStatus(db, route, { status: 'success', target })
|
|
158
|
-
} else if (outcome === 'error') {
|
|
159
|
-
// The page itself rendered to a server error (5xx) — a non-200 RESPONSE, not a thrown exception, so
|
|
160
|
-
// it never reaches the catch below. Record it so the editor ampel shows the failure; the
|
|
161
|
-
// previously-written file (if any) stays in place.
|
|
162
|
-
const message = `Render failed with HTTP ${result.status}`
|
|
163
|
-
recordPublishStatus(db, route, { status: 'error', error: message, target })
|
|
164
|
-
console.error(`[kestrel] publish failed for ${route}: ${message}`)
|
|
165
|
-
}
|
|
166
|
-
// outcome === 'skip': an expected non-200 (a draft / unpublish race) — leave the status untouched.
|
|
167
|
-
} catch (error) {
|
|
168
|
-
// Render or write (incl. S3 put) THREW for THIS route: record the outcome and carry on so one bad
|
|
169
|
-
// route doesn't abort the whole publish. The failure stays visible in the logs and the editor ampel.
|
|
170
|
-
const message = (error as Error)?.message ?? String(error)
|
|
171
|
-
recordPublishStatus(db, route, { status: 'error', error: message, target })
|
|
172
|
-
console.error(`[kestrel] publish failed for ${route}:`, message)
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
return written
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Delete the given routes' static files (idempotent) — unpublish / delete / old-path prune. */
|
|
179
|
-
export async function prunePages(routes: string[], driver: StorageDriver): Promise<void> {
|
|
180
|
-
// pruneEmptyDirs: a page is `<path>/index.html`, so removing it must also clear the now-empty `<path>/`.
|
|
181
|
-
const db = useDb()
|
|
182
|
-
for (const route of routes) {
|
|
183
|
-
await driver.delete(htmlKeyForRoute(route), { pruneEmptyDirs: true })
|
|
184
|
-
clearPublishStatus(db, route) // the file is gone → no live status (after the delete, so a failed delete keeps the row)
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Render + write the crawler/agent artifacts — sitemap/robots/llms/redirects, served at literal keys
|
|
189
|
-
* rather than `<path>/index.html`.
|
|
190
|
-
* A 404 means the route is switched off (`llms-full.txt` without `kestrel.seo.llmsFull`) — the file it
|
|
191
|
-
* wrote while it WAS on must go, or the flag would stop publishing new content while the last full dump
|
|
192
|
-
* stayed live. That is a deterministic route-level answer, not the incomplete-read case a delete must
|
|
193
|
-
* never act on; any other non-200 leaves the existing file alone. */
|
|
194
|
-
async function publishMeta(driver: StorageDriver): Promise<void> {
|
|
195
|
-
for (const key of META_KEYS) {
|
|
196
|
-
const { body, status } = await renderRoute(`/${key}`)
|
|
197
|
-
if (body) await driver.put(key, body, contentTypeFor(key), { cacheControl: cacheControlFor(key) })
|
|
198
|
-
else if (status === 404) await driver.delete(key)
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** Mirror the built client bundle + static assets (`_nuxt/**`, favicon, fonts) into the output, but
|
|
203
|
-
* NOT the build's stale `*.html`/sitemap/robots — those are rendered fresh from the live DB. */
|
|
204
|
-
async function syncStaticAssets(driver: StorageDriver, publicDir: string): Promise<void> {
|
|
205
|
-
const walk = async (dir: string, prefix: string): Promise<void> => {
|
|
206
|
-
let entries
|
|
207
|
-
try { entries = await readdir(dir, { withFileTypes: true }) }
|
|
208
|
-
catch (error) {
|
|
209
|
-
// The root directory failing is a misconfig (missing/unreadable output.publicDir), not an empty
|
|
210
|
-
// tree — silently returning here means zero assets get synced while the render below still
|
|
211
|
-
// uploads fresh HTML referencing hashed `_nuxt/*` chunks that were never uploaded. A nested
|
|
212
|
-
// directory disappearing mid-walk is comparatively benign and stays silent.
|
|
213
|
-
if (!prefix) {
|
|
214
|
-
console.error(`[kestrel] output.publicDir "${dir}" is unreadable — publishing HTML without syncing _nuxt/assets: ${(error as Error)?.message ?? error}`)
|
|
215
|
-
}
|
|
216
|
-
return
|
|
217
|
-
}
|
|
218
|
-
const names = entries.map((s) => s.name)
|
|
219
|
-
for (const e of entries) {
|
|
220
|
-
const rel = prefix ? `${prefix}/${e.name}` : e.name
|
|
221
|
-
if (e.isDirectory()) { await walk(resolve(dir, e.name), rel); continue }
|
|
222
|
-
// Strip a precompressed `.br`/`.gz` suffix before the skip test: `index.html.br` is still the
|
|
223
|
-
// build's stale HTML (only content-negotiable, not literally named `*.html`) and must be excluded
|
|
224
|
-
// exactly like its uncompressed sibling, or it ships as a live sidecar for the fresh HTML rendered
|
|
225
|
-
// below — served to any Accept-Encoding-negotiating client instead of the page just published.
|
|
226
|
-
const base = rel.replace(/\.(?:br|gz)$/i, '')
|
|
227
|
-
if (!e.isFile() || base.endsWith('.html') || isMetaKey(base)) continue
|
|
228
|
-
const bytes = await readFile(resolve(dir, e.name))
|
|
229
|
-
// Tag a precompressed sibling (`.br`/`.gz` beside its base) with Content-Encoding so a proxy over S3
|
|
230
|
-
// serves it directly; a standalone archive stays unencoded so browsers don't decode + corrupt it.
|
|
231
|
-
const contentEncoding = precompressedEncoding(e.name, names)
|
|
232
|
-
await driver.put(rel, bytes, contentTypeFor(rel, !!contentEncoding), { cacheControl: cacheControlFor(rel), contentEncoding })
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
await walk(publicDir, '')
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** Full publish: sync `_nuxt`/assets first (so HTML never references not-yet-uploaded chunks), render
|
|
239
|
-
* every published route + sitemap/robots, and prune routes that left the published set (unpublish /
|
|
240
|
-
* delete / slug change) — tracked via the now-durable deps index, so a page unpublished/deleted while the
|
|
241
|
-
* server was down is pruned on the next boot too. Targeted (only ever deletes routes this publisher wrote),
|
|
242
|
-
* and skipped entirely when the published set could not be enumerated in full.
|
|
243
|
-
*
|
|
244
|
-
* Deliberately NOT single-flighted: each caller brings its own driver and DepsStore, and folding a later
|
|
245
|
-
* caller into a run already in progress would hand it that run's older route snapshot — an edit made
|
|
246
|
-
* after the snapshot would never render and its deps would never be recorded. Serializing overlapping
|
|
247
|
-
* triggers is the publish queue's job. */
|
|
248
|
-
export async function publishFull(driver: StorageDriver = outputDriver(), deps?: DepsStore): Promise<{ rendered: number; pruned: number }> {
|
|
249
|
-
const cfg = outputConfig()
|
|
250
|
-
// Keep the registry read (`allPublishedRoutes` → `allCollections()`) AFTER this first `await`: on a
|
|
251
|
-
// boot publish it lets the synchronous Nitro plugin loop — including collections/01.register, which
|
|
252
|
-
// runs late (layer-then-filename order) — finish populating the registry first. Moving the read before
|
|
253
|
-
// an await would silently render an empty registry. See docs/architecture.md → "Server plugins".
|
|
254
|
-
await syncStaticAssets(driver, cfg.publicDir)
|
|
255
|
-
const { routes, savedAt, recordTag, failed } = allPublishedRoutes()
|
|
256
|
-
if (failed.length) {
|
|
257
|
-
// Keep on doubt: with a collection missing from the enumeration, every one of its live pages looks
|
|
258
|
-
// stale, so a prune would wipe it from the output. Rendering still proceeds — a stale extra file is
|
|
259
|
-
// recoverable, a deleted site is not.
|
|
260
|
-
console.error(`[kestrel] publish: prune skipped — routes of ${failed.join(', ')} could not be enumerated; existing files kept`)
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// A full run resynchronizes the output with the DB, so without this it would push every saved-but-
|
|
264
|
-
// unpublished edit live — exactly what deferring the publish exists to prevent. Those routes keep the
|
|
265
|
-
// file their last publish wrote.
|
|
266
|
-
// …unless the consumer opted out of the split (`output.publishOnSave`): there, a save IS a publish, so
|
|
267
|
-
// "saved after the last publish" means a republish is merely in flight, not deliberately withheld.
|
|
268
|
-
// Computed BEFORE the prune, because a held record's live file may sit at a route the DB no longer names
|
|
269
|
-
// (an unpublished rename), and that file is what the site is still serving. Without `deps` there is no
|
|
270
|
-
// way to find those prior routes, so only same-route withholding applies — the pre-rename behaviour.
|
|
271
|
-
const { hold, keep } = cfg.publishOnSave
|
|
272
|
-
? { hold: new Set<string>(), keep: new Set<string>() }
|
|
273
|
-
: heldRoutes(savedAt, lastPublishedAt(useDb()), recordTag, (tag) => deps?.routesForTags([tag]) ?? [])
|
|
274
|
-
if (hold.size) {
|
|
275
|
-
console.info(`[kestrel] publish: ${hold.size} route(s) held at their published version (unpublished changes): ${[...hold].join(', ')}`)
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Targeted prune: a route we previously published that is no longer in the published set — a page
|
|
279
|
-
// unpublished, deleted, or whose slug changed — must lose its static file. Safe because it only deletes
|
|
280
|
-
// files this publisher wrote (tracked in deps, durable across restarts). Output ≡ DB; no opt-in toggle.
|
|
281
|
-
let pruned = 0
|
|
282
|
-
if (deps && !failed.length) {
|
|
283
|
-
const stale = staleRoutes(deps.routes(), routes).filter((route) => !keep.has(route))
|
|
284
|
-
if (stale.length) {
|
|
285
|
-
await prunePages(stale, driver)
|
|
286
|
-
for (const route of stale) deps.forget(route)
|
|
287
|
-
pruned = stale.length
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const renderRoutes = routes.filter((route) => !hold.has(route))
|
|
292
|
-
|
|
293
|
-
// Reset the discovery accumulator so this full run reconciles ONLY what it actually renders — an earlier
|
|
294
|
-
// incremental (tag) publish also feeds the accumulator, and a variant it recorded whose usage was later
|
|
295
|
-
// removed would otherwise survive and be re-registered here (defeating usage-driven narrowing).
|
|
296
|
-
clearVariants()
|
|
297
|
-
const written = await publishRoutes(renderRoutes, driver, deps)
|
|
298
|
-
const rendered = written.length
|
|
299
|
-
await publishMeta(driver)
|
|
300
|
-
// Auto-discovery: a FULL render just visited every published route, so the capture accumulator now holds
|
|
301
|
-
// the complete set of variants actually used — reconcile it into the media_settings registry (scan entries
|
|
302
|
-
// replaced, manual/pinned kept). A partial (tag) publish must NOT do this: it would drop variants used only
|
|
303
|
-
// by the un-rendered routes. ONLY narrow when EVERY route rendered: a partial failure leaves the accumulator
|
|
304
|
-
// incomplete, so reconciling would deregister variants still referenced by the stale (kept) published HTML,
|
|
305
|
-
// which a later backfill would then delete out from under the live page. An un-enumerated collection is
|
|
306
|
-
// the same partial-coverage case: its pages were never visited, so their variants are missing too — and
|
|
307
|
-
// so is a route held back at its published version: its live file still references the variants this run
|
|
308
|
-
// never saw.
|
|
309
|
-
if (!failed.length && !hold.size && rendered === renderRoutes.length) saveDiscoveredVariants(useDb())
|
|
310
|
-
return { rendered, pruned }
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** Dispatch a coalesced invalidation from the queue: `full` → full publish; `tags` → re-render exactly
|
|
314
|
-
* the affected routes (deps-matched ∪ explicit `render`) + prune the `prune` routes + regen sitemap/robots
|
|
315
|
-
* (their `<lastmod>` may have changed). */
|
|
316
|
-
export interface PublishResult { rendered: string[]; pruned: string[]; counts: { rendered: number; pruned: number } }
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Drop the routes a tag match dragged in that are holding their published version back. Withholding is a
|
|
320
|
-
* property of the ROUTE, not of the full publish: a route whose record was saved after its last publish
|
|
321
|
-
* serves that published file until someone publishes it. Without this, publishing one record re-renders
|
|
322
|
-
* every route tagged with the collection — and every route reads the `site` singleton — from the live DB,
|
|
323
|
-
* so a routine Publish writes an unrelated record's withheld body to the live site.
|
|
324
|
-
*
|
|
325
|
-
* A route named in `render` is exempt: it IS what the publish was for, and pressing Publish is what clears
|
|
326
|
-
* the withholding. The prune set is untouched — removal has no publish intent left to protect, so an
|
|
327
|
-
* unpublished or deleted record's page still goes at once (ADR-0008).
|
|
328
|
-
*
|
|
329
|
-
* The cost, deliberately accepted: a withheld route keeps the baked links and hreflang of its last
|
|
330
|
-
* publish, so a link to a record that has since been unpublished stays stale until the referrer itself is
|
|
331
|
-
* published. That is the same staleness its body already carries — a frozen route is one publish
|
|
332
|
-
* generation throughout, rather than a mix of two. Rendering a referrer from its published state while
|
|
333
|
-
* resolving fresh links needs a published snapshot per record, which is ADR-0008's "Future".
|
|
334
|
-
*/
|
|
335
|
-
function withheldRemoved(inv: Extract<Invalidation, { type: 'tags' }>, routes: string[]): string[] {
|
|
336
|
-
if (outputConfig().publishOnSave) return routes // that mode never defers a publish in the first place
|
|
337
|
-
// An un-enumerable collection contributes no `savedAt` entry, so its routes are simply not withheld —
|
|
338
|
-
// the same direction publishFull takes, and the non-destructive one (a stale re-render, never a delete).
|
|
339
|
-
const held = new Set(pendingRoutes(allPublishedRoutes().savedAt, lastPublishedAt(useDb())))
|
|
340
|
-
const explicit = new Set(inv.render)
|
|
341
|
-
return routes.filter((route) => explicit.has(route) || !held.has(route))
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
export async function publishInvalidation(inv: Invalidation, driver: StorageDriver = outputDriver(), deps?: DepsStore): Promise<PublishResult> {
|
|
345
|
-
if (inv.type === 'noop') return { rendered: [], pruned: [], counts: { rendered: 0, pruned: 0 } }
|
|
346
|
-
if (inv.type === 'full') {
|
|
347
|
-
const r = await publishFull(driver, deps) // full: counts only (don't list every route)
|
|
348
|
-
return { rendered: [], pruned: [], counts: { rendered: r.rendered, pruned: r.pruned } }
|
|
349
|
-
}
|
|
350
|
-
const routes = withheldRemoved(inv, [...new Set([...(deps?.routesForTags(inv.tags) ?? []), ...inv.render])])
|
|
351
|
-
const rendered = await publishRoutes(routes, driver, deps)
|
|
352
|
-
let pruned: string[] = []
|
|
353
|
-
// Never prune a route we just wrote live — render wins a coalesced render+prune collision (see routesToPrune).
|
|
354
|
-
const toPrune = routesToPrune(inv.prune, rendered)
|
|
355
|
-
if (toPrune.length) {
|
|
356
|
-
await prunePages(toPrune, driver)
|
|
357
|
-
for (const route of toPrune) deps?.forget(route)
|
|
358
|
-
pruned = toPrune
|
|
359
|
-
}
|
|
360
|
-
await publishMeta(driver)
|
|
361
|
-
return { rendered, pruned, counts: { rendered: rendered.length, pruned: pruned.length } }
|
|
362
|
-
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import type { Invalidation } from './invalidation'
|
|
2
|
-
import { coalesce } from './coalesce'
|
|
3
|
-
|
|
4
|
-
export interface PublishQueueOptions {
|
|
5
|
-
/** Dispatch a coalesced invalidation (full or tags). Rejections are caught + re-queued. */
|
|
6
|
-
run: (inv: Invalidation) => Promise<void>
|
|
7
|
-
/** Quiet window before a pending batch fires (default 3000ms). */
|
|
8
|
-
debounceMs?: number
|
|
9
|
-
/** Cap on how long a steady stream of writes can defer the flush (default 60000ms). */
|
|
10
|
-
maxWaitMs?: number
|
|
11
|
-
onError?: (error: unknown) => void
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface PublishQueue {
|
|
15
|
-
/** Record an invalidation; a coalesced run fires after the quiet window. noop is ignored. */
|
|
16
|
-
enqueue: (inv: Invalidation) => void
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Debounce + coalesce + single-flight queue for publish invalidations. A burst of content writes
|
|
21
|
-
* collapses to ONE run after a quiet window (capped by maxWait); only one run is in flight at a time,
|
|
22
|
-
* and anything enqueued mid-run defers to the next pass — so a publish always reflects the CURRENT DB
|
|
23
|
-
* state and converges (idempotent). A failed run re-queues its batch (never silently drops writes).
|
|
24
|
-
*/
|
|
25
|
-
export function createPublishQueue(opts: PublishQueueOptions): PublishQueue {
|
|
26
|
-
const debounceMs = opts.debounceMs ?? 3000
|
|
27
|
-
const maxWaitMs = opts.maxWaitMs ?? 60000
|
|
28
|
-
let pending: Invalidation[] = []
|
|
29
|
-
let timer: ReturnType<typeof setTimeout> | null = null
|
|
30
|
-
let firstAt = 0
|
|
31
|
-
let running = false
|
|
32
|
-
|
|
33
|
-
function arm() {
|
|
34
|
-
if (timer) clearTimeout(timer)
|
|
35
|
-
const now = Date.now()
|
|
36
|
-
if (!firstAt) firstAt = now
|
|
37
|
-
const wait = Math.max(0, Math.min(debounceMs, firstAt + maxWaitMs - now))
|
|
38
|
-
timer = setTimeout(flush, wait)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function flush() {
|
|
42
|
-
timer = null
|
|
43
|
-
firstAt = 0
|
|
44
|
-
if (running || pending.length === 0) return // a run is in flight; its finally() re-arms
|
|
45
|
-
const inv = coalesce(pending)
|
|
46
|
-
pending = []
|
|
47
|
-
if (inv.type === 'noop') return
|
|
48
|
-
running = true
|
|
49
|
-
void opts.run(inv)
|
|
50
|
-
.catch((error) => {
|
|
51
|
-
opts.onError?.(error)
|
|
52
|
-
pending.unshift(inv) // never drop a batch on failure — retry on the next pass
|
|
53
|
-
})
|
|
54
|
-
.finally(() => {
|
|
55
|
-
running = false
|
|
56
|
-
if (pending.length) arm()
|
|
57
|
-
})
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function enqueue(inv: Invalidation) {
|
|
61
|
-
if (inv.type === 'noop') return
|
|
62
|
-
pending.push(inv)
|
|
63
|
-
if (!running) arm() // mid-run enqueues are collected; finally() re-arms once the run settles
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return { enqueue }
|
|
67
|
-
}
|
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Compile the editor-authored redirect rows into the flat `redirects.json` artifact the edge consumes.
|
|
3
|
-
*
|
|
4
|
-
* Editors never write a regex: `from` is a path with `*` (one segment) or `**` (one or more) wildcards,
|
|
5
|
-
* and `to` may reference them positionally as `$1`, `$2`, … in authored order. The translation happens
|
|
6
|
-
* here — versioned, tested code — so the edge script only has to match and substitute.
|
|
7
|
-
*
|
|
8
|
-
* Pure by design (no Nuxt/Nitro imports): it is the executable spec for the edge as much as it is the
|
|
9
|
-
* artifact writer's input, which is why `matchRedirect` lives here even though nothing in Kestrel
|
|
10
|
-
* serves redirects at runtime.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/** One entry of the published artifact. `pattern` is a regex SOURCE string, anchored, path-only. */
|
|
14
|
-
export interface RedirectRule {
|
|
15
|
-
pattern: string
|
|
16
|
-
target: string
|
|
17
|
-
status: number
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** The statuses the editor offers. 301/302 cover SEO; 307/308 preserve the method on non-GET requests. */
|
|
21
|
-
export const REDIRECT_STATUSES = ['301', '302', '307', '308'] as const
|
|
22
|
-
|
|
23
|
-
/** A rule the editor has to fix. The message names the 1-based row so it is actionable in the UI. */
|
|
24
|
-
export class RedirectRuleError extends Error {}
|
|
25
|
-
|
|
26
|
-
const ESCAPE = /[.*+?^${}()|[\]\\-]/g
|
|
27
|
-
// eslint-disable-next-line no-control-regex -- deliberately rejects control characters headed for a Location header
|
|
28
|
-
const CONTROL = /[\u0000-\u001f\u007f]/
|
|
29
|
-
const SCHEME = /^[a-z][a-z0-9+.-]*:/i
|
|
30
|
-
|
|
31
|
-
function escapeLiteral(s: string): string {
|
|
32
|
-
return s.replace(ESCAPE, '\\$&')
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* What a wildcard may capture. This is a SECURITY boundary, not a convenience: a capture comes from the
|
|
37
|
-
* request, not from the editor, so `normalizeTarget`'s checks — which only ever saw the authored literal
|
|
38
|
-
* — say nothing about it. The pattern is the guard instead, and a request that would splice something
|
|
39
|
-
* dangerous into `Location` simply does not match and falls through to the origin.
|
|
40
|
-
*
|
|
41
|
-
* Excluded everywhere: a backslash (every browser resolves `Location: /\host` as `//host` — an open
|
|
42
|
-
* redirect) and the control characters that split a header (CR/LF) or terminate it (NUL, DEL).
|
|
43
|
-
* Additionally, a multi-segment capture may not START with `/`, or a target of `/$1` would become the
|
|
44
|
-
* protocol-relative `//host`. Anything a legitimate path contains still matches.
|
|
45
|
-
*/
|
|
46
|
-
const SEGMENT_CHAR = '[^/\\\\\\x00-\\x1f\\x7f]'
|
|
47
|
-
const PATH_CHAR = '[^\\\\\\x00-\\x1f\\x7f]'
|
|
48
|
-
const ONE_SEGMENT = `(${SEGMENT_CHAR}+)`
|
|
49
|
-
const MANY_SEGMENTS = `(${SEGMENT_CHAR}${PATH_CHAR}*?)`
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Translate an authored `from` into an anchored regex source. Matching is path-only and case-sensitive;
|
|
53
|
-
* an authored trailing slash is dropped and one is tolerated at match time, so `/blog` and `/blog/` are
|
|
54
|
-
* the same rule.
|
|
55
|
-
*/
|
|
56
|
-
export function patternToRegexSource(from: string): string {
|
|
57
|
-
const raw = from.trim()
|
|
58
|
-
if (!raw) throw new RedirectRuleError('"From" must not be blank')
|
|
59
|
-
if (SCHEME.test(raw) || raw.startsWith('//')) {
|
|
60
|
-
throw new RedirectRuleError('"From" matches the request path only — drop the scheme and host')
|
|
61
|
-
}
|
|
62
|
-
if (/[?#]/.test(raw)) throw new RedirectRuleError('"From" must not contain a query string or fragment')
|
|
63
|
-
if (CONTROL.test(raw)) throw new RedirectRuleError('"From" must not contain control characters')
|
|
64
|
-
if (raw.includes('\\')) throw new RedirectRuleError('"From" must not contain a backslash')
|
|
65
|
-
if (raw.split('/').some((seg) => seg === '..')) throw new RedirectRuleError('"From" must not contain ".."')
|
|
66
|
-
// Two `**` with only a separator between them match the same thing through every split point, which is
|
|
67
|
-
// quadratic on a long path — seconds of CPU per request on a path an attacker chooses. It is also never
|
|
68
|
-
// what the author meant, so it is an authoring error rather than a limit to tune.
|
|
69
|
-
if (/\*\*\/?\*\*/.test(raw)) throw new RedirectRuleError('"From" has two `**` in a row — one already matches any number of segments')
|
|
70
|
-
|
|
71
|
-
const path = `/${raw.replace(/^\/+/, '').replace(/\/+$/, '')}`
|
|
72
|
-
if (path === '/') return '^/$'
|
|
73
|
-
|
|
74
|
-
let body = ''
|
|
75
|
-
let last = 0
|
|
76
|
-
for (const m of path.matchAll(/\*\*|\*/g)) {
|
|
77
|
-
body += escapeLiteral(path.slice(last, m.index)) + (m[0] === '**' ? MANY_SEGMENTS : ONE_SEGMENT)
|
|
78
|
-
last = m.index + m[0].length
|
|
79
|
-
}
|
|
80
|
-
return `^${body + escapeLiteral(path.slice(last))}/?$`
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Number of capture groups `patternToRegexSource` emits for an authored `from`. */
|
|
84
|
-
function wildcardCount(from: string): number {
|
|
85
|
-
return (from.trim().match(/\*\*|\*/g) ?? []).length
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Normalize an authored `to` into a `Location` value. A path gets its leading slash; an absolute
|
|
90
|
-
* http(s) URL is kept verbatim (a moved domain is a legitimate target). Everything else is rejected —
|
|
91
|
-
* a `javascript:`/`data:` scheme or a protocol-relative `//host` would turn a redirect into a hazard.
|
|
92
|
-
*/
|
|
93
|
-
export function normalizeTarget(to: string): string {
|
|
94
|
-
const raw = to.trim()
|
|
95
|
-
if (!raw) throw new RedirectRuleError('"To" must not be blank')
|
|
96
|
-
if (CONTROL.test(raw)) throw new RedirectRuleError('"To" must not contain control characters')
|
|
97
|
-
if (raw.includes('\\')) throw new RedirectRuleError('"To" must not contain a backslash')
|
|
98
|
-
if (raw.startsWith('//')) {
|
|
99
|
-
throw new RedirectRuleError('"To" must not be protocol-relative — write the full https:// URL')
|
|
100
|
-
}
|
|
101
|
-
if (!SCHEME.test(raw)) return `/${raw.replace(/^\/+/, '')}`
|
|
102
|
-
|
|
103
|
-
if (!/^https?:\/\//i.test(raw)) throw new RedirectRuleError('"To" may only use http:// or https://')
|
|
104
|
-
let url: URL
|
|
105
|
-
try {
|
|
106
|
-
url = new URL(raw)
|
|
107
|
-
} catch {
|
|
108
|
-
throw new RedirectRuleError(`"To" is not a valid URL: ${raw}`)
|
|
109
|
-
}
|
|
110
|
-
if (!url.host) throw new RedirectRuleError('"To" is missing a host')
|
|
111
|
-
if (url.username || url.password) throw new RedirectRuleError('"To" must not embed credentials')
|
|
112
|
-
return raw
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* A placeholder inside an absolute target's HOST would let a visitor choose the destination
|
|
117
|
-
* (`https://neu.example.com$1` + a request of `/blog/.evil.com` → `https://neu.example.com.evil.com`).
|
|
118
|
-
* The capture classes cannot prevent that one — the hazard is where `$n` sits, not what it holds — so it
|
|
119
|
-
* is rejected at authoring time.
|
|
120
|
-
*/
|
|
121
|
-
function assertPlaceholdersAfterHost(target: string): void {
|
|
122
|
-
if (!SCHEME.test(target)) return
|
|
123
|
-
const firstPlaceholder = target.indexOf('$')
|
|
124
|
-
if (firstPlaceholder === -1) return
|
|
125
|
-
const pathStart = target.indexOf('/', target.indexOf('://') + 3)
|
|
126
|
-
if (pathStart === -1 || firstPlaceholder < pathStart) {
|
|
127
|
-
throw new RedirectRuleError('"To" may only use $1, $2, … after the host — a placeholder in the host lets a visitor pick the destination')
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function readStatus(value: unknown): number {
|
|
132
|
-
const s = value === undefined || value === null || value === '' ? '301' : String(value)
|
|
133
|
-
if (!(REDIRECT_STATUSES as readonly string[]).includes(s)) {
|
|
134
|
-
throw new RedirectRuleError(`Status must be one of ${REDIRECT_STATUSES.join(', ')} (got ${s})`)
|
|
135
|
-
}
|
|
136
|
-
return Number(s)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function readText(value: unknown, what: string): string {
|
|
140
|
-
if (typeof value !== 'string' || !value.trim()) throw new RedirectRuleError(`${what} must not be blank`)
|
|
141
|
-
return value.trim()
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Rows → artifact entries, in authored order (order IS priority: the edge takes the first match).
|
|
146
|
-
* An empty/absent field is zero redirects, which is a fully supported state — not an error.
|
|
147
|
-
* A row the edge could not honour throws instead of being dropped, so a typo fails the save loudly.
|
|
148
|
-
*/
|
|
149
|
-
export function compileRedirects(rows: unknown): RedirectRule[] {
|
|
150
|
-
if (rows === null || rows === undefined) return []
|
|
151
|
-
if (!Array.isArray(rows)) throw new RedirectRuleError('Redirect rules must be a list')
|
|
152
|
-
|
|
153
|
-
return rows.map((raw, i) => {
|
|
154
|
-
try {
|
|
155
|
-
const entry = (raw ?? {}) as Record<string, unknown>
|
|
156
|
-
const from = readText(entry.from, '"From"')
|
|
157
|
-
const target = normalizeTarget(readText(entry.to, '"To"'))
|
|
158
|
-
const groups = wildcardCount(from)
|
|
159
|
-
for (const [, n] of target.matchAll(/\$(\d+)/g)) {
|
|
160
|
-
if (Number(n) < 1 || Number(n) > groups) {
|
|
161
|
-
throw new RedirectRuleError(`"To" references $${n} but "From" has ${groups} wildcard(s)`)
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
// `${1}` is the plausible typo — it compiles clean and then ships verbatim in every Location, a
|
|
165
|
-
// rule that silently 404s. A bare `$` is left alone: it is a legal path character (RFC 3986
|
|
166
|
-
// sub-delim), and only `$` followed by digits is reserved.
|
|
167
|
-
if (/\$\{/.test(target)) {
|
|
168
|
-
throw new RedirectRuleError('"To" writes a placeholder as $1, $2, … — not ${1}')
|
|
169
|
-
}
|
|
170
|
-
assertPlaceholdersAfterHost(target)
|
|
171
|
-
return { pattern: patternToRegexSource(from), target, status: readStatus(entry.status) }
|
|
172
|
-
} catch (err) {
|
|
173
|
-
throw new RedirectRuleError(`Row ${i + 1}: ${err instanceof Error ? err.message : String(err)}`)
|
|
174
|
-
}
|
|
175
|
-
})
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* The PUBLISHING counterpart of `compileRedirects`. Identical on data the save path accepted — which is
|
|
180
|
-
* all of it, since the collection's `validate` compiles every row before the write. The difference only
|
|
181
|
-
* shows after an upgrade that tightened a rule: a row stored by an older version would otherwise make
|
|
182
|
-
* this artifact unrenderable forever, taking every OTHER redirect down with it (and, at build time,
|
|
183
|
-
* suppressing the deploy's reconcile). Publishing the rest and naming what was dropped is the lesser
|
|
184
|
-
* failure; the editor learns about it the moment they next save, which is the strict path.
|
|
185
|
-
*
|
|
186
|
-
* A malformed `rows` still throws: that is a read bug, not a bad row, and `[]` would be a lie.
|
|
187
|
-
*/
|
|
188
|
-
export function compilePublishableRedirects(rows: unknown): { rules: RedirectRule[]; skipped: string[] } {
|
|
189
|
-
if (rows === null || rows === undefined) return { rules: [], skipped: [] }
|
|
190
|
-
if (!Array.isArray(rows)) throw new RedirectRuleError('Redirect rules must be a list')
|
|
191
|
-
|
|
192
|
-
const rules: RedirectRule[] = []
|
|
193
|
-
const skipped: string[] = []
|
|
194
|
-
rows.forEach((row, i) => {
|
|
195
|
-
try {
|
|
196
|
-
rules.push(...compileRedirects([row]))
|
|
197
|
-
} catch (error) {
|
|
198
|
-
skipped.push(`Row ${i + 1}: ${(error as Error).message.replace(/^Row 1: /, '')}`)
|
|
199
|
-
}
|
|
200
|
-
})
|
|
201
|
-
return { rules, skipped }
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** The artifact body. An empty list is a valid document (`[]`), never an absent file. */
|
|
205
|
-
export function serializeRedirects(rules: RedirectRule[]): string {
|
|
206
|
-
return JSON.stringify(rules)
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Reference implementation of the edge's match step — first rule that matches wins, `$n` substituted
|
|
211
|
-
* from the capture groups. Kestrel does not serve redirects; this pins the semantics the njs handler
|
|
212
|
-
* has to reproduce, and is what the tests assert against.
|
|
213
|
-
*/
|
|
214
|
-
export function matchRedirect(rules: RedirectRule[], path: string): { target: string; status: number } | null {
|
|
215
|
-
for (const rule of rules) {
|
|
216
|
-
const m = new RegExp(rule.pattern).exec(path)
|
|
217
|
-
if (!m) continue
|
|
218
|
-
return { target: rule.target.replace(/\$(\d+)/g, (_, n: string) => m[Number(n)] ?? ''), status: rule.status }
|
|
219
|
-
}
|
|
220
|
-
return null
|
|
221
|
-
}
|