@avelonjs/core 0.1.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.
@@ -0,0 +1,148 @@
1
+ import type { DeliveryMode, ListenerContext } from '../events'
2
+ import type { QueueJob } from '../drivers/queue'
3
+ import { Invalid } from '../errors'
4
+ import { backoffSeconds, jobBoard } from './jobs'
5
+
6
+ /** Job contract for background work with the same three delivery modes as listeners. */
7
+ export interface ErrandDefinition<TPayload = unknown> {
8
+ /** Registered errand name used when dispatching. */
9
+ name: string
10
+ /** Delivery mode. `sync` is the default. */
11
+ delivery?: DeliveryMode
12
+ /** Named queue for `queued` delivery. */
13
+ queue?: string
14
+ /** Maximum attempts for `queued` delivery. */
15
+ tries?: number
16
+ /** Backoff in seconds, or a schedule indexed by attempt. */
17
+ backoff?: number | readonly number[]
18
+ /** Performs the work. */
19
+ handle(payload: TPayload, context: ListenerContext): Promise<void> | void
20
+ }
21
+
22
+ const registry = new Map<string, ErrandDefinition>()
23
+ const afterJobs: Array<() => Promise<void>> = []
24
+
25
+ /** Creates an errand definition while preserving its name and delivery mode. */
26
+ export function defineErrand<TPayload>(
27
+ definition: ErrandDefinition<TPayload>,
28
+ ): ErrandDefinition<TPayload> {
29
+ return definition
30
+ }
31
+
32
+ /** Registers an errand so `Errands.dispatch` can find it by name. */
33
+ export function registerErrand<TPayload>(definition: ErrandDefinition<TPayload>): void {
34
+ registry.set(definition.name, definition as ErrandDefinition)
35
+ }
36
+
37
+ /** Clears registered errands and scheduled after-response work. Intended for tests. */
38
+ export function resetErrands(): void {
39
+ registry.clear()
40
+ afterJobs.length = 0
41
+ }
42
+
43
+ async function run(
44
+ definition: ErrandDefinition,
45
+ payload: unknown,
46
+ context: ListenerContext,
47
+ ): Promise<void> {
48
+ await definition.handle(payload, context)
49
+ }
50
+
51
+ /** Runs errands scheduled with `delivery: 'after'`. */
52
+ export async function flushAfterErrands(): Promise<void> {
53
+ const pending = afterJobs.splice(0, afterJobs.length)
54
+ for (const job of pending) await job()
55
+ }
56
+
57
+ /** Background-work facade: dispatch, drain, failed table, retry, flush. */
58
+ export const Errands = {
59
+ /** Registers one errand. */
60
+ register: registerErrand,
61
+
62
+ /** Runs or schedules an errand by name. */
63
+ async dispatch<TPayload>(name: string, payload: TPayload): Promise<string | undefined> {
64
+ const definition = registry.get(name)
65
+ if (definition === undefined) {
66
+ throw new Invalid(`Unknown errand '${name}'.`, {
67
+ metadata: { fields: { errand: [`${name} is not registered.`] } },
68
+ })
69
+ }
70
+ const delivery = definition.delivery ?? 'sync'
71
+ if (delivery === 'sync') {
72
+ await run(definition, payload, { delivery: 'sync' })
73
+ return undefined
74
+ }
75
+ if (delivery === 'after') {
76
+ const captured = { definition, payload }
77
+ afterJobs.push(() => run(captured.definition, captured.payload, { delivery: 'after' }))
78
+ return undefined
79
+ }
80
+ const tries = definition.tries ?? 1
81
+ const queue = definition.queue ?? 'default'
82
+ const job: QueueJob = { name: 'avelon.errand', payload: { errand: name, payload }, queue }
83
+ return jobBoard().enqueue(job, {
84
+ tries,
85
+ backoff: definition.backoff ?? 0,
86
+ })
87
+ },
88
+
89
+ /** Drains queued errands from the process-local failure table. */
90
+ async work(options: { queue?: string; limit?: number; now?: number } = {}): Promise<number> {
91
+ await flushAfterErrands()
92
+ const receipts = jobBoard().take({ ...options, name: 'avelon.errand' })
93
+ let handled = 0
94
+ for (const receipt of receipts) {
95
+ const body = receipt.job.payload as { errand: string; payload: unknown }
96
+ const definition = registry.get(body.errand)
97
+ if (definition === undefined) {
98
+ jobBoard().fail(
99
+ receipt,
100
+ new Invalid(`Unknown errand '${body.errand}'.`, {
101
+ metadata: { fields: { errand: [`${body.errand} is not registered.`] } },
102
+ }),
103
+ )
104
+ continue
105
+ }
106
+ const tries = definition.tries ?? 1
107
+ try {
108
+ await run(definition, body.payload, {
109
+ delivery: 'queued',
110
+ queue: receipt.job.queue ?? 'default',
111
+ attempt: receipt.attempt,
112
+ tries,
113
+ })
114
+ handled += 1
115
+ } catch (error) {
116
+ if (receipt.attempt < tries) {
117
+ jobBoard().enqueue(receipt.job, {
118
+ attempt: receipt.attempt,
119
+ availableAt:
120
+ (options.now ?? Date.now()) +
121
+ backoffSeconds(definition.backoff, receipt.attempt) * 1000,
122
+ tries,
123
+ backoff: definition.backoff ?? 0,
124
+ })
125
+ } else {
126
+ jobBoard().fail(receipt, error)
127
+ }
128
+ }
129
+ }
130
+ return handled
131
+ },
132
+
133
+ /** Lists retained terminal failures. */
134
+ failed(queue?: string) {
135
+ return jobBoard().failed(queue)
136
+ },
137
+
138
+ /** Releases a failed job. */
139
+ async retry(id: string, delaySeconds?: number): Promise<void> {
140
+ jobBoard().retry(id, delaySeconds)
141
+ },
142
+
143
+ /** Drops pending and failed jobs. */
144
+ flush(): void {
145
+ afterJobs.length = 0
146
+ jobBoard().flush()
147
+ },
148
+ }
@@ -0,0 +1,96 @@
1
+ import { Forbidden, Unauthenticated } from '../errors'
2
+
3
+ /** Actor shape accepted by the gate. `null` is an anonymous caller. */
4
+ export type GateActor = { readonly id: string } | null
5
+
6
+ /** Ability callback registered for a resource. The actor may be anonymous. */
7
+ export type PolicyHandler = (actor: GateActor, resource?: unknown) => boolean | Promise<boolean>
8
+
9
+ /** Map of ability names to handlers, matching `definePolicy(Post, { update, delete })`. */
10
+ export type PolicyAbilities = Readonly<Record<string, PolicyHandler>>
11
+
12
+ type ResourceKey = string | { readonly name: string }
13
+
14
+ const policies = new Map<string, PolicyAbilities>()
15
+ let currentActor: GateActor = null
16
+
17
+ function resourceName(resource: ResourceKey | object): string {
18
+ if (typeof resource === 'string') return resource
19
+ if (typeof resource === 'function') return resource.name
20
+ const ctor = Reflect.get(resource, 'constructor')
21
+ if (typeof ctor === 'function' && typeof ctor.name === 'string' && ctor.name.length > 0) {
22
+ return ctor.name
23
+ }
24
+ if ('name' in resource && typeof resource.name === 'string') return resource.name
25
+ return 'Resource'
26
+ }
27
+
28
+ /** Registers a global ability used by `can:*` middleware. */
29
+ export function defineAbility(ability: string, handler: PolicyHandler): void {
30
+ const existing = policies.get('Gate') ?? {}
31
+ policies.set('Gate', { ...existing, [ability]: handler })
32
+ }
33
+
34
+ /** Registers policy abilities for a resource class or name. */
35
+ export function definePolicy(resource: ResourceKey, abilities: PolicyAbilities): void {
36
+ policies.set(resourceName(resource), abilities)
37
+ }
38
+
39
+ /** Clears registered policies and the request actor. Intended for tests. */
40
+ export function resetPolicies(): void {
41
+ policies.clear()
42
+ currentActor = null
43
+ }
44
+
45
+ async function allows(actor: GateActor, ability: string, resource?: unknown): Promise<boolean> {
46
+ if (resource === undefined) return false
47
+ const name =
48
+ typeof resource === 'function' || typeof resource === 'string'
49
+ ? resourceName(resource)
50
+ : resourceName(resource as object)
51
+ const abilities = policies.get(name)
52
+ const handler = abilities?.[ability]
53
+ if (handler === undefined) return false
54
+ return handler(actor, typeof resource === 'function' ? undefined : resource)
55
+ }
56
+
57
+ /** Authorization helpers used by controllers. */
58
+ export const Gate = {
59
+ /** Binds the request actor used when `authorize`/`can` omit one. */
60
+ setActor(actor: GateActor): void {
61
+ currentActor = actor
62
+ },
63
+
64
+ /** Returns the actor bound for this request, or `null`. */
65
+ actor(): GateActor {
66
+ return currentActor
67
+ },
68
+
69
+ /** Returns whether the actor may perform the ability. */
70
+ async can(
71
+ ability: string,
72
+ resource?: unknown,
73
+ actor: GateActor = currentActor,
74
+ ): Promise<boolean> {
75
+ return allows(actor, ability, resource)
76
+ },
77
+
78
+ /** Throws when the actor may not perform the ability. */
79
+ async authorize(
80
+ ability: string,
81
+ resource?: unknown,
82
+ actor: GateActor = currentActor,
83
+ ): Promise<void> {
84
+ if (actor === null) {
85
+ const handlerAllowsAnonymous = await allows(null, ability, resource)
86
+ if (handlerAllowsAnonymous) return
87
+ throw new Unauthenticated('Authentication is required.', { metadata: { guard: 'gate' } })
88
+ }
89
+ const allowed = await allows(actor, ability, resource)
90
+ if (!allowed) {
91
+ throw new Forbidden('This action is unauthorized.', {
92
+ metadata: { ability, resource: resourceName((resource ?? ability) as ResourceKey) },
93
+ })
94
+ }
95
+ },
96
+ }
@@ -0,0 +1,81 @@
1
+ export {
2
+ auth,
3
+ capabilityMatrix,
4
+ DB,
5
+ defineConfig,
6
+ disks,
7
+ getConfig,
8
+ mailers,
9
+ queues,
10
+ resetConfig,
11
+ resolveNamedDriver,
12
+ type AvelonConfig,
13
+ type AvelonDrivers,
14
+ type MaybeNamed,
15
+ type NamedDrivers,
16
+ } from './config'
17
+ export {
18
+ createEventDispatcher,
19
+ eventDispatcher,
20
+ Events,
21
+ InProcessEventDispatcher,
22
+ } from './dispatcher'
23
+ export {
24
+ defineErrand,
25
+ Errands,
26
+ flushAfterErrands,
27
+ registerErrand,
28
+ resetErrands,
29
+ type ErrandDefinition,
30
+ } from './errands'
31
+ export {
32
+ defineAbility,
33
+ definePolicy,
34
+ Gate,
35
+ resetPolicies,
36
+ type GateActor,
37
+ type PolicyAbilities,
38
+ type PolicyHandler,
39
+ } from './gate'
40
+ export {
41
+ createKernel,
42
+ finishResponse,
43
+ mapKernelException,
44
+ redirect,
45
+ view,
46
+ type ControllerClass,
47
+ type ControllerMethod,
48
+ type CreateKernelOptions,
49
+ type KernelMiddleware,
50
+ } from './kernel'
51
+ export { backoffSeconds, jobBoard, MemoryJobBoard } from './jobs'
52
+ export {
53
+ defineRequest,
54
+ validateRequest,
55
+ type FormRequest,
56
+ type FormRequestDefinition,
57
+ type RequestSchema,
58
+ } from './requests'
59
+ export { holdUntilCommit, isInTransaction, resetTransaction, runInTransaction } from './transaction'
60
+ export {
61
+ compilePredicateToSql,
62
+ compileWardPolicy,
63
+ compileWardShorthand,
64
+ defineWard,
65
+ detectPolicyDrift,
66
+ detectWardDrift,
67
+ evaluatePredicate,
68
+ injectWard,
69
+ refusedWardShapes,
70
+ resetWards,
71
+ resolveWard,
72
+ rowAllowedByWard,
73
+ withWard,
74
+ type CompiledSql,
75
+ type WardAbilities,
76
+ type WardAction,
77
+ type WardDrift,
78
+ type WardFixture,
79
+ type WardInput,
80
+ type WardShorthand,
81
+ } from './wards'
@@ -0,0 +1,126 @@
1
+ import { Invalid } from '../errors'
2
+ import type { FailedQueueJob, QueueJob, QueueReceipt } from '../drivers/queue'
3
+
4
+ interface MemoryJob {
5
+ id: string
6
+ job: QueueJob
7
+ attempt: number
8
+ availableAt: number
9
+ tries: number
10
+ backoff: number | readonly number[]
11
+ }
12
+
13
+ /** Process-local job board used when no queue driver is wired. */
14
+ export class MemoryJobBoard {
15
+ readonly #pending = new Map<string, MemoryJob>()
16
+ readonly #failed = new Map<string, FailedQueueJob>()
17
+ #nextId = 1
18
+
19
+ /** Enqueues a job for delivery no earlier than `availableAt`. */
20
+ enqueue(
21
+ job: QueueJob,
22
+ options: {
23
+ availableAt?: number
24
+ tries?: number
25
+ backoff?: number | readonly number[]
26
+ attempt?: number
27
+ } = {},
28
+ ): string {
29
+ const id = `job-${this.#nextId++}`
30
+ this.#pending.set(id, {
31
+ id,
32
+ job: { name: job.name, payload: job.payload, queue: job.queue },
33
+ attempt: options.attempt ?? 0,
34
+ availableAt: options.availableAt ?? Date.now(),
35
+ tries: options.tries ?? 1,
36
+ backoff: options.backoff ?? 0,
37
+ })
38
+ return id
39
+ }
40
+
41
+ /** Takes currently visible jobs, incrementing attempt, up to `limit`. */
42
+ take(
43
+ options: { queue?: string; limit?: number; now?: number; name?: string } = {},
44
+ ): QueueReceipt[] {
45
+ const now = options.now ?? Date.now()
46
+ const selected = [...this.#pending.values()]
47
+ .filter((entry) => entry.availableAt <= now)
48
+ .filter((entry) => options.queue === undefined || entry.job.queue === options.queue)
49
+ .filter((entry) => options.name === undefined || entry.job.name === options.name)
50
+ .sort((left, right) => left.id.localeCompare(right.id))
51
+ .slice(0, options.limit ?? Number.POSITIVE_INFINITY)
52
+
53
+ return selected.map((entry) => {
54
+ this.#pending.delete(entry.id)
55
+ const attempt = entry.attempt + 1
56
+ entry.attempt = attempt
57
+ return { id: entry.id, job: entry.job, attempt }
58
+ })
59
+ }
60
+
61
+ /** Records a terminal failure. */
62
+ fail(receipt: QueueReceipt, error: unknown, failedAt = new Date()): void {
63
+ this.#failed.set(receipt.id, {
64
+ id: receipt.id,
65
+ job: receipt.job,
66
+ attempt: receipt.attempt,
67
+ error: error instanceof Error ? error.message : String(error),
68
+ failedAt,
69
+ })
70
+ }
71
+
72
+ /** Releases a failed job for another attempt. */
73
+ retry(id: string, delaySeconds = 0): void {
74
+ const failed = this.#failed.get(id)
75
+ if (failed === undefined) {
76
+ throw new Invalid(`Unknown failed job ${id}.`, {
77
+ metadata: { fields: { job: [`${id} is not in the failed table.`] } },
78
+ })
79
+ }
80
+ this.#failed.delete(id)
81
+ this.#pending.set(id, {
82
+ id,
83
+ job: failed.job,
84
+ attempt: failed.attempt,
85
+ availableAt: Date.now() + delaySeconds * 1000,
86
+ tries: failed.attempt + 1,
87
+ backoff: delaySeconds,
88
+ })
89
+ }
90
+
91
+ /** Permanently removes a retained terminal failure. */
92
+ forget(id: string): void {
93
+ this.#failed.delete(id)
94
+ }
95
+
96
+ /** Lists retained terminal failures. */
97
+ failed(queue?: string): readonly FailedQueueJob[] {
98
+ return [...this.#failed.values()].filter(
99
+ (entry) => queue === undefined || entry.job.queue === queue,
100
+ )
101
+ }
102
+
103
+ /** Drops pending and failed jobs. */
104
+ flush(): void {
105
+ this.#pending.clear()
106
+ this.#failed.clear()
107
+ }
108
+ }
109
+
110
+ const shared = new MemoryJobBoard()
111
+
112
+ /** Returns the process-local job board used by events and errands. */
113
+ export function jobBoard(): MemoryJobBoard {
114
+ return shared
115
+ }
116
+
117
+ /** Delay in seconds for a one-based attempt. */
118
+ export function backoffSeconds(
119
+ backoff: number | readonly number[] | undefined,
120
+ attempt: number,
121
+ ): number {
122
+ if (backoff === undefined) return 0
123
+ if (typeof backoff === 'number') return backoff
124
+ const last = backoff[backoff.length - 1]
125
+ return backoff[attempt - 1] ?? last ?? 0
126
+ }
@@ -0,0 +1,202 @@
1
+ import type {
2
+ ActionResult,
3
+ HttpRequest,
4
+ Kernel,
5
+ KernelResult,
6
+ RedirectResult,
7
+ RouteDefinition,
8
+ ViewResult,
9
+ } from '../adapter'
10
+ import { Forbidden, Invalid, NotFound, Unauthenticated } from '../errors'
11
+ import { Gate, type GateActor } from './gate'
12
+ import { Events } from './dispatcher'
13
+ import { flushAfterErrands } from './errands'
14
+
15
+ /** Controller method signature accepted by the default kernel. */
16
+ export type ControllerMethod = (
17
+ request: HttpRequest,
18
+ ...bindings: unknown[]
19
+ ) => Promise<KernelResult | unknown> | KernelResult | unknown
20
+
21
+ /** Constructable controller with action methods. */
22
+ export type ControllerClass = new () => object
23
+
24
+ /** Middleware invoked before controller dispatch. */
25
+ export type KernelMiddleware = (
26
+ request: HttpRequest,
27
+ route: RouteDefinition<ControllerClass>,
28
+ ) => Promise<void> | void
29
+
30
+ /** Options for {@link createKernel}. */
31
+ export interface CreateKernelOptions {
32
+ /** Resolves a route-model binding name to a hydrated value. */
33
+ resolveBinding?: (name: string, value: string) => Promise<unknown>
34
+ /** Resolves the request actor for `auth` and `can:*` middleware. */
35
+ resolveActor?: (request: HttpRequest) => Promise<GateActor> | GateActor
36
+ /** Named middleware aliases in addition to the built-in `auth` and `can:*` handlers. */
37
+ middleware?: Readonly<Record<string, KernelMiddleware>>
38
+ /** Maps framework errors to kernel results. */
39
+ mapException?: (error: unknown) => KernelResult | undefined
40
+ }
41
+
42
+ function hasHeader(headers: Readonly<Record<string, string>>, name: string): boolean {
43
+ const target = name.toLowerCase()
44
+ return Object.entries(headers).some(
45
+ ([key, value]) => key.toLowerCase() === target && value.length > 0,
46
+ )
47
+ }
48
+
49
+ /** Maps framework errors onto transport-neutral kernel results. */
50
+ export function mapKernelException(error: unknown): KernelResult | undefined {
51
+ if (error instanceof Invalid) {
52
+ return {
53
+ type: 'action',
54
+ ok: false,
55
+ errors: error.metadata.fields ?? { _form: [error.message] },
56
+ status: 422,
57
+ } satisfies ActionResult
58
+ }
59
+ if (error instanceof Unauthenticated) {
60
+ return {
61
+ type: 'redirect',
62
+ location: '/login',
63
+ status: 302,
64
+ } satisfies RedirectResult
65
+ }
66
+ if (error instanceof Forbidden) {
67
+ return {
68
+ type: 'action',
69
+ ok: false,
70
+ errors: { _form: [error.message] },
71
+ status: 403,
72
+ } satisfies ActionResult
73
+ }
74
+ if (error instanceof NotFound) {
75
+ return {
76
+ type: 'action',
77
+ ok: false,
78
+ errors: { _form: [error.message] },
79
+ status: 404,
80
+ } satisfies ActionResult
81
+ }
82
+ return undefined
83
+ }
84
+
85
+ /**
86
+ * Creates the default request pipeline kernel.
87
+ *
88
+ * Adapters mount routes into this dispatcher rather than reimplementing middleware, binding, or
89
+ * exception mapping.
90
+ */
91
+ export function createKernel(options: CreateKernelOptions = {}): Kernel<ControllerClass> {
92
+ return {
93
+ async dispatch(route, request) {
94
+ try {
95
+ if (options.resolveActor) {
96
+ Gate.setActor(await options.resolveActor(request))
97
+ }
98
+ await runMiddleware(route, request, options)
99
+ const bindings = await resolveBindings(route, request, options.resolveBinding)
100
+ const controller = new route.controller()
101
+ const action = Reflect.get(controller, route.action)
102
+ if (typeof action !== 'function') {
103
+ throw new NotFound(`Controller action ${route.action} was not found.`, {
104
+ metadata: { resource: 'controller_action', identifier: route.action },
105
+ })
106
+ }
107
+ const result = await (action as ControllerMethod).call(controller, request, ...bindings)
108
+ return normalizeResult(result)
109
+ } catch (error) {
110
+ const mapped = options.mapException?.(error) ?? mapKernelException(error)
111
+ if (mapped !== undefined) return mapped
112
+ throw error
113
+ }
114
+ },
115
+ }
116
+ }
117
+
118
+ async function runMiddleware(
119
+ route: RouteDefinition<ControllerClass>,
120
+ request: HttpRequest,
121
+ options: CreateKernelOptions,
122
+ ): Promise<void> {
123
+ for (const name of route.middleware) {
124
+ const custom = options.middleware?.[name]
125
+ if (custom !== undefined) {
126
+ await custom(request, route)
127
+ continue
128
+ }
129
+ if (name === 'auth') {
130
+ const actor = options.resolveActor ? await options.resolveActor(request) : Gate.actor()
131
+ const signedIn =
132
+ actor !== null ||
133
+ hasHeader(request.headers, 'authorization') ||
134
+ hasHeader(request.headers, 'cookie')
135
+ if (!signedIn) {
136
+ throw new Unauthenticated('Authentication is required.', { metadata: { guard: 'auth' } })
137
+ }
138
+ continue
139
+ }
140
+ if (name.startsWith('can:')) {
141
+ const ability = name.slice(4)
142
+ const actor = options.resolveActor ? await options.resolveActor(request) : Gate.actor()
143
+ await Gate.authorize(ability, 'Gate', actor)
144
+ continue
145
+ }
146
+ }
147
+ }
148
+
149
+ async function resolveBindings(
150
+ route: RouteDefinition<ControllerClass>,
151
+ request: HttpRequest,
152
+ resolveBinding?: CreateKernelOptions['resolveBinding'],
153
+ ): Promise<unknown[]> {
154
+ const bindings: unknown[] = []
155
+ for (const [param, model] of Object.entries(route.bindings)) {
156
+ const value = request.params[param]
157
+ if (value === undefined) {
158
+ throw new NotFound(`Route parameter ${param} is missing.`, {
159
+ metadata: { resource: model, identifier: param },
160
+ })
161
+ }
162
+ bindings.push(resolveBinding ? await resolveBinding(model, value) : value)
163
+ }
164
+ return bindings
165
+ }
166
+
167
+ function normalizeResult(result: unknown): KernelResult {
168
+ if (isKernelResult(result)) return result
169
+ return { type: 'action', ok: true, data: result } satisfies ActionResult
170
+ }
171
+
172
+ function isKernelResult(value: unknown): value is KernelResult {
173
+ return (
174
+ typeof value === 'object' &&
175
+ value !== null &&
176
+ 'type' in value &&
177
+ (value.type === 'action' ||
178
+ value.type === 'redirect' ||
179
+ value.type === 'view' ||
180
+ value.type === 'stream')
181
+ )
182
+ }
183
+
184
+ /** Helper that builds a view result with an opaque adapter-owned reference. */
185
+ export function view<TView, TProps extends object>(
186
+ viewRef: TView,
187
+ props: TProps,
188
+ status = 200,
189
+ ): ViewResult<TView, TProps> {
190
+ return { type: 'view', view: viewRef, props, status }
191
+ }
192
+
193
+ /** Helper that builds a redirect result. */
194
+ export function redirect(location: string, status: RedirectResult['status'] = 302): RedirectResult {
195
+ return { type: 'redirect', location, status }
196
+ }
197
+
198
+ /** Flushes `after` listeners once the adapter has sent a response. */
199
+ export async function finishResponse(): Promise<void> {
200
+ await Events.flushAfterResponse()
201
+ await flushAfterErrands()
202
+ }