@pikku/core 0.12.15 → 0.12.17
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/CHANGELOG.md +34 -0
- package/dist/function/index.d.ts +1 -0
- package/dist/function/list.types.d.ts +113 -0
- package/dist/function/list.types.js +28 -0
- package/dist/index.d.ts +2 -1
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/telemetry.d.ts +47 -0
- package/dist/middleware/telemetry.js +106 -0
- package/dist/middleware-runner.js +31 -4
- package/dist/types/core.types.d.ts +13 -0
- package/dist/types/core.types.js +7 -1
- package/dist/wirings/cli/cli-runner.js +2 -1
- package/dist/wirings/http/http.types.d.ts +1 -0
- package/dist/wirings/http/pikku-fetch-http-response.d.ts +1 -0
- package/dist/wirings/http/pikku-fetch-http-response.js +3 -0
- package/dist/wirings/mcp/mcp.types.d.ts +1 -1
- package/package.json +1 -1
- package/src/function/index.ts +7 -0
- package/src/function/list.types.test.ts +122 -0
- package/src/function/list.types.ts +121 -0
- package/src/index.ts +8 -0
- package/src/middleware/index.ts +1 -0
- package/src/middleware/telemetry.ts +110 -0
- package/src/middleware-runner.test.ts +149 -1
- package/src/middleware-runner.ts +48 -4
- package/src/types/core.types.ts +28 -1
- package/src/wirings/channel/local/local-channel-runner.test.ts +5 -1
- package/src/wirings/cli/cli-runner.ts +2 -1
- package/src/wirings/http/http.types.ts +1 -0
- package/src/wirings/http/pikku-fetch-http-response.ts +4 -0
- package/src/wirings/mcp/mcp.types.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-level tests for list-function primitives.
|
|
3
|
+
*
|
|
4
|
+
* No runtime assertions — the point is that valid shapes compile and
|
|
5
|
+
* invalid shapes fail with `@ts-expect-error`. The file is excluded
|
|
6
|
+
* from `yarn build` via tsconfig's `**∕*.test.ts` pattern.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ListInput, ListOutput, Filter } from './list.types.js'
|
|
10
|
+
|
|
11
|
+
// -- ListInput ---------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
type SessionFilter = {
|
|
14
|
+
status?: string[]
|
|
15
|
+
therapistId?: string
|
|
16
|
+
uploadedAt?: string
|
|
17
|
+
}
|
|
18
|
+
type SessionSort = 'user' | 'status' | 'uploaded_at'
|
|
19
|
+
|
|
20
|
+
const _basic: ListInput<SessionFilter, SessionSort> = {
|
|
21
|
+
cursor: 'abc',
|
|
22
|
+
limit: 50,
|
|
23
|
+
sort: [
|
|
24
|
+
{ column: 'uploaded_at', direction: 'desc' },
|
|
25
|
+
{ column: 'status', direction: 'asc' },
|
|
26
|
+
],
|
|
27
|
+
filter: { status: ['pending'] },
|
|
28
|
+
search: 'sarah',
|
|
29
|
+
}
|
|
30
|
+
void _basic
|
|
31
|
+
|
|
32
|
+
const _empty: ListInput = {}
|
|
33
|
+
void _empty
|
|
34
|
+
|
|
35
|
+
// sort.column must be from the declared union
|
|
36
|
+
// @ts-expect-error — 'created_at' is not in SessionSort
|
|
37
|
+
const _badSort: ListInput<SessionFilter, SessionSort> = {
|
|
38
|
+
sort: [{ column: 'created_at', direction: 'asc' }],
|
|
39
|
+
}
|
|
40
|
+
void _badSort
|
|
41
|
+
|
|
42
|
+
// sort.direction must be 'asc' or 'desc'
|
|
43
|
+
// @ts-expect-error — 'descending' is not a valid direction
|
|
44
|
+
const _badDirection: ListInput<SessionFilter, SessionSort> = {
|
|
45
|
+
sort: [{ column: 'user', direction: 'descending' }],
|
|
46
|
+
}
|
|
47
|
+
void _badDirection
|
|
48
|
+
|
|
49
|
+
// -- ListOutput --------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
interface Session {
|
|
52
|
+
id: string
|
|
53
|
+
user: string
|
|
54
|
+
status: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const _output: ListOutput<Session> = {
|
|
58
|
+
rows: [{ id: '1', user: 'sarah', status: 'pending' }],
|
|
59
|
+
nextCursor: null,
|
|
60
|
+
totalCount: 42,
|
|
61
|
+
}
|
|
62
|
+
void _output
|
|
63
|
+
|
|
64
|
+
// nextCursor is required (must be string or null, not undefined-only)
|
|
65
|
+
// @ts-expect-error — missing nextCursor
|
|
66
|
+
const _badOutput: ListOutput<Session> = {
|
|
67
|
+
rows: [],
|
|
68
|
+
}
|
|
69
|
+
void _badOutput
|
|
70
|
+
|
|
71
|
+
// -- Filter: leaves ----------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
// Simple equality
|
|
74
|
+
const _leafEq: Filter<SessionFilter> = { therapistId: 'kim' }
|
|
75
|
+
void _leafEq
|
|
76
|
+
|
|
77
|
+
// Array = IN shorthand
|
|
78
|
+
const _leafIn: Filter<SessionFilter> = { status: ['pending', 'processed'] }
|
|
79
|
+
void _leafIn
|
|
80
|
+
|
|
81
|
+
// Operator object
|
|
82
|
+
const _leafOp: Filter<SessionFilter> = {
|
|
83
|
+
uploadedAt: { gt: '2026-04-10', lte: '2026-04-17' },
|
|
84
|
+
}
|
|
85
|
+
void _leafOp
|
|
86
|
+
|
|
87
|
+
// Null = IS NULL
|
|
88
|
+
const _leafNull: Filter<SessionFilter> = { therapistId: null }
|
|
89
|
+
void _leafNull
|
|
90
|
+
|
|
91
|
+
// NOT on a value
|
|
92
|
+
const _leafNot: Filter<SessionFilter> = { therapistId: { not: 'kim' } }
|
|
93
|
+
void _leafNot
|
|
94
|
+
|
|
95
|
+
// -- Filter: AND (array) -----------------------------------------------------
|
|
96
|
+
|
|
97
|
+
const _and: Filter<SessionFilter> = [
|
|
98
|
+
{ status: ['pending'] },
|
|
99
|
+
{ therapistId: 'kim' },
|
|
100
|
+
{ uploadedAt: { gt: '2026-04-10' } },
|
|
101
|
+
]
|
|
102
|
+
void _and
|
|
103
|
+
|
|
104
|
+
// -- Filter: OR (object with arbitrary label keys) --------------------------
|
|
105
|
+
|
|
106
|
+
const _or: Filter<SessionFilter> = {
|
|
107
|
+
viaKim: { therapistId: 'kim' },
|
|
108
|
+
viaPark: { therapistId: 'park' },
|
|
109
|
+
}
|
|
110
|
+
void _or
|
|
111
|
+
|
|
112
|
+
// -- Filter: nested AND/OR ---------------------------------------------------
|
|
113
|
+
|
|
114
|
+
const _nested: Filter<SessionFilter> = [
|
|
115
|
+
{ status: ['pending'] },
|
|
116
|
+
{
|
|
117
|
+
kim: { therapistId: 'kim' },
|
|
118
|
+
park: { therapistId: 'park' },
|
|
119
|
+
},
|
|
120
|
+
{ uploadedAt: { gt: '2026-04-10' } },
|
|
121
|
+
]
|
|
122
|
+
void _nested
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List-function primitives.
|
|
3
|
+
*
|
|
4
|
+
* A "list function" is any Pikku function that returns a paginated
|
|
5
|
+
* collection. Adopting this shape unlocks a shared vocabulary across
|
|
6
|
+
* MCP tools, AI agents, typed RPC clients, and widget libraries — they
|
|
7
|
+
* all reason about cursor, filter, sort, and search uniformly.
|
|
8
|
+
*
|
|
9
|
+
* Under the hood, a list function is still a normal `pikkuFunc`. These
|
|
10
|
+
* types are purely structural constraints; no runtime behaviour change.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { pikkuFunc } from '#pikku'
|
|
15
|
+
* import type { ListInput, ListOutput } from '@pikku/core'
|
|
16
|
+
*
|
|
17
|
+
* export const listSessions = pikkuFunc<
|
|
18
|
+
* ListInput<{ status?: SessionStatus[] }, 'user' | 'status' | 'uploaded_at'>,
|
|
19
|
+
* ListOutput<Session>
|
|
20
|
+
* >({
|
|
21
|
+
* func: async ({ kysely }, input) => {
|
|
22
|
+
* // input.sort / input.filter / input.cursor / input.search are typed
|
|
23
|
+
* return { rows, nextCursor, totalCount }
|
|
24
|
+
* },
|
|
25
|
+
* })
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Shape every list function accepts as input.
|
|
31
|
+
*
|
|
32
|
+
* @template F - User-defined filter shape. Each key is a filterable field.
|
|
33
|
+
* @template S - String union of sortable column names.
|
|
34
|
+
*/
|
|
35
|
+
export interface ListInput<
|
|
36
|
+
F extends Record<string, unknown> = Record<string, never>,
|
|
37
|
+
S extends string = never,
|
|
38
|
+
> {
|
|
39
|
+
/** Opaque cursor from the previous page's `nextCursor`. */
|
|
40
|
+
cursor?: string
|
|
41
|
+
/** Page size. Server may cap. */
|
|
42
|
+
limit?: number
|
|
43
|
+
/** Ordered sort criteria — first entry is primary. */
|
|
44
|
+
sort?: Array<{ column: S; direction: 'asc' | 'desc' }>
|
|
45
|
+
/** Structured filter tree. See {@link Filter}. */
|
|
46
|
+
filter?: Filter<F>
|
|
47
|
+
/** Unstructured text search across server-configured fields. */
|
|
48
|
+
search?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Shape every list function returns.
|
|
53
|
+
*/
|
|
54
|
+
export interface ListOutput<Row> {
|
|
55
|
+
rows: Row[]
|
|
56
|
+
/** Null when no more pages. */
|
|
57
|
+
nextCursor: string | null
|
|
58
|
+
/** Optional — backend may skip when expensive. */
|
|
59
|
+
totalCount?: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Leaf predicate value on a single field.
|
|
64
|
+
*
|
|
65
|
+
* Operator keywords (`equals`, `not`, `in`, `notIn`, `gt`, `gte`, `lt`,
|
|
66
|
+
* `lte`, `contains`, `startsWith`, `endsWith`, `mode`) mirror Prisma's
|
|
67
|
+
* vocabulary for dev familiarity. These keys are reserved and cannot be
|
|
68
|
+
* used as user field names in a Filter's `F` type.
|
|
69
|
+
*/
|
|
70
|
+
export type LeafValue<T> =
|
|
71
|
+
| T
|
|
72
|
+
| null
|
|
73
|
+
| T[]
|
|
74
|
+
| {
|
|
75
|
+
equals?: T | null
|
|
76
|
+
not?: T | null | LeafValue<T>
|
|
77
|
+
in?: T[]
|
|
78
|
+
notIn?: T[]
|
|
79
|
+
gt?: T
|
|
80
|
+
gte?: T
|
|
81
|
+
lt?: T
|
|
82
|
+
lte?: T
|
|
83
|
+
contains?: string
|
|
84
|
+
startsWith?: string
|
|
85
|
+
endsWith?: string
|
|
86
|
+
mode?: 'sensitive' | 'insensitive'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A single-key object keyed by a field name from F, value is a leaf
|
|
91
|
+
* predicate. Single-key so the runtime discriminator is unambiguous:
|
|
92
|
+
* multi-key objects are OR groups.
|
|
93
|
+
*/
|
|
94
|
+
export type LeafFilter<F extends Record<string, unknown>> = {
|
|
95
|
+
[K in keyof F]: { [Key in K]: LeafValue<F[K]> }
|
|
96
|
+
}[keyof F]
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Recursive filter tree.
|
|
100
|
+
*
|
|
101
|
+
* - `Array<Filter<F>>` — AND of children.
|
|
102
|
+
* - `{ [label: string]: Filter<F> }` — OR of children. Keys are arbitrary
|
|
103
|
+
* labels (unique strings), ignored at evaluation time.
|
|
104
|
+
* - `LeafFilter<F>` — single-key predicate on a declared field.
|
|
105
|
+
*
|
|
106
|
+
* @example "status = pending AND (therapist = kim OR therapist = park) AND uploaded_at > 2026-04-10"
|
|
107
|
+
* ```ts
|
|
108
|
+
* const filter: Filter<{ status: string; therapistId: string; uploadedAt: string }> = [
|
|
109
|
+
* { status: 'pending' },
|
|
110
|
+
* {
|
|
111
|
+
* kim: { therapistId: 'kim' },
|
|
112
|
+
* park: { therapistId: 'park' },
|
|
113
|
+
* },
|
|
114
|
+
* { uploadedAt: { gt: '2026-04-10' } },
|
|
115
|
+
* ]
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export type Filter<F extends Record<string, unknown>> =
|
|
119
|
+
| LeafFilter<F>
|
|
120
|
+
| Filter<F>[]
|
|
121
|
+
| { [label: string]: Filter<F> }
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ export type {
|
|
|
22
22
|
JSONValue,
|
|
23
23
|
MakeRequired,
|
|
24
24
|
MiddlewareMetadata,
|
|
25
|
+
MiddlewarePriority,
|
|
25
26
|
PermissionMetadata,
|
|
26
27
|
PickOptional,
|
|
27
28
|
PickRequired,
|
|
@@ -58,6 +59,13 @@ export {
|
|
|
58
59
|
pikkuApprovalDescription,
|
|
59
60
|
} from './function/functions.types.js'
|
|
60
61
|
export { addFunction, getAllFunctionNames } from './function/index.js'
|
|
62
|
+
export type {
|
|
63
|
+
ListInput,
|
|
64
|
+
ListOutput,
|
|
65
|
+
Filter,
|
|
66
|
+
LeafFilter,
|
|
67
|
+
LeafValue,
|
|
68
|
+
} from './function/list.types.js'
|
|
61
69
|
export { PikkuRequest } from './pikku-request.js'
|
|
62
70
|
export {
|
|
63
71
|
getRelativeTimeOffsetFromNow,
|
package/src/middleware/index.ts
CHANGED
|
@@ -3,5 +3,6 @@ export { authCookie } from './auth-cookie.js'
|
|
|
3
3
|
export { authBearer } from './auth-bearer.js'
|
|
4
4
|
export { pikkuRemoteAuthMiddleware } from './remote-auth.js'
|
|
5
5
|
export { cors } from './cors.js'
|
|
6
|
+
export { telemetryOuter, telemetryInner } from './telemetry.js'
|
|
6
7
|
export { addMiddleware, runMiddleware } from '../middleware-runner.js'
|
|
7
8
|
export { addPermission } from '../permissions.js'
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { pikkuMiddleware, pikkuMiddlewareFactory } from '../types/core.types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Outer telemetry middleware that captures total request duration and outcome.
|
|
5
|
+
* Runs at `highest` priority (outermost in the middleware chain).
|
|
6
|
+
*
|
|
7
|
+
* Emits a structured JSON log entry with `__pikku_telemetry: 'end'` containing:
|
|
8
|
+
* - Total duration (including all middleware)
|
|
9
|
+
* - Outcome (ok/error)
|
|
10
|
+
* - Wire metadata (wireType, wireId, traceId)
|
|
11
|
+
* - HTTP details (method, path, status) if applicable
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* import { telemetryOuter } from '@pikku/core/middleware'
|
|
16
|
+
* addMiddleware('myTag', [telemetryOuter()])
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export const telemetryOuter = pikkuMiddlewareFactory<{
|
|
20
|
+
environmentId?: string
|
|
21
|
+
orgId?: string
|
|
22
|
+
} | void>(({ environmentId, orgId } = {}) => {
|
|
23
|
+
return pikkuMiddleware({
|
|
24
|
+
name: 'telemetry-outer',
|
|
25
|
+
priority: 'highest',
|
|
26
|
+
func: async (services, wire, next) => {
|
|
27
|
+
const start = performance.now()
|
|
28
|
+
let outcome = 'ok'
|
|
29
|
+
let errorMessage: string | undefined
|
|
30
|
+
try {
|
|
31
|
+
await next()
|
|
32
|
+
} catch (e) {
|
|
33
|
+
outcome = 'error'
|
|
34
|
+
errorMessage = e instanceof Error ? e.message : String(e)
|
|
35
|
+
throw e
|
|
36
|
+
} finally {
|
|
37
|
+
services.logger.info({
|
|
38
|
+
__pikku_telemetry: 'end',
|
|
39
|
+
__pikku_layer: 'outer',
|
|
40
|
+
traceId: wire.traceId,
|
|
41
|
+
wireType: wire.wireType,
|
|
42
|
+
wireId: wire.wireId,
|
|
43
|
+
totalDuration: Math.round(performance.now() - start),
|
|
44
|
+
outcome,
|
|
45
|
+
...(errorMessage ? { errorMessage } : {}),
|
|
46
|
+
...(wire.http
|
|
47
|
+
? {
|
|
48
|
+
httpStatus: wire.http.response?.statusCode,
|
|
49
|
+
httpMethod: wire.http.request?.method(),
|
|
50
|
+
httpPath: wire.http.request?.path(),
|
|
51
|
+
}
|
|
52
|
+
: {}),
|
|
53
|
+
...(environmentId ? { environmentId } : {}),
|
|
54
|
+
...(orgId ? { orgId } : {}),
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Inner telemetry middleware that captures function execution duration and user context.
|
|
63
|
+
* Runs at `lowest` priority (innermost, closest to the function).
|
|
64
|
+
*
|
|
65
|
+
* Emits a structured JSON log entry with `__pikku_telemetry: 'end'` containing:
|
|
66
|
+
* - Function-only duration (excluding outer middleware like auth)
|
|
67
|
+
* - User identity (pikkuUserId) if authenticated
|
|
68
|
+
* - Wire metadata (wireType, wireId, traceId)
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```typescript
|
|
72
|
+
* import { telemetryInner } from '@pikku/core/middleware'
|
|
73
|
+
* addMiddleware('myTag', [telemetryInner()])
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export const telemetryInner = pikkuMiddlewareFactory<{
|
|
77
|
+
environmentId?: string
|
|
78
|
+
orgId?: string
|
|
79
|
+
} | void>(({ environmentId, orgId } = {}) => {
|
|
80
|
+
return pikkuMiddleware({
|
|
81
|
+
name: 'telemetry-inner',
|
|
82
|
+
priority: 'lowest',
|
|
83
|
+
func: async (services, wire, next) => {
|
|
84
|
+
const start = performance.now()
|
|
85
|
+
let outcome = 'ok'
|
|
86
|
+
let errorMessage: string | undefined
|
|
87
|
+
try {
|
|
88
|
+
await next()
|
|
89
|
+
} catch (e) {
|
|
90
|
+
outcome = 'error'
|
|
91
|
+
errorMessage = e instanceof Error ? e.message : String(e)
|
|
92
|
+
throw e
|
|
93
|
+
} finally {
|
|
94
|
+
services.logger.info({
|
|
95
|
+
__pikku_telemetry: 'end',
|
|
96
|
+
__pikku_layer: 'inner',
|
|
97
|
+
traceId: wire.traceId,
|
|
98
|
+
wireType: wire.wireType,
|
|
99
|
+
wireId: wire.wireId,
|
|
100
|
+
functionDuration: Math.round(performance.now() - start),
|
|
101
|
+
outcome,
|
|
102
|
+
pikkuUserId: wire.pikkuUserId,
|
|
103
|
+
...(errorMessage ? { errorMessage } : {}),
|
|
104
|
+
...(environmentId ? { environmentId } : {}),
|
|
105
|
+
...(orgId ? { orgId } : {}),
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -6,7 +6,27 @@ import {
|
|
|
6
6
|
runMiddleware,
|
|
7
7
|
} from './middleware-runner.js'
|
|
8
8
|
import { resetPikkuState } from './pikku-state.js'
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
CorePikkuMiddleware,
|
|
11
|
+
MiddlewarePriority,
|
|
12
|
+
} from './types/core.types.js'
|
|
13
|
+
import { pikkuMiddleware } from './types/core.types.js'
|
|
14
|
+
|
|
15
|
+
const withPriority = (
|
|
16
|
+
name: string,
|
|
17
|
+
priority: MiddlewarePriority,
|
|
18
|
+
log: string[]
|
|
19
|
+
): CorePikkuMiddleware => {
|
|
20
|
+
return pikkuMiddleware({
|
|
21
|
+
name,
|
|
22
|
+
priority,
|
|
23
|
+
func: async (_services, _wire, next) => {
|
|
24
|
+
log.push(`start:${name}`)
|
|
25
|
+
await next()
|
|
26
|
+
log.push(`end:${name}`)
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
}
|
|
10
30
|
|
|
11
31
|
beforeEach(() => {
|
|
12
32
|
resetPikkuState()
|
|
@@ -329,6 +349,56 @@ describe('combineMiddleware', () => {
|
|
|
329
349
|
assert.equal(result.length, 1)
|
|
330
350
|
assert.equal(result[0], middleware)
|
|
331
351
|
})
|
|
352
|
+
|
|
353
|
+
test('should sort middleware by priority', () => {
|
|
354
|
+
const log: string[] = []
|
|
355
|
+
const low = withPriority('low', 'low', log)
|
|
356
|
+
const highest = withPriority('highest', 'highest', log)
|
|
357
|
+
const medium = withPriority('medium', 'medium', log)
|
|
358
|
+
|
|
359
|
+
const result = combineMiddleware('http', Math.random().toString(), {
|
|
360
|
+
wireMiddleware: [low, highest, medium],
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
assert.equal(result.length, 3)
|
|
364
|
+
assert.equal(result[0], highest)
|
|
365
|
+
assert.equal(result[1], medium)
|
|
366
|
+
assert.equal(result[2], low)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
test('should default unprioritized middleware to medium', () => {
|
|
370
|
+
const log: string[] = []
|
|
371
|
+
const highest = withPriority('highest', 'highest', log)
|
|
372
|
+
const lowest = withPriority('lowest', 'lowest', log)
|
|
373
|
+
const noPriority: CorePikkuMiddleware = async (_services, _wire, next) => {
|
|
374
|
+
await next()
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const result = combineMiddleware('http', Math.random().toString(), {
|
|
378
|
+
wireMiddleware: [lowest, noPriority, highest],
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
assert.equal(result.length, 3)
|
|
382
|
+
assert.equal(result[0], highest)
|
|
383
|
+
assert.equal(result[1], noPriority) // defaults to medium
|
|
384
|
+
assert.equal(result[2], lowest)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
test('should preserve registration order within same priority', () => {
|
|
388
|
+
const log: string[] = []
|
|
389
|
+
const first = withPriority('first', 'medium', log)
|
|
390
|
+
const second = withPriority('second', 'medium', log)
|
|
391
|
+
const third = withPriority('third', 'medium', log)
|
|
392
|
+
|
|
393
|
+
const result = combineMiddleware('http', Math.random().toString(), {
|
|
394
|
+
wireMiddleware: [first, second, third],
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
assert.equal(result.length, 3)
|
|
398
|
+
assert.equal(result[0], first)
|
|
399
|
+
assert.equal(result[1], second)
|
|
400
|
+
assert.equal(result[2], third)
|
|
401
|
+
})
|
|
332
402
|
})
|
|
333
403
|
|
|
334
404
|
describe('runMiddleware', () => {
|
|
@@ -415,4 +485,82 @@ describe('runMiddleware', () => {
|
|
|
415
485
|
|
|
416
486
|
assert.deepEqual(executionOrder, ['middleware'])
|
|
417
487
|
})
|
|
488
|
+
|
|
489
|
+
test('should execute middleware in priority order (highest first, lowest last)', async () => {
|
|
490
|
+
const log: string[] = []
|
|
491
|
+
const low = withPriority('low', 'low', log)
|
|
492
|
+
const highest = withPriority('highest', 'highest', log)
|
|
493
|
+
const medium = withPriority('medium', 'medium', log)
|
|
494
|
+
|
|
495
|
+
await runMiddleware(
|
|
496
|
+
{} as any,
|
|
497
|
+
{} as any,
|
|
498
|
+
[low, highest, medium],
|
|
499
|
+
async () => {
|
|
500
|
+
log.push('main')
|
|
501
|
+
}
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
assert.deepEqual(log, [
|
|
505
|
+
'start:highest',
|
|
506
|
+
'start:medium',
|
|
507
|
+
'start:low',
|
|
508
|
+
'main',
|
|
509
|
+
'end:low',
|
|
510
|
+
'end:medium',
|
|
511
|
+
'end:highest',
|
|
512
|
+
])
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
test('should sort unsorted middleware passed directly to runMiddleware', async () => {
|
|
516
|
+
const log: string[] = []
|
|
517
|
+
const lowest = withPriority('lowest', 'lowest', log)
|
|
518
|
+
const highest = withPriority('highest', 'highest', log)
|
|
519
|
+
|
|
520
|
+
await runMiddleware({} as any, {} as any, [lowest, highest], async () => {
|
|
521
|
+
log.push('main')
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
// highest should run first (outermost), lowest last (innermost)
|
|
525
|
+
assert.deepEqual(log, [
|
|
526
|
+
'start:highest',
|
|
527
|
+
'start:lowest',
|
|
528
|
+
'main',
|
|
529
|
+
'end:lowest',
|
|
530
|
+
'end:highest',
|
|
531
|
+
])
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
test('should handle all five priority levels in correct order', async () => {
|
|
535
|
+
const log: string[] = []
|
|
536
|
+
const lowest = withPriority('lowest', 'lowest', log)
|
|
537
|
+
const low = withPriority('low', 'low', log)
|
|
538
|
+
const medium = withPriority('medium', 'medium', log)
|
|
539
|
+
const high = withPriority('high', 'high', log)
|
|
540
|
+
const highest = withPriority('highest', 'highest', log)
|
|
541
|
+
|
|
542
|
+
// Pass in reverse order to verify sorting
|
|
543
|
+
await runMiddleware(
|
|
544
|
+
{} as any,
|
|
545
|
+
{} as any,
|
|
546
|
+
[lowest, low, medium, high, highest],
|
|
547
|
+
async () => {
|
|
548
|
+
log.push('main')
|
|
549
|
+
}
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
assert.deepEqual(log, [
|
|
553
|
+
'start:highest',
|
|
554
|
+
'start:high',
|
|
555
|
+
'start:medium',
|
|
556
|
+
'start:low',
|
|
557
|
+
'start:lowest',
|
|
558
|
+
'main',
|
|
559
|
+
'end:lowest',
|
|
560
|
+
'end:low',
|
|
561
|
+
'end:medium',
|
|
562
|
+
'end:high',
|
|
563
|
+
'end:highest',
|
|
564
|
+
])
|
|
565
|
+
})
|
|
418
566
|
})
|
package/src/middleware-runner.ts
CHANGED
|
@@ -5,10 +5,34 @@ import type {
|
|
|
5
5
|
CorePikkuMiddlewareGroup,
|
|
6
6
|
PikkuWiringTypes,
|
|
7
7
|
MiddlewareMetadata,
|
|
8
|
+
MiddlewarePriority,
|
|
8
9
|
} from './types/core.types.js'
|
|
9
10
|
import { pikkuState } from './pikku-state.js'
|
|
10
11
|
import { freezeDedupe, getTagGroups } from './utils.js'
|
|
11
12
|
|
|
13
|
+
const PRIORITY_ORDER: Record<MiddlewarePriority, number> = {
|
|
14
|
+
highest: 0,
|
|
15
|
+
high: 1,
|
|
16
|
+
medium: 2,
|
|
17
|
+
low: 3,
|
|
18
|
+
lowest: 4,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const getMiddlewarePriority = (fn: CorePikkuMiddleware): number => {
|
|
22
|
+
const priority = (
|
|
23
|
+
fn as CorePikkuMiddleware & { __priority?: MiddlewarePriority }
|
|
24
|
+
).__priority
|
|
25
|
+
return PRIORITY_ORDER[priority ?? 'medium']
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const sortByPriority = (
|
|
29
|
+
middlewares: CorePikkuMiddleware[]
|
|
30
|
+
): CorePikkuMiddleware[] => {
|
|
31
|
+
return middlewares.sort(
|
|
32
|
+
(a, b) => getMiddlewarePriority(a) - getMiddlewarePriority(b)
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
12
36
|
/**
|
|
13
37
|
* Runs a chain of middleware functions in sequence before executing the main function.
|
|
14
38
|
*
|
|
@@ -32,11 +56,16 @@ export const runMiddleware = async <Middleware extends CorePikkuMiddleware>(
|
|
|
32
56
|
middlewares: readonly Middleware[],
|
|
33
57
|
main?: () => Promise<unknown>
|
|
34
58
|
): Promise<unknown> => {
|
|
35
|
-
//
|
|
59
|
+
// Sort by priority if not already sorted (cached middleware is pre-sorted)
|
|
60
|
+
const sorted = isSortedByPriority(middlewares)
|
|
61
|
+
? middlewares
|
|
62
|
+
: ([...middlewares].sort(
|
|
63
|
+
(a, b) => getMiddlewarePriority(a) - getMiddlewarePriority(b)
|
|
64
|
+
) as readonly Middleware[])
|
|
36
65
|
let result: any
|
|
37
66
|
const dispatch = async (index: number): Promise<any> => {
|
|
38
|
-
if (
|
|
39
|
-
return await
|
|
67
|
+
if (sorted && index < sorted.length) {
|
|
68
|
+
return await sorted[index]!(services as any, wire, () =>
|
|
40
69
|
dispatch(index + 1)
|
|
41
70
|
)
|
|
42
71
|
} else if (main) {
|
|
@@ -47,6 +76,20 @@ export const runMiddleware = async <Middleware extends CorePikkuMiddleware>(
|
|
|
47
76
|
return result
|
|
48
77
|
}
|
|
49
78
|
|
|
79
|
+
const isSortedByPriority = (
|
|
80
|
+
middlewares: readonly CorePikkuMiddleware[]
|
|
81
|
+
): boolean => {
|
|
82
|
+
for (let i = 1; i < middlewares.length; i++) {
|
|
83
|
+
if (
|
|
84
|
+
getMiddlewarePriority(middlewares[i]) <
|
|
85
|
+
getMiddlewarePriority(middlewares[i - 1])
|
|
86
|
+
) {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
|
|
50
93
|
/**
|
|
51
94
|
* Registers global middleware for a specific tag.
|
|
52
95
|
*
|
|
@@ -231,7 +274,8 @@ export const combineMiddleware = (
|
|
|
231
274
|
resolved.push(...funcMiddleware)
|
|
232
275
|
}
|
|
233
276
|
|
|
234
|
-
//
|
|
277
|
+
// Sort by priority, deduplicate, and freeze
|
|
278
|
+
sortByPriority(resolved)
|
|
235
279
|
middlewareCache[wireType][uid] = freezeDedupe(
|
|
236
280
|
resolved
|
|
237
281
|
) as readonly CorePikkuMiddleware[]
|
package/src/types/core.types.ts
CHANGED
|
@@ -305,6 +305,23 @@ export type CorePikkuMiddleware<
|
|
|
305
305
|
next: () => Promise<void>
|
|
306
306
|
) => Promise<void>
|
|
307
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Priority levels for middleware execution order.
|
|
310
|
+
* Lower priority runs first (outermost in the onion model).
|
|
311
|
+
*
|
|
312
|
+
* - `highest` — Runs first (outermost). Use for telemetry, request tracing.
|
|
313
|
+
* - `high` — Runs early. Use for CORS, rate limiting.
|
|
314
|
+
* - `medium` — Default. Use for auth, most user middleware.
|
|
315
|
+
* - `low` — Runs late. Use for post-auth processing.
|
|
316
|
+
* - `lowest` — Runs last (innermost, closest to function). Use for inner telemetry.
|
|
317
|
+
*/
|
|
318
|
+
export type MiddlewarePriority =
|
|
319
|
+
| 'highest'
|
|
320
|
+
| 'high'
|
|
321
|
+
| 'medium'
|
|
322
|
+
| 'low'
|
|
323
|
+
| 'lowest'
|
|
324
|
+
|
|
308
325
|
/**
|
|
309
326
|
* Configuration object for creating middleware with metadata
|
|
310
327
|
*
|
|
@@ -321,6 +338,8 @@ export type CorePikkuMiddlewareConfig<
|
|
|
321
338
|
name?: string
|
|
322
339
|
/** Optional description of what the middleware does */
|
|
323
340
|
description?: string
|
|
341
|
+
/** Execution priority. Lower runs first (outermost). Defaults to 'medium'. */
|
|
342
|
+
priority?: MiddlewarePriority
|
|
324
343
|
}
|
|
325
344
|
|
|
326
345
|
/**
|
|
@@ -378,7 +397,15 @@ export const pikkuMiddleware = <
|
|
|
378
397
|
| CorePikkuMiddleware<SingletonServices, UserSession>
|
|
379
398
|
| CorePikkuMiddlewareConfig<SingletonServices, UserSession>
|
|
380
399
|
): CorePikkuMiddleware<SingletonServices, UserSession> => {
|
|
381
|
-
|
|
400
|
+
if (typeof middleware === 'function') return middleware
|
|
401
|
+
const func = middleware.func as CorePikkuMiddleware<
|
|
402
|
+
SingletonServices,
|
|
403
|
+
UserSession
|
|
404
|
+
> & { __priority?: MiddlewarePriority }
|
|
405
|
+
if (middleware.priority) {
|
|
406
|
+
func.__priority = middleware.priority
|
|
407
|
+
}
|
|
408
|
+
return func
|
|
382
409
|
}
|
|
383
410
|
|
|
384
411
|
/**
|
|
@@ -70,7 +70,11 @@ export class PikkuMockRequest implements PikkuHTTPRequest {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
export class PikkuMockResponse implements PikkuHTTPResponse {
|
|
73
|
-
public _status: number
|
|
73
|
+
public _status: number = 200
|
|
74
|
+
|
|
75
|
+
public get statusCode(): number {
|
|
76
|
+
return this._status
|
|
77
|
+
}
|
|
74
78
|
|
|
75
79
|
status(code: number): this {
|
|
76
80
|
this._status = code
|