@michaelthielemann/kestrel 3.0.0 → 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 +6 -8
- 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/Button.vue +2 -2
- package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
- package/layers/ui/app/components/ui/Checkbox.vue +2 -2
- package/layers/ui/app/components/ui/Combobox.vue +5 -5
- package/layers/ui/app/components/ui/DatePicker.vue +2 -2
- package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +4 -4
- package/layers/ui/app/components/ui/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
- package/layers/ui/app/components/ui/Select.vue +2 -2
- package/layers/ui/app/components/ui/TextInput.vue +4 -4
- package/layers/ui/app/components/ui/Textarea.vue +2 -2
- package/layers/ui/app/components/ui/TimeInput.vue +2 -2
- package/layers/ui/app/components/ui/Toasts.vue +3 -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,384 +0,0 @@
|
|
|
1
|
-
import { readdirSync, readFileSync } from 'node:fs'
|
|
2
|
-
import { join, relative, sep, extname } from 'node:path'
|
|
3
|
-
import { setTimeout as sleepFor } from 'node:timers/promises'
|
|
4
|
-
import { resolveS3Settings } from '../../../core/server/utils/kestrel-config'
|
|
5
|
-
import type { StorageDriver } from '../../../core/server/utils/storage'
|
|
6
|
-
|
|
7
|
-
const MIME: Record<string, string> = {
|
|
8
|
-
// markup / scripts / data
|
|
9
|
-
'.html': 'text/html; charset=utf-8',
|
|
10
|
-
'.css': 'text/css; charset=utf-8',
|
|
11
|
-
'.js': 'text/javascript; charset=utf-8',
|
|
12
|
-
'.mjs': 'text/javascript; charset=utf-8',
|
|
13
|
-
'.json': 'application/json; charset=utf-8',
|
|
14
|
-
'.map': 'application/json; charset=utf-8',
|
|
15
|
-
'.xml': 'application/xml; charset=utf-8',
|
|
16
|
-
'.txt': 'text/plain; charset=utf-8',
|
|
17
|
-
'.csv': 'text/csv; charset=utf-8',
|
|
18
|
-
'.md': 'text/markdown; charset=utf-8',
|
|
19
|
-
'.wasm': 'application/wasm',
|
|
20
|
-
'.pdf': 'application/pdf',
|
|
21
|
-
'.webmanifest': 'application/manifest+json',
|
|
22
|
-
// images
|
|
23
|
-
'.svg': 'image/svg+xml',
|
|
24
|
-
'.ico': 'image/x-icon',
|
|
25
|
-
'.png': 'image/png',
|
|
26
|
-
'.jpg': 'image/jpeg',
|
|
27
|
-
'.jpeg': 'image/jpeg',
|
|
28
|
-
'.gif': 'image/gif',
|
|
29
|
-
'.webp': 'image/webp',
|
|
30
|
-
'.avif': 'image/avif',
|
|
31
|
-
'.apng': 'image/apng',
|
|
32
|
-
'.bmp': 'image/bmp',
|
|
33
|
-
// fonts
|
|
34
|
-
'.woff': 'font/woff',
|
|
35
|
-
'.woff2': 'font/woff2',
|
|
36
|
-
'.ttf': 'font/ttf',
|
|
37
|
-
'.otf': 'font/otf',
|
|
38
|
-
'.eot': 'application/vnd.ms-fontobject',
|
|
39
|
-
// audio / video
|
|
40
|
-
'.mp3': 'audio/mpeg',
|
|
41
|
-
'.ogg': 'audio/ogg',
|
|
42
|
-
'.wav': 'audio/wav',
|
|
43
|
-
'.m4a': 'audio/mp4',
|
|
44
|
-
'.mp4': 'video/mp4',
|
|
45
|
-
'.webm': 'video/webm',
|
|
46
|
-
'.mov': 'video/quicktime',
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** MIME type for a static output file, inferred from its extension; octet-stream when unknown. A
|
|
50
|
-
* pre-compressed sibling (`app.js.br`) carries the encoding in `Content-Encoding`, so its type is the
|
|
51
|
-
* underlying asset's — strip a trailing `.br`/`.gz` before the lookup. Only strip it when a
|
|
52
|
-
* `Content-Encoding` will actually be sent (`hasEncoding`, default true for existing callers): a
|
|
53
|
-
* standalone archive with no such header must keep its own extension, or it ships mislabelled as the
|
|
54
|
-
* underlying (uncompressed) type — e.g. a browser trying to parse raw gzip bytes as JSON. */
|
|
55
|
-
export function contentTypeFor(filename: string, hasEncoding = true): string {
|
|
56
|
-
const base = hasEncoding ? filename.replace(/\.(?:br|gz)$/i, '') : filename
|
|
57
|
-
return MIME[extname(base).toLowerCase()] ?? 'application/octet-stream'
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** The `Content-Encoding` for a precompressed sidecar (`app.js.br` beside `app.js`), or undefined for a
|
|
61
|
-
* standalone archive with no uncompressed base — tagging that would make a browser transparently decode
|
|
62
|
-
* genuine content and corrupt it. `siblingNames` are the file names in the same directory. */
|
|
63
|
-
export function precompressedEncoding(filename: string, siblingNames: string[]): 'br' | 'gzip' | undefined {
|
|
64
|
-
const m = /\.(br|gz)$/.exec(filename)
|
|
65
|
-
if (!m || !siblingNames.includes(filename.slice(0, m.index))) return undefined
|
|
66
|
-
return m[1] === 'br' ? 'br' : 'gzip'
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
|
|
70
|
-
const REVALIDATE_CACHE = 'public, max-age=0, must-revalidate'
|
|
71
|
-
|
|
72
|
-
/** Artifacts served at a LITERAL key (not `<path>/index.html`) and rendered from the live DB rather than
|
|
73
|
-
* copied from the build. One list, because each of them has to be handled the same way in four places:
|
|
74
|
-
* re-rendered on every publish, excluded from the build-asset mirror (a stale copy must never overwrite
|
|
75
|
-
* a fresh one), seeded into the prerender routes, and cached as revalidate-always below.
|
|
76
|
-
* `llms-full.txt` is opt-in (`kestrel.seo.llmsFull`) and simply renders to a 404 when off — it stays in
|
|
77
|
-
* this list either way, so the rules that are about the FILENAME hold regardless of the flag; only the
|
|
78
|
-
* prerender seeding, which asks for the ROUTE, has to consult it. */
|
|
79
|
-
export const META_KEYS = ['sitemap.xml', 'robots.txt', 'llms.txt', 'llms-full.txt', 'redirects.json'] as const
|
|
80
|
-
|
|
81
|
-
export function isMetaKey(key: string): boolean {
|
|
82
|
-
return (META_KEYS as readonly string[]).includes(key)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* `Cache-Control` for a static-output key, or `undefined` for no explicit policy. Content-hashed
|
|
87
|
-
* `_nuxt/` assets get a year + `immutable` (the hash is the cache key — new content ⇒ new URL). HTML and
|
|
88
|
-
* the `META_KEYS` artifacts live at *stable* URLs whose content changes on any deploy, so they get
|
|
89
|
-
* `max-age=0, must-revalidate` (cacheable but always revalidated) — `redirects.json` especially, since a
|
|
90
|
-
* cached copy keeps serving withdrawn redirects. Everything else (favicons, fonts, un-hashed media) is
|
|
91
|
-
* left to the host default.
|
|
92
|
-
*/
|
|
93
|
-
export function cacheControlFor(key: string): string | undefined {
|
|
94
|
-
// Nuxt's app manifest lives at a STABLE _nuxt URL but its content (the buildId) changes every build, so it
|
|
95
|
-
// must revalidate — else the immutable rule below pins it and clients never detect a new deploy.
|
|
96
|
-
if (key === '_nuxt/builds/latest.json') return REVALIDATE_CACHE
|
|
97
|
-
if (key === '_nuxt' || key.startsWith('_nuxt/')) return IMMUTABLE_CACHE
|
|
98
|
-
const base = key.split('/').pop() ?? key
|
|
99
|
-
if (base.endsWith('.html') || isMetaKey(base)) return REVALIDATE_CACHE
|
|
100
|
-
return undefined
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** A boolean env flag is on for any of the common truthy spellings (case-insensitive, trimmed). */
|
|
104
|
-
export function isEnvTrue(value: string | undefined): boolean {
|
|
105
|
-
const t = value?.trim().toLowerCase()
|
|
106
|
-
return t === 'true' || t === '1' || t === 'yes' || t === 'on'
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Decide what the deploy hook should do, kept pure so the trigger guard is unit-testable. Returns
|
|
111
|
-
* `'skip'` when this isn't a static `nuxt generate` — a plain `nuxt build` (or dev) must never deploy,
|
|
112
|
-
* even with `driver: s3` configured — and when `output.auto` hands publishing to the running server
|
|
113
|
-
* (see `autoPublish`). For a real (non-dry-run) static generate it throws when the
|
|
114
|
-
* bucket or `KESTREL_S3_*` credentials are missing, so a misconfigured deploy fails the generate
|
|
115
|
-
* loudly instead of warning and exiting 0 with a stale live bucket. A dry-run needs neither.
|
|
116
|
-
*/
|
|
117
|
-
/** Strip leading/trailing slashes so prefixes compare on bare path segments. */
|
|
118
|
-
const normPrefix = (p: string): string => p.replace(/^\/+|\/+$/g, '')
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Do two S3 key prefixes share keyspace on a path-segment boundary — i.e. is one equal to, or nested
|
|
122
|
-
* under, the other? Catches both nesting directions: a prune of `a` reaches everything under `a/b`, and
|
|
123
|
-
* a prune of `a/b` reaches the `a/b/…` slice of media stored under `a`. A pure-string collision like
|
|
124
|
-
* `site` vs `siteother` is *not* an overlap. Empty prefixes never overlap (handled by their own guards).
|
|
125
|
-
*/
|
|
126
|
-
function prefixesOverlap(a: string, b: string): boolean {
|
|
127
|
-
const x = normPrefix(a)
|
|
128
|
-
const y = normPrefix(b)
|
|
129
|
-
if (!x || !y) return false
|
|
130
|
-
return x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function planS3Deploy(opts: {
|
|
134
|
-
isStaticGenerate: boolean
|
|
135
|
-
dryRun: boolean
|
|
136
|
-
bucket: string
|
|
137
|
-
accessKeyId: string
|
|
138
|
-
secretAccessKey: string
|
|
139
|
-
prefix?: string
|
|
140
|
-
/**
|
|
141
|
-
* `output.auto` — the running server publishes to this bucket/prefix itself. Build-time route seeding is
|
|
142
|
-
* then disabled (a prerendered file would shadow the live route), so `.output/public` holds only what
|
|
143
|
-
* Nitro's crawler reached: shipping it would reconcile the server's live pages, sitemap and robots away.
|
|
144
|
-
*/
|
|
145
|
-
autoPublish?: boolean
|
|
146
|
-
/** Bucket the S3 media driver writes live uploads to (empty when media isn't on S3 — then irrelevant). */
|
|
147
|
-
mediaBucket?: string
|
|
148
|
-
/** Key prefix of those live media objects, to keep the reconcile from deleting them. */
|
|
149
|
-
mediaPrefix?: string
|
|
150
|
-
}): 'skip' | 'deploy' {
|
|
151
|
-
if (!opts.isStaticGenerate) return 'skip'
|
|
152
|
-
if (opts.autoPublish) return 'skip'
|
|
153
|
-
if (!opts.dryRun) {
|
|
154
|
-
const missing: string[] = []
|
|
155
|
-
if (!opts.bucket) missing.push('bucket (KESTREL_OUTPUT_S3_BUCKET)')
|
|
156
|
-
if (!opts.accessKeyId) missing.push('KESTREL_OUTPUT_S3_ACCESS_KEY_ID / KESTREL_S3_ACCESS_KEY_ID')
|
|
157
|
-
if (!opts.secretAccessKey) missing.push('KESTREL_OUTPUT_S3_SECRET_ACCESS_KEY / KESTREL_S3_SECRET_ACCESS_KEY')
|
|
158
|
-
if (missing.length) {
|
|
159
|
-
throw new Error(
|
|
160
|
-
`[kestrel] output.driver=s3 but ${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} missing`
|
|
161
|
-
+ ' — refusing to finish `nuxt generate` with a broken S3 deploy.',
|
|
162
|
-
)
|
|
163
|
-
}
|
|
164
|
-
// The deploy always reconciles (deletes every remote object under the prefix not in this generate —
|
|
165
|
-
// output ≡ DB). With an empty prefix that's the whole bucket root, which may hold the media bucket's
|
|
166
|
-
// objects too. Refuse rather than risk wiping it: an S3 deploy must target a dedicated prefix.
|
|
167
|
-
if (!opts.prefix) {
|
|
168
|
-
throw new Error(
|
|
169
|
-
'[kestrel] output.s3.prefix is empty — refusing to deploy (the always-on reconcile would delete the'
|
|
170
|
-
+ ' whole bucket root, and a shared/media bucket could be wiped). Set KESTREL_OUTPUT_S3_PREFIX to a'
|
|
171
|
-
+ ' prefix dedicated to the generated site.',
|
|
172
|
-
)
|
|
173
|
-
}
|
|
174
|
-
// Same bucket as the S3 media driver AND overlapping prefixes → the reconcile would delete live uploads.
|
|
175
|
-
if (opts.mediaBucket && opts.mediaBucket === opts.bucket
|
|
176
|
-
&& opts.mediaPrefix && prefixesOverlap(opts.prefix, opts.mediaPrefix)) {
|
|
177
|
-
throw new Error(
|
|
178
|
-
`[kestrel] the output reconcile would delete live media: output prefix "${opts.prefix}" overlaps the`
|
|
179
|
-
+ ` S3 media prefix "${opts.mediaPrefix}" in the same bucket "${opts.bucket}". Give the generated`
|
|
180
|
-
+ ' site its own bucket or a non-overlapping KESTREL_OUTPUT_S3_PREFIX.',
|
|
181
|
-
)
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
return 'deploy'
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
export interface OutputTarget {
|
|
188
|
-
driver: 'local' | 's3'
|
|
189
|
-
/** Non-secret S3 settings; credentials are resolved separately by `resolveOutputCreds`. */
|
|
190
|
-
s3: { bucket: string; region: string; endpoint: string; prefix: string }
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
type OutputConfig = { driver?: string; s3?: { bucket?: string; region?: string; endpoint?: string; prefix?: string } }
|
|
194
|
-
const clean = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Resolve where `nuxt generate`'s static output is shipped: a local directory (default) or an S3
|
|
198
|
-
* bucket. Precedence per setting: `KESTREL_OUTPUT_*` env → config → default (the documented Kestrel
|
|
199
|
-
* order, same as the runtime publisher). Pure, so the precedence is unit-testable; secrets are resolved
|
|
200
|
-
* by `resolveOutputCreds`, never here.
|
|
201
|
-
*/
|
|
202
|
-
export function resolveOutputTarget(out: OutputConfig = {}, env: Record<string, string | undefined> = {}): OutputTarget {
|
|
203
|
-
const driver = (clean(env.KESTREL_OUTPUT_DRIVER) ?? clean(out.driver))?.toLowerCase() === 's3' ? 's3' : 'local'
|
|
204
|
-
// Same non-secret resolver media uses, under the KESTREL_OUTPUT_S3_* prefix (own bucket, own creds).
|
|
205
|
-
return { driver, s3: resolveS3Settings(out.s3, env, 'KESTREL_OUTPUT_S3') }
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* S3 credentials for the output deploy: the output-specific `KESTREL_OUTPUT_S3_*`, falling back to the
|
|
210
|
-
* shared media `KESTREL_S3_*` so a single S3 account "just works" — identical to the runtime publisher's
|
|
211
|
-
* resolution. Required keys default to '' (not undefined) so `planS3Deploy` detects a missing credential.
|
|
212
|
-
*/
|
|
213
|
-
export function resolveOutputCreds(env: Record<string, string | undefined> = {}): { accessKeyId: string; secretAccessKey: string; sessionToken?: string } {
|
|
214
|
-
return {
|
|
215
|
-
accessKeyId: clean(env.KESTREL_OUTPUT_S3_ACCESS_KEY_ID) ?? clean(env.KESTREL_S3_ACCESS_KEY_ID) ?? '',
|
|
216
|
-
secretAccessKey: clean(env.KESTREL_OUTPUT_S3_SECRET_ACCESS_KEY) ?? clean(env.KESTREL_S3_SECRET_ACCESS_KEY) ?? '',
|
|
217
|
-
sessionToken: clean(env.KESTREL_OUTPUT_S3_SESSION_TOKEN) ?? clean(env.KESTREL_S3_SESSION_TOKEN),
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Every file under `dir` as `{ abs, key }`, the key being its POSIX-relative path (stable order). */
|
|
222
|
-
function listFiles(dir: string): Array<{ abs: string; key: string }> {
|
|
223
|
-
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
224
|
-
.filter((e) => e.isFile())
|
|
225
|
-
.map((e) => {
|
|
226
|
-
const abs = join(e.parentPath, e.name)
|
|
227
|
-
return { abs, key: relative(dir, abs).split(sep).join('/') }
|
|
228
|
-
})
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* A `*.gz`/`*.br` file is a pre-compressed sidecar (from `nitro.compressPublicAssets`) when its
|
|
233
|
-
* uncompressed base is also in the output. On a plain static S3 host these are dead weight — the host
|
|
234
|
-
* does no `Accept-Encoding` negotiation, so nobody fetches `index.html.gz`, and storing it *as*
|
|
235
|
-
* `index.html` with `Content-Encoding` would collide with the real file — so the deploy skips them.
|
|
236
|
-
* A standalone `*.gz` archive (no base file) is genuine content and is kept.
|
|
237
|
-
*/
|
|
238
|
-
export function isCompressedSidecar(key: string, all: Set<string>): boolean {
|
|
239
|
-
const m = /\.(?:gz|br)$/.exec(key)
|
|
240
|
-
return m ? all.has(key.slice(0, m.index)) : false
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
export interface DeployResult { pruned: number; keys: string[] }
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* What build-time route discovery enumerated for this generate, plus WHY that enumeration is known to be
|
|
247
|
-
* short of the site's pages (`incomplete` unset ⇒ it is complete). The runtime publisher's
|
|
248
|
-
* `{ routes, failed }` is the same contract: an enumeration that came up short must never be the
|
|
249
|
-
* authority for what gets deleted.
|
|
250
|
-
*/
|
|
251
|
-
export interface RouteDiscovery { routes: string[]; incomplete?: string }
|
|
252
|
-
|
|
253
|
-
interface DiscoveryHost { _kestrelRouteDiscovery?: RouteDiscovery }
|
|
254
|
-
|
|
255
|
-
/** The prerender and deploy modules are loaded separately and share no module scope — but they are handed
|
|
256
|
-
* the same Nuxt instance, so that object is what carries the signal from the one to the other. */
|
|
257
|
-
export function recordRouteDiscovery(nuxt: object, discovery: RouteDiscovery): void {
|
|
258
|
-
(nuxt as DiscoveryHost)._kestrelRouteDiscovery = discovery
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** `undefined` ⇒ discovery never reported anything, which is not itself evidence of a degraded build. */
|
|
262
|
-
export function readRouteDiscovery(nuxt: object): RouteDiscovery | undefined {
|
|
263
|
-
return (nuxt as DiscoveryHost)._kestrelRouteDiscovery
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** The deploy needs `put` always, and `list`+`delete` for the reconcile; both are optional on the driver. */
|
|
267
|
-
type DeployDriver = Pick<StorageDriver, 'put'> & Partial<Pick<StorageDriver, 'list' | 'delete'>>
|
|
268
|
-
|
|
269
|
-
export interface DeployOptions {
|
|
270
|
-
dryRun?: boolean
|
|
271
|
-
log?: (msg: string) => void
|
|
272
|
-
/** Max operations in flight at once (default 8). Bounded so a large tree never opens thousands of sockets. */
|
|
273
|
-
concurrency?: number
|
|
274
|
-
/** Total attempts per operation before giving up (default 3); retries ride out transient S3 errors. */
|
|
275
|
-
retries?: number
|
|
276
|
-
/** Sleep between retries (exponential backoff); injectable so tests run without real delay. */
|
|
277
|
-
sleep?: (ms: number) => Promise<void>
|
|
278
|
-
/**
|
|
279
|
-
* Why a build step REPORTED that this output tree is short of pages (routes that failed to prerender,
|
|
280
|
-
* route discovery that could not read the DB). Set ⇒ upload but never reconcile: pages missing from the
|
|
281
|
-
* input are indistinguishable from pages removed from the CMS, so the prune would delete live ones.
|
|
282
|
-
* Only a step's own failure report belongs here: the SHAPE of the output cannot tell a degraded build
|
|
283
|
-
* from a small site, so inferring it would strand the pages an editor deleted.
|
|
284
|
-
*/
|
|
285
|
-
incomplete?: string
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/** Run `task` over `items` with at most `concurrency` in flight at once. */
|
|
289
|
-
async function runPool<T>(items: T[], concurrency: number, task: (item: T) => Promise<void>): Promise<void> {
|
|
290
|
-
let next = 0
|
|
291
|
-
const worker = async (): Promise<void> => {
|
|
292
|
-
while (next < items.length) {
|
|
293
|
-
const i = next++
|
|
294
|
-
await task(items[i])
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker))
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** HTTP statuses the S3 driver surfaces in its error message (e.g. `S3 put failed (403) for x`). */
|
|
301
|
-
function statusFromError(err: unknown): number | undefined {
|
|
302
|
-
const m = /\((\d{3})\)/.exec((err as Error)?.message ?? '')
|
|
303
|
-
return m ? Number(m[1]) : undefined
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* A permanent client error — bad/forbidden credentials, missing bucket, denied access (400/401/403/404).
|
|
308
|
-
* These never become successful on a later attempt, so retrying only delays the inevitable loud failure.
|
|
309
|
-
*/
|
|
310
|
-
export function isPermanentError(err: unknown): boolean {
|
|
311
|
-
const s = statusFromError(err)
|
|
312
|
-
return s === 400 || s === 401 || s === 403 || s === 404
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/** Retry `fn` with exponential backoff up to `retries` total attempts, surfacing the last error. */
|
|
316
|
-
async function withRetry(fn: () => Promise<void>, label: string, retries: number, sleep: (ms: number) => Promise<void>, log?: (msg: string) => void): Promise<void> {
|
|
317
|
-
for (let attempt = 1; ; attempt++) {
|
|
318
|
-
try {
|
|
319
|
-
await fn()
|
|
320
|
-
return
|
|
321
|
-
} catch (err) {
|
|
322
|
-
// A permanent 4xx can't be ridden out — re-throw at once so the deploy fails loudly, not slowly.
|
|
323
|
-
if (isPermanentError(err) || attempt >= retries) throw err
|
|
324
|
-
log?.(`retry ${attempt}/${retries - 1} for ${label}: ${(err as Error).message}`)
|
|
325
|
-
await sleep(100 * 2 ** (attempt - 1))
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Upload every file under `dir` to `driver`, keyed by its POSIX-relative path, with a content type
|
|
332
|
-
* inferred from the extension — the S3 equivalent of `aws s3 sync` (overwrites). Then reconciles: deletes
|
|
333
|
-
* remote objects under the prefix that this generate didn't produce (`--delete`), so pages removed from
|
|
334
|
-
* the CMS stop being served (output ≡ DB, no toggle). The reconcile is skipped when the driver can't
|
|
335
|
-
* list+delete and when the caller flagged the tree `incomplete` — a build step reported it is short of
|
|
336
|
-
* pages, so their absence says nothing about the CMS. Uploads still run then: shipping the pages that did
|
|
337
|
-
* render is safe, deleting on their absence is not.
|
|
338
|
-
* Uploads and deletes run with bounded concurrency and each is retried with
|
|
339
|
-
* backoff, so a transient 5xx/throttle/dropped-connection no longer aborts the deploy mid-way and
|
|
340
|
-
* half-updates the bucket; only an operation that fails every attempt rejects (true rollback isn't
|
|
341
|
-
* possible on S3). With `dryRun`, walks/lists and reports without writing.
|
|
342
|
-
*/
|
|
343
|
-
export async function deployStaticOutput(
|
|
344
|
-
dir: string,
|
|
345
|
-
driver: DeployDriver,
|
|
346
|
-
opts: DeployOptions = {},
|
|
347
|
-
): Promise<DeployResult> {
|
|
348
|
-
const concurrency = Math.max(1, opts.concurrency ?? 8)
|
|
349
|
-
const retries = Math.max(1, opts.retries ?? 3)
|
|
350
|
-
const sleep = opts.sleep ?? ((ms: number) => sleepFor(ms))
|
|
351
|
-
const all = listFiles(dir)
|
|
352
|
-
const allKeys = new Set(all.map((f) => f.key))
|
|
353
|
-
const files = all.filter((f) => !isCompressedSidecar(f.key, allKeys))
|
|
354
|
-
const skipped = all.length - files.length
|
|
355
|
-
if (skipped) opts.log?.(`skipping ${skipped} pre-compressed sidecar(s) (.gz/.br)`)
|
|
356
|
-
const keys = files.map((f) => f.key)
|
|
357
|
-
|
|
358
|
-
if (!opts.dryRun) {
|
|
359
|
-
// `files` already excludes pre-compressed sidecars (above), so anything ending `.gz`/`.br` here is a
|
|
360
|
-
// standalone archive shipped with no `Content-Encoding` — keep its own extension in the type lookup.
|
|
361
|
-
await runPool(files, concurrency, (f) =>
|
|
362
|
-
withRetry(() => driver.put(f.key, readFileSync(f.abs), contentTypeFor(f.key, false), { cacheControl: cacheControlFor(f.key) }), f.key, retries, sleep, opts.log))
|
|
363
|
-
}
|
|
364
|
-
opts.log?.(`${opts.dryRun ? 'Would deploy' : 'Deployed'} ${keys.length} file(s)`)
|
|
365
|
-
|
|
366
|
-
// Reconcile (output ≡ DB) — skipped when the driver lacks list/delete (e.g. a put-only target) and when
|
|
367
|
-
// a build step reported that this generate is short of pages.
|
|
368
|
-
let pruned = 0
|
|
369
|
-
if (!driver.list || !driver.delete) {
|
|
370
|
-
opts.log?.('driver cannot list/delete — skipping reconcile')
|
|
371
|
-
} else if (opts.incomplete) {
|
|
372
|
-
opts.log?.(`skipping reconcile — ${opts.incomplete}; the missing pages would read as stale and be deleted`)
|
|
373
|
-
} else {
|
|
374
|
-
const keep = new Set(keys)
|
|
375
|
-
const stale = (await driver.list()).filter((k) => !keep.has(k))
|
|
376
|
-
if (!opts.dryRun) {
|
|
377
|
-
const del = driver.delete
|
|
378
|
-
await runPool(stale, concurrency, (k) => withRetry(() => del(k), `delete ${k}`, retries, sleep, opts.log))
|
|
379
|
-
pruned = stale.length
|
|
380
|
-
}
|
|
381
|
-
opts.log?.(`${opts.dryRun ? 'Would prune' : 'Pruned'} ${stale.length} stale object(s)`)
|
|
382
|
-
}
|
|
383
|
-
return { pruned, keys }
|
|
384
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { resolveInternalHref } from '../../utils/link-resolve'
|
|
2
|
-
|
|
3
|
-
// Batch-resolve internal link refs (`?refs=collection:id,collection:id`) to public hrefs for the admin
|
|
4
|
-
// block preview. Mirrors the read-path populator, status gate included; only resolved refs are returned,
|
|
5
|
-
// so the client renders '#' for a target that is missing or still a draft — same as the generated site.
|
|
6
|
-
export default defineEventHandler((event) => {
|
|
7
|
-
const refs = String(getQuery(event).refs ?? '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
8
|
-
const data: { collection: string; id: number; href: string }[] = []
|
|
9
|
-
for (const ref of refs) {
|
|
10
|
-
const sep = ref.lastIndexOf(':')
|
|
11
|
-
if (sep <= 0) continue
|
|
12
|
-
const collection = ref.slice(0, sep)
|
|
13
|
-
const id = Number(ref.slice(sep + 1))
|
|
14
|
-
if (!Number.isInteger(id) || id <= 0) continue
|
|
15
|
-
const href = resolveInternalHref(collection, id)
|
|
16
|
-
if (href != null) data.push({ collection, id, href })
|
|
17
|
-
}
|
|
18
|
-
return { data }
|
|
19
|
-
})
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { usePreviewStore, previewOwner } from '../utils/preview-token'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Read back a preview ticket (ADR-0008) — the public page fetches it during SSR when the URL carries
|
|
5
|
-
* `?kestrel-preview-token=…` and renders the editor's unsaved state instead of the stored record. Admin-
|
|
6
|
-
* only and session-bound; an expired, foreign or unknown token is `null`, which the page treats as
|
|
7
|
-
* "nothing to preview" and falls back to the saved content rather than failing the render.
|
|
8
|
-
*/
|
|
9
|
-
export default defineEventHandler((event) => {
|
|
10
|
-
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
11
|
-
const token = getQuery(event).token
|
|
12
|
-
if (typeof token !== 'string' || !token) return null
|
|
13
|
-
const payload = usePreviewStore().read(token, previewOwner(event))
|
|
14
|
-
if (!payload) return null
|
|
15
|
-
|
|
16
|
-
// The editor sends what a SAVE would send — raw ids for media and relations — so the ticket goes through
|
|
17
|
-
// the same read population a stored record does. Without it the preview would render a page with the
|
|
18
|
-
// images and internal links stripped, which is worse than useless for judging a layout.
|
|
19
|
-
const c = getCollection(payload.collection)
|
|
20
|
-
if (!c) return { payload }
|
|
21
|
-
const locale = payload.locale || primaryLocale()
|
|
22
|
-
const values = withResolveScope(
|
|
23
|
-
() => populateRow({ ...payload.values }, { depth: 1, locale, def: c.def }),
|
|
24
|
-
resolveBudgetFor(1),
|
|
25
|
-
`preview ${payload.collection}`,
|
|
26
|
-
)
|
|
27
|
-
return { payload: { ...payload, values } }
|
|
28
|
-
})
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { usePreviewStore, previewOwner, type PreviewPayload } from '../utils/preview-token'
|
|
2
|
-
import type { BuiltCollection } from '../../../core/server/utils/collection-types'
|
|
3
|
-
import { fieldIs, type FieldDef } from '../../../core/server/utils/defineCollection'
|
|
4
|
-
import { resolveColumnName } from '../../../fields/server/field-registry/naming'
|
|
5
|
-
import { sanitizeRichtext } from '../../../fields/server/field-registry/sanitize'
|
|
6
|
-
import { getBlock } from '../../../fields/server/utils/defineBlock'
|
|
7
|
-
|
|
8
|
-
/** Editor payloads are form state, not uploads — a block tree with populated media is far below this. */
|
|
9
|
-
const MAX_PAYLOAD_BYTES = 2_000_000
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Sanitize every richtext leaf reachable from `scope` (top-level fields + nested repeater entries), in
|
|
13
|
-
* place. Mirrors the WALK shape of crud.ts's `applyFieldTransforms`/`transformNested`, but not the
|
|
14
|
-
* mechanism: richtext's sanitizer is wired into the Zod validator (`z.string().transform(sanitizeRichtext)`
|
|
15
|
-
* in field-registry/index.ts), not the field-type's `transform` hook that walk calls — and running the
|
|
16
|
-
* collection's Zod schema here isn't an option anyway, see `sanitizePreviewValues` below. So this calls
|
|
17
|
-
* `sanitizeRichtext` directly wherever a `richtext` field is present, and only there; every other field
|
|
18
|
-
* passes through untouched.
|
|
19
|
-
*/
|
|
20
|
-
function sanitizeRichtextFields(fields: Record<string, FieldDef>, scope: Record<string, unknown>): void {
|
|
21
|
-
for (const [key, fieldDef] of Object.entries(fields)) {
|
|
22
|
-
const { jsKey } = resolveColumnName(key, fieldDef)
|
|
23
|
-
if (!Object.hasOwn(scope, jsKey)) continue
|
|
24
|
-
if (fieldIs(fieldDef, 'richtext')) {
|
|
25
|
-
if (typeof scope[jsKey] === 'string') scope[jsKey] = sanitizeRichtext(scope[jsKey] as string)
|
|
26
|
-
} else if (fieldIs(fieldDef, 'repeater')) {
|
|
27
|
-
const entries = scope[jsKey]
|
|
28
|
-
if (Array.isArray(entries)) {
|
|
29
|
-
for (const entry of entries) if (entry && typeof entry === 'object') sanitizeRichtextFields(fieldDef.options.fields, entry as Record<string, unknown>)
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Same walk as `transformBlocks` in crud.ts: each block's props (by its registered field defs) + its
|
|
36
|
-
* slots' nested blocks, recursively. An unregistered block type is left as-is (matches crud.ts). */
|
|
37
|
-
function sanitizeRichtextInBlocks(blocks: unknown): void {
|
|
38
|
-
if (!Array.isArray(blocks)) return
|
|
39
|
-
for (const block of blocks) {
|
|
40
|
-
if (!block || typeof block !== 'object') continue
|
|
41
|
-
const b = block as { type?: string; props?: unknown; slots?: unknown }
|
|
42
|
-
const def = typeof b.type === 'string' ? getBlock(b.type) : undefined
|
|
43
|
-
if (def && b.props && typeof b.props === 'object') sanitizeRichtextFields(def.fields, b.props as Record<string, unknown>)
|
|
44
|
-
if (b.slots && typeof b.slots === 'object') for (const sub of Object.values(b.slots as Record<string, unknown>)) sanitizeRichtextInBlocks(sub)
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Apply the richtext sanitizer to a preview ticket's `values` — the fix for the ticket bypassing it
|
|
50
|
-
* entirely (it normally runs as a Zod `.transform()` on WRITE, `create`/`update`/`putSingleton` in
|
|
51
|
-
* crud.ts; a ticket never reaches those). Deliberately NOT a schema parse: `values` is the editor's
|
|
52
|
-
* in-progress, possibly-invalid draft (a missing required field, an out-of-range number, …), and a preview
|
|
53
|
-
* exists precisely so a draft can be inspected BEFORE it would pass a save — a strict or even a `.partial()`
|
|
54
|
-
* parse would legitimately reject some of what it must accept. Sanitizing is not a validity constraint
|
|
55
|
-
* though (it only narrows stored bytes, the same way a save would), so a direct field-tree walk gets the
|
|
56
|
-
* safety without the rejection risk. Walks top-level fields, repeater entries, and (when the collection
|
|
57
|
-
* uses the block editor) block props + nested slots — everywhere a richtext leaf can live.
|
|
58
|
-
*/
|
|
59
|
-
function sanitizePreviewValues(collection: BuiltCollection, values: Record<string, unknown>): void {
|
|
60
|
-
sanitizeRichtextFields(collection.def.fields, values)
|
|
61
|
-
if (collection.def.blocks?.enabled && Object.hasOwn(values, 'content')) sanitizeRichtextInBlocks(values.content)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Mint a preview ticket for the editor's UNSAVED state (ADR-0008). The editor posts what it currently
|
|
66
|
-
* holds and opens the page at `?kestrel-preview-token=<token>` in a new tab; nothing is written to the DB,
|
|
67
|
-
* so previewing never doubles as an unasked-for save. Admin-only (default-deny API guard + the backstop),
|
|
68
|
-
* and the ticket is bound to this session.
|
|
69
|
-
*
|
|
70
|
-
* body: { collection, id: number | null, locale?, values }
|
|
71
|
-
* 200: { token, expiresAt }
|
|
72
|
-
*/
|
|
73
|
-
export default defineEventHandler(async (event) => {
|
|
74
|
-
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
75
|
-
const body = (await readBody(event)) as Partial<PreviewPayload> | null
|
|
76
|
-
|
|
77
|
-
const name = typeof body?.collection === 'string' ? body.collection : ''
|
|
78
|
-
const collection = getCollection(name) as BuiltCollection | undefined
|
|
79
|
-
if (!collection) throw createError({ statusCode: 404, statusMessage: `Unknown collection: ${name}` })
|
|
80
|
-
|
|
81
|
-
const values = body?.values
|
|
82
|
-
if (!values || typeof values !== 'object' || Array.isArray(values)) {
|
|
83
|
-
throw createError({ statusCode: 400, statusMessage: 'preview requires a `values` object' })
|
|
84
|
-
}
|
|
85
|
-
if (JSON.stringify(values).length > MAX_PAYLOAD_BYTES) {
|
|
86
|
-
throw createError({ statusCode: 413, statusMessage: 'Preview payload too large' })
|
|
87
|
-
}
|
|
88
|
-
sanitizePreviewValues(collection, values as Record<string, unknown>)
|
|
89
|
-
|
|
90
|
-
const id = typeof body?.id === 'number' && Number.isInteger(body.id) ? body.id : null
|
|
91
|
-
const locale = typeof body?.locale === 'string' ? body.locale : undefined
|
|
92
|
-
return usePreviewStore().mint(previewOwner(event), { collection: name, id, locale, values })
|
|
93
|
-
})
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
3
|
-
import { publishStatus } from '../database/publish-status'
|
|
4
|
-
import { routeForRecord } from '../utils/publish/route-for-record'
|
|
5
|
-
import { hasPendingChanges } from '../utils/publish/pending'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Admin-only read of the LIVE publish state of a record's static page (`?collection=&id=`). Admin-only by
|
|
9
|
-
* construction: `/api/*` is default-deny for anonymous and `publish-status` is not a public pageLike
|
|
10
|
-
* resource, so the access guard rejects an unauthenticated request before this runs. Resolves the record's
|
|
11
|
-
* route, then looks up its `publish_status` row. Returns `{ route, status }` (+ error/updatedAt on a row),
|
|
12
|
-
* or `{ route: null, status: null }` when the record has no route (non-pageLike / blank slug / missing),
|
|
13
|
-
* or `{ route, status: null }` when there is no status row yet (never published — dev, a draft, or a
|
|
14
|
-
* republish still in flight). The editor's live-ampel reads this and refetches after each save.
|
|
15
|
-
*/
|
|
16
|
-
export default defineEventHandler((event) => {
|
|
17
|
-
// Environment-level facts the right lamp needs beyond the per-route row: where the NEXT publish would go
|
|
18
|
-
// (`driver`) and whether the runtime publisher actually produces files HERE (prod + `output.auto`). In dev
|
|
19
|
-
// (or with auto off) nothing is ever generated, so the lamp shows a calm "Not built" instead of a stuck
|
|
20
|
-
// "Generating". Read straight from runtimeConfig — no need to pull in the whole publisher module.
|
|
21
|
-
const output = (useRuntimeConfig().kestrel as { output?: { driver?: 'local' | 's3'; auto?: boolean; publishOnSave?: boolean } }).output
|
|
22
|
-
// `publishOnSave` also tells the editor whether to offer a Publish button at all: with the split turned
|
|
23
|
-
// off there is nothing left for it to do.
|
|
24
|
-
const publishOnSave = !!output?.publishOnSave
|
|
25
|
-
const env = { driver: (output?.driver ?? 'local') as 'local' | 's3', generates: !import.meta.dev && !!output?.auto, publishOnSave }
|
|
26
|
-
|
|
27
|
-
const q = getQuery(event)
|
|
28
|
-
const name = typeof q.collection === 'string' ? q.collection : ''
|
|
29
|
-
const id = Number(typeof q.id === 'string' ? q.id : NaN)
|
|
30
|
-
const c = getCollection(name)
|
|
31
|
-
if (!c || !c.def.pageLike || !Number.isInteger(id) || id <= 0) return { route: null, status: null, pending: false, neverPublished: false, ...env }
|
|
32
|
-
|
|
33
|
-
const db = useDb()
|
|
34
|
-
const table = c.table as AnySQLiteTable
|
|
35
|
-
const cols = getTableColumns(table) as Record<string, never>
|
|
36
|
-
const row = db.select().from(table).where(eq(cols.id, id)).get() as { path?: unknown; locale?: unknown; updatedAt?: unknown } | undefined
|
|
37
|
-
const route = routeForRecord(row, true, primaryLocale(), prefixPrimaryLocale())
|
|
38
|
-
if (!route) return { route: null, status: null, pending: false, neverPublished: false, ...env }
|
|
39
|
-
|
|
40
|
-
// Saved after it was last published: with publishing deferred to an explicit action, that is the normal
|
|
41
|
-
// working state of a page being edited — the live file is the previous version until someone publishes.
|
|
42
|
-
const savedAt = row?.updatedAt instanceof Date ? row.updatedAt.getTime() : null
|
|
43
|
-
try {
|
|
44
|
-
const st = db.select().from(publishStatus).where(eq(publishStatus.route, route)).get()
|
|
45
|
-
// A routable page with no row was never published. Before the split that was indistinguishable from
|
|
46
|
-
// "a publish is running" — a save always enqueued one — but now nothing is in flight and nothing will be
|
|
47
|
-
// until someone presses Publish, so the lamp must not claim progress that is not happening.
|
|
48
|
-
if (!st) return { route, status: null, pending: false, neverPublished: true, ...env }
|
|
49
|
-
// With the split off, a save republishes on its own, so a newer save means a republish is in flight —
|
|
50
|
-
// reporting that as "unpublished changes" would ask the user to act on something already happening.
|
|
51
|
-
const pending = !publishOnSave && hasPendingChanges(savedAt, st.updatedAt instanceof Date ? st.updatedAt.getTime() : null)
|
|
52
|
-
return { route, status: st.status, error: st.error, updatedAt: st.updatedAt, target: st.target, pending, neverPublished: false, ...env }
|
|
53
|
-
} catch {
|
|
54
|
-
// publish_status not migrated yet → treat as "no status" rather than a 500. Not "never published"
|
|
55
|
-
// either: the table is unreadable, so the page's real state is unknown, not known to be absent.
|
|
56
|
-
return { route, status: null, pending: false, neverPublished: false, ...env }
|
|
57
|
-
}
|
|
58
|
-
})
|