@hile/context 3.0.1

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 ADDED
@@ -0,0 +1,161 @@
1
+ # @hile/context
2
+
3
+ Typed async context propagation primitives for Hile applications.
4
+
5
+ `@hile/context` does not define business fields. It stores whatever context shape the application declares and keeps that data isolated across async work. Adapters can seed context from HTTP, model pipelines, logger bindings, and `@hile/micro` calls without adding fields to business payloads.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @hile/context
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```typescript
16
+ import { getContext, runWithContext } from '@hile/context'
17
+
18
+ type AppContext = {
19
+ shopId: string
20
+ memberId: string
21
+ channel: 'web' | 'wechat'
22
+ }
23
+
24
+ await runWithContext<AppContext>({
25
+ shopId: 'shop-1',
26
+ memberId: 'member-1',
27
+ channel: 'web',
28
+ }, async () => {
29
+ const context = getContext<AppContext>()
30
+ console.log(context.shopId)
31
+ })
32
+ ```
33
+
34
+ Outside `runWithContext()`, `getContext()` returns an empty object.
35
+
36
+ ## Core API
37
+
38
+ ### runWithContext(context, callback, options?)
39
+
40
+ Runs `callback` inside an `AsyncLocalStorage` scope.
41
+
42
+ ```typescript
43
+ await runWithContext<AppContext>({ shopId: 'shop-1' }, async () => {
44
+ await doWork()
45
+ })
46
+ ```
47
+
48
+ Nested calls merge with the parent context by default:
49
+
50
+ ```typescript
51
+ await runWithContext<AppContext>({ shopId: 'shop-1', channel: 'web' }, async () => {
52
+ await runWithContext<AppContext>({ channel: 'wechat' }, async () => {
53
+ getContext<AppContext>() // { shopId: 'shop-1', channel: 'wechat' }
54
+ })
55
+ })
56
+ ```
57
+
58
+ Pass `{ merge: false }` to replace the parent context.
59
+
60
+ ### getContext()
61
+
62
+ Returns a readonly shallow snapshot of the active context. Mutating the returned object does not mutate the store.
63
+
64
+ ### snapshotContext()
65
+
66
+ Returns a readonly shallow snapshot for propagation. Cross-process transports should only put JSON-serializable values into context.
67
+
68
+ ### requireContext(keys)
69
+
70
+ Asserts that selected application-defined keys are present.
71
+
72
+ ```typescript
73
+ const context = requireContext<AppContext>(['shopId', 'channel'])
74
+ ```
75
+
76
+ It throws `MissingContextError` when a selected key is missing.
77
+
78
+ ## HTTP Adapter
79
+
80
+ `contextHttp()` is mapping-only. The package does not prescribe header names.
81
+
82
+ ```typescript
83
+ import { contextHttp } from '@hile/context'
84
+
85
+ app.use(contextHttp<AppContext, Koa.Context>({
86
+ read: ctx => ({
87
+ shopId: ctx.get('x-shop'),
88
+ channel: ctx.get('x-channel') as AppContext['channel'],
89
+ }),
90
+ write: (context, ctx) => {
91
+ if (context.shopId) ctx.set('x-current-shop', context.shopId)
92
+ },
93
+ }))
94
+ ```
95
+
96
+ ## Model Adapter
97
+
98
+ ```typescript
99
+ import { contextModel, requireContextModel } from '@hile/context'
100
+ import { defineModel } from '@hile/model'
101
+
102
+ const model = defineModel({
103
+ pipelines: [
104
+ contextModel<{ store: string; source: 'web' | 'wechat' }, AppContext>({
105
+ read: input => ({
106
+ shopId: input.store,
107
+ channel: input.source,
108
+ }),
109
+ }),
110
+ requireContextModel<{ store: string; source: 'web' | 'wechat' }, AppContext>(['shopId']),
111
+ ],
112
+ async main(input) {
113
+ return getContext<AppContext>()
114
+ },
115
+ })
116
+ ```
117
+
118
+ ## Logger Binding
119
+
120
+ Logger bindings are opt-in. Without `pick` or `map`, no context fields are logged.
121
+
122
+ ```typescript
123
+ import { withContextLogger } from '@hile/context'
124
+
125
+ const logger = withContextLogger<AppContext>(baseLogger, {
126
+ pick: ['shopId', 'channel'],
127
+ })
128
+
129
+ logger.info({ event: 'checkout' }, 'checkout created')
130
+ ```
131
+
132
+ ## @hile/micro Propagation
133
+
134
+ When `@hile/micro` depends on `@hile/context`, calls made inside `runWithContext()` propagate the current context through message metadata:
135
+
136
+ ```typescript
137
+ await runWithContext<AppContext>({ shopId: 'shop-1', channel: 'web' }, async () => {
138
+ await app.call('inventory', '/reserve', { sku: 'sku-1' })
139
+ })
140
+ ```
141
+
142
+ The receiving handler can call `getContext<AppContext>()`. The business `data` payload remains unchanged; context travels separately in `metadata.context`.
143
+
144
+ ## Boundaries
145
+
146
+ - No field names are built into this package.
147
+ - Context is a request/work-unit scope, not a process-level configuration store.
148
+ - `getContext()` returns a shallow snapshot, not a deep clone.
149
+ - Cross-process propagation should use JSON-serializable values.
150
+ - Logger integration logs only fields selected by the application.
151
+
152
+ ## Testing
153
+
154
+ ```bash
155
+ pnpm --filter @hile/context test
156
+ pnpm --filter @hile/context build
157
+ ```
158
+
159
+ ## License
160
+
161
+ MIT
package/SKILL.md ADDED
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: context
3
+ description: Use when implementing typed async context propagation, request/work-unit context, HTTP context mapping, model pipeline context, logger context bindings, or @hile/micro context propagation.
4
+ ---
5
+
6
+ # Context
7
+
8
+ Use `@hile/context` when a Hile app needs request/work-unit data to flow through async code and microservice calls without passing it through every function argument.
9
+
10
+ ## Core Rule
11
+
12
+ Never bake business fields into the package. The library owns storage and propagation only; applications own the context shape.
13
+
14
+ ```typescript
15
+ type AppContext = {
16
+ shopId: string
17
+ channel: 'web' | 'wechat'
18
+ }
19
+
20
+ await runWithContext<AppContext>({ shopId, channel }, async () => {
21
+ await doWork()
22
+ })
23
+ ```
24
+
25
+ ## Design Boundaries
26
+
27
+ - Core storage is an `AsyncLocalStorage<Record<string, unknown>>`.
28
+ - `getContext()` and `snapshotContext()` return readonly shallow snapshots.
29
+ - Nested `runWithContext()` calls merge by default; pass `{ merge: false }` to replace.
30
+ - `requireContext(keys)` validates only keys selected by the application.
31
+ - Do not add fixed fields like organization, user, or request dimensions to core types.
32
+ - Cross-process propagation should use JSON-serializable context values.
33
+
34
+ ## Adapters
35
+
36
+ - Use `contextHttp({ read, write })` to map arbitrary HTTP request/response details to and from context. The package must not prescribe header names.
37
+ - Use `contextModel({ read })` inside `@hile/model` pipelines to seed context from model input.
38
+ - Use `requireContextModel(keys)` to enforce selected application-owned keys inside model pipelines.
39
+ - Use `withContextLogger(logger, { pick })` or `{ map }` to opt into logger bindings. Never log the whole context by default.
40
+ - `@hile/micro` propagates context in `metadata.context`; business payloads remain unchanged.
@@ -0,0 +1,4 @@
1
+ export declare class MissingContextError extends Error {
2
+ readonly keys: readonly string[];
3
+ constructor(keys: readonly string[]);
4
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,9 @@
1
+ export class MissingContextError extends Error {
2
+ keys;
3
+ constructor(keys) {
4
+ super(`Missing required context keys: ${keys.join(', ')}`);
5
+ this.name = 'MissingContextError';
6
+ this.keys = [...keys];
7
+ Object.setPrototypeOf(this, new.target.prototype);
8
+ }
9
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { ContextData, ContextInput, MaybePromise, RunWithContextOptions } from './types';
2
+ export type ContextHttpOptions<TContext extends object = ContextData, THttpContext = unknown> = RunWithContextOptions & {
3
+ read: (ctx: THttpContext) => MaybePromise<ContextInput<TContext>>;
4
+ write?: (context: Readonly<Partial<TContext>>, ctx: THttpContext) => MaybePromise<void>;
5
+ };
6
+ export type ContextHttpMiddleware<THttpContext = unknown> = (ctx: THttpContext, next: () => Promise<unknown>) => Promise<void>;
7
+ export declare function contextHttp<TContext extends object = ContextData, THttpContext = unknown>(options: ContextHttpOptions<TContext, THttpContext>): ContextHttpMiddleware<THttpContext>;
package/dist/http.js ADDED
@@ -0,0 +1,26 @@
1
+ import { getContext, runWithContext } from './store.js';
2
+ export function contextHttp(options) {
3
+ return async (ctx, next) => {
4
+ const context = await options.read(ctx);
5
+ await runWithContext(context, async () => {
6
+ let nextError;
7
+ try {
8
+ await next();
9
+ }
10
+ catch (err) {
11
+ nextError = err;
12
+ }
13
+ if (options.write) {
14
+ try {
15
+ await options.write(getContext(), ctx);
16
+ }
17
+ catch (writeError) {
18
+ if (nextError === undefined)
19
+ throw writeError;
20
+ }
21
+ }
22
+ if (nextError !== undefined)
23
+ throw nextError;
24
+ }, { merge: options.merge });
25
+ };
26
+ }
@@ -0,0 +1,6 @@
1
+ export * from './errors.js';
2
+ export * from './http.js';
3
+ export * from './logger.js';
4
+ export * from './model.js';
5
+ export * from './store.js';
6
+ export type * from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from './errors.js';
2
+ export * from './http.js';
3
+ export * from './logger.js';
4
+ export * from './model.js';
5
+ export * from './store.js';
@@ -0,0 +1,10 @@
1
+ import type { ContextData, ContextKey, ContextSnapshot } from './types';
2
+ export type ContextLoggerOptions<TContext extends object = ContextData> = {
3
+ pick?: readonly ContextKey<TContext>[];
4
+ map?: (context: ContextSnapshot<TContext>) => Record<string, unknown>;
5
+ };
6
+ export type ContextLoggerLike = object & {
7
+ child?: (bindings: Record<string, unknown>) => unknown;
8
+ };
9
+ export declare function contextBindings<TContext extends object = ContextData>(options?: ContextLoggerOptions<TContext>, context?: ContextSnapshot<TContext>): Record<string, unknown>;
10
+ export declare function withContextLogger<TContext extends object = ContextData, TLogger extends object = any>(logger: TLogger, options?: ContextLoggerOptions<TContext>): TLogger;
package/dist/logger.js ADDED
@@ -0,0 +1,50 @@
1
+ import { getContext } from './store.js';
2
+ const LOG_METHODS = new Set(['trace', 'debug', 'info', 'warn', 'error', 'fatal']);
3
+ export function contextBindings(options = {}, context = getContext()) {
4
+ const bindings = {};
5
+ const source = context;
6
+ for (const key of options.pick ?? []) {
7
+ if (source[key] !== undefined) {
8
+ bindings[key] = source[key];
9
+ }
10
+ }
11
+ if (options.map) {
12
+ Object.assign(bindings, options.map(context));
13
+ }
14
+ return bindings;
15
+ }
16
+ export function withContextLogger(logger, options = {}) {
17
+ return new Proxy(logger, {
18
+ get(target, property, receiver) {
19
+ const value = Reflect.get(target, property, receiver);
20
+ if (typeof property !== 'string' || !LOG_METHODS.has(property) || typeof value !== 'function') {
21
+ return typeof value === 'function' ? value.bind(target) : value;
22
+ }
23
+ return (...args) => {
24
+ const bindings = contextBindings(options);
25
+ if (Object.keys(bindings).length === 0) {
26
+ return value.apply(target, args);
27
+ }
28
+ const targetLogger = target;
29
+ if (typeof targetLogger.child === 'function') {
30
+ const child = targetLogger.child(bindings);
31
+ const method = child[property];
32
+ if (typeof method === 'function') {
33
+ return method.apply(child, args);
34
+ }
35
+ }
36
+ const [first, ...rest] = args;
37
+ if (isMergeableLogObject(first)) {
38
+ return value.call(target, { ...bindings, ...first }, ...rest);
39
+ }
40
+ return value.call(target, bindings, ...args);
41
+ };
42
+ },
43
+ });
44
+ }
45
+ function isMergeableLogObject(value) {
46
+ return (typeof value === 'object' &&
47
+ value !== null &&
48
+ !Array.isArray(value) &&
49
+ !(value instanceof Error));
50
+ }
@@ -0,0 +1,7 @@
1
+ import type { PipelineContext, PipelineMiddleware } from '@hile/model';
2
+ import type { ContextData, ContextInput, ContextKey, MaybePromise, RunWithContextOptions } from './types';
3
+ export type ContextModelOptions<TInput extends object = Record<string, unknown>, TContext extends object = ContextData> = RunWithContextOptions & {
4
+ read: (input: TInput, ctx: PipelineContext<TInput>) => MaybePromise<ContextInput<TContext>>;
5
+ };
6
+ export declare function contextModel<TInput extends object = Record<string, unknown>, TContext extends object = ContextData>(options: ContextModelOptions<TInput, TContext>): PipelineMiddleware<TInput>;
7
+ export declare function requireContextModel<TInput extends object = Record<string, unknown>, TContext extends object = ContextData>(keys: readonly ContextKey<TContext>[]): PipelineMiddleware<TInput>;
package/dist/model.js ADDED
@@ -0,0 +1,13 @@
1
+ import { requireContext, runWithContext } from './store.js';
2
+ export function contextModel(options) {
3
+ return async (ctx, next) => {
4
+ const context = await options.read(ctx.args, ctx);
5
+ await runWithContext(context, () => next(), { merge: options.merge });
6
+ };
7
+ }
8
+ export function requireContextModel(keys) {
9
+ return async (_ctx, next) => {
10
+ requireContext(keys);
11
+ await next();
12
+ };
13
+ }
@@ -0,0 +1,11 @@
1
+ import type { ContextData, ContextInput, ContextKey, ContextSnapshot, RunWithContextOptions } from './types';
2
+ export declare function isContextData(value: unknown): value is ContextData;
3
+ export declare function hasContext(): boolean;
4
+ export declare function getContext<TContext extends object = ContextData>(): ContextSnapshot<TContext>;
5
+ export declare function snapshotContext<TContext extends object = ContextData>(): ContextSnapshot<TContext>;
6
+ export declare function runWithContext<TContext extends object = ContextData, TResult = any>(context: ContextInput<TContext>, callback: () => TResult, options?: RunWithContextOptions): TResult;
7
+ type RequiredContext<TContext extends object, TKey extends ContextKey<TContext>> = Readonly<Partial<TContext>> & {
8
+ readonly [K in TKey]-?: Exclude<TContext[K], undefined>;
9
+ };
10
+ export declare function requireContext<TContext extends object = ContextData, const TKeys extends readonly ContextKey<TContext>[] = readonly ContextKey<TContext>[]>(keys: TKeys): RequiredContext<TContext, TKeys[number]>;
11
+ export {};
package/dist/store.js ADDED
@@ -0,0 +1,38 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { MissingContextError } from './errors.js';
3
+ const storage = new AsyncLocalStorage();
4
+ function toStore(context) {
5
+ if (!isContextData(context))
6
+ return {};
7
+ return { ...context };
8
+ }
9
+ function freezeSnapshot(store) {
10
+ return Object.freeze({ ...(store ?? {}) });
11
+ }
12
+ export function isContextData(value) {
13
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ }
15
+ export function hasContext() {
16
+ return storage.getStore() !== undefined;
17
+ }
18
+ export function getContext() {
19
+ return freezeSnapshot(storage.getStore());
20
+ }
21
+ export function snapshotContext() {
22
+ return getContext();
23
+ }
24
+ export function runWithContext(context, callback, options = {}) {
25
+ const parent = storage.getStore();
26
+ const current = toStore(context);
27
+ const next = options.merge === false ? current : { ...(parent ?? {}), ...current };
28
+ return storage.run(next, callback);
29
+ }
30
+ export function requireContext(keys) {
31
+ const context = getContext();
32
+ const source = context;
33
+ const missing = keys.filter(key => source[key] === undefined);
34
+ if (missing.length > 0) {
35
+ throw new MissingContextError(missing);
36
+ }
37
+ return context;
38
+ }
@@ -0,0 +1,11 @@
1
+ export type ContextData = Record<string, unknown>;
2
+ export type ContextInput<TContext extends object = ContextData> = Readonly<Partial<TContext>>;
3
+ export type ContextSnapshot<TContext extends object = ContextData> = Readonly<Partial<TContext>>;
4
+ export type ContextKey<TContext extends object = ContextData> = Extract<keyof TContext, string>;
5
+ export type MaybePromise<T> = T | Promise<T>;
6
+ export type RunWithContextOptions = {
7
+ /**
8
+ * Merge with the current async context. Defaults to true.
9
+ */
10
+ merge?: boolean;
11
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@hile/context",
3
+ "version": "3.0.1",
4
+ "description": "Typed async context propagation primitives for Hile applications",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "scripts": {
8
+ "build": "tsc -b && fix-esm-import-path --preserve-import-type ./dist",
9
+ "dev": "tsc -b --watch",
10
+ "test": "vitest run"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md",
15
+ "SKILL.md"
16
+ ],
17
+ "license": "MIT",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^25.3.1",
23
+ "fix-esm-import-path": "^1.10.3",
24
+ "vitest": "^4.0.18"
25
+ },
26
+ "dependencies": {
27
+ "@hile/model": "^3.0.1"
28
+ },
29
+ "gitHead": "14b55afba0e9af80a782eaa84f6f48c1e66861a1"
30
+ }