@michaelthielemann/kestrel 4.1.2 → 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.
- package/README.md +26 -201
- package/dist/boot.d.ts +45 -0
- package/dist/boot.js +274 -0
- package/dist/cast.d.ts +3 -0
- package/dist/cast.js +6 -0
- package/dist/catalogue.d.ts +11 -0
- package/dist/catalogue.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +55 -0
- package/dist/context.d.ts +62 -0
- package/dist/context.js +61 -0
- package/dist/dataflow.d.ts +3 -0
- package/dist/dataflow.js +65 -0
- package/dist/defineConfig.d.ts +157 -0
- package/dist/defineConfig.js +41 -0
- package/dist/defineContract.d.ts +12 -0
- package/dist/defineContract.js +16 -0
- package/dist/defineModule.d.ts +75 -0
- package/dist/defineModule.js +9 -0
- package/dist/definePipeline.d.ts +7 -0
- package/dist/definePipeline.js +11 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.js +48 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +14 -0
- package/dist/load.d.ts +7 -0
- package/dist/load.js +79 -0
- package/dist/logger.d.ts +18 -0
- package/dist/logger.js +36 -0
- package/dist/registry.d.ts +14 -0
- package/dist/registry.js +87 -0
- package/dist/result.d.ts +19 -0
- package/dist/result.js +19 -0
- package/dist/runner.d.ts +29 -0
- package/dist/runner.js +133 -0
- package/dist/schema.d.ts +7 -0
- package/dist/schema.js +142 -0
- package/dist/sort.d.ts +2 -0
- package/dist/sort.js +50 -0
- package/dist/testing/runPipeline.d.ts +9 -0
- package/dist/testing/runPipeline.js +22 -0
- package/dist/triggers/cron.d.ts +10 -0
- package/dist/triggers/cron.js +44 -0
- package/dist/triggers/cronMatch.d.ts +10 -0
- package/dist/triggers/cronMatch.js +68 -0
- package/dist/triggers/http.d.ts +58 -0
- package/dist/triggers/http.js +292 -0
- package/package.json +25 -151
- package/NOTICE +0 -12
- package/kestrel.config.ts +0 -28
- package/layers/access/nuxt.config.ts +0 -5
- package/layers/access/server/middleware/00.ip-allowlist.ts +0 -52
- package/layers/access/server/middleware/access-guard.ts +0 -32
- package/layers/access/server/types.d.ts +0 -7
- package/layers/admin/app/components/AdminAccount.vue +0 -212
- package/layers/admin/app/components/AdminNav.vue +0 -44
- package/layers/admin/app/components/BlockFields.vue +0 -67
- package/layers/admin/app/components/BlockPreview.vue +0 -413
- package/layers/admin/app/components/BlockTree.vue +0 -433
- package/layers/admin/app/components/BlocksBody.vue +0 -163
- package/layers/admin/app/components/CollectionDeleteDialog.vue +0 -38
- package/layers/admin/app/components/CollectionEditor.vue +0 -324
- package/layers/admin/app/components/CollectionList.vue +0 -429
- package/layers/admin/app/components/CollectionListBulkBar.vue +0 -62
- package/layers/admin/app/components/CollectionListColumnsPanel.vue +0 -37
- package/layers/admin/app/components/CollectionListFilterPanel.vue +0 -145
- package/layers/admin/app/components/CollectionListPager.vue +0 -70
- package/layers/admin/app/components/CollectionListTable.vue +0 -347
- package/layers/admin/app/components/EditorStatus.vue +0 -211
- package/layers/admin/app/components/EditorUnsupported.vue +0 -28
- package/layers/admin/app/components/FieldsBody.vue +0 -23
- package/layers/admin/app/components/LocaleBar.vue +0 -107
- package/layers/admin/app/components/PageFields.vue +0 -129
- package/layers/admin/app/components/PageFieldsPane.vue +0 -39
- package/layers/admin/app/components/SeoFields.vue +0 -210
- package/layers/admin/app/components/SingletonEditor.vue +0 -93
- package/layers/admin/app/composables/useAuth.ts +0 -68
- package/layers/admin/app/composables/useBlockTree.ts +0 -95
- package/layers/admin/app/composables/useBlocks.ts +0 -7
- package/layers/admin/app/composables/useCachedResource.ts +0 -35
- package/layers/admin/app/composables/useCollectionOps.ts +0 -102
- package/layers/admin/app/composables/useCollections.ts +0 -6
- package/layers/admin/app/composables/useContentLocales.ts +0 -23
- package/layers/admin/app/composables/useEditForm.ts +0 -402
- package/layers/admin/app/composables/useLinkResolver.ts +0 -40
- package/layers/admin/app/composables/useListBatchActions.ts +0 -78
- package/layers/admin/app/composables/useListColumns.ts +0 -53
- package/layers/admin/app/composables/useListFilterDraft.ts +0 -129
- package/layers/admin/app/composables/useListRows.ts +0 -75
- package/layers/admin/app/composables/useListSelection.ts +0 -28
- package/layers/admin/app/composables/useListUrlState.ts +0 -53
- package/layers/admin/app/composables/usePendingPublishPoll.ts +0 -15
- package/layers/admin/app/composables/usePreviewViewport.ts +0 -30
- package/layers/admin/app/composables/usePublishStatus.ts +0 -107
- package/layers/admin/app/composables/useRailCollapsed.ts +0 -14
- package/layers/admin/app/composables/useTheme.ts +0 -36
- package/layers/admin/app/composables/useToolbarPanel.ts +0 -38
- package/layers/admin/app/composables/useUnsavedGuard.ts +0 -22
- package/layers/admin/app/layouts/admin.vue +0 -266
- package/layers/admin/app/middleware/admin-auth.ts +0 -4
- package/layers/admin/app/pages/admin/[collection]/[id].vue +0 -203
- package/layers/admin/app/pages/admin/[collection]/index.vue +0 -62
- package/layers/admin/app/pages/admin/index.vue +0 -118
- package/layers/admin/app/pages/admin/login.vue +0 -68
- package/layers/admin/app/pages/admin/media.vue +0 -28
- package/layers/admin/app/pages/admin/references.vue +0 -146
- package/layers/admin/app/plugins/admin-style-guard.client.ts +0 -76
- package/layers/admin/app/plugins/reauth.client.ts +0 -20
- package/layers/admin/app/utils/admin-style-guard.ts +0 -20
- package/layers/admin/app/utils/block-tree.ts +0 -214
- package/layers/admin/app/utils/collection-ops.ts +0 -69
- package/layers/admin/app/utils/dead-refs.ts +0 -30
- package/layers/admin/app/utils/edit-form.ts +0 -267
- package/layers/admin/app/utils/editor-expose.ts +0 -36
- package/layers/admin/app/utils/editor-form-context.ts +0 -82
- package/layers/admin/app/utils/editor-registry.ts +0 -20
- package/layers/admin/app/utils/field-keys.ts +0 -11
- package/layers/admin/app/utils/list-cell.ts +0 -46
- package/layers/admin/app/utils/list-columns.ts +0 -142
- package/layers/admin/app/utils/list-query.ts +0 -96
- package/layers/admin/app/utils/nav-active.ts +0 -9
- package/layers/admin/app/utils/populate-blocks.ts +0 -288
- package/layers/admin/app/utils/preview-channel.ts +0 -62
- package/layers/admin/app/utils/preview-viewport.ts +0 -80
- package/layers/admin/app/utils/reauth.ts +0 -67
- package/layers/admin/app/utils/record-title.ts +0 -15
- package/layers/admin/app/utils/register-builtin-editors.ts +0 -9
- package/layers/admin/app/utils/safe-redirect.ts +0 -12
- package/layers/admin/nuxt.config.ts +0 -20
- package/layers/auth/nuxt.config.ts +0 -1
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +0 -7
- package/layers/collections/nuxt.config.ts +0 -1
- package/layers/collections/server/plugins/01.register.ts +0 -29
- package/layers/collections/server/plugins/02.register-relation-populate.ts +0 -18
- package/layers/core/app/composables/layouts.ts +0 -5
- package/layers/core/app/utils/field-factories.ts +0 -129
- package/layers/core/app/utils/layouts.ts +0 -38
- package/layers/core/modules/auto-discovery/extract-block.ts +0 -179
- package/layers/core/modules/auto-discovery/index.ts +0 -112
- package/layers/core/modules/auto-discovery/package-registry.ts +0 -19
- package/layers/core/modules/auto-discovery/scan.ts +0 -170
- package/layers/core/modules/auto-discovery/virtual.d.ts +0 -20
- package/layers/core/modules/component-namespace/index.ts +0 -44
- package/layers/core/modules/component-namespace/shared.ts +0 -24
- package/layers/core/modules/kestrel/app-shell.ts +0 -55
- package/layers/core/modules/kestrel/auto-imports.ts +0 -44
- package/layers/core/modules/kestrel/index.ts +0 -144
- package/layers/core/modules/plugin-order/index.ts +0 -69
- package/layers/core/modules/plugin-order/plugin-order.ts +0 -273
- package/layers/core/nuxt.config.ts +0 -10
- package/layers/core/server/api/[...path].ts +0 -98
- package/layers/core/server/plugins/00.config.ts +0 -12
- package/layers/core/server/plugins/00.migrate.ts +0 -12
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +0 -5
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +0 -5
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +0 -5
- package/layers/core/server/plugins/02.schema-sync.ts +0 -58
- package/layers/core/server/plugins/04.outbox-worker.ts +0 -31
- package/layers/core/server/plugins/05.reindex-refs.ts +0 -5
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +0 -29
- package/layers/core/server/tasks/db/migrate-module.ts +0 -83
- package/layers/core/server/tasks/db/migrate-revisions.ts +0 -20
- package/layers/core/server/tasks/db/migrate.ts +0 -50
- package/layers/core/server/utils/server-config.ts +0 -81
- package/layers/fields/nuxt.config.ts +0 -1
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +0 -7
- package/layers/fields/server/plugins/01.register-field-populate.ts +0 -11
- package/layers/media/app/components/KestrelImg.vue +0 -94
- package/layers/media/app/components/MediaConflictDialog.vue +0 -35
- package/layers/media/app/components/MediaDeleteDialog.vue +0 -52
- package/layers/media/app/components/MediaGrid.vue +0 -113
- package/layers/media/app/components/MediaImage.vue +0 -70
- package/layers/media/app/components/MediaLibrary.vue +0 -456
- package/layers/media/app/components/MediaNewFolderDialog.vue +0 -32
- package/layers/media/app/components/MediaPathBar.vue +0 -163
- package/layers/media/app/components/MediaPicker.vue +0 -19
- package/layers/media/app/components/MediaRenameDialog.vue +0 -32
- package/layers/media/app/components/MediaTable.vue +0 -75
- package/layers/media/app/components/MediaThumb.vue +0 -25
- package/layers/media/app/components/MediaToolbar.vue +0 -90
- package/layers/media/app/components/MediaUploadDialog.vue +0 -87
- package/layers/media/app/components/MediaViewer.vue +0 -137
- package/layers/media/app/components/field/Media.vue +0 -263
- package/layers/media/app/composables/useBlurUp.ts +0 -55
- package/layers/media/app/composables/useMediaClipboard.ts +0 -14
- package/layers/media/app/composables/useMediaContextMenu.ts +0 -56
- package/layers/media/app/composables/useMediaDnd.ts +0 -56
- package/layers/media/app/composables/useMediaLibrary.ts +0 -152
- package/layers/media/app/composables/useMediaOps.ts +0 -50
- package/layers/media/app/composables/useMediaRelocate.ts +0 -39
- package/layers/media/app/composables/useMediaResolver.ts +0 -50
- package/layers/media/app/composables/useMediaUpload.ts +0 -93
- package/layers/media/app/composables/useMediaVariant.ts +0 -34
- package/layers/media/app/plugins/field-media.client.ts +0 -6
- package/layers/media/app/utils/ai-disclosure.ts +0 -19
- package/layers/media/app/utils/dnd.ts +0 -69
- package/layers/media/app/utils/library.ts +0 -81
- package/layers/media/app/utils/ops.ts +0 -60
- package/layers/media/app/utils/picture-model.ts +0 -125
- package/layers/media/app/utils/thumbhash.ts +0 -19
- package/layers/media/app/utils/variant-name.ts +0 -20
- package/layers/media/nuxt.config.ts +0 -7
- package/layers/media/server/middleware/ondemand-variants.ts +0 -38
- package/layers/media/server/plugins/01.register-media-pipelines.ts +0 -7
- package/layers/media/server/plugins/02.register-media.ts +0 -22
- package/layers/media/server/plugins/04.variant-capture.ts +0 -27
- package/layers/media/server/plugins/05.media-cleanup.ts +0 -5
- package/layers/media/server/tasks/media/backfill.ts +0 -17
- package/layers/public/app/app.vue +0 -5
- package/layers/public/app/components/BlockRenderer.vue +0 -56
- package/layers/public/app/components/KestrelLink.vue +0 -26
- package/layers/public/app/components/KestrelPreviewBridge.vue +0 -133
- package/layers/public/app/composables/public-page.ts +0 -24
- package/layers/public/app/error.vue +0 -12
- package/layers/public/app/layouts/default.vue +0 -5
- package/layers/public/app/pages/[...slug].vue +0 -239
- package/layers/public/app/pages/__kestrel/preview.vue +0 -46
- package/layers/public/app/utils/block-edit-context.ts +0 -16
- package/layers/public/app/utils/json-ld.ts +0 -139
- package/layers/public/app/utils/link-href.ts +0 -46
- package/layers/public/app/utils/page-head.ts +0 -84
- package/layers/public/app/utils/page-layout.ts +0 -18
- package/layers/public/app/utils/preview-protocol.ts +0 -117
- package/layers/public/app/utils/public-route.ts +0 -46
- package/layers/public/app/utils/site-head.ts +0 -43
- package/layers/public/modules/deploy-output/index.ts +0 -80
- package/layers/public/modules/prerender-routes/discover.ts +0 -37
- package/layers/public/modules/prerender-routes/index.ts +0 -88
- package/layers/public/modules/prerender-routes/routes.ts +0 -16
- package/layers/public/modules/prune-media/index.ts +0 -48
- package/layers/public/modules/prune-media/prune-media.ts +0 -81
- package/layers/public/nuxt.config.ts +0 -14
- package/layers/public/server/delivery-live/serve.ts +0 -111
- package/layers/public/server/middleware/delivery-live-catchall.ts +0 -7
- package/layers/public/server/pipelines/index.ts +0 -23
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +0 -14
- package/layers/public/server/plugins/01.register-public-pipelines.ts +0 -7
- package/layers/public/server/plugins/02.register-links.ts +0 -16
- package/layers/public/server/plugins/03.redirects.ts +0 -55
- package/layers/public/server/plugins/05.plan-publish.ts +0 -5
- package/layers/public/server/plugins/zz.publish.ts +0 -127
- package/layers/public/server/routes/llms-full.txt.get.ts +0 -99
- package/layers/public/server/routes/llms.txt.get.ts +0 -66
- package/layers/public/server/routes/redirects.json.get.ts +0 -58
- package/layers/public/server/routes/robots.txt.get.ts +0 -11
- package/layers/public/server/routes/sitemap.xml.get.ts +0 -79
- package/layers/public/server/tasks/publish/run.ts +0 -32
- package/layers/public/server/utils/publish/render-live.ts +0 -26
- package/layers/ui/app/assets/scss/_base.scss +0 -16
- package/layers/ui/app/assets/scss/_datepicker.scss +0 -82
- package/layers/ui/app/assets/scss/_mixins.scss +0 -50
- package/layers/ui/app/assets/scss/_reset.scss +0 -65
- package/layers/ui/app/assets/scss/_tokens.scss +0 -183
- package/layers/ui/app/assets/scss/main.scss +0 -4
- package/layers/ui/app/components/field/Boolean.vue +0 -30
- package/layers/ui/app/components/field/Choice.vue +0 -73
- package/layers/ui/app/components/field/Datetime.vue +0 -98
- package/layers/ui/app/components/field/DeadRefNote.vue +0 -28
- package/layers/ui/app/components/field/Json.vue +0 -62
- package/layers/ui/app/components/field/Layout.vue +0 -138
- package/layers/ui/app/components/field/Link.vue +0 -112
- package/layers/ui/app/components/field/LinkInternalPicker.vue +0 -95
- package/layers/ui/app/components/field/Number.vue +0 -30
- package/layers/ui/app/components/field/Relation.vue +0 -49
- package/layers/ui/app/components/field/Renderer.vue +0 -30
- package/layers/ui/app/components/field/Repeater.vue +0 -460
- package/layers/ui/app/components/field/Richtext.vue +0 -26
- package/layers/ui/app/components/field/Slug.vue +0 -65
- package/layers/ui/app/components/field/Text.vue +0 -35
- package/layers/ui/app/components/field/Unsupported.vue +0 -34
- package/layers/ui/app/components/ui/Alert.vue +0 -50
- package/layers/ui/app/components/ui/Brand.vue +0 -22
- package/layers/ui/app/components/ui/Button.vue +0 -116
- package/layers/ui/app/components/ui/ButtonGroup.vue +0 -96
- package/layers/ui/app/components/ui/Checkbox.vue +0 -53
- package/layers/ui/app/components/ui/CheckboxGroup.vue +0 -51
- package/layers/ui/app/components/ui/Combobox.vue +0 -345
- package/layers/ui/app/components/ui/DatePicker.vue +0 -92
- package/layers/ui/app/components/ui/DateRangePicker.vue +0 -100
- package/layers/ui/app/components/ui/Dialog.vue +0 -107
- package/layers/ui/app/components/ui/EmptyState.vue +0 -46
- package/layers/ui/app/components/ui/Field.vue +0 -58
- package/layers/ui/app/components/ui/Fieldset.vue +0 -65
- package/layers/ui/app/components/ui/Icon.vue +0 -44
- package/layers/ui/app/components/ui/Menu.vue +0 -54
- package/layers/ui/app/components/ui/NumberInput.vue +0 -175
- package/layers/ui/app/components/ui/Richtext.vue +0 -141
- package/layers/ui/app/components/ui/RichtextToolbar.vue +0 -251
- package/layers/ui/app/components/ui/Select.vue +0 -52
- package/layers/ui/app/components/ui/TextInput.vue +0 -111
- package/layers/ui/app/components/ui/Textarea.vue +0 -39
- package/layers/ui/app/components/ui/TimeInput.vue +0 -18
- package/layers/ui/app/components/ui/Toasts.vue +0 -103
- package/layers/ui/app/components/ui/Tooltip.vue +0 -45
- package/layers/ui/app/composables/useAdminLang.ts +0 -8
- package/layers/ui/app/composables/useDragReorder.ts +0 -41
- package/layers/ui/app/composables/useEchoGuard.ts +0 -28
- package/layers/ui/app/composables/useLinkField.ts +0 -70
- package/layers/ui/app/composables/useRecordOptions.ts +0 -84
- package/layers/ui/app/composables/useRepeater.ts +0 -99
- package/layers/ui/app/composables/useT.ts +0 -32
- package/layers/ui/app/composables/useToast.ts +0 -46
- package/layers/ui/app/i18n/de.ts +0 -432
- package/layers/ui/app/i18n/en.ts +0 -434
- package/layers/ui/app/utils/boolean-field.ts +0 -10
- package/layers/ui/app/utils/field-component.ts +0 -22
- package/layers/ui/app/utils/field-empty.ts +0 -21
- package/layers/ui/app/utils/field-registry.ts +0 -36
- package/layers/ui/app/utils/humanize.ts +0 -14
- package/layers/ui/app/utils/icons.ts +0 -143
- package/layers/ui/app/utils/iso-date.ts +0 -18
- package/layers/ui/app/utils/localized.ts +0 -13
- package/layers/ui/app/utils/record-context.ts +0 -15
- package/layers/ui/app/utils/reorder.ts +0 -8
- package/layers/ui/app/utils/richtext-preserve-class.ts +0 -31
- package/layers/ui/app/utils/useFieldA11y.ts +0 -40
- package/layers/ui/nuxt.config.ts +0 -9
- package/nuxt.config.ts +0 -108
- package/scripts/consumer-template-ci.mjs +0 -160
- package/scripts/copy-create-payload.mjs +0 -49
- package/scripts/coverage-all.mjs +0 -163
- package/scripts/dashboard.mjs +0 -89
- package/scripts/hash-password.mjs +0 -29
- package/scripts/kestrel.mjs +0 -240
- package/scripts/lib/cli.mjs +0 -129
- package/scripts/lib/coverage-model.mjs +0 -51
- package/scripts/lib/password.mjs +0 -19
- package/scripts/lib/scaffold.mjs +0 -205
- package/scripts/port-graph.mjs +0 -350
- package/scripts/typecheck.mjs +0 -62
- package/templates/starter/README.md +0 -65
- package/templates/starter/_env.example +0 -17
- package/templates/starter/_gitignore +0 -26
- package/templates/starter/_package.json +0 -22
- package/templates/starter/app/app.vue +0 -7
- package/templates/starter/app/blocks/Prose.vue +0 -13
- package/templates/starter/app/layouts/default.vue +0 -6
- package/templates/starter/nuxt.config.ts +0 -23
- package/templates/starter/pnpm-workspace.yaml +0 -8
- package/templates/starter/tsconfig.json +0 -3
package/README.md
CHANGED
|
@@ -1,201 +1,26 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
##
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
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
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
|
+
}
|