@michaelthielemann/kestrel 4.1.3 → 5.0.2
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 +284 -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 +162 -0
- package/dist/defineConfig.js +42 -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/allowlist.d.ts +9 -0
- package/dist/triggers/allowlist.js +49 -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 +59 -0
- package/dist/triggers/http.js +300 -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
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { silentLogger } from "../logger.js";
|
|
2
|
+
import { StepRegistry } from "../registry.js";
|
|
3
|
+
import { runPipeline as execute } from "../runner.js";
|
|
4
|
+
export function runPipeline(pipeline, input, options) {
|
|
5
|
+
const registry = new StepRegistry();
|
|
6
|
+
const byPrefix = new Map();
|
|
7
|
+
for (const [name, fn] of Object.entries(options.steps)) {
|
|
8
|
+
const dot = name.indexOf(".");
|
|
9
|
+
if (dot === -1)
|
|
10
|
+
throw new Error(`fake step "${name}" must be named "<module>.<step>"`);
|
|
11
|
+
const prefix = name.slice(0, dot);
|
|
12
|
+
const group = byPrefix.get(prefix) ?? {};
|
|
13
|
+
group[name.slice(dot + 1)] = fn;
|
|
14
|
+
byPrefix.set(prefix, group);
|
|
15
|
+
}
|
|
16
|
+
for (const [prefix, group] of byPrefix) {
|
|
17
|
+
const descriptions = Object.fromEntries(Object.keys(group).map((key) => [key, { summary: `${prefix}.${key}`, reads: [], writes: ["result"] }]));
|
|
18
|
+
registry.register("test", prefix, group, descriptions);
|
|
19
|
+
}
|
|
20
|
+
const resolved = { name: pipeline.name, steps: pipeline.steps.map((s) => registry.resolve(s, `pipelines/${pipeline.name}`)) };
|
|
21
|
+
return execute(resolved, { trigger: { kind: "http", name: "test" }, ...input }, options.logger ?? silentLogger);
|
|
22
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface Allowlist {
|
|
2
|
+
check(ip: string | undefined): boolean;
|
|
3
|
+
}
|
|
4
|
+
export declare function normalizeIp(ip: string): {
|
|
5
|
+
address: string;
|
|
6
|
+
family: "ipv4" | "ipv6";
|
|
7
|
+
} | null;
|
|
8
|
+
/** `null` for an empty list: nothing to enforce. Throws on an entry that is neither an address nor a CIDR. */
|
|
9
|
+
export declare function createAllowlist(entries: readonly string[]): Allowlist | null;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { BlockList, isIP } from "node:net";
|
|
2
|
+
const MAPPED_IPV4 = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
|
|
3
|
+
function invalid(entry, reason) {
|
|
4
|
+
return new Error(`ip allowlist: invalid entry ${JSON.stringify(entry)} (${reason})`);
|
|
5
|
+
}
|
|
6
|
+
// Strips an IPv6 zone id and unwraps an IPv4-mapped IPv6 address so both sides of the check use
|
|
7
|
+
// the same family the operator wrote into the list.
|
|
8
|
+
export function normalizeIp(ip) {
|
|
9
|
+
const bare = ip.includes("%") ? (ip.split("%")[0] ?? "") : ip;
|
|
10
|
+
const mapped = MAPPED_IPV4.exec(bare);
|
|
11
|
+
const address = mapped ? (mapped[1] ?? bare) : bare;
|
|
12
|
+
const version = isIP(address);
|
|
13
|
+
if (version === 4)
|
|
14
|
+
return { address, family: "ipv4" };
|
|
15
|
+
if (version === 6)
|
|
16
|
+
return { address, family: "ipv6" };
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
/** `null` for an empty list: nothing to enforce. Throws on an entry that is neither an address nor a CIDR. */
|
|
20
|
+
export function createAllowlist(entries) {
|
|
21
|
+
if (entries.length === 0)
|
|
22
|
+
return null;
|
|
23
|
+
const list = new BlockList();
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
const [raw, prefixText, ...rest] = entry.trim().split("/");
|
|
26
|
+
if (raw === undefined || raw === "" || rest.length > 0)
|
|
27
|
+
throw invalid(entry, "expected an IP address or a CIDR range");
|
|
28
|
+
const ip = normalizeIp(raw);
|
|
29
|
+
if (ip === null)
|
|
30
|
+
throw invalid(entry, "not an IPv4 or IPv6 address");
|
|
31
|
+
if (prefixText === undefined) {
|
|
32
|
+
list.addAddress(ip.address, ip.family);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const bits = ip.family === "ipv4" ? 32 : 128;
|
|
36
|
+
const prefix = /^\d{1,3}$/.test(prefixText) ? Number(prefixText) : Number.NaN;
|
|
37
|
+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > bits)
|
|
38
|
+
throw invalid(entry, `prefix must be 0-${bits}`);
|
|
39
|
+
list.addSubnet(ip.address, prefix, ip.family);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
check(ip) {
|
|
43
|
+
if (ip === undefined)
|
|
44
|
+
return false;
|
|
45
|
+
const normalized = normalizeIp(ip);
|
|
46
|
+
return normalized !== null && list.check(normalized.address, normalized.family);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Logger } from "../logger.ts";
|
|
2
|
+
import type { Runner } from "../runner.ts";
|
|
3
|
+
import { type CronSpec } from "./cronMatch.ts";
|
|
4
|
+
export interface CronEntry {
|
|
5
|
+
expression: string;
|
|
6
|
+
spec: CronSpec;
|
|
7
|
+
pipeline: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function createCronEntry(expression: string, pipeline: string): CronEntry;
|
|
10
|
+
export declare function startCron(entries: readonly CronEntry[], run: Runner, logger: Logger, now?: () => Date): () => void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { logWarn } from "../logger.js";
|
|
2
|
+
import { cronMatches, parseCron } from "./cronMatch.js";
|
|
3
|
+
export function createCronEntry(expression, pipeline) {
|
|
4
|
+
return { expression, spec: parseCron(expression), pipeline };
|
|
5
|
+
}
|
|
6
|
+
export function startCron(entries, run, logger, now = () => new Date()) {
|
|
7
|
+
let timer;
|
|
8
|
+
let stopped = false;
|
|
9
|
+
const running = new Set();
|
|
10
|
+
const tick = () => {
|
|
11
|
+
const date = now();
|
|
12
|
+
for (const entry of entries) {
|
|
13
|
+
if (!cronMatches(entry.spec, date))
|
|
14
|
+
continue;
|
|
15
|
+
if (running.has(entry.pipeline)) {
|
|
16
|
+
logWarn(logger, `cron "${entry.expression}" skipped: pipeline "${entry.pipeline}" is still running`, { cron: entry.expression, pipeline: entry.pipeline });
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
running.add(entry.pipeline);
|
|
20
|
+
void run(entry.pipeline, { trigger: { kind: "cron", name: entry.expression } })
|
|
21
|
+
.then((res) => {
|
|
22
|
+
if (res.status >= 400)
|
|
23
|
+
logger.error(`cron "${entry.expression}" pipeline "${entry.pipeline}" ended with ${res.status}`, { runId: res.runId, error: res.error });
|
|
24
|
+
}, (err) => {
|
|
25
|
+
logger.error(`cron "${entry.expression}" pipeline "${entry.pipeline}" threw`, { error: err instanceof Error ? (err.stack ?? err.message) : String(err) });
|
|
26
|
+
})
|
|
27
|
+
.finally(() => running.delete(entry.pipeline));
|
|
28
|
+
}
|
|
29
|
+
schedule();
|
|
30
|
+
};
|
|
31
|
+
const schedule = () => {
|
|
32
|
+
if (stopped)
|
|
33
|
+
return;
|
|
34
|
+
const ms = 60_000 - (now().getTime() % 60_000);
|
|
35
|
+
timer = setTimeout(tick, ms);
|
|
36
|
+
timer.unref();
|
|
37
|
+
};
|
|
38
|
+
schedule();
|
|
39
|
+
return () => {
|
|
40
|
+
stopped = true;
|
|
41
|
+
if (timer)
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface CronSpec {
|
|
2
|
+
minute: Set<number>;
|
|
3
|
+
hour: Set<number>;
|
|
4
|
+
dayOfMonth: Set<number>;
|
|
5
|
+
month: Set<number>;
|
|
6
|
+
dayOfWeek: Set<number>;
|
|
7
|
+
anyDay: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function parseCron(expression: string): CronSpec;
|
|
10
|
+
export declare function cronMatches(spec: CronSpec, date: Date): boolean;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const RANGES = [
|
|
2
|
+
["minute", 0, 59],
|
|
3
|
+
["hour", 0, 23],
|
|
4
|
+
["day of month", 1, 31],
|
|
5
|
+
["month", 1, 12],
|
|
6
|
+
["day of week", 0, 7],
|
|
7
|
+
];
|
|
8
|
+
function parseField(field, label, min, max) {
|
|
9
|
+
const values = new Set();
|
|
10
|
+
let restricted = false;
|
|
11
|
+
for (const part of field.split(",")) {
|
|
12
|
+
const [rangePart, stepPart] = part.split("/");
|
|
13
|
+
if (rangePart === undefined || rangePart === "")
|
|
14
|
+
throw new Error(`cron ${label}: empty entry in "${field}"`);
|
|
15
|
+
const step = stepPart === undefined ? 1 : Number(stepPart);
|
|
16
|
+
if (!Number.isInteger(step) || step < 1)
|
|
17
|
+
throw new Error(`cron ${label}: invalid step in "${part}"`);
|
|
18
|
+
let lo;
|
|
19
|
+
let hi;
|
|
20
|
+
if (rangePart === "*") {
|
|
21
|
+
lo = min;
|
|
22
|
+
hi = max;
|
|
23
|
+
if (stepPart !== undefined)
|
|
24
|
+
restricted = true;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
restricted = true;
|
|
28
|
+
const [a, b] = rangePart.split("-");
|
|
29
|
+
lo = Number(a);
|
|
30
|
+
hi = b === undefined ? (stepPart === undefined ? lo : max) : Number(b);
|
|
31
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo < min || hi > max || lo > hi) {
|
|
32
|
+
throw new Error(`cron ${label}: "${part}" is outside ${min}-${max}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (let v = lo; v <= hi; v += step)
|
|
36
|
+
values.add(v);
|
|
37
|
+
}
|
|
38
|
+
return { values, restricted };
|
|
39
|
+
}
|
|
40
|
+
export function parseCron(expression) {
|
|
41
|
+
const fields = expression.trim().split(/\s+/);
|
|
42
|
+
if (fields.length !== 5)
|
|
43
|
+
throw new Error(`cron expression "${expression}" must have 5 fields`);
|
|
44
|
+
const parsed = fields.map((f, i) => {
|
|
45
|
+
const [label, min, max] = RANGES[i];
|
|
46
|
+
return parseField(f, label, min, max);
|
|
47
|
+
});
|
|
48
|
+
const dow = parsed[4].values;
|
|
49
|
+
if (dow.has(7)) {
|
|
50
|
+
dow.delete(7);
|
|
51
|
+
dow.add(0);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
minute: parsed[0].values,
|
|
55
|
+
hour: parsed[1].values,
|
|
56
|
+
dayOfMonth: parsed[2].values,
|
|
57
|
+
month: parsed[3].values,
|
|
58
|
+
dayOfWeek: dow,
|
|
59
|
+
anyDay: parsed[2].restricted && parsed[4].restricted,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function cronMatches(spec, date) {
|
|
63
|
+
if (!spec.minute.has(date.getMinutes()) || !spec.hour.has(date.getHours()) || !spec.month.has(date.getMonth() + 1))
|
|
64
|
+
return false;
|
|
65
|
+
const domOk = spec.dayOfMonth.has(date.getDate());
|
|
66
|
+
const dowOk = spec.dayOfWeek.has(date.getDay());
|
|
67
|
+
return spec.anyDay ? domOk || dowOk : domOk && dowOk;
|
|
68
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type IncomingMessage, type Server } from "node:http";
|
|
2
|
+
import type { AddressInfo } from "node:net";
|
|
3
|
+
import { type UploadedFile } from "../context.ts";
|
|
4
|
+
import type { Logger } from "../logger.ts";
|
|
5
|
+
import type { RunResult, Runner } from "../runner.ts";
|
|
6
|
+
export interface Route {
|
|
7
|
+
method: string;
|
|
8
|
+
segments: readonly string[];
|
|
9
|
+
pipeline: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function parseRoute(http: string, pipeline: string): Route;
|
|
12
|
+
/** Method plus segment shape; two routes differing only in a param name are the same pattern. */
|
|
13
|
+
export declare function routePattern(route: Route): string;
|
|
14
|
+
export declare function matchRoute(routes: readonly Route[], method: string, pathname: string): {
|
|
15
|
+
route: Route;
|
|
16
|
+
params: Record<string, string>;
|
|
17
|
+
} | undefined;
|
|
18
|
+
export declare class BodyTooLarge extends Error {
|
|
19
|
+
}
|
|
20
|
+
export declare function parseQuery(searchParams: URLSearchParams): Record<string, unknown>;
|
|
21
|
+
export interface ParsedBody {
|
|
22
|
+
payload: Record<string, unknown>;
|
|
23
|
+
files: UploadedFile[];
|
|
24
|
+
}
|
|
25
|
+
export declare function parseRequestBody(contentType: string, body: Uint8Array): Promise<ParsedBody>;
|
|
26
|
+
export interface HttpResponse {
|
|
27
|
+
status: number;
|
|
28
|
+
headers: Record<string, string>;
|
|
29
|
+
body: Uint8Array | string;
|
|
30
|
+
}
|
|
31
|
+
export interface ResponseMeta {
|
|
32
|
+
runId?: string;
|
|
33
|
+
requestId?: string;
|
|
34
|
+
retryable?: boolean;
|
|
35
|
+
retryAfterSeconds?: number;
|
|
36
|
+
}
|
|
37
|
+
export declare function buildResponse(status: number, body: unknown, meta?: ResponseMeta, extraInline?: readonly string[]): HttpResponse;
|
|
38
|
+
export declare function responseForRun(result: RunResult, extraInline?: readonly string[]): HttpResponse;
|
|
39
|
+
/** The one shape for errors raised before a pipeline runs (unknown route, unreadable body). */
|
|
40
|
+
export declare function errorResponse(status: number, error: string, requestId?: string): HttpResponse;
|
|
41
|
+
/** RFC 6266: a quoted ASCII fallback, plus filename* only when the name is not printable ASCII. */
|
|
42
|
+
export declare function disposition(contentType: string, filename: string | undefined, extraInline?: readonly string[]): string;
|
|
43
|
+
export interface HttpTimeouts {
|
|
44
|
+
requestMs?: number;
|
|
45
|
+
headersMs?: number;
|
|
46
|
+
keepAliveMs?: number;
|
|
47
|
+
}
|
|
48
|
+
export interface HttpOptions {
|
|
49
|
+
allow?: readonly string[];
|
|
50
|
+
corsOrigin?: string;
|
|
51
|
+
maxBodyBytes?: number;
|
|
52
|
+
trustProxy?: boolean;
|
|
53
|
+
healthPath?: string | null;
|
|
54
|
+
inlineTypes?: readonly string[];
|
|
55
|
+
timeouts?: HttpTimeouts;
|
|
56
|
+
}
|
|
57
|
+
export declare function clientIp(req: IncomingMessage, trustProxy: boolean): string | undefined;
|
|
58
|
+
export declare function createHttpServer(routes: readonly Route[], run: Runner, logger: Logger, options?: HttpOptions): Server;
|
|
59
|
+
export declare function listen(server: Server, port: number, host: string): Promise<AddressInfo>;
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { isBinaryResult, requestIdOf } from "../context.js";
|
|
4
|
+
import { createAllowlist } from "./allowlist.js";
|
|
5
|
+
export function parseRoute(http, pipeline) {
|
|
6
|
+
const [method, path] = http.split(" ");
|
|
7
|
+
if (method === undefined || path === undefined || !path.startsWith("/"))
|
|
8
|
+
throw new Error(`invalid http trigger "${http}"`);
|
|
9
|
+
const segments = path === "/" ? [] : path.slice(1).split("/");
|
|
10
|
+
if (segments.some((s) => s === ""))
|
|
11
|
+
throw new Error(`invalid http trigger "${http}": empty path segment`);
|
|
12
|
+
const wildcard = segments.findIndex((s) => s.startsWith("*"));
|
|
13
|
+
if (wildcard !== -1 && wildcard !== segments.length - 1)
|
|
14
|
+
throw new Error(`invalid http trigger "${http}": wildcard must be the last segment`);
|
|
15
|
+
return { method, segments, pipeline };
|
|
16
|
+
}
|
|
17
|
+
/** literal > :param > end of route > *rest, so a literal route wins no matter when it was registered. */
|
|
18
|
+
function specificity(segment) {
|
|
19
|
+
if (segment === undefined)
|
|
20
|
+
return 1;
|
|
21
|
+
if (segment.startsWith("*"))
|
|
22
|
+
return 0;
|
|
23
|
+
if (segment.startsWith(":"))
|
|
24
|
+
return 2;
|
|
25
|
+
return 3;
|
|
26
|
+
}
|
|
27
|
+
function bySpecificity(a, b) {
|
|
28
|
+
const len = Math.max(a.segments.length, b.segments.length);
|
|
29
|
+
for (let i = 0; i < len; i++) {
|
|
30
|
+
const diff = specificity(b.segments[i]) - specificity(a.segments[i]);
|
|
31
|
+
if (diff !== 0)
|
|
32
|
+
return diff;
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
function decodeSegment(part) {
|
|
37
|
+
try {
|
|
38
|
+
return decodeURIComponent(part);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Method plus segment shape; two routes differing only in a param name are the same pattern. */
|
|
45
|
+
export function routePattern(route) {
|
|
46
|
+
return `${route.method} /${route.segments.map((s) => (s.startsWith(":") ? ":" : s.startsWith("*") ? "*" : s)).join("/")}`;
|
|
47
|
+
}
|
|
48
|
+
export function matchRoute(routes, method, pathname) {
|
|
49
|
+
const parts = pathname === "/" ? [] : pathname.replace(/\/+$/, "").slice(1).split("/");
|
|
50
|
+
for (const route of [...routes].sort(bySpecificity)) {
|
|
51
|
+
if (route.method !== method)
|
|
52
|
+
continue;
|
|
53
|
+
const last = route.segments[route.segments.length - 1];
|
|
54
|
+
const wildcard = last?.startsWith("*") ? route.segments.length - 1 : -1;
|
|
55
|
+
if (wildcard === -1 ? route.segments.length !== parts.length : parts.length < wildcard)
|
|
56
|
+
continue;
|
|
57
|
+
const params = {};
|
|
58
|
+
let ok = true;
|
|
59
|
+
for (let i = 0; i < route.segments.length; i++) {
|
|
60
|
+
const seg = route.segments[i];
|
|
61
|
+
if (i === wildcard) {
|
|
62
|
+
const rest = parts.slice(i).map(decodeSegment);
|
|
63
|
+
// An encoded "/" would make the rest indistinguishable from a deeper path, so it never matches.
|
|
64
|
+
if (rest.some((p) => p === undefined || p.includes("/")))
|
|
65
|
+
ok = false;
|
|
66
|
+
else
|
|
67
|
+
params[seg.slice(1)] = rest.join("/");
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
const part = decodeSegment(parts[i]);
|
|
71
|
+
if (part === undefined)
|
|
72
|
+
ok = false;
|
|
73
|
+
else if (seg.startsWith(":"))
|
|
74
|
+
params[seg.slice(1)] = part;
|
|
75
|
+
else if (seg !== part)
|
|
76
|
+
ok = false;
|
|
77
|
+
if (!ok)
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
if (ok)
|
|
81
|
+
return { route, params };
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
export class BodyTooLarge extends Error {
|
|
86
|
+
}
|
|
87
|
+
async function readBody(req, maxBytes) {
|
|
88
|
+
const chunks = [];
|
|
89
|
+
let size = 0;
|
|
90
|
+
for await (const chunk of req) {
|
|
91
|
+
size += chunk.length;
|
|
92
|
+
if (size > maxBytes)
|
|
93
|
+
throw new BodyTooLarge(`body exceeds ${maxBytes} bytes`);
|
|
94
|
+
chunks.push(chunk);
|
|
95
|
+
}
|
|
96
|
+
return Buffer.concat(chunks);
|
|
97
|
+
}
|
|
98
|
+
export function parseQuery(searchParams) {
|
|
99
|
+
const payload = {};
|
|
100
|
+
for (const key of new Set(searchParams.keys())) {
|
|
101
|
+
const values = searchParams.getAll(key);
|
|
102
|
+
payload[key] = values.length > 1 ? values : values[0];
|
|
103
|
+
}
|
|
104
|
+
return payload;
|
|
105
|
+
}
|
|
106
|
+
export async function parseRequestBody(contentType, body) {
|
|
107
|
+
if (contentType.startsWith("multipart/form-data")) {
|
|
108
|
+
const copy = new Uint8Array(body.byteLength);
|
|
109
|
+
copy.set(body);
|
|
110
|
+
const form = await new Response(copy.buffer, { headers: { "content-type": contentType } }).formData();
|
|
111
|
+
const payload = {};
|
|
112
|
+
const files = [];
|
|
113
|
+
for (const [field, value] of form) {
|
|
114
|
+
if (typeof value === "string")
|
|
115
|
+
payload[field] = value;
|
|
116
|
+
else
|
|
117
|
+
files.push({ field, filename: value.name, contentType: value.type || "application/octet-stream", data: new Uint8Array(await value.arrayBuffer()) });
|
|
118
|
+
}
|
|
119
|
+
return { payload, files };
|
|
120
|
+
}
|
|
121
|
+
const text = Buffer.from(body).toString("utf8");
|
|
122
|
+
if (text.trim() === "")
|
|
123
|
+
return { payload: {}, files: [] };
|
|
124
|
+
const parsed = JSON.parse(text);
|
|
125
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
126
|
+
throw new Error("body must be a JSON object");
|
|
127
|
+
return { payload: parsed, files: [] };
|
|
128
|
+
}
|
|
129
|
+
const DEFAULT_RETRY_AFTER_SECONDS = 1;
|
|
130
|
+
function retryAfterSecondsOf(meta) {
|
|
131
|
+
const seconds = meta.retryAfterSeconds;
|
|
132
|
+
return typeof seconds === "number" && Number.isInteger(seconds) && seconds > 0 ? seconds : DEFAULT_RETRY_AFTER_SECONDS;
|
|
133
|
+
}
|
|
134
|
+
export function buildResponse(status, body, meta = {}, extraInline = []) {
|
|
135
|
+
const headers = { "x-content-type-options": "nosniff", "cache-control": "no-store" };
|
|
136
|
+
if (meta.runId !== undefined)
|
|
137
|
+
headers["x-kestrel-run-id"] = meta.runId;
|
|
138
|
+
if (meta.requestId !== undefined)
|
|
139
|
+
headers["x-request-id"] = meta.requestId;
|
|
140
|
+
if (meta.retryable === true && (status === 429 || status === 503))
|
|
141
|
+
headers["retry-after"] = String(retryAfterSecondsOf(meta));
|
|
142
|
+
if (isBinaryResult(body)) {
|
|
143
|
+
return {
|
|
144
|
+
status,
|
|
145
|
+
headers: {
|
|
146
|
+
// Caller headers may add, never weaken: the mandatory ones are spread last.
|
|
147
|
+
...Object.fromEntries(Object.entries(body.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])),
|
|
148
|
+
...headers,
|
|
149
|
+
"content-type": body.contentType,
|
|
150
|
+
"content-length": String(body.data.byteLength),
|
|
151
|
+
"content-disposition": disposition(body.contentType, body.filename, extraInline),
|
|
152
|
+
"content-security-policy": "default-src 'none'; sandbox",
|
|
153
|
+
},
|
|
154
|
+
body: body.data,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return { status, headers: { ...headers, "content-type": "application/json; charset=utf-8" }, body: JSON.stringify(body ?? null) };
|
|
158
|
+
}
|
|
159
|
+
function errorBody(result) {
|
|
160
|
+
const body = { error: result.error, code: result.code, retryable: result.retryable, runId: result.runId };
|
|
161
|
+
if (result.step !== undefined)
|
|
162
|
+
body.step = result.step;
|
|
163
|
+
if (result.details !== undefined)
|
|
164
|
+
body.details = result.details;
|
|
165
|
+
return body;
|
|
166
|
+
}
|
|
167
|
+
function metaOf(result) {
|
|
168
|
+
const meta = { runId: result.runId };
|
|
169
|
+
if (result.requestId !== undefined)
|
|
170
|
+
meta.requestId = result.requestId;
|
|
171
|
+
if (result.retryable !== undefined)
|
|
172
|
+
meta.retryable = result.retryable;
|
|
173
|
+
const retryAfterSeconds = result.details?.retryAfterSeconds;
|
|
174
|
+
if (typeof retryAfterSeconds === "number")
|
|
175
|
+
meta.retryAfterSeconds = retryAfterSeconds;
|
|
176
|
+
return meta;
|
|
177
|
+
}
|
|
178
|
+
export function responseForRun(result, extraInline = []) {
|
|
179
|
+
if (result.status >= 400)
|
|
180
|
+
return buildResponse(result.status, errorBody(result), metaOf(result), extraInline);
|
|
181
|
+
return buildResponse(result.status, result.result, metaOf(result), extraInline);
|
|
182
|
+
}
|
|
183
|
+
/** Fixed code for errors raised before a pipeline runs, where there is no KestrelError to read one from. */
|
|
184
|
+
const EDGE_CODE_BY_STATUS = {
|
|
185
|
+
400: "VALIDATION",
|
|
186
|
+
403: "FORBIDDEN",
|
|
187
|
+
404: "NOT_FOUND",
|
|
188
|
+
413: "PAYLOAD_TOO_LARGE",
|
|
189
|
+
500: "INTERNAL",
|
|
190
|
+
};
|
|
191
|
+
/** The one shape for errors raised before a pipeline runs (unknown route, unreadable body). */
|
|
192
|
+
export function errorResponse(status, error, requestId) {
|
|
193
|
+
const runId = randomUUID();
|
|
194
|
+
const code = EDGE_CODE_BY_STATUS[status] ?? "INTERNAL";
|
|
195
|
+
const body = { error, code, retryable: false, runId };
|
|
196
|
+
return buildResponse(status, body, requestId === undefined ? { runId } : { runId, requestId });
|
|
197
|
+
}
|
|
198
|
+
const INLINE_SAFE = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "video/mp4", "video/webm", "audio/mpeg", "audio/ogg", "audio/wav"]);
|
|
199
|
+
/** RFC 6266: a quoted ASCII fallback, plus filename* only when the name is not printable ASCII. */
|
|
200
|
+
export function disposition(contentType, filename, extraInline = []) {
|
|
201
|
+
const mediaType = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
202
|
+
const mode = INLINE_SAFE.has(mediaType) || extraInline.includes(mediaType) ? "inline" : "attachment";
|
|
203
|
+
if (filename === undefined)
|
|
204
|
+
return mode;
|
|
205
|
+
const ascii = `${mode}; filename="${filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "\\$&")}"`;
|
|
206
|
+
if (/^[\x20-\x7e]*$/.test(filename))
|
|
207
|
+
return ascii;
|
|
208
|
+
const encoded = encodeURIComponent(filename).replace(/['()!*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
209
|
+
return `${ascii}; filename*=UTF-8''${encoded}`;
|
|
210
|
+
}
|
|
211
|
+
function sendError(res, status, error, requestId) {
|
|
212
|
+
write(res, errorResponse(status, error, requestId));
|
|
213
|
+
}
|
|
214
|
+
function write(res, response) {
|
|
215
|
+
res.writeHead(response.status, response.headers);
|
|
216
|
+
res.end(typeof response.body === "string" ? response.body : Buffer.from(response.body));
|
|
217
|
+
}
|
|
218
|
+
export function clientIp(req, trustProxy) {
|
|
219
|
+
if (trustProxy) {
|
|
220
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
221
|
+
const first = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim();
|
|
222
|
+
if (first)
|
|
223
|
+
return first;
|
|
224
|
+
}
|
|
225
|
+
return req.socket.remoteAddress ?? undefined;
|
|
226
|
+
}
|
|
227
|
+
export function createHttpServer(routes, run, logger, options = {}) {
|
|
228
|
+
const started = Date.now();
|
|
229
|
+
const healthPath = options.healthPath === undefined ? "/health" : options.healthPath;
|
|
230
|
+
const allowlist = createAllowlist(options.allow ?? []);
|
|
231
|
+
const trustProxy = options.trustProxy ?? false;
|
|
232
|
+
const server = createServer((req, res) => {
|
|
233
|
+
if (allowlist !== null && !allowlist.check(clientIp(req, trustProxy))) {
|
|
234
|
+
sendError(res, 403, "forbidden", requestIdOf(req.headers["x-request-id"]));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (options.corsOrigin !== undefined) {
|
|
238
|
+
res.setHeader("access-control-allow-origin", options.corsOrigin);
|
|
239
|
+
res.setHeader("access-control-allow-headers", "content-type, authorization");
|
|
240
|
+
res.setHeader("access-control-allow-methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
241
|
+
res.setHeader("vary", "origin");
|
|
242
|
+
if (req.method === "OPTIONS") {
|
|
243
|
+
res.writeHead(204, { "x-content-type-options": "nosniff", "cache-control": "no-store", "access-control-max-age": "600" });
|
|
244
|
+
res.end();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
void handle(req, res).catch((err) => {
|
|
249
|
+
logger.error("http handler failed", { error: err instanceof Error ? err.stack : String(err) });
|
|
250
|
+
if (!res.headersSent)
|
|
251
|
+
sendError(res, 500, "internal error", requestIdOf(req.headers["x-request-id"]));
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
server.requestTimeout = options.timeouts?.requestMs ?? 30_000;
|
|
255
|
+
server.headersTimeout = options.timeouts?.headersMs ?? 10_000;
|
|
256
|
+
server.keepAliveTimeout = options.timeouts?.keepAliveMs ?? 5_000;
|
|
257
|
+
return server;
|
|
258
|
+
async function handle(req, res) {
|
|
259
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
260
|
+
const requestId = requestIdOf(req.headers["x-request-id"]);
|
|
261
|
+
if (healthPath && url.pathname === healthPath && req.method === "GET") {
|
|
262
|
+
const meta = requestId === undefined ? { runId: randomUUID() } : { runId: randomUUID(), requestId };
|
|
263
|
+
return write(res, buildResponse(200, { ok: true, uptimeSeconds: Math.round((Date.now() - started) / 1000) }, meta));
|
|
264
|
+
}
|
|
265
|
+
const begin = performance.now();
|
|
266
|
+
const match = matchRoute(routes, req.method ?? "GET", url.pathname);
|
|
267
|
+
if (!match)
|
|
268
|
+
return sendError(res, 404, `no route for ${req.method} ${url.pathname}`, requestId);
|
|
269
|
+
let body;
|
|
270
|
+
try {
|
|
271
|
+
body = await parseRequestBody(req.headers["content-type"] ?? "", await readBody(req, options.maxBodyBytes ?? 10 * 1024 * 1024));
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
if (err instanceof BodyTooLarge)
|
|
275
|
+
return sendError(res, 413, err.message, requestId);
|
|
276
|
+
return sendError(res, 400, `invalid body: ${err instanceof Error ? err.message : String(err)}`, requestId);
|
|
277
|
+
}
|
|
278
|
+
const payload = { ...parseQuery(url.searchParams), ...body.payload };
|
|
279
|
+
const headers = {};
|
|
280
|
+
for (const [k, v] of Object.entries(req.headers))
|
|
281
|
+
if (typeof v === "string")
|
|
282
|
+
headers[k] = v;
|
|
283
|
+
const input = { trigger: { kind: "http", name: `${req.method} ${url.pathname}` }, payload, params: match.params, headers, files: body.files };
|
|
284
|
+
const ip = clientIp(req, trustProxy);
|
|
285
|
+
if (ip !== undefined)
|
|
286
|
+
input.ip = ip;
|
|
287
|
+
const result = await run(match.route.pipeline, input);
|
|
288
|
+
logger.info("http", { runId: result.runId, ...(requestId === undefined ? {} : { requestId }), method: req.method, path: url.pathname, pipeline: match.route.pipeline, status: result.status, ms: Math.round((performance.now() - begin) * 100) / 100, ip });
|
|
289
|
+
write(res, responseForRun(result, options.inlineTypes));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
export function listen(server, port, host) {
|
|
293
|
+
return new Promise((resolve, reject) => {
|
|
294
|
+
server.once("error", reject);
|
|
295
|
+
server.listen(port, host, () => {
|
|
296
|
+
server.off("error", reject);
|
|
297
|
+
resolve(server.address());
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|