@michaelthielemann/kestrel 4.1.3 → 5.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 (340) hide show
  1. package/README.md +26 -201
  2. package/dist/boot.d.ts +45 -0
  3. package/dist/boot.js +274 -0
  4. package/dist/cast.d.ts +3 -0
  5. package/dist/cast.js +6 -0
  6. package/dist/catalogue.d.ts +11 -0
  7. package/dist/catalogue.js +1 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +55 -0
  10. package/dist/context.d.ts +62 -0
  11. package/dist/context.js +61 -0
  12. package/dist/dataflow.d.ts +3 -0
  13. package/dist/dataflow.js +65 -0
  14. package/dist/defineConfig.d.ts +157 -0
  15. package/dist/defineConfig.js +41 -0
  16. package/dist/defineContract.d.ts +12 -0
  17. package/dist/defineContract.js +16 -0
  18. package/dist/defineModule.d.ts +75 -0
  19. package/dist/defineModule.js +9 -0
  20. package/dist/definePipeline.d.ts +7 -0
  21. package/dist/definePipeline.js +11 -0
  22. package/dist/errors.d.ts +26 -0
  23. package/dist/errors.js +48 -0
  24. package/dist/index.d.ts +19 -0
  25. package/dist/index.js +14 -0
  26. package/dist/load.d.ts +7 -0
  27. package/dist/load.js +79 -0
  28. package/dist/logger.d.ts +18 -0
  29. package/dist/logger.js +36 -0
  30. package/dist/registry.d.ts +14 -0
  31. package/dist/registry.js +87 -0
  32. package/dist/result.d.ts +19 -0
  33. package/dist/result.js +19 -0
  34. package/dist/runner.d.ts +29 -0
  35. package/dist/runner.js +133 -0
  36. package/dist/schema.d.ts +7 -0
  37. package/dist/schema.js +142 -0
  38. package/dist/sort.d.ts +2 -0
  39. package/dist/sort.js +50 -0
  40. package/dist/testing/runPipeline.d.ts +9 -0
  41. package/dist/testing/runPipeline.js +22 -0
  42. package/dist/triggers/cron.d.ts +10 -0
  43. package/dist/triggers/cron.js +44 -0
  44. package/dist/triggers/cronMatch.d.ts +10 -0
  45. package/dist/triggers/cronMatch.js +68 -0
  46. package/dist/triggers/http.d.ts +58 -0
  47. package/dist/triggers/http.js +292 -0
  48. package/package.json +25 -151
  49. package/NOTICE +0 -12
  50. package/kestrel.config.ts +0 -28
  51. package/layers/access/nuxt.config.ts +0 -5
  52. package/layers/access/server/middleware/00.ip-allowlist.ts +0 -52
  53. package/layers/access/server/middleware/access-guard.ts +0 -32
  54. package/layers/access/server/types.d.ts +0 -7
  55. package/layers/admin/app/components/AdminAccount.vue +0 -212
  56. package/layers/admin/app/components/AdminNav.vue +0 -44
  57. package/layers/admin/app/components/BlockFields.vue +0 -67
  58. package/layers/admin/app/components/BlockPreview.vue +0 -413
  59. package/layers/admin/app/components/BlockTree.vue +0 -433
  60. package/layers/admin/app/components/BlocksBody.vue +0 -163
  61. package/layers/admin/app/components/CollectionDeleteDialog.vue +0 -38
  62. package/layers/admin/app/components/CollectionEditor.vue +0 -324
  63. package/layers/admin/app/components/CollectionList.vue +0 -429
  64. package/layers/admin/app/components/CollectionListBulkBar.vue +0 -62
  65. package/layers/admin/app/components/CollectionListColumnsPanel.vue +0 -37
  66. package/layers/admin/app/components/CollectionListFilterPanel.vue +0 -145
  67. package/layers/admin/app/components/CollectionListPager.vue +0 -70
  68. package/layers/admin/app/components/CollectionListTable.vue +0 -347
  69. package/layers/admin/app/components/EditorStatus.vue +0 -211
  70. package/layers/admin/app/components/EditorUnsupported.vue +0 -28
  71. package/layers/admin/app/components/FieldsBody.vue +0 -23
  72. package/layers/admin/app/components/LocaleBar.vue +0 -107
  73. package/layers/admin/app/components/PageFields.vue +0 -129
  74. package/layers/admin/app/components/PageFieldsPane.vue +0 -39
  75. package/layers/admin/app/components/SeoFields.vue +0 -210
  76. package/layers/admin/app/components/SingletonEditor.vue +0 -93
  77. package/layers/admin/app/composables/useAuth.ts +0 -68
  78. package/layers/admin/app/composables/useBlockTree.ts +0 -95
  79. package/layers/admin/app/composables/useBlocks.ts +0 -7
  80. package/layers/admin/app/composables/useCachedResource.ts +0 -35
  81. package/layers/admin/app/composables/useCollectionOps.ts +0 -102
  82. package/layers/admin/app/composables/useCollections.ts +0 -6
  83. package/layers/admin/app/composables/useContentLocales.ts +0 -23
  84. package/layers/admin/app/composables/useEditForm.ts +0 -402
  85. package/layers/admin/app/composables/useLinkResolver.ts +0 -40
  86. package/layers/admin/app/composables/useListBatchActions.ts +0 -78
  87. package/layers/admin/app/composables/useListColumns.ts +0 -53
  88. package/layers/admin/app/composables/useListFilterDraft.ts +0 -129
  89. package/layers/admin/app/composables/useListRows.ts +0 -75
  90. package/layers/admin/app/composables/useListSelection.ts +0 -28
  91. package/layers/admin/app/composables/useListUrlState.ts +0 -53
  92. package/layers/admin/app/composables/usePendingPublishPoll.ts +0 -15
  93. package/layers/admin/app/composables/usePreviewViewport.ts +0 -30
  94. package/layers/admin/app/composables/usePublishStatus.ts +0 -107
  95. package/layers/admin/app/composables/useRailCollapsed.ts +0 -14
  96. package/layers/admin/app/composables/useTheme.ts +0 -36
  97. package/layers/admin/app/composables/useToolbarPanel.ts +0 -38
  98. package/layers/admin/app/composables/useUnsavedGuard.ts +0 -22
  99. package/layers/admin/app/layouts/admin.vue +0 -266
  100. package/layers/admin/app/middleware/admin-auth.ts +0 -4
  101. package/layers/admin/app/pages/admin/[collection]/[id].vue +0 -203
  102. package/layers/admin/app/pages/admin/[collection]/index.vue +0 -62
  103. package/layers/admin/app/pages/admin/index.vue +0 -118
  104. package/layers/admin/app/pages/admin/login.vue +0 -68
  105. package/layers/admin/app/pages/admin/media.vue +0 -28
  106. package/layers/admin/app/pages/admin/references.vue +0 -146
  107. package/layers/admin/app/plugins/admin-style-guard.client.ts +0 -76
  108. package/layers/admin/app/plugins/reauth.client.ts +0 -20
  109. package/layers/admin/app/utils/admin-style-guard.ts +0 -20
  110. package/layers/admin/app/utils/block-tree.ts +0 -214
  111. package/layers/admin/app/utils/collection-ops.ts +0 -69
  112. package/layers/admin/app/utils/dead-refs.ts +0 -30
  113. package/layers/admin/app/utils/edit-form.ts +0 -267
  114. package/layers/admin/app/utils/editor-expose.ts +0 -36
  115. package/layers/admin/app/utils/editor-form-context.ts +0 -82
  116. package/layers/admin/app/utils/editor-registry.ts +0 -20
  117. package/layers/admin/app/utils/field-keys.ts +0 -11
  118. package/layers/admin/app/utils/list-cell.ts +0 -46
  119. package/layers/admin/app/utils/list-columns.ts +0 -142
  120. package/layers/admin/app/utils/list-query.ts +0 -96
  121. package/layers/admin/app/utils/nav-active.ts +0 -9
  122. package/layers/admin/app/utils/populate-blocks.ts +0 -288
  123. package/layers/admin/app/utils/preview-channel.ts +0 -62
  124. package/layers/admin/app/utils/preview-viewport.ts +0 -80
  125. package/layers/admin/app/utils/reauth.ts +0 -67
  126. package/layers/admin/app/utils/record-title.ts +0 -15
  127. package/layers/admin/app/utils/register-builtin-editors.ts +0 -9
  128. package/layers/admin/app/utils/safe-redirect.ts +0 -12
  129. package/layers/admin/nuxt.config.ts +0 -20
  130. package/layers/auth/nuxt.config.ts +0 -1
  131. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +0 -7
  132. package/layers/collections/nuxt.config.ts +0 -1
  133. package/layers/collections/server/plugins/01.register.ts +0 -29
  134. package/layers/collections/server/plugins/02.register-relation-populate.ts +0 -18
  135. package/layers/core/app/composables/layouts.ts +0 -5
  136. package/layers/core/app/utils/field-factories.ts +0 -129
  137. package/layers/core/app/utils/layouts.ts +0 -38
  138. package/layers/core/modules/auto-discovery/extract-block.ts +0 -179
  139. package/layers/core/modules/auto-discovery/index.ts +0 -112
  140. package/layers/core/modules/auto-discovery/package-registry.ts +0 -19
  141. package/layers/core/modules/auto-discovery/scan.ts +0 -170
  142. package/layers/core/modules/auto-discovery/virtual.d.ts +0 -20
  143. package/layers/core/modules/component-namespace/index.ts +0 -44
  144. package/layers/core/modules/component-namespace/shared.ts +0 -24
  145. package/layers/core/modules/kestrel/app-shell.ts +0 -55
  146. package/layers/core/modules/kestrel/auto-imports.ts +0 -44
  147. package/layers/core/modules/kestrel/index.ts +0 -144
  148. package/layers/core/modules/plugin-order/index.ts +0 -69
  149. package/layers/core/modules/plugin-order/plugin-order.ts +0 -273
  150. package/layers/core/nuxt.config.ts +0 -10
  151. package/layers/core/server/api/[...path].ts +0 -98
  152. package/layers/core/server/plugins/00.config.ts +0 -12
  153. package/layers/core/server/plugins/00.migrate.ts +0 -12
  154. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +0 -5
  155. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +0 -5
  156. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +0 -5
  157. package/layers/core/server/plugins/02.schema-sync.ts +0 -58
  158. package/layers/core/server/plugins/04.outbox-worker.ts +0 -31
  159. package/layers/core/server/plugins/05.reindex-refs.ts +0 -5
  160. package/layers/core/server/routes/__kestrel/dashboard.get.ts +0 -29
  161. package/layers/core/server/tasks/db/migrate-module.ts +0 -83
  162. package/layers/core/server/tasks/db/migrate-revisions.ts +0 -20
  163. package/layers/core/server/tasks/db/migrate.ts +0 -50
  164. package/layers/core/server/utils/server-config.ts +0 -81
  165. package/layers/fields/nuxt.config.ts +0 -1
  166. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +0 -7
  167. package/layers/fields/server/plugins/01.register-field-populate.ts +0 -11
  168. package/layers/media/app/components/KestrelImg.vue +0 -94
  169. package/layers/media/app/components/MediaConflictDialog.vue +0 -35
  170. package/layers/media/app/components/MediaDeleteDialog.vue +0 -52
  171. package/layers/media/app/components/MediaGrid.vue +0 -113
  172. package/layers/media/app/components/MediaImage.vue +0 -70
  173. package/layers/media/app/components/MediaLibrary.vue +0 -456
  174. package/layers/media/app/components/MediaNewFolderDialog.vue +0 -32
  175. package/layers/media/app/components/MediaPathBar.vue +0 -163
  176. package/layers/media/app/components/MediaPicker.vue +0 -19
  177. package/layers/media/app/components/MediaRenameDialog.vue +0 -32
  178. package/layers/media/app/components/MediaTable.vue +0 -75
  179. package/layers/media/app/components/MediaThumb.vue +0 -25
  180. package/layers/media/app/components/MediaToolbar.vue +0 -90
  181. package/layers/media/app/components/MediaUploadDialog.vue +0 -87
  182. package/layers/media/app/components/MediaViewer.vue +0 -137
  183. package/layers/media/app/components/field/Media.vue +0 -263
  184. package/layers/media/app/composables/useBlurUp.ts +0 -55
  185. package/layers/media/app/composables/useMediaClipboard.ts +0 -14
  186. package/layers/media/app/composables/useMediaContextMenu.ts +0 -56
  187. package/layers/media/app/composables/useMediaDnd.ts +0 -56
  188. package/layers/media/app/composables/useMediaLibrary.ts +0 -152
  189. package/layers/media/app/composables/useMediaOps.ts +0 -50
  190. package/layers/media/app/composables/useMediaRelocate.ts +0 -39
  191. package/layers/media/app/composables/useMediaResolver.ts +0 -50
  192. package/layers/media/app/composables/useMediaUpload.ts +0 -93
  193. package/layers/media/app/composables/useMediaVariant.ts +0 -34
  194. package/layers/media/app/plugins/field-media.client.ts +0 -6
  195. package/layers/media/app/utils/ai-disclosure.ts +0 -19
  196. package/layers/media/app/utils/dnd.ts +0 -69
  197. package/layers/media/app/utils/library.ts +0 -81
  198. package/layers/media/app/utils/ops.ts +0 -60
  199. package/layers/media/app/utils/picture-model.ts +0 -125
  200. package/layers/media/app/utils/thumbhash.ts +0 -19
  201. package/layers/media/app/utils/variant-name.ts +0 -20
  202. package/layers/media/nuxt.config.ts +0 -7
  203. package/layers/media/server/middleware/ondemand-variants.ts +0 -38
  204. package/layers/media/server/plugins/01.register-media-pipelines.ts +0 -7
  205. package/layers/media/server/plugins/02.register-media.ts +0 -22
  206. package/layers/media/server/plugins/04.variant-capture.ts +0 -27
  207. package/layers/media/server/plugins/05.media-cleanup.ts +0 -5
  208. package/layers/media/server/tasks/media/backfill.ts +0 -17
  209. package/layers/public/app/app.vue +0 -5
  210. package/layers/public/app/components/BlockRenderer.vue +0 -56
  211. package/layers/public/app/components/KestrelLink.vue +0 -26
  212. package/layers/public/app/components/KestrelPreviewBridge.vue +0 -133
  213. package/layers/public/app/composables/public-page.ts +0 -24
  214. package/layers/public/app/error.vue +0 -12
  215. package/layers/public/app/layouts/default.vue +0 -5
  216. package/layers/public/app/pages/[...slug].vue +0 -239
  217. package/layers/public/app/pages/__kestrel/preview.vue +0 -46
  218. package/layers/public/app/utils/block-edit-context.ts +0 -16
  219. package/layers/public/app/utils/json-ld.ts +0 -139
  220. package/layers/public/app/utils/link-href.ts +0 -46
  221. package/layers/public/app/utils/page-head.ts +0 -84
  222. package/layers/public/app/utils/page-layout.ts +0 -18
  223. package/layers/public/app/utils/preview-protocol.ts +0 -117
  224. package/layers/public/app/utils/public-route.ts +0 -46
  225. package/layers/public/app/utils/site-head.ts +0 -43
  226. package/layers/public/modules/deploy-output/index.ts +0 -80
  227. package/layers/public/modules/prerender-routes/discover.ts +0 -37
  228. package/layers/public/modules/prerender-routes/index.ts +0 -88
  229. package/layers/public/modules/prerender-routes/routes.ts +0 -16
  230. package/layers/public/modules/prune-media/index.ts +0 -48
  231. package/layers/public/modules/prune-media/prune-media.ts +0 -81
  232. package/layers/public/nuxt.config.ts +0 -14
  233. package/layers/public/server/delivery-live/serve.ts +0 -111
  234. package/layers/public/server/middleware/delivery-live-catchall.ts +0 -7
  235. package/layers/public/server/pipelines/index.ts +0 -23
  236. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +0 -14
  237. package/layers/public/server/plugins/01.register-public-pipelines.ts +0 -7
  238. package/layers/public/server/plugins/02.register-links.ts +0 -16
  239. package/layers/public/server/plugins/03.redirects.ts +0 -55
  240. package/layers/public/server/plugins/05.plan-publish.ts +0 -5
  241. package/layers/public/server/plugins/zz.publish.ts +0 -127
  242. package/layers/public/server/routes/llms-full.txt.get.ts +0 -99
  243. package/layers/public/server/routes/llms.txt.get.ts +0 -66
  244. package/layers/public/server/routes/redirects.json.get.ts +0 -58
  245. package/layers/public/server/routes/robots.txt.get.ts +0 -11
  246. package/layers/public/server/routes/sitemap.xml.get.ts +0 -79
  247. package/layers/public/server/tasks/publish/run.ts +0 -32
  248. package/layers/public/server/utils/publish/render-live.ts +0 -26
  249. package/layers/ui/app/assets/scss/_base.scss +0 -16
  250. package/layers/ui/app/assets/scss/_datepicker.scss +0 -82
  251. package/layers/ui/app/assets/scss/_mixins.scss +0 -50
  252. package/layers/ui/app/assets/scss/_reset.scss +0 -65
  253. package/layers/ui/app/assets/scss/_tokens.scss +0 -183
  254. package/layers/ui/app/assets/scss/main.scss +0 -4
  255. package/layers/ui/app/components/field/Boolean.vue +0 -30
  256. package/layers/ui/app/components/field/Choice.vue +0 -73
  257. package/layers/ui/app/components/field/Datetime.vue +0 -98
  258. package/layers/ui/app/components/field/DeadRefNote.vue +0 -28
  259. package/layers/ui/app/components/field/Json.vue +0 -62
  260. package/layers/ui/app/components/field/Layout.vue +0 -138
  261. package/layers/ui/app/components/field/Link.vue +0 -112
  262. package/layers/ui/app/components/field/LinkInternalPicker.vue +0 -95
  263. package/layers/ui/app/components/field/Number.vue +0 -30
  264. package/layers/ui/app/components/field/Relation.vue +0 -49
  265. package/layers/ui/app/components/field/Renderer.vue +0 -30
  266. package/layers/ui/app/components/field/Repeater.vue +0 -460
  267. package/layers/ui/app/components/field/Richtext.vue +0 -26
  268. package/layers/ui/app/components/field/Slug.vue +0 -65
  269. package/layers/ui/app/components/field/Text.vue +0 -35
  270. package/layers/ui/app/components/field/Unsupported.vue +0 -34
  271. package/layers/ui/app/components/ui/Alert.vue +0 -50
  272. package/layers/ui/app/components/ui/Brand.vue +0 -22
  273. package/layers/ui/app/components/ui/Button.vue +0 -116
  274. package/layers/ui/app/components/ui/ButtonGroup.vue +0 -96
  275. package/layers/ui/app/components/ui/Checkbox.vue +0 -53
  276. package/layers/ui/app/components/ui/CheckboxGroup.vue +0 -51
  277. package/layers/ui/app/components/ui/Combobox.vue +0 -345
  278. package/layers/ui/app/components/ui/DatePicker.vue +0 -92
  279. package/layers/ui/app/components/ui/DateRangePicker.vue +0 -100
  280. package/layers/ui/app/components/ui/Dialog.vue +0 -107
  281. package/layers/ui/app/components/ui/EmptyState.vue +0 -46
  282. package/layers/ui/app/components/ui/Field.vue +0 -58
  283. package/layers/ui/app/components/ui/Fieldset.vue +0 -65
  284. package/layers/ui/app/components/ui/Icon.vue +0 -44
  285. package/layers/ui/app/components/ui/Menu.vue +0 -54
  286. package/layers/ui/app/components/ui/NumberInput.vue +0 -175
  287. package/layers/ui/app/components/ui/Richtext.vue +0 -141
  288. package/layers/ui/app/components/ui/RichtextToolbar.vue +0 -251
  289. package/layers/ui/app/components/ui/Select.vue +0 -52
  290. package/layers/ui/app/components/ui/TextInput.vue +0 -111
  291. package/layers/ui/app/components/ui/Textarea.vue +0 -39
  292. package/layers/ui/app/components/ui/TimeInput.vue +0 -18
  293. package/layers/ui/app/components/ui/Toasts.vue +0 -103
  294. package/layers/ui/app/components/ui/Tooltip.vue +0 -45
  295. package/layers/ui/app/composables/useAdminLang.ts +0 -8
  296. package/layers/ui/app/composables/useDragReorder.ts +0 -41
  297. package/layers/ui/app/composables/useEchoGuard.ts +0 -28
  298. package/layers/ui/app/composables/useLinkField.ts +0 -70
  299. package/layers/ui/app/composables/useRecordOptions.ts +0 -84
  300. package/layers/ui/app/composables/useRepeater.ts +0 -99
  301. package/layers/ui/app/composables/useT.ts +0 -32
  302. package/layers/ui/app/composables/useToast.ts +0 -46
  303. package/layers/ui/app/i18n/de.ts +0 -432
  304. package/layers/ui/app/i18n/en.ts +0 -434
  305. package/layers/ui/app/utils/boolean-field.ts +0 -10
  306. package/layers/ui/app/utils/field-component.ts +0 -22
  307. package/layers/ui/app/utils/field-empty.ts +0 -21
  308. package/layers/ui/app/utils/field-registry.ts +0 -36
  309. package/layers/ui/app/utils/humanize.ts +0 -14
  310. package/layers/ui/app/utils/icons.ts +0 -143
  311. package/layers/ui/app/utils/iso-date.ts +0 -18
  312. package/layers/ui/app/utils/localized.ts +0 -13
  313. package/layers/ui/app/utils/record-context.ts +0 -15
  314. package/layers/ui/app/utils/reorder.ts +0 -8
  315. package/layers/ui/app/utils/richtext-preserve-class.ts +0 -31
  316. package/layers/ui/app/utils/useFieldA11y.ts +0 -40
  317. package/layers/ui/nuxt.config.ts +0 -9
  318. package/nuxt.config.ts +0 -108
  319. package/scripts/consumer-template-ci.mjs +0 -160
  320. package/scripts/copy-create-payload.mjs +0 -49
  321. package/scripts/coverage-all.mjs +0 -163
  322. package/scripts/dashboard.mjs +0 -89
  323. package/scripts/hash-password.mjs +0 -29
  324. package/scripts/kestrel.mjs +0 -240
  325. package/scripts/lib/cli.mjs +0 -129
  326. package/scripts/lib/coverage-model.mjs +0 -51
  327. package/scripts/lib/password.mjs +0 -19
  328. package/scripts/lib/scaffold.mjs +0 -205
  329. package/scripts/port-graph.mjs +0 -350
  330. package/scripts/typecheck.mjs +0 -62
  331. package/templates/starter/README.md +0 -65
  332. package/templates/starter/_env.example +0 -17
  333. package/templates/starter/_gitignore +0 -26
  334. package/templates/starter/_package.json +0 -22
  335. package/templates/starter/app/app.vue +0 -7
  336. package/templates/starter/app/blocks/Prose.vue +0 -13
  337. package/templates/starter/app/layouts/default.vue +0 -6
  338. package/templates/starter/nuxt.config.ts +0 -23
  339. package/templates/starter/pnpm-workspace.yaml +0 -8
  340. package/templates/starter/tsconfig.json +0 -3
package/README.md CHANGED
@@ -1,201 +1,26 @@
1
- # Kestrel
2
-
3
- **Please be aware that this project is under rapid development and may not be suitable for long-term projects.**
4
-
5
- A slim, **static-site-generator CMS**: a generic collection-driven CRUD core with a schema-driven admin
6
- editor. You declare collections and fields in TypeScript; Kestrel derives the database, a typed CRUD API,
7
- and a full admin UI from those definitions, and renders published content to static HTML via
8
- `nuxt generate` there is **no live public SSR**.
9
-
10
- Built on **Nuxt 4 layers + Drizzle/SQLite + Zod**. The whole CMS is a stack of swappable Nuxt layers, so
11
- a consumer extends it (`extends: ['@michaelthielemann/kestrel']`) and drops in their own collection/field/block definitions.
12
-
13
- ## What Kestrel is — and isn't
14
-
15
- Kestrel is a **CMS engine**, not a finished website. It ships the admin UI, the schema/CRUD/render
16
- engines, and two toggleable built-in collections (`pages` + the `media` library); **you** define your
17
- content model and bring the public components and layout that render it.
18
-
19
- It is for a private editing origin that publishes **static HTML** (a local dir or S3), served by any
20
- static host. It is deliberately **not**:
21
-
22
- - **No live public SSR** published pages are static (`nuxt generate` or the runtime incremental
23
- publisher); there is no per-request rendering of the public site.
24
- - **Single-user** one admin (session + CSRF). No roles, capabilities, or multi-user.
25
- - **SQLite only** one database, single instance. (A Postgres `Dialect` seam exists; no impl is active.)
26
- - **No public data API** only published *page-like* records have a read surface (mirroring the static
27
- output); everything else stays behind the admin guard. No per-request rate limiting / live API.
28
- - **No runtime redirect engine** — redirects are *authored* in the CMS and published as a `redirects.json`
29
- artifact, but Kestrel never answers a 30x itself; an edge (NGINX / njs / CloudFront) has to read it.
30
- - **No per-file access control on uploads.** The admin guard protects the media *library*, not the
31
- bytes; the editing origin is meant to be non-public and published media is public by definition. See
32
- [media.md](./docs/guide/media.md).
33
-
34
- ## Features
35
-
36
- - **Runnable in one command** — `pnpm create kestrel` scaffolds a project that boots with a working
37
- `/admin`, asking you to choose the admin password and writing its hash; `kestrel init` does the same to an
38
- existing project without clobbering it, and `kestrel doctor` names whatever is still missing.
39
- - **Collection-driven** — declare collections + fields in TypeScript; Kestrel derives the SQLite tables, a
40
- typed CRUD REST API, and the full admin UI. The schema **migrates itself** (additive in dev; explicit
41
- `db:migrate` in prod).
42
- - **Field types** — twelve built in: text (single- or multi-line), slug, richtext, number, boolean,
43
- datetime (date / time / range), choice (select / buttons / checkboxes), media, relation (single /
44
- many), link (internal / external / email / tel), repeater (nestable), JSON — plus per-field `condition`
45
- visibility rules. Register your own with `defineFieldType`.
46
- - **Page-like collections** — give a collection a `path`, SEO meta, and block or flat content; published
47
- records render to static HTML and join the sitemap. **Singletons** for settings / navigation.
48
- - **Block page-builder** — a 3-pane editor (tree · live preview · fields) with nestable block slots; each
49
- block is a single `app/blocks/*.vue` SFC (schema via field-factory `defineProps` + the display template).
50
- - **Media library** — upload (magic-byte sniff + SVG sanitize), local or S3 storage, responsive WebP
51
- derivatives + thumbhash, folders, and a full-screen asset viewer. Optional **EU AI Act (Art. 50)
52
- disclosure** fields per asset, with an upload-time scan for AI-origin signals (off by default).
53
- - **Multilingual content** — optional per-record translations, an editor locale flow, locale-prefixed
54
- routing, hreflang alternates in the sitemap.
55
- - **Static output** — `nuxt generate` (full rebuild) or a runtime **incremental publisher** (re-renders
56
- only the pages a write affects), to a local dir or S3; emits `sitemap.xml`, `robots.txt`,
57
- **`llms.txt`** (an [llmstxt.org](https://llmstxt.org) site map so AI agents grasp the site) and
58
- `redirects.json`. A live in-dashboard preview renders straight from the origin.
59
- - **Search + answer engines** — canonical / Open Graph / hreflang tags and a schema.org JSON-LD graph
60
- (`WebSite` + `WebPage`/`Article` + `BreadcrumbList`) on every page, no wiring. Two opt-in extras publish
61
- more than the page already showed: `seo.articleMeta` (author / publication date / keywords) and
62
- `seo.llmsFull` (`/llms-full.txt`, every published page's body in one document).
63
- - **Reference integrity** — writes invalidate exactly the affected static pages; dead-reference warnings;
64
- required + globally-unique page slugs per locale.
65
-
66
- ## Quickstart (consumer)
67
-
68
- ```bash
69
- pnpm create kestrel my-site
70
- cd my-site && pnpm install && pnpm dev
71
- ```
72
-
73
- It asks you to choose an admin password and writes a project that runs as-is: `nuxt.config.ts` extending the
74
- meta-layer, an `app.vue` that renders, a `.env` holding a fresh session secret and the scrypt hash of
75
- your password, and one example block. Sign in at <http://localhost:3000/admin>.
76
-
77
- Already have a project? Run it in place — existing files are kept, `package.json` and `.env` are merged:
78
-
79
- ```bash
80
- pnpm add @michaelthielemann/kestrel
81
- pnpm kestrel init # completes the project
82
- pnpm kestrel doctor # or just diagnose one that misbehaves
83
- ```
84
-
85
- Installing the package **alone does nothing**: Nuxt only loads Kestrel once a config extends it. If you
86
- would rather wire it up by hand, that is two files:
87
-
88
- ```ts
89
- // nuxt.config.ts
90
- export default defineNuxtConfig({
91
- extends: ['@michaelthielemann/kestrel'],
92
- kestrel: {
93
- db: '.data/site.sqlite',
94
- siteUrl: 'https://example.com',
95
- siteName: 'Example', // used in the generated llms.txt
96
- locales: ['en', 'de'],
97
- media: { uploadDir: '.data/uploads' },
98
- // collections: { pages: false }, // drop a built-in to define your own with that name
99
- },
100
- })
101
- ```
102
-
103
- ```ts
104
- // server/collections/products.ts — auto-discovered; the table is created on next boot
105
- export default defineCollection({
106
- name: 'products',
107
- mode: 'multi',
108
- fields: { title: { type: 'text', required: true }, price: { type: 'number' }, image: { type: 'media' } },
109
- })
110
- ```
111
-
112
- Set the auth env (`KESTREL_SESSION_SECRET`, `KESTREL_ADMIN_PASSWORD_HASH`), start the app, and manage
113
- content at `/admin`. You bring your own **public layout** and **block SFCs**
114
- (`app/blocks/Hero.vue` — one file for schema + display — is the `hero` block).
115
-
116
- > **Do not add an `app/app.vue` that omits `<NuxtPage />`.** A project-owned one shadows the layer's, and
117
- > the file `nuxi init` writes renders `<NuxtWelcome />` instead of your routes — the admin then appears to
118
- > be missing rather than blank. Kestrel reports this at build time; `kestrel doctor` catches it earlier.
119
-
120
- Full walkthrough: **[getting-started.md](docs/guide/getting-started.md)**.
121
-
122
- ## Documentation
123
-
124
- Two doors, one per audience:
125
-
126
- - **[Guide](docs/guide/README.md)** — building a site with the package: collections, fields, blocks,
127
- media, publishing, deployment, configuration, extension points.
128
- - **[Internals](docs/internals/README.md)** — developing Kestrel itself: architecture, layers and
129
- packages, the pipeline engine, test rails, releasing, the ADR log.
130
-
131
- Most-asked pages: [getting started](docs/guide/getting-started.md) ·
132
- [field types](docs/guide/field-types.md) · [blocks](docs/guide/blocks.md) ·
133
- [configuration](docs/guide/configuration.md) · [deploying](docs/guide/deploying.md) ·
134
- [troubleshooting](docs/guide/troubleshooting.md).
135
-
136
- ## Layout
137
-
138
- The domain/server code lives in ten `@michaelthielemann/kestrel-*` packages under `packages/` (`contracts`, `core`,
139
- `fields`, `auth`, `access`, `collections`, `media`, `publishing`, `delivery-live`, `delivery-static`).
140
- The nine Nuxt layers under `layers/` are thin wiring shells around those packages, except where a surface
141
- depends on Nuxt's own component resolution and auto-imports: the `ui` and `admin` layers (design system
142
- and editor SPA) and the `app/` halves of `media` and `public` stay real code in layers. Layers import
143
- packages by name; packages never import layers.
144
-
145
- `playground/` is a small consuming example, `templates/starter/` is what the scaffolder writes,
146
- `scripts/kestrel.mjs` is the CLI, and `packages/create-kestrel/` the `pnpm create kestrel` front end.
147
- The full map: [layers-and-packages.md](docs/internals/layers-and-packages.md).
148
-
149
- ## Development
150
-
151
- Requires Node + [pnpm](https://pnpm.io).
152
-
153
- ```bash
154
- pnpm install
155
- pnpm dev # dev server (admin at /admin)
156
- pnpm build # production build
157
- pnpm generate # render the static public site to .output/public
158
-
159
- pnpm test # node + happy-dom unit tests
160
- pnpm test:nuxt # Nuxt-environment component tests
161
- pnpm test:e2e # end-to-end tests (real dev server)
162
- pnpm lint # ESLint (Nuxt-aware, generated from the playground)
163
-
164
- pnpm db:generate # drizzle-kit: generate a migration
165
- pnpm db:migrate # drizzle-kit: apply migrations
166
- pnpm hash-password # produce a KESTREL_ADMIN_PASSWORD_HASH
167
-
168
- node scripts/kestrel.mjs init <dir> # the consumer scaffolder, from a checkout
169
- node scripts/kestrel.mjs doctor <dir> # diagnose a consumer project
170
- ```
171
-
172
- Conventions, test rails and the release flow: [internals/testing.md](docs/internals/testing.md) and
173
- [internals/releasing.md](docs/internals/releasing.md). Running a built site — production env, the static
174
- publisher, rehearsing a deploy locally: [deploying.md](docs/guide/deploying.md).
175
-
176
- ## Configuration
177
-
178
- Non-auth settings live in `kestrel.config.ts` (`satisfies KestrelConfig`); each setting follows
179
- `KESTREL_* env → config → default`, resolved at build/dev start and frozen into `runtimeConfig` (a prebuilt
180
- server is retuned with the `NUXT_*` runtimeConfig names, not `KESTREL_*`). Auth/session secrets are
181
- **env-only** and read per request (`KESTREL_SESSION_SECRET`, `KESTREL_ADMIN_PASSWORD_HASH`, …). Full reference:
182
- [configuration.md](docs/guide/configuration.md).
183
-
184
- ## Security
185
-
186
- Report vulnerabilities privately — see [SECURITY.md](SECURITY.md), which also lists the documented design
187
- decisions that are explicitly out of scope (uploads are not access-controlled; the editing origin is
188
- assumed to be non-public).
189
-
190
- ## Licence
191
-
192
- Apache License 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE). You may use, modify and distribute
193
- Kestrel, including commercially and in closed-source work, as long as you keep the copyright notice and
194
- the licence, state significant changes you make to the files, and pass the NOTICE along. The licence
195
- grants patent rights but **no trademark rights**: the name "Kestrel" and the project's branding are not
196
- covered, so a fork needs its own name.
197
-
198
- Kestrel is provided free of charge, developed and published outside any commercial activity, **with no
199
- warranty and no commercial support** (see sections 7 and 8 of the licence). If you need guarantees for a
200
- production deployment, the honest answer is to read the code, run the test suite, and decide for yourself
201
- — that is what the licence terms mean in practice.
1
+ # @michaelthielemann/kestrel
2
+ The core: `defineContract`, `defineModule`, `definePipeline`, `defineConfig`, the boot check and
3
+ the pipeline runner with HTTP, event and cron triggers. It ships no contracts and no modules;
4
+ a config without modules boots as an empty shell. `boot()` reads the config, sorts modules by
5
+ `requires`, runs `setup()`, checks every provided contract's methods, registers steps, resolves
6
+ pipelines and validates triggers; any problem throws `KestrelBootError { module, reason }`.
7
+ The `kestrel` binary loads `kestrel.config.ts`, the modules named in `use` and `pipelines/*.ts`
8
+ from the current directory. `testing/runPipeline` runs a pipeline against fake steps.
9
+ Binary results are served `inline` for a built-in safe set of content types plus any listed in `http.inlineTypes`.
10
+ `http.inlineTypes` with `image/svg+xml` needs a module registering the step `sanitize.svg`
11
+ (`sanitize-svg`), otherwise boot fails.
12
+
13
+ ## HTTP config
14
+ `http` takes `port`, `host`, `corsOrigin`, `maxBodyBytes`, `trustProxy`, `healthPath`, `inlineTypes`
15
+ and `timeouts: { requestMs, headersMs, keepAliveMs }` (defaults 30000 / 10000 / 5000), which map to
16
+ `server.requestTimeout`, `server.headersTimeout` and `server.keepAliveTimeout`. An incoming
17
+ `x-request-id` header of short printable ASCII becomes `ctx.requestId`, appears in the `http` log
18
+ line and is echoed as the `x-request-id` response header next to `x-kestrel-run-id`.
19
+
20
+ ## Shutdown
21
+ `stop()` closes the listener, waits for the in-flight runs and for connections still sending a
22
+ request or receiving a response, then drops the remaining connections, stops the cron and event
23
+ triggers and tears the modules down in reverse boot order. The deadline is `stop({ timeoutMs })`
24
+ or the top-level config `shutdownTimeoutMs` (default 30000); the returned `{ drained }` says whether
25
+ everything finished inside it. The `kestrel` binary wires SIGINT/SIGTERM to `stop()` (exit 1 when the
26
+ deadline is hit) and logs `unhandledRejection`/`uncaughtException` before stopping and exiting 1.
package/dist/boot.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { AddressInfo } from "node:net";
2
+ import type { ContextInput } from "./context.ts";
3
+ import { type KestrelConfig, type KestrelConfigInput } from "./defineConfig.ts";
4
+ import { type Deps, type EventEntry, type ModuleDefinition } from "./defineModule.ts";
5
+ import type { PipelineDefinition } from "./definePipeline.ts";
6
+ import { type Logger } from "./logger.ts";
7
+ import { StepRegistry } from "./registry.ts";
8
+ import { type ResolvedPipeline, type RunResult } from "./runner.ts";
9
+ import { type CronEntry } from "./triggers/cron.ts";
10
+ import { type Route } from "./triggers/http.ts";
11
+ export interface BootInput {
12
+ config: KestrelConfigInput;
13
+ modules: readonly ModuleDefinition[];
14
+ pipelines: readonly PipelineDefinition[];
15
+ logger?: Logger;
16
+ root?: string;
17
+ /** Validates `ctx.payload` against `describe().input` before every step; defaults to `NODE_ENV !== "production"`. */
18
+ dev?: boolean;
19
+ }
20
+ export interface Triggers {
21
+ http: readonly Route[];
22
+ events: readonly EventEntry[];
23
+ crons: readonly CronEntry[];
24
+ }
25
+ export interface StopOptions {
26
+ timeoutMs?: number;
27
+ }
28
+ export interface StopResult {
29
+ drained: boolean;
30
+ }
31
+ export interface Kestrel {
32
+ config: KestrelConfig;
33
+ contracts: Deps & {
34
+ names(): string[];
35
+ };
36
+ steps: StepRegistry;
37
+ pipelines: ReadonlyMap<string, ResolvedPipeline>;
38
+ triggers: Triggers;
39
+ run(pipeline: string, input: ContextInput): Promise<RunResult>;
40
+ start(): Promise<{
41
+ http?: AddressInfo;
42
+ }>;
43
+ stop(options?: StopOptions): Promise<StopResult>;
44
+ }
45
+ export declare function boot(input: BootInput): Promise<Kestrel>;
package/dist/boot.js ADDED
@@ -0,0 +1,274 @@
1
+ import { missingMethods } from "./defineContract.js";
2
+ import { checkDataflow } from "./dataflow.js";
3
+ import { configSchema } from "./defineConfig.js";
4
+ import { stepPrefix } from "./defineModule.js";
5
+ import { KestrelBootError } from "./errors.js";
6
+ import { consoleLogger } from "./logger.js";
7
+ import { StepRegistry } from "./registry.js";
8
+ import { createRunTracker, runPipeline } from "./runner.js";
9
+ import { sortModules } from "./sort.js";
10
+ import { createCronEntry, startCron } from "./triggers/cron.js";
11
+ import { createHttpServer, listen, parseRoute, routePattern } from "./triggers/http.js";
12
+ const CORE = "kestrel";
13
+ const SVG = "image/svg+xml";
14
+ const SANITIZE_SVG = "sanitize.svg";
15
+ const BARREL = new Set(["module", "index", "src", "dist"]);
16
+ /** `use` is a package name or a path and never equals `mod.name`; both "@scope/kestrel-authn-multi" and "./modules/authn/multi/module.ts" reduce to ["authn", "multi"]. */
17
+ function useTokens(use) {
18
+ const parts = (use.split(/[?#]/)[0] ?? use).split("/").filter((p) => p !== "" && p !== "." && p !== "..");
19
+ if (parts[0]?.startsWith("@"))
20
+ parts.shift();
21
+ const tokens = parts.flatMap((p) => p.replace(/\.[cm]?[jt]sx?$/, "").toLowerCase().split("-")).filter((t) => t !== "");
22
+ while (BARREL.has(tokens.at(-1) ?? ""))
23
+ tokens.pop();
24
+ if (tokens[0] === "kestrel")
25
+ tokens.shift();
26
+ return tokens;
27
+ }
28
+ function entryNamesModule(use, moduleName) {
29
+ const tokens = useTokens(use);
30
+ if (tokens.length === 0)
31
+ return false;
32
+ const wanted = moduleName.split("/");
33
+ // Too few tokens to spell out "<module>/<submodule>": accept a hit on either half, which still
34
+ // separates any two modules whose names differ in that half.
35
+ if (tokens.length < wanted.length)
36
+ return wanted.includes(tokens[tokens.length - 1]);
37
+ return wanted.every((w, i) => tokens[tokens.length - wanted.length + i] === w);
38
+ }
39
+ function settledWithin(promise, ms) {
40
+ if (ms <= 0)
41
+ return Promise.resolve(false);
42
+ return new Promise((resolve) => {
43
+ const timer = setTimeout(() => resolve(false), ms);
44
+ timer.unref();
45
+ void promise.then(() => {
46
+ clearTimeout(timer);
47
+ resolve(true);
48
+ });
49
+ });
50
+ }
51
+ /** A connection that goes idle after close() would otherwise hold the server until keepAliveTimeout. */
52
+ async function drainConnections(server, closing, deadline) {
53
+ let closed = false;
54
+ void closing.then(() => {
55
+ closed = true;
56
+ });
57
+ while (!closed && Date.now() < deadline) {
58
+ server.closeIdleConnections();
59
+ await settledWithin(closing, Math.min(50, deadline - Date.now()));
60
+ }
61
+ return closed;
62
+ }
63
+ async function teardownAll(ordered, instances, logger) {
64
+ for (const mod of [...ordered].reverse()) {
65
+ if (!mod.teardown || !instances.has(mod))
66
+ continue;
67
+ try {
68
+ await mod.teardown(instances.get(mod));
69
+ }
70
+ catch (err) {
71
+ logger.error(`teardown failed for module "${mod.name}"`, { module: mod.name, error: err instanceof Error ? err.message : String(err) });
72
+ }
73
+ }
74
+ }
75
+ export async function boot(input) {
76
+ const logger = input.logger ?? consoleLogger;
77
+ const root = input.root ?? process.cwd();
78
+ const dev = input.dev ?? process.env.NODE_ENV !== "production";
79
+ const parsed = configSchema.safeParse(input.config);
80
+ if (!parsed.success)
81
+ throw new KestrelBootError(CORE, `invalid kestrel.config: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
82
+ const config = parsed.data;
83
+ if (input.modules.length !== config.modules.length)
84
+ throw new KestrelBootError(CORE, `config lists ${config.modules.length} modules but ${input.modules.length} were loaded`);
85
+ const configByModule = new Map();
86
+ const seen = new Set();
87
+ input.modules.forEach((mod, i) => {
88
+ const entry = config.modules[i];
89
+ if (seen.has(mod.name))
90
+ throw new KestrelBootError(mod.name, `listed twice in kestrel.config (${entry.use})`);
91
+ seen.add(mod.name);
92
+ if (!entryNamesModule(entry.use, mod.name)) {
93
+ // A `use` that names no loaded module at all is not evidence of anything — third-party
94
+ // packages need not be named after their module. Only a hit on a different one is.
95
+ const other = input.modules.find((m) => m !== mod && entryNamesModule(entry.use, m.name));
96
+ if (other)
97
+ throw new KestrelBootError(CORE, `config.modules[${i}] "${entry.use}" names module "${other.name}", not "${mod.name}"; config entries and loaded modules must be in the same order`);
98
+ }
99
+ configByModule.set(mod, entry.config ?? {});
100
+ });
101
+ const ordered = sortModules(input.modules);
102
+ for (const mod of ordered) {
103
+ if (mod.provides.length === 0)
104
+ continue;
105
+ const prefix = stepPrefix(mod.name);
106
+ if (mod.provides.some((c) => c.name.split("@")[0] === prefix))
107
+ continue;
108
+ throw new KestrelBootError(mod.name, `provides ${mod.provides.map((c) => `"${c.name}"`).join(", ")} but registers steps under "${prefix}."; a module must be named after a contract it provides`);
109
+ }
110
+ const registered = new Map();
111
+ const contracts = {
112
+ get(contract) {
113
+ if (!registered.has(contract.name))
114
+ throw new Error(`no provider for "${contract.name}"`);
115
+ return registered.get(contract.name);
116
+ },
117
+ find(contract) {
118
+ return registered.get(contract.name);
119
+ },
120
+ names: () => [...registered.keys()],
121
+ logger,
122
+ root,
123
+ };
124
+ const instances = new Map();
125
+ try {
126
+ for (const mod of ordered) {
127
+ const cfg = mod.configSchema.safeParse(configByModule.get(mod));
128
+ if (!cfg.success)
129
+ throw new KestrelBootError(mod.name, `invalid config: ${cfg.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
130
+ let instance;
131
+ try {
132
+ instance = await mod.setup(cfg.data, contracts);
133
+ }
134
+ catch (err) {
135
+ throw new KestrelBootError(mod.name, `setup() threw: ${err instanceof Error ? err.message : String(err)}`);
136
+ }
137
+ instances.set(mod, instance);
138
+ for (const contract of mod.provides) {
139
+ const missing = missingMethods(contract, instance);
140
+ if (missing.length > 0)
141
+ throw new KestrelBootError(mod.name, `setup() result lacks ${contract.name} method(s): ${missing.join(", ")}`);
142
+ registered.set(contract.name, instance);
143
+ }
144
+ }
145
+ const steps = new StepRegistry();
146
+ for (const mod of ordered) {
147
+ if (!mod.steps)
148
+ continue;
149
+ steps.register(mod.name, stepPrefix(mod.name), mod.steps(instances.get(mod)), mod.describe?.(instances.get(mod)) ?? {});
150
+ }
151
+ if (config.http?.inlineTypes.includes(SVG) && !steps.has(SANITIZE_SVG)) {
152
+ throw new KestrelBootError(CORE, `http.inlineTypes contains "${SVG}" but no module registers the step "${SANITIZE_SVG}"`);
153
+ }
154
+ const definitions = new Map();
155
+ for (const p of input.pipelines) {
156
+ if (definitions.has(p.name))
157
+ throw new KestrelBootError(`pipelines/${p.name}`, "defined twice");
158
+ definitions.set(p.name, p);
159
+ }
160
+ const pipelines = new Map();
161
+ for (const def of definitions.values()) {
162
+ if (def.steps.length === 0)
163
+ throw new KestrelBootError(`pipelines/${def.name}`, "has no steps");
164
+ pipelines.set(def.name, { name: def.name, steps: def.steps.map((s) => steps.resolve(s, `pipelines/${def.name}`)) });
165
+ }
166
+ const routes = [];
167
+ const events = [];
168
+ const crons = [];
169
+ const byPattern = new Map();
170
+ for (const t of config.triggers) {
171
+ if (!pipelines.has(t.pipeline))
172
+ throw new KestrelBootError(CORE, `trigger references unknown pipeline "${t.pipeline}"`);
173
+ let route;
174
+ try {
175
+ if ("http" in t)
176
+ route = parseRoute(t.http, t.pipeline);
177
+ else if ("event" in t)
178
+ events.push({ event: t.event, pipeline: t.pipeline });
179
+ else
180
+ crons.push(createCronEntry(t.cron, t.pipeline));
181
+ }
182
+ catch (err) {
183
+ throw new KestrelBootError(CORE, `invalid trigger for "${t.pipeline}": ${err instanceof Error ? err.message : String(err)}`);
184
+ }
185
+ if (route === undefined)
186
+ continue;
187
+ const pattern = routePattern(route);
188
+ const other = byPattern.get(pattern);
189
+ if (other !== undefined)
190
+ throw new KestrelBootError(CORE, `http triggers for pipelines "${other}" and "${t.pipeline}" both resolve to "${pattern}"`);
191
+ byPattern.set(pattern, t.pipeline);
192
+ routes.push(route);
193
+ }
194
+ for (const [name, pipeline] of pipelines) {
195
+ checkDataflow(pipeline, routes.filter((r) => r.pipeline === name), crons.some((c) => c.pipeline === name), events.some((e) => e.pipeline === name));
196
+ }
197
+ const eventHooks = ordered.filter((mod) => mod.triggers?.event !== undefined);
198
+ if (eventHooks.length > 1)
199
+ throw new KestrelBootError(CORE, `modules "${eventHooks[0]?.name ?? ""}" and "${eventHooks[1]?.name ?? ""}" both provide an event trigger hook`);
200
+ if (events.length > 0 && eventHooks.length === 0)
201
+ throw new KestrelBootError(CORE, "event triggers need a module that provides an event trigger hook");
202
+ const eventHook = eventHooks[0];
203
+ const runs = createRunTracker();
204
+ const run = (name, ctxInput) => {
205
+ const pipeline = pipelines.get(name);
206
+ if (!pipeline)
207
+ throw new Error(`unknown pipeline "${name}"`);
208
+ return runs.track(() => runPipeline(pipeline, ctxInput, logger, { validateInput: dev }));
209
+ };
210
+ let server;
211
+ const stoppers = [];
212
+ let torndown = false;
213
+ return {
214
+ config,
215
+ contracts,
216
+ steps,
217
+ pipelines,
218
+ triggers: { http: routes, events, crons },
219
+ run,
220
+ async start() {
221
+ const started = {};
222
+ if (events.length > 0 && eventHook?.triggers?.event)
223
+ stoppers.push(eventHook.triggers.event(instances.get(eventHook), events, run, logger));
224
+ if (crons.length > 0)
225
+ stoppers.push(startCron(crons, run, logger));
226
+ const http = config.http;
227
+ if (http !== null) {
228
+ server = createHttpServer(routes, run, logger, {
229
+ maxBodyBytes: http.maxBodyBytes,
230
+ trustProxy: http.trustProxy,
231
+ healthPath: http.healthPath,
232
+ inlineTypes: http.inlineTypes,
233
+ timeouts: http.timeouts,
234
+ ...(http.corsOrigin === undefined ? {} : { corsOrigin: http.corsOrigin }),
235
+ });
236
+ started.http = await listen(server, http.port, http.host);
237
+ logger.info(`http listening on ${started.http.address}:${started.http.port}`, { routes: routes.length });
238
+ }
239
+ return started;
240
+ },
241
+ async stop(options = {}) {
242
+ const deadline = Date.now() + (options.timeoutMs ?? config.shutdownTimeoutMs);
243
+ let httpError;
244
+ const s = server;
245
+ server = undefined;
246
+ let closing;
247
+ if (s) {
248
+ closing = new Promise((resolve, reject) => s.close((err) => (err ? reject(err) : resolve()))).catch((err) => {
249
+ httpError = err instanceof Error ? err : new Error(String(err));
250
+ });
251
+ }
252
+ let drained = await runs.drain(deadline - Date.now());
253
+ if (s && closing) {
254
+ drained = (await drainConnections(s, closing, deadline)) && drained;
255
+ s.closeAllConnections();
256
+ await closing;
257
+ }
258
+ for (const stop of stoppers.splice(0))
259
+ stop();
260
+ if (!torndown) {
261
+ torndown = true;
262
+ await teardownAll(ordered, instances, logger);
263
+ }
264
+ if (httpError)
265
+ throw httpError;
266
+ return { drained };
267
+ },
268
+ };
269
+ }
270
+ catch (err) {
271
+ await teardownAll(ordered, instances, logger);
272
+ throw err;
273
+ }
274
+ }
package/dist/cast.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export type Boundary = "json" | "ast" | "dom" | "host";
2
+ /** The only sanctioned double cast. `boundary` says which untyped source the value came from. */
3
+ export declare function boundaryCast<T>(value: unknown, boundary: Boundary): T;
package/dist/cast.js ADDED
@@ -0,0 +1,6 @@
1
+ /** The only sanctioned double cast. `boundary` says which untyped source the value came from. */
2
+ export function boundaryCast(value, boundary) {
3
+ void boundary;
4
+ // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unnecessary-type-assertion
5
+ return value;
6
+ }
@@ -0,0 +1,11 @@
1
+ import type { StepFactory, StepMap } from "./context.ts";
2
+ import type { ModuleDefinition } from "./defineModule.ts";
3
+ type Prefix<N extends string> = N extends `${infer P}/${string}` ? P : never;
4
+ type StepNamesOf<P extends string, T extends StepMap> = {
5
+ [K in keyof T & string]: T[K] extends StepFactory ? `${P}.${K}:${string}` : `${P}.${K}`;
6
+ }[keyof T & string];
7
+ /** Every step name a module registers, factories with their `:arg` tail. */
8
+ export type StepsOf<M> = M extends ModuleDefinition<infer N, infer T> ? StepNamesOf<Prefix<N>, T> : never;
9
+ /** The union for a loaded module list — the `modules` array a consumer hands to `boot()`. */
10
+ export type StepCatalogue<Ms extends readonly ModuleDefinition[]> = StepsOf<Ms[number]>;
11
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env -S node --disable-warning=ExperimentalWarning
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env -S node --disable-warning=ExperimentalWarning
2
+ import { resolve } from "node:path";
3
+ import { boot } from "./boot.js";
4
+ import { boundaryCast } from "./cast.js";
5
+ import { KestrelBootError } from "./errors.js";
6
+ import { loadConfig, loadModules, loadPipelines } from "./load.js";
7
+ import { consoleLogger } from "./logger.js";
8
+ const root = process.cwd();
9
+ const FATAL_TIMEOUT_MS = 5_000;
10
+ const FATAL_HANDLERS = Symbol.for("kestrel.fatalHandlers");
11
+ function installFatalHandlers(kestrel) {
12
+ const installed = boundaryCast(process, "host");
13
+ if (installed[FATAL_HANDLERS])
14
+ return;
15
+ installed[FATAL_HANDLERS] = true;
16
+ const fatal = (event) => (reason) => {
17
+ const error = reason instanceof Error ? reason : new Error(String(reason));
18
+ consoleLogger.error(event, { reason: error.message, stack: error.stack ?? null });
19
+ const exit = () => process.exit(1);
20
+ void kestrel.stop({ timeoutMs: FATAL_TIMEOUT_MS }).then(exit, exit);
21
+ };
22
+ process.on("unhandledRejection", fatal("unhandledRejection"));
23
+ process.on("uncaughtException", fatal("uncaughtException"));
24
+ }
25
+ try {
26
+ const config = await loadConfig(resolve(root, "kestrel.config.ts"));
27
+ const modules = await loadModules(root, config);
28
+ const pipelines = await loadPipelines(root, config.pipelinesDir ?? "./pipelines");
29
+ const kestrel = await boot({ config, modules, pipelines, logger: consoleLogger, root });
30
+ await kestrel.start();
31
+ consoleLogger.info("kestrel started", { modules: modules.length, pipelines: kestrel.pipelines.size, triggers: kestrel.config.triggers.length });
32
+ installFatalHandlers(kestrel);
33
+ const shutdown = (signal) => () => {
34
+ consoleLogger.info("kestrel stopping", { signal });
35
+ void kestrel.stop().then(({ drained }) => {
36
+ if (!drained)
37
+ consoleLogger.error("shutdown deadline hit, closing anyway", { signal });
38
+ process.exit(drained ? 0 : 1);
39
+ }, (err) => {
40
+ consoleLogger.error("shutdown failed", { signal, error: err instanceof Error ? (err.stack ?? err.message) : String(err) });
41
+ process.exit(1);
42
+ });
43
+ };
44
+ process.once("SIGINT", shutdown("SIGINT"));
45
+ process.once("SIGTERM", shutdown("SIGTERM"));
46
+ }
47
+ catch (err) {
48
+ if (err instanceof KestrelBootError) {
49
+ consoleLogger.error("boot failed", { module: err.module, reason: err.reason });
50
+ }
51
+ else {
52
+ consoleLogger.error("boot failed", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) });
53
+ }
54
+ process.exit(1);
55
+ }