@wooksjs/event-core 0.6.6 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -4
- package/dist/index.cjs +440 -230
- package/dist/index.d.ts +386 -107
- package/dist/index.mjs +422 -220
- package/package.json +11 -13
- package/scripts/setup-skills.js +78 -0
- package/skills/wooksjs-event-core/.gitkeep +0 -0
- package/skills/wooksjs-event-core/SKILL.md +50 -0
- package/skills/wooksjs-event-core/composables.md +200 -0
- package/skills/wooksjs-event-core/context.md +270 -0
- package/skills/wooksjs-event-core/core.md +91 -0
- package/skills/wooksjs-event-core/primitives.md +213 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Core concepts & setup — @wooksjs/event-core
|
|
2
|
+
|
|
3
|
+
> Package overview, mental model, installation, and exports map.
|
|
4
|
+
|
|
5
|
+
## What it is
|
|
6
|
+
|
|
7
|
+
`@wooksjs/event-core` is the context engine underneath all Wooks adapters. It provides:
|
|
8
|
+
|
|
9
|
+
- **Typed slots** (`key<T>`) — explicit get/set per event
|
|
10
|
+
- **Lazy cached computations** (`cached`, `cachedBy`) — compute once, cache for event lifetime
|
|
11
|
+
- **Event kind schemas** (`defineEventKind`) — typed seed bundles for domain-specific data
|
|
12
|
+
- **Composable factories** (`defineWook`) — per-event cached functions, resolved via `AsyncLocalStorage`
|
|
13
|
+
- **Async propagation** (`run`, `current`) — context available anywhere in the call stack
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @wooksjs/event-core
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Typically you don't install this directly — it's a dependency of `@wooksjs/event-http`, `@wooksjs/event-cli`, etc. Install it when building custom adapters or composables that don't depend on a specific event type.
|
|
22
|
+
|
|
23
|
+
## Mental model
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
EventContext = Map<number, unknown> (flat, typed via key/cached accessors)
|
|
27
|
+
│
|
|
28
|
+
├── key<T>(name) → writable slot, explicit set/get
|
|
29
|
+
├── cached<T>(fn) → read-only slot, lazy, computed on first access
|
|
30
|
+
├── cachedBy<K,V>(fn) → like cached but keyed — one result per unique argument
|
|
31
|
+
│
|
|
32
|
+
├── defineEventKind(name, schema) → groups slots into a named seed bundle
|
|
33
|
+
│ └── ctx.seed(kind, seeds) → populates slots from seeds
|
|
34
|
+
│
|
|
35
|
+
├── defineWook(factory) → creates a composable: factory runs once per context, cached
|
|
36
|
+
│ └── useX() → resolves from AsyncLocalStorage, returns cached result
|
|
37
|
+
│
|
|
38
|
+
└── parent chain → optional parent: EventContext link
|
|
39
|
+
├── get/set/has → traverse local → parent → grandparent → ...
|
|
40
|
+
└── getOwn/setOwn/hasOwn → local-only (no traversal)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Every event (HTTP request, CLI invocation, workflow step) gets its own `EventContext`. The context is propagated via `AsyncLocalStorage` — composables resolve it implicitly.
|
|
44
|
+
|
|
45
|
+
## Exports
|
|
46
|
+
|
|
47
|
+
| Export | Kind | Purpose |
|
|
48
|
+
| --------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
49
|
+
| `key<T>(name)` | primitive | Writable typed slot |
|
|
50
|
+
| `cached<T>(fn)` | primitive | Lazy computed slot, cached per event |
|
|
51
|
+
| `cachedBy<K,V>(fn)` | primitive | Lazy computed slot, cached per key per event |
|
|
52
|
+
| `slot<T>()` | primitive | Type marker for `defineEventKind` schemas |
|
|
53
|
+
| `defineEventKind(name, schema)` | primitive | Typed seed bundle definition |
|
|
54
|
+
| `defineWook<T>(factory)` | primitive | Composable factory with per-event caching |
|
|
55
|
+
| `EventContext` | class | Per-event context container (`get`, `set`, `has`, `seed`, `getOwn`, `setOwn`, `hasOwn`; optional `parent` chain) |
|
|
56
|
+
| `run(ctx, fn)` | function | Execute `fn` within `ctx` via `AsyncLocalStorage` |
|
|
57
|
+
| `current()` | function | Get active `EventContext` (throws if none) |
|
|
58
|
+
| `tryGetCurrent()` | function | Get active `EventContext` or `undefined` |
|
|
59
|
+
| `createEventContext(opts, [kind, seeds,] fn)` | function | Create context (with optional parent) + optional seed + run |
|
|
60
|
+
| `useLogger(ctx?)` | composable | Shorthand for `(ctx ?? current()).logger` |
|
|
61
|
+
| `useRouteParams(ctx?)` | composable | Route parameters from router |
|
|
62
|
+
| `useEventId(ctx?)` | composable | Lazy UUID per event |
|
|
63
|
+
| `routeParamsKey` | key | Standard key for route params |
|
|
64
|
+
| `eventTypeKey` | key | Standard key for event type name |
|
|
65
|
+
| `ContextInjector` | class | Observability hook point (OpenTelemetry etc.) |
|
|
66
|
+
| `getContextInjector()` | function | Get current injector |
|
|
67
|
+
| `replaceContextInjector(ci)` | function | Replace injector (e.g. with OTel spans) |
|
|
68
|
+
|
|
69
|
+
## Types
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
interface Logger {
|
|
73
|
+
info(msg: string, ...args: unknown[]): void
|
|
74
|
+
warn(msg: string, ...args: unknown[]): void
|
|
75
|
+
error(msg: string, ...args: unknown[]): void
|
|
76
|
+
debug(msg: string, ...args: unknown[]): void
|
|
77
|
+
topic?: (name?: string) => Logger
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface Key<T> { readonly _id: number; readonly _name: string }
|
|
81
|
+
interface Cached<T> { readonly _id: number; readonly _name: string; readonly _fn: (ctx: EventContext) => T }
|
|
82
|
+
type Accessor<T> = Key<T> | Cached<T>
|
|
83
|
+
interface SlotMarker<T> {} // phantom type marker
|
|
84
|
+
|
|
85
|
+
type EventKind<S extends Record<string, SlotMarker<any>>> = {
|
|
86
|
+
readonly name: string
|
|
87
|
+
readonly keys: { [K in keyof S]: S[K] extends SlotMarker<infer V> ? Key<V> : never }
|
|
88
|
+
readonly _entries: Array<[string, Key<unknown>]> // internal, used by ctx.seed()
|
|
89
|
+
}
|
|
90
|
+
type EventKindSeeds<K> = // maps SlotMarker<V> → V for each property
|
|
91
|
+
```
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Primitives — @wooksjs/event-core
|
|
2
|
+
|
|
3
|
+
> `key`, `cached`, `cachedBy`, `slot`, `defineEventKind` — the building blocks for typed per-event state.
|
|
4
|
+
|
|
5
|
+
## Concepts
|
|
6
|
+
|
|
7
|
+
All state lives in `EventContext` — a flat `Map<number, unknown>`. Primitives give you typed accessors into that map:
|
|
8
|
+
|
|
9
|
+
- **`key<T>`** — a writable slot. You `set` and `get` values explicitly.
|
|
10
|
+
- **`cached<T>`** — a read-only slot. Computed lazily on first `get`, cached for the event lifetime.
|
|
11
|
+
- **`cachedBy<K,V>`** — like `cached`, but parameterized. One cached result per unique key.
|
|
12
|
+
- **`slot<T>`** / **`defineEventKind`** — group multiple keys into a typed seed bundle that can be seeded into a context in one call.
|
|
13
|
+
|
|
14
|
+
All primitives are defined at module level (not per request). They produce lightweight descriptors with auto-incremented numeric IDs.
|
|
15
|
+
|
|
16
|
+
## API Reference
|
|
17
|
+
|
|
18
|
+
### `key<T>(name: string): Key<T>`
|
|
19
|
+
|
|
20
|
+
Creates a writable typed slot. The `name` is for debugging only — lookup is by numeric ID.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { key, EventContext } from '@wooksjs/event-core'
|
|
24
|
+
|
|
25
|
+
const userId = key<string>('userId')
|
|
26
|
+
|
|
27
|
+
const ctx = new EventContext({ logger })
|
|
28
|
+
ctx.set(userId, 'u-123')
|
|
29
|
+
ctx.get(userId) // 'u-123' (typed as string)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Throws `Key "name" is not set` if `get` is called before `set`.
|
|
33
|
+
|
|
34
|
+
Supports `undefined`, `null`, `0`, `''`, `false` — all falsy values are stored correctly.
|
|
35
|
+
|
|
36
|
+
### `cached<T>(fn: (ctx: EventContext) => T): Cached<T>`
|
|
37
|
+
|
|
38
|
+
Creates a lazy computed slot. The function runs on first `ctx.get()` and the result is cached.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { cached, key } from '@wooksjs/event-core'
|
|
42
|
+
|
|
43
|
+
const url = key<string>('url')
|
|
44
|
+
const parsedQuery = cached((ctx) => {
|
|
45
|
+
return Object.fromEntries(new URL(ctx.get(url)).searchParams)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// First access: computes and caches
|
|
49
|
+
ctx.get(parsedQuery)
|
|
50
|
+
// Second access: returns cached result, fn never runs again
|
|
51
|
+
ctx.get(parsedQuery)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Async:** The Promise itself is cached — concurrent access deduplicates:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const rawBody = cached(async (ctx) => {
|
|
58
|
+
const chunks: Buffer[] = []
|
|
59
|
+
for await (const chunk of ctx.get(reqStream)) chunks.push(chunk)
|
|
60
|
+
return Buffer.concat(chunks)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// Two concurrent calls → one stream read, one Promise
|
|
64
|
+
const [a, b] = await Promise.all([ctx.get(rawBody), ctx.get(rawBody)])
|
|
65
|
+
a === b // true
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Errors** are cached too — a failed computation throws the same error on subsequent access without re-executing.
|
|
69
|
+
|
|
70
|
+
**Circular dependencies** are detected and throw immediately: `Circular dependency detected for "name"`.
|
|
71
|
+
|
|
72
|
+
### `cachedBy<K, V>(fn: (key: K, ctx: EventContext) => V): (key: K, ctx?: EventContext) => V`
|
|
73
|
+
|
|
74
|
+
Like `cached`, but parameterized. Maintains a `Map<K, V>` per context — each unique key computes once.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { cachedBy } from '@wooksjs/event-core'
|
|
78
|
+
|
|
79
|
+
const getCookie = cachedBy((name: string, ctx) => {
|
|
80
|
+
const header = ctx.get(rawHeaders)['cookie'] ?? ''
|
|
81
|
+
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))
|
|
82
|
+
return match?.[1]
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
getCookie('session') // extracts + caches
|
|
86
|
+
getCookie('session') // cache hit
|
|
87
|
+
getCookie('theme') // extracts + caches (different key)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The returned function accepts an optional `ctx` parameter to skip `AsyncLocalStorage` lookup:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const ctx = current()
|
|
94
|
+
getCookie('session', ctx) // explicit context, no ALS overhead
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `slot<T>(): SlotMarker<T>`
|
|
98
|
+
|
|
99
|
+
A phantom type marker used in `defineEventKind` schemas. Has no runtime behavior — it only carries the type `T`.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { slot } from '@wooksjs/event-core'
|
|
103
|
+
|
|
104
|
+
const schema = {
|
|
105
|
+
method: slot<string>(),
|
|
106
|
+
path: slot<string>(),
|
|
107
|
+
req: slot<IncomingMessage>(),
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### `defineEventKind<S>(name: string, schema: S): EventKind<S>`
|
|
112
|
+
|
|
113
|
+
Groups slots into a named kind. Creates a `Key<T>` for each slot in the schema, namespaced under the kind name.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { defineEventKind, slot } from '@wooksjs/event-core'
|
|
117
|
+
|
|
118
|
+
const httpKind = defineEventKind('http', {
|
|
119
|
+
method: slot<string>(),
|
|
120
|
+
path: slot<string>(),
|
|
121
|
+
rawHeaders: slot<Record<string, string>>(),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// httpKind.name === 'http'
|
|
125
|
+
// httpKind.keys.method is Key<string> with _name 'http.method'
|
|
126
|
+
// httpKind.keys.path is Key<string> with _name 'http.path'
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Seed into a context with `ctx.seed()`:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
ctx.seed(httpKind, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
path: '/api/users',
|
|
135
|
+
rawHeaders: { 'content-type': 'application/json' },
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
ctx.get(httpKind.keys.method) // 'POST'
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Multiple kinds can be layered via **parent-linked contexts** instead of seeding everything into one context:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const parentCtx = new EventContext({ logger })
|
|
145
|
+
parentCtx.seed(httpKind, { method: 'POST', path: '/webhook', rawHeaders: {} })
|
|
146
|
+
|
|
147
|
+
const childCtx = new EventContext({ logger, parent: parentCtx })
|
|
148
|
+
childCtx.seed(workflowKind, { triggerId: 'deploy-42', payload: { env: 'prod' } })
|
|
149
|
+
|
|
150
|
+
// Child sees both its own and parent data via the parent chain
|
|
151
|
+
childCtx.get(httpKind.keys.method) // 'POST' (from parent)
|
|
152
|
+
childCtx.get(workflowKind.keys.triggerId) // 'deploy-42' (local)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Common Patterns
|
|
156
|
+
|
|
157
|
+
### Pattern: Derived computation chain
|
|
158
|
+
|
|
159
|
+
Cached values can depend on keys and on other cached values. The dependency graph is resolved lazily.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
const base = key<number>('base')
|
|
163
|
+
const doubled = cached((ctx) => ctx.get(base) * 2)
|
|
164
|
+
const quadrupled = cached((ctx) => ctx.get(doubled) * 2)
|
|
165
|
+
|
|
166
|
+
ctx.set(base, 5)
|
|
167
|
+
ctx.get(quadrupled) // 20 — computes doubled (10) then quadrupled (20)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Pattern: Library extension via cached slots
|
|
171
|
+
|
|
172
|
+
Libraries export `cached` slots for other libraries to depend on:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// http-context library
|
|
176
|
+
export const rawBody = cached(async (ctx) => readStream(ctx.get(httpKind.keys.req)))
|
|
177
|
+
|
|
178
|
+
// body-parser library (depends on http-context)
|
|
179
|
+
import { rawBody } from 'http-context'
|
|
180
|
+
|
|
181
|
+
const parsedBody = cached(async (ctx) => {
|
|
182
|
+
const buf = await ctx.get(rawBody)
|
|
183
|
+
return JSON.parse(buf.toString())
|
|
184
|
+
})
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Pattern: Sparse access with cachedBy
|
|
188
|
+
|
|
189
|
+
When the data source is large but your app reads few entries:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// Parses only requested cookies, not all 40
|
|
193
|
+
const getCookie = cachedBy((name: string, ctx) => {
|
|
194
|
+
const header = ctx.get(rawHeaders)['cookie'] ?? ''
|
|
195
|
+
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))
|
|
196
|
+
return match?.[1]
|
|
197
|
+
})
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Best Practices
|
|
201
|
+
|
|
202
|
+
- Define keys/cached/cachedBy at **module level**, not inside handlers — they're descriptors, not values
|
|
203
|
+
- Use `cached` for anything computed from request data — it guarantees single computation
|
|
204
|
+
- Use `cachedBy` when computation varies by a parameter (cookie name, header name, etc.)
|
|
205
|
+
- Async `cached` values cache the Promise — safe for concurrent access
|
|
206
|
+
- Errors from `cached` are also cached — a failing computation won't retry
|
|
207
|
+
|
|
208
|
+
## Gotchas
|
|
209
|
+
|
|
210
|
+
- `key` throws on `get` if never `set` — use `ctx.has(k)` to check first
|
|
211
|
+
- `cached` functions receive `ctx` as parameter — don't call `current()` inside them, use the provided `ctx`
|
|
212
|
+
- Circular `cached` dependencies throw immediately — the cycle is detected at runtime
|
|
213
|
+
- `cachedBy` uses a `Map` — keys are compared by identity (`===`), not deep equality
|