@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.
Files changed (341) hide show
  1. package/README.md +35 -110
  2. package/kestrel.config.ts +1 -1
  3. package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
  4. package/layers/access/server/middleware/access-guard.ts +25 -23
  5. package/layers/access/server/types.d.ts +1 -2
  6. package/layers/admin/app/components/AdminAccount.vue +1 -1
  7. package/layers/admin/app/components/BlockFields.vue +1 -2
  8. package/layers/admin/app/components/BlockPreview.vue +17 -5
  9. package/layers/admin/app/components/BlocksBody.vue +1 -1
  10. package/layers/admin/app/components/CollectionEditor.vue +35 -16
  11. package/layers/admin/app/components/CollectionList.vue +34 -3
  12. package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
  13. package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
  14. package/layers/admin/app/components/CollectionListPager.vue +1 -1
  15. package/layers/admin/app/components/CollectionListTable.vue +41 -2
  16. package/layers/admin/app/components/EditorStatus.vue +8 -7
  17. package/layers/admin/app/components/LocaleBar.vue +3 -1
  18. package/layers/admin/app/components/PageFields.vue +2 -4
  19. package/layers/admin/app/components/SeoFields.vue +2 -4
  20. package/layers/admin/app/components/SingletonEditor.vue +3 -2
  21. package/layers/admin/app/composables/useAuth.ts +3 -3
  22. package/layers/admin/app/composables/useBlockTree.ts +1 -1
  23. package/layers/admin/app/composables/useBlocks.ts +1 -1
  24. package/layers/admin/app/composables/useCollectionOps.ts +45 -8
  25. package/layers/admin/app/composables/useCollections.ts +1 -2
  26. package/layers/admin/app/composables/useEditForm.ts +35 -17
  27. package/layers/admin/app/composables/useLinkResolver.ts +1 -1
  28. package/layers/admin/app/composables/useListBatchActions.ts +20 -3
  29. package/layers/admin/app/composables/useListColumns.ts +1 -1
  30. package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
  31. package/layers/admin/app/composables/useListRows.ts +7 -2
  32. package/layers/admin/app/composables/useListUrlState.ts +1 -1
  33. package/layers/admin/app/composables/usePublishStatus.ts +2 -2
  34. package/layers/admin/app/layouts/admin.vue +0 -1
  35. package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
  36. package/layers/admin/app/pages/admin/index.vue +1 -1
  37. package/layers/admin/app/pages/admin/references.vue +2 -2
  38. package/layers/admin/app/utils/block-tree.ts +1 -1
  39. package/layers/admin/app/utils/collection-ops.ts +28 -3
  40. package/layers/admin/app/utils/edit-form.ts +34 -2
  41. package/layers/admin/app/utils/editor-expose.ts +2 -0
  42. package/layers/admin/app/utils/editor-form-context.ts +1 -2
  43. package/layers/admin/app/utils/field-keys.ts +1 -1
  44. package/layers/admin/app/utils/list-columns.ts +3 -2
  45. package/layers/admin/app/utils/list-query.ts +2 -2
  46. package/layers/admin/app/utils/populate-blocks.ts +2 -2
  47. package/layers/admin/app/utils/reauth.ts +5 -6
  48. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
  49. package/layers/collections/server/plugins/01.register.ts +9 -12
  50. package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
  51. package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
  52. package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
  53. package/layers/core/modules/auto-discovery/index.ts +68 -11
  54. package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
  55. package/layers/core/modules/auto-discovery/scan.ts +77 -0
  56. package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
  57. package/layers/core/modules/component-namespace/index.ts +24 -0
  58. package/layers/core/modules/kestrel/index.ts +6 -1
  59. package/layers/core/modules/plugin-order/index.ts +69 -0
  60. package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
  61. package/layers/core/nuxt.config.ts +1 -0
  62. package/layers/core/server/api/[...path].ts +98 -0
  63. package/layers/core/server/plugins/00.config.ts +12 -0
  64. package/layers/core/server/plugins/00.migrate.ts +1 -0
  65. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
  66. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
  67. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
  68. package/layers/core/server/plugins/02.schema-sync.ts +2 -7
  69. package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
  70. package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
  71. package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
  72. package/layers/core/server/tasks/db/migrate-module.ts +83 -0
  73. package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
  74. package/layers/core/server/tasks/db/migrate.ts +2 -6
  75. package/layers/core/server/utils/server-config.ts +66 -3
  76. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
  77. package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
  78. package/layers/media/app/components/KestrelImg.vue +3 -3
  79. package/layers/media/app/components/MediaLibrary.vue +2 -2
  80. package/layers/media/app/components/field/Media.vue +1 -1
  81. package/layers/media/app/composables/useMediaResolver.ts +1 -1
  82. package/layers/media/app/composables/useMediaUpload.ts +1 -1
  83. package/layers/media/app/composables/useMediaVariant.ts +2 -2
  84. package/layers/media/app/utils/ai-disclosure.ts +1 -1
  85. package/layers/media/app/utils/library.ts +1 -1
  86. package/layers/media/app/utils/ops.ts +1 -3
  87. package/layers/media/app/utils/picture-model.ts +2 -2
  88. package/layers/media/app/utils/variant-name.ts +1 -1
  89. package/layers/media/server/middleware/ondemand-variants.ts +6 -5
  90. package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
  91. package/layers/media/server/plugins/02.register-media.ts +10 -7
  92. package/layers/media/server/plugins/04.variant-capture.ts +12 -8
  93. package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
  94. package/layers/media/server/tasks/media/backfill.ts +3 -4
  95. package/layers/public/app/components/KestrelLink.vue +1 -1
  96. package/layers/public/app/pages/[...slug].vue +1 -1
  97. package/layers/public/app/pages/__kestrel/preview.vue +2 -2
  98. package/layers/public/app/utils/json-ld.ts +1 -1
  99. package/layers/public/app/utils/link-href.ts +1 -1
  100. package/layers/public/app/utils/page-head.ts +1 -1
  101. package/layers/public/modules/deploy-output/index.ts +4 -6
  102. package/layers/public/modules/prerender-routes/index.ts +4 -4
  103. package/layers/public/modules/prerender-routes/routes.ts +1 -1
  104. package/layers/public/modules/prune-media/index.ts +2 -2
  105. package/layers/public/server/delivery-live/serve.ts +111 -0
  106. package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
  107. package/layers/public/server/pipelines/index.ts +23 -0
  108. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
  109. package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
  110. package/layers/public/server/plugins/02.register-links.ts +2 -3
  111. package/layers/public/server/plugins/03.redirects.ts +41 -23
  112. package/layers/public/server/plugins/05.plan-publish.ts +5 -0
  113. package/layers/public/server/plugins/zz.publish.ts +79 -24
  114. package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
  115. package/layers/public/server/routes/llms.txt.get.ts +4 -2
  116. package/layers/public/server/routes/redirects.json.get.ts +2 -2
  117. package/layers/public/server/routes/robots.txt.get.ts +2 -0
  118. package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
  119. package/layers/public/server/tasks/publish/run.ts +9 -4
  120. package/layers/public/server/utils/publish/render-live.ts +26 -0
  121. package/layers/ui/app/components/field/Choice.vue +1 -1
  122. package/layers/ui/app/components/field/Datetime.vue +1 -1
  123. package/layers/ui/app/components/field/Json.vue +1 -2
  124. package/layers/ui/app/components/field/Layout.vue +2 -4
  125. package/layers/ui/app/components/field/Link.vue +1 -1
  126. package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
  127. package/layers/ui/app/components/field/Number.vue +2 -2
  128. package/layers/ui/app/components/field/Relation.vue +1 -1
  129. package/layers/ui/app/components/field/Repeater.vue +1 -3
  130. package/layers/ui/app/components/field/Slug.vue +2 -3
  131. package/layers/ui/app/components/field/Text.vue +1 -1
  132. package/layers/ui/app/components/ui/Richtext.vue +1 -1
  133. package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
  134. package/layers/ui/app/composables/useLinkField.ts +1 -1
  135. package/layers/ui/app/composables/useRepeater.ts +1 -1
  136. package/layers/ui/app/i18n/de.ts +7 -7
  137. package/layers/ui/app/i18n/en.ts +7 -39
  138. package/layers/ui/app/utils/field-component.ts +1 -1
  139. package/layers/ui/app/utils/field-registry.ts +1 -1
  140. package/layers/ui/app/utils/icons.ts +2 -6
  141. package/layers/ui/app/utils/localized.ts +1 -1
  142. package/nuxt.config.ts +1 -1
  143. package/package.json +53 -19
  144. package/scripts/consumer-template-ci.mjs +157 -0
  145. package/scripts/coverage-all.mjs +163 -0
  146. package/scripts/dashboard.mjs +89 -0
  147. package/scripts/hash-password.mjs +1 -1
  148. package/scripts/lib/coverage-model.mjs +51 -0
  149. package/scripts/port-graph.mjs +350 -0
  150. package/scripts/typecheck.mjs +11 -0
  151. package/templates/starter/app/blocks/Prose.vue +1 -1
  152. package/layers/access/server/utils/csrf.ts +0 -39
  153. package/layers/access/server/utils/grant-registry.ts +0 -36
  154. package/layers/access/server/utils/guard.ts +0 -44
  155. package/layers/access/server/utils/ip-allowlist.ts +0 -79
  156. package/layers/access/server/utils/policy.ts +0 -98
  157. package/layers/access/server/utils/public-resources.ts +0 -11
  158. package/layers/access/server/utils/render-context.ts +0 -41
  159. package/layers/access/server/utils/require-admin.ts +0 -15
  160. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
  161. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
  162. package/layers/auth/server/api/auth/login.post.ts +0 -38
  163. package/layers/auth/server/api/auth/logout.post.ts +0 -9
  164. package/layers/auth/server/api/auth/session.get.ts +0 -1
  165. package/layers/auth/server/utils/admin-credential.ts +0 -42
  166. package/layers/auth/server/utils/client-ip.ts +0 -88
  167. package/layers/auth/server/utils/login-throttle.ts +0 -126
  168. package/layers/auth/server/utils/password.ts +0 -52
  169. package/layers/auth/server/utils/session-cookie.ts +0 -44
  170. package/layers/auth/server/utils/session-epoch.ts +0 -96
  171. package/layers/auth/server/utils/session.ts +0 -88
  172. package/layers/collections/server/collections/pages.ts +0 -19
  173. package/layers/collections/server/utils/populate-relations.ts +0 -79
  174. package/layers/core/app/utils/bulk-actions.ts +0 -9
  175. package/layers/core/app/utils/filter-ops.ts +0 -98
  176. package/layers/core/app/utils/list-limits.ts +0 -27
  177. package/layers/core/app/utils/locale-path.ts +0 -12
  178. package/layers/core/app/utils/slugify.ts +0 -16
  179. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
  180. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
  181. package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
  182. package/layers/core/server/api/[collection]/[id].get.ts +0 -11
  183. package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
  184. package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
  185. package/layers/core/server/api/[collection]/index.get.ts +0 -24
  186. package/layers/core/server/api/[collection]/index.post.ts +0 -8
  187. package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
  188. package/layers/core/server/api/[collection]/index.put.ts +0 -24
  189. package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
  190. package/layers/core/server/api/[collection]/options.get.ts +0 -18
  191. package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
  192. package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
  193. package/layers/core/server/api/[collection]/translations.get.ts +0 -25
  194. package/layers/core/server/api/collections/[name].get.ts +0 -4
  195. package/layers/core/server/api/collections/index.get.ts +0 -3
  196. package/layers/core/server/api/references/broken.get.ts +0 -9
  197. package/layers/core/server/api/references/referrers.get.ts +0 -24
  198. package/layers/core/server/database/record-refs.ts +0 -20
  199. package/layers/core/server/plugins/03.record-refs.ts +0 -12
  200. package/layers/core/server/schema/bootstrap.ts +0 -41
  201. package/layers/core/server/schema/desired.ts +0 -99
  202. package/layers/core/server/schema/dev-sync.ts +0 -23
  203. package/layers/core/server/schema/dialect.ts +0 -61
  204. package/layers/core/server/schema/diff.ts +0 -110
  205. package/layers/core/server/schema/introspect.ts +0 -68
  206. package/layers/core/server/schema/model.ts +0 -61
  207. package/layers/core/server/schema/render-sqlite.ts +0 -89
  208. package/layers/core/server/schema/sync.ts +0 -201
  209. package/layers/core/server/utils/blocks.ts +0 -6
  210. package/layers/core/server/utils/collection-types.ts +0 -28
  211. package/layers/core/server/utils/crud.ts +0 -578
  212. package/layers/core/server/utils/db.ts +0 -26
  213. package/layers/core/server/utils/defineCollection.ts +0 -213
  214. package/layers/core/server/utils/duplicate.ts +0 -162
  215. package/layers/core/server/utils/field-layout.ts +0 -125
  216. package/layers/core/server/utils/filter-predicate.ts +0 -101
  217. package/layers/core/server/utils/http.ts +0 -50
  218. package/layers/core/server/utils/if-unmodified.ts +0 -13
  219. package/layers/core/server/utils/kestrel-config.ts +0 -451
  220. package/layers/core/server/utils/key-lock.ts +0 -33
  221. package/layers/core/server/utils/locale.ts +0 -53
  222. package/layers/core/server/utils/page-route.ts +0 -58
  223. package/layers/core/server/utils/page-slug.ts +0 -127
  224. package/layers/core/server/utils/picker.ts +0 -82
  225. package/layers/core/server/utils/populate.ts +0 -50
  226. package/layers/core/server/utils/prerender-memo.ts +0 -23
  227. package/layers/core/server/utils/read-capture.ts +0 -37
  228. package/layers/core/server/utils/record-ref-index.ts +0 -200
  229. package/layers/core/server/utils/registry.ts +0 -24
  230. package/layers/core/server/utils/resolve-scope.ts +0 -79
  231. package/layers/core/server/utils/seo.ts +0 -42
  232. package/layers/core/server/utils/serialize-collection.ts +0 -147
  233. package/layers/core/server/utils/sql.ts +0 -2
  234. package/layers/core/server/utils/storage.local.ts +0 -117
  235. package/layers/core/server/utils/storage.s3.ts +0 -197
  236. package/layers/core/server/utils/storage.ts +0 -90
  237. package/layers/core/server/utils/write-effects.ts +0 -40
  238. package/layers/core/server/utils/write-events.ts +0 -31
  239. package/layers/fields/app/utils/condition.ts +0 -71
  240. package/layers/fields/app/utils/define-block.ts +0 -13
  241. package/layers/fields/app/utils/field-constraints.ts +0 -17
  242. package/layers/fields/app/utils/field-meta.ts +0 -33
  243. package/layers/fields/app/utils/field-validate.ts +0 -75
  244. package/layers/fields/app/utils/field-value.ts +0 -7
  245. package/layers/fields/app/utils/richtext-links.ts +0 -48
  246. package/layers/fields/server/api/blocks/index.get.ts +0 -9
  247. package/layers/fields/server/field-registry/index.ts +0 -249
  248. package/layers/fields/server/field-registry/naming.ts +0 -10
  249. package/layers/fields/server/field-registry/sanitize.ts +0 -43
  250. package/layers/fields/server/field-registry/types.ts +0 -19
  251. package/layers/fields/server/utils/block-ids.ts +0 -43
  252. package/layers/fields/server/utils/block-populate.ts +0 -41
  253. package/layers/fields/server/utils/buildCollection.ts +0 -90
  254. package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
  255. package/layers/fields/server/utils/buildTable.ts +0 -113
  256. package/layers/fields/server/utils/conditional-required.ts +0 -25
  257. package/layers/fields/server/utils/defineBlock.ts +0 -86
  258. package/layers/fields/server/utils/defineFieldType.ts +0 -33
  259. package/layers/fields/server/utils/extract-refs.ts +0 -152
  260. package/layers/fields/server/utils/field-populate.ts +0 -68
  261. package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
  262. package/layers/media/server/api/media/[id].delete.ts +0 -18
  263. package/layers/media/server/api/media/[id].get.ts +0 -10
  264. package/layers/media/server/api/media/[id].patch.test.ts +0 -66
  265. package/layers/media/server/api/media/[id].patch.ts +0 -65
  266. package/layers/media/server/api/media/backfill.post.ts +0 -18
  267. package/layers/media/server/api/media/copy.post.ts +0 -7
  268. package/layers/media/server/api/media/delete.post.ts +0 -13
  269. package/layers/media/server/api/media/folders.post.ts +0 -18
  270. package/layers/media/server/api/media/index.get.ts +0 -15
  271. package/layers/media/server/api/media/index.post.ts +0 -173
  272. package/layers/media/server/api/media/library.get.ts +0 -17
  273. package/layers/media/server/api/media/move.post.ts +0 -7
  274. package/layers/media/server/api/media/rename.post.ts +0 -7
  275. package/layers/media/server/api/media/resolve.get.ts +0 -13
  276. package/layers/media/server/collections/media-settings.ts +0 -25
  277. package/layers/media/server/collections/media.ts +0 -43
  278. package/layers/media/server/database/folders.ts +0 -10
  279. package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
  280. package/layers/media/server/schema-tables/folders.ts +0 -4
  281. package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
  282. package/layers/media/server/utils/ai-signal-detect.ts +0 -155
  283. package/layers/media/server/utils/backfill.ts +0 -144
  284. package/layers/media/server/utils/derive.ts +0 -59
  285. package/layers/media/server/utils/folder-paths.ts +0 -42
  286. package/layers/media/server/utils/folders.ts +0 -12
  287. package/layers/media/server/utils/library.ts +0 -91
  288. package/layers/media/server/utils/media-cleanup.ts +0 -27
  289. package/layers/media/server/utils/media-enabled.ts +0 -23
  290. package/layers/media/server/utils/media-ops.ts +0 -118
  291. package/layers/media/server/utils/media-write.ts +0 -13
  292. package/layers/media/server/utils/naming.ts +0 -52
  293. package/layers/media/server/utils/ondemand.ts +0 -69
  294. package/layers/media/server/utils/persist-upload.ts +0 -62
  295. package/layers/media/server/utils/populate.ts +0 -47
  296. package/layers/media/server/utils/record.ts +0 -71
  297. package/layers/media/server/utils/relocate-ops.ts +0 -341
  298. package/layers/media/server/utils/relocate-plan.ts +0 -63
  299. package/layers/media/server/utils/resolve.ts +0 -98
  300. package/layers/media/server/utils/sanitize-svg.ts +0 -12
  301. package/layers/media/server/utils/sniff.ts +0 -68
  302. package/layers/media/server/utils/storage-relocate.ts +0 -139
  303. package/layers/media/server/utils/translations.ts +0 -19
  304. package/layers/media/server/utils/usages.ts +0 -66
  305. package/layers/media/server/utils/variant-capture.ts +0 -70
  306. package/layers/media/server/utils/variants.ts +0 -123
  307. package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
  308. package/layers/public/server/api/links/resolve.get.ts +0 -19
  309. package/layers/public/server/api/preview.get.ts +0 -28
  310. package/layers/public/server/api/preview.post.ts +0 -93
  311. package/layers/public/server/api/publish-status.get.ts +0 -58
  312. package/layers/public/server/api/publish.post.ts +0 -84
  313. package/layers/public/server/api/route.get.ts +0 -54
  314. package/layers/public/server/collections/redirects.ts +0 -75
  315. package/layers/public/server/collections/site.ts +0 -44
  316. package/layers/public/server/database/publish-deps.ts +0 -12
  317. package/layers/public/server/database/publish-status.ts +0 -19
  318. package/layers/public/server/schema-tables/publish-deps.ts +0 -4
  319. package/layers/public/server/schema-tables/publish-status.ts +0 -4
  320. package/layers/public/server/utils/link-resolve.ts +0 -38
  321. package/layers/public/server/utils/llms-full.ts +0 -125
  322. package/layers/public/server/utils/llms.ts +0 -52
  323. package/layers/public/server/utils/page-resolve.ts +0 -208
  324. package/layers/public/server/utils/populate-links.ts +0 -66
  325. package/layers/public/server/utils/preview-token.ts +0 -109
  326. package/layers/public/server/utils/publish/coalesce.ts +0 -22
  327. package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
  328. package/layers/public/server/utils/publish/deps.ts +0 -87
  329. package/layers/public/server/utils/publish/invalidation.ts +0 -225
  330. package/layers/public/server/utils/publish/pending.ts +0 -74
  331. package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
  332. package/layers/public/server/utils/publish/publish-status.ts +0 -72
  333. package/layers/public/server/utils/publish/publisher.ts +0 -362
  334. package/layers/public/server/utils/publish/queue.ts +0 -67
  335. package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
  336. package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
  337. package/layers/public/server/utils/publish/route-for-record.ts +0 -16
  338. package/layers/public/server/utils/publish/route-keys.ts +0 -13
  339. package/layers/public/server/utils/richtext-markdown.ts +0 -260
  340. package/layers/public/server/utils/site-url.ts +0 -40
  341. 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
- }