@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.
- package/LICENSE +21 -0
- package/README.md +566 -0
- package/package.json +46 -0
- package/src/adapter.ts +204 -0
- package/src/drivers/ai.ts +99 -0
- package/src/drivers/cache.ts +48 -0
- package/src/drivers/common.ts +29 -0
- package/src/drivers/database.ts +160 -0
- package/src/drivers/flags.ts +50 -0
- package/src/drivers/identity.ts +133 -0
- package/src/drivers/logs.ts +61 -0
- package/src/drivers/mail.ts +73 -0
- package/src/drivers/notifications.ts +64 -0
- package/src/drivers/payments.ts +107 -0
- package/src/drivers/queue.ts +87 -0
- package/src/drivers/ratelimit.ts +66 -0
- package/src/drivers/realtime.ts +63 -0
- package/src/drivers/search.ts +92 -0
- package/src/drivers/social.ts +63 -0
- package/src/drivers/storage.ts +78 -0
- package/src/drivers/tokens.ts +87 -0
- package/src/errors.ts +166 -0
- package/src/events.ts +185 -0
- package/src/index.ts +22 -0
- package/src/query.ts +86 -0
- package/src/runtime/config.ts +231 -0
- package/src/runtime/dispatcher.ts +335 -0
- package/src/runtime/errands.ts +148 -0
- package/src/runtime/gate.ts +96 -0
- package/src/runtime/index.ts +81 -0
- package/src/runtime/jobs.ts +126 -0
- package/src/runtime/kernel.ts +202 -0
- package/src/runtime/requests.ts +77 -0
- package/src/runtime/transaction.ts +45 -0
- package/src/runtime/wards.ts +362 -0
|
@@ -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
|
+
}
|