@alxia/context-storage 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Tsala
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # @alxia/context-storage
2
+
3
+ The request's context, anywhere it runs — a service, a repository, a logger
4
+ three calls down — without passing it: [alxia](https://www.npmjs.com/package/@alxia/core)'s
5
+ `hono/context-storage`, on `AsyncLocalStorage`, **typed by the app**. No
6
+ dependency.
7
+
8
+ ```sh
9
+ bun add @alxia/context-storage @alxia/core
10
+ bun add -d typescript
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { alxia } from '@alxia/core';
17
+ import { contextStorage } from '@alxia/context-storage';
18
+ import { session } from '@alxia/janus';
19
+
20
+ const base = alxia().decorate({ db }).use(session(auth, { required: true }));
21
+
22
+ export const requestContext = contextStorage<typeof base>();
23
+
24
+ const app = base
25
+ .use(requestContext)
26
+ .get('/orders', async ({ reply }) => reply(200, await listOrders()));
27
+ ```
28
+
29
+ ```ts
30
+ // orders.ts — no context passed down
31
+ import { requestContext } from './app';
32
+
33
+ export async function listOrders() {
34
+ const { db, user, set } = requestContext.context(); // typed: user, db
35
+ set.headers.set('cache-control', 'private');
36
+ return db.orders.forUser(user.id);
37
+ }
38
+ ```
39
+
40
+ The context holds through every `await`, timer and promise of the
41
+ request, and never leaks into another's: twenty concurrent requests read
42
+ twenty contexts.
43
+
44
+ ## Reading it
45
+
46
+ | | |
47
+ | --- | --- |
48
+ | `requestContext.context()` | the route's context, typed by the app the plugin was given: the request, `set`, `reply`, and what every hook before it added. Throws outside |
49
+ | `requestContext.tryContext()` | the same, or `undefined`: code that runs in and out of requests |
50
+ | `getContext<Ctx>()`, `tryGetContext<Ctx>()` | untyped, as `hono/context-storage`'s: `Ctx` is yours to state |
51
+ | `getRequestContext()`, `tryGetRequestContext()` | the request as global hooks see it — in a 404, an `onResponse` — with the `route` it reached and its `error`; the `try` form returns `undefined` outside a request |
52
+ | `runWithContext(ctx, work)` | runs `work` with a context: a job, a queue consumer, a test of a service |
53
+
54
+ Outside a request, `getContext()` throws a `ContextStorageError` coded
55
+ `OUTSIDE_REQUEST`; in a request that reached no route declared after the
56
+ plugin, `NOT_ROUTED`. Declare it before the routes whose code reads it.
57
+
58
+ Pass the plugin to `use` called: `use(contextStorage)`, uncalled, is refused by
59
+ `tsc` (`TS2769`) and throws a `TypeError` at startup
60
+ ([troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/context-storage/docs/troubleshooting.md#typeerror-contextstorage-is-a-factory-usecontextstorage-not-usecontextstorage)).
61
+
62
+ ## API
63
+
64
+ | export | |
65
+ | --- | --- |
66
+ | `contextStorage<App>()` | the plugin, with `context()` and `tryContext()` typed by `App` |
67
+ | `ContextStoragePlugin<App>` | its type |
68
+ | `getContext`, `tryGetContext`, `getRequestContext`, `tryGetRequestContext`, `runWithContext` | the store, untyped |
69
+ | `ContextStorageError`, `ContextStorageErrorCode` | why there is no context |
70
+
71
+ ## Documentation
72
+
73
+ - [Guide](https://github.com/softistx/alxia/tree/develop/packages/context-storage/docs): what the plugin stores and when, reading it from a service or a logger, its typing, where it sits among hooks, what a timer or a detached callback sees, and jobs and tests with `runWithContext`.
74
+ - [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/context-storage/docs/troubleshooting.md): a `ContextStorageError`, the `TypeError` of `use(contextStorage)`, or a `tsc` error, and what to do about it.
75
+ - [Roadmap](https://github.com/softistx/alxia/blob/develop/packages/context-storage/docs/roadmap.md): what is coming, and what is not planned.
@@ -0,0 +1,2 @@
1
+ export { ContextStorageError, type ContextStorageErrorCode, type ContextStoragePlugin, contextStorage, getContext, getRequestContext, runWithContext, tryGetContext, tryGetRequestContext, } from './storage';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,mBAAmB,EACnB,KAAK,uBAAuB,EAC5B,KAAK,oBAAoB,EACzB,cAAc,EACd,UAAU,EACV,iBAAiB,EACjB,cAAc,EACd,aAAa,EACb,oBAAoB,GACpB,MAAM,WAAW,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,70 @@
1
+ // src/storage.ts
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import {
4
+ alxia
5
+ } from "@alxia/core";
6
+
7
+ class ContextStorageError extends Error {
8
+ name = "ContextStorageError";
9
+ code;
10
+ constructor(code) {
11
+ super(code === "OUTSIDE_REQUEST" ? "getContext(): called outside a request — use tryGetContext(), or runWithContext() in a job or a test" : "getContext(): this request reached no route declared after contextStorage() — use it earlier, or getRequestContext()");
12
+ this.code = code;
13
+ }
14
+ }
15
+ var storage = new AsyncLocalStorage;
16
+ function getContext() {
17
+ const holder = storage.getStore();
18
+ if (holder === undefined)
19
+ throw new ContextStorageError("OUTSIDE_REQUEST");
20
+ if (holder.ctx === undefined)
21
+ throw new ContextStorageError("NOT_ROUTED");
22
+ return holder.ctx;
23
+ }
24
+ function tryGetContext() {
25
+ return storage.getStore()?.ctx;
26
+ }
27
+ function getRequestContext() {
28
+ const holder = storage.getStore();
29
+ if (holder === undefined)
30
+ throw new ContextStorageError("OUTSIDE_REQUEST");
31
+ return holder.request;
32
+ }
33
+ function tryGetRequestContext() {
34
+ return storage.getStore()?.request;
35
+ }
36
+ function runWithContext(ctx, work) {
37
+ return storage.run({ request: ctx, ctx }, work);
38
+ }
39
+ function contextStorage(...uncalled) {
40
+ if (uncalled.length > 0) {
41
+ throw new TypeError("contextStorage is a factory: use(contextStorage()), not use(contextStorage)");
42
+ }
43
+ const plugin = alxia().around((request, next) => {
44
+ const current = storage.getStore();
45
+ if (current?.request.request === request.request)
46
+ return next();
47
+ return storage.run({ request, ctx: undefined }, next);
48
+ }).wrap((ctx, next) => {
49
+ const holder = storage.getStore();
50
+ if (holder !== undefined)
51
+ holder.ctx = ctx;
52
+ return next();
53
+ });
54
+ return Object.assign(plugin, {
55
+ context: () => getContext(),
56
+ tryContext: () => tryGetContext()
57
+ });
58
+ }
59
+ export {
60
+ ContextStorageError,
61
+ contextStorage,
62
+ getContext,
63
+ getRequestContext,
64
+ runWithContext,
65
+ tryGetContext,
66
+ tryGetRequestContext
67
+ };
68
+
69
+ //# debugId=F3B664A2ABF678EE64756E2164756E21
70
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/storage.ts"],
4
+ "sourcesContent": [
5
+ "import { AsyncLocalStorage } from 'node:async_hooks';\nimport {\n\ttype Alxia,\n\talxia,\n\ttype BaseContext,\n\ttype ContextOf,\n\ttype Empty,\n\ttype RequestContext,\n} from '@alxia/core';\n\n/** Why there is no context to read. */\nexport type ContextStorageErrorCode =\n\t/** Called outside any request: at startup, in a job, after the response. */\n\t| 'OUTSIDE_REQUEST'\n\t/** In a request, but not in a route declared after `contextStorage()`: a 404, a hook, a route before it. */\n\t| 'NOT_ROUTED';\n\nexport class ContextStorageError extends Error {\n\toverride readonly name = 'ContextStorageError';\n\treadonly code: ContextStorageErrorCode;\n\n\tconstructor(code: ContextStorageErrorCode) {\n\t\tsuper(\n\t\t\tcode === 'OUTSIDE_REQUEST'\n\t\t\t\t? 'getContext(): called outside a request — use tryGetContext(), or runWithContext() in a job or a test'\n\t\t\t\t: 'getContext(): this request reached no route declared after contextStorage() — use it earlier, or getRequestContext()',\n\t\t);\n\t\tthis.code = code;\n\t}\n}\n\ninterface Holder {\n\treadonly request: RequestContext;\n\tctx: BaseContext | undefined;\n}\n\n/**\n * One store for the process: every `contextStorage()` writes to it, and\n * `getContext()` reads it wherever it is called — the way `hono/context-storage`\n * works, so code written against one ports to the other.\n */\nconst storage = new AsyncLocalStorage<Holder>();\n\n/**\n * The context of the route the current request reached: what its handler\n * reads — the request, `set`, `reply`, and what every hook before it added.\n * Throws a `ContextStorageError` outside a route declared after\n * `contextStorage()`.\n *\n * `Ctx` types what the hooks added; prefer the typed `context()` of the plugin\n * itself, typed by the app.\n */\nexport function getContext<Ctx extends object = Empty>(): BaseContext & Ctx {\n\tconst holder = storage.getStore();\n\tif (holder === undefined) throw new ContextStorageError('OUTSIDE_REQUEST');\n\tif (holder.ctx === undefined) throw new ContextStorageError('NOT_ROUTED');\n\treturn holder.ctx as BaseContext & Ctx;\n}\n\n/** `getContext()`, or `undefined` where it would throw: code that runs in and out of requests. */\nexport function tryGetContext<Ctx extends object = Empty>():\n\t| (BaseContext & Ctx)\n\t| undefined {\n\treturn storage.getStore()?.ctx as (BaseContext & Ctx) | undefined;\n}\n\n/**\n * The request as every global hook sees it — before routing, in a 404, in\n * an `onResponse` — with the route it reached and the error it failed with.\n */\nexport function getRequestContext(): RequestContext {\n\tconst holder = storage.getStore();\n\tif (holder === undefined) throw new ContextStorageError('OUTSIDE_REQUEST');\n\treturn holder.request;\n}\n\n/** `getRequestContext()`, or `undefined` outside a request. */\nexport function tryGetRequestContext(): RequestContext | undefined {\n\treturn storage.getStore()?.request;\n}\n\n/**\n * Runs `work` with `ctx` as the current context: a job, a queue consumer,\n * a test calling a service that reads `getContext()`.\n */\nexport function runWithContext<T>(ctx: BaseContext, work: () => T): T {\n\treturn storage.run({ request: ctx, ctx }, work);\n}\n\n/** The plugin, and its context typed by the app it follows. */\nexport type ContextStoragePlugin<App> = Alxia<Empty, Empty, '', never> & {\n\t/** `getContext()`, typed by `App`. */\n\tcontext(): ContextOf<App> extends never ? BaseContext : ContextOf<App>;\n\t/** `tryGetContext()`, typed by `App`. */\n\ttryContext():\n\t\t| (ContextOf<App> extends never ? BaseContext : ContextOf<App>)\n\t\t| undefined;\n};\n\n/**\n * The request's context, anywhere it runs, as a plugin: from the routes\n * declared after it, every function their handlers call — however deep,\n * through every `await` and timer — reads it with `getContext()`, without\n * it being passed down.\n *\n * Typed by the app it is used on: give the plugin that app's type, and its\n * `context()` returns what its routes read — the `user` a session derived, the\n * `db` decorated.\n *\n * ```ts\n * const base = alxia().decorate({ db }).use(session(auth, { required: true }));\n * export const requestContext = contextStorage<typeof base>();\n * const app = base.use(requestContext).get('/orders', ({ reply }) => reply(200, listOrders()));\n *\n * // orders.ts — no context passed\n * export const listOrders = () => {\n * const { db, user } = requestContext.context();\n * return db.orders.forUser(user.id);\n * };\n * ```\n */\nexport function contextStorage<App = undefined>(\n\t...uncalled: readonly never[]\n): ContextStoragePlugin<App> {\n\tif (uncalled.length > 0) {\n\t\t// `use(contextStorage)`: the app is handed to the factory, and what\n\t\t// follows would be declared on a plugin nobody serves.\n\t\tthrow new TypeError(\n\t\t\t'contextStorage is a factory: use(contextStorage()), not use(contextStorage)',\n\t\t);\n\t}\n\tconst plugin = alxia()\n\t\t.around((request, next) => {\n\t\t\tconst current = storage.getStore();\n\t\t\tif (current?.request.request === request.request) return next();\n\t\t\treturn storage.run({ request, ctx: undefined }, next);\n\t\t})\n\t\t.wrap((ctx, next) => {\n\t\t\tconst holder = storage.getStore();\n\t\t\tif (holder !== undefined) holder.ctx = ctx;\n\t\t\treturn next();\n\t\t});\n\treturn Object.assign(plugin, {\n\t\tcontext: () => getContext(),\n\t\ttryContext: () => tryGetContext(),\n\t}) as unknown as ContextStoragePlugin<App>;\n}\n"
6
+ ],
7
+ "mappings": ";AAAA;AACA;AAAA;AAAA;AAAA;AAgBO,MAAM,4BAA4B,MAAM;AAAA,EAC5B,OAAO;AAAA,EAChB;AAAA,EAET,WAAW,CAAC,MAA+B;AAAA,IAC1C,MACC,SAAS,oBACN,yGACA,sHACJ;AAAA,IACA,KAAK,OAAO;AAAA;AAEd;AAYA,IAAM,UAAU,IAAI;AAWb,SAAS,UAAsC,GAAsB;AAAA,EAC3E,MAAM,SAAS,QAAQ,SAAS;AAAA,EAChC,IAAI,WAAW;AAAA,IAAW,MAAM,IAAI,oBAAoB,iBAAiB;AAAA,EACzE,IAAI,OAAO,QAAQ;AAAA,IAAW,MAAM,IAAI,oBAAoB,YAAY;AAAA,EACxE,OAAO,OAAO;AAAA;AAIR,SAAS,aAAyC,GAE5C;AAAA,EACZ,OAAO,QAAQ,SAAS,GAAG;AAAA;AAOrB,SAAS,iBAAiB,GAAmB;AAAA,EACnD,MAAM,SAAS,QAAQ,SAAS;AAAA,EAChC,IAAI,WAAW;AAAA,IAAW,MAAM,IAAI,oBAAoB,iBAAiB;AAAA,EACzE,OAAO,OAAO;AAAA;AAIR,SAAS,oBAAoB,GAA+B;AAAA,EAClE,OAAO,QAAQ,SAAS,GAAG;AAAA;AAOrB,SAAS,cAAiB,CAAC,KAAkB,MAAkB;AAAA,EACrE,OAAO,QAAQ,IAAI,EAAE,SAAS,KAAK,IAAI,GAAG,IAAI;AAAA;AAmCxC,SAAS,cAA+B,IAC3C,UACyB;AAAA,EAC5B,IAAI,SAAS,SAAS,GAAG;AAAA,IAGxB,MAAM,IAAI,UACT,6EACD;AAAA,EACD;AAAA,EACA,MAAM,SAAS,MAAM,EACnB,OAAO,CAAC,SAAS,SAAS;AAAA,IAC1B,MAAM,UAAU,QAAQ,SAAS;AAAA,IACjC,IAAI,SAAS,QAAQ,YAAY,QAAQ;AAAA,MAAS,OAAO,KAAK;AAAA,IAC9D,OAAO,QAAQ,IAAI,EAAE,SAAS,KAAK,UAAU,GAAG,IAAI;AAAA,GACpD,EACA,KAAK,CAAC,KAAK,SAAS;AAAA,IACpB,MAAM,SAAS,QAAQ,SAAS;AAAA,IAChC,IAAI,WAAW;AAAA,MAAW,OAAO,MAAM;AAAA,IACvC,OAAO,KAAK;AAAA,GACZ;AAAA,EACF,OAAO,OAAO,OAAO,QAAQ;AAAA,IAC5B,SAAS,MAAM,WAAW;AAAA,IAC1B,YAAY,MAAM,cAAc;AAAA,EACjC,CAAC;AAAA;",
8
+ "debugId": "F3B664A2ABF678EE64756E2164756E21",
9
+ "names": []
10
+ }
@@ -0,0 +1,67 @@
1
+ import { type Alxia, type BaseContext, type ContextOf, type Empty, type RequestContext } from '@alxia/core';
2
+ /** Why there is no context to read. */
3
+ export type ContextStorageErrorCode =
4
+ /** Called outside any request: at startup, in a job, after the response. */
5
+ 'OUTSIDE_REQUEST'
6
+ /** In a request, but not in a route declared after `contextStorage()`: a 404, a hook, a route before it. */
7
+ | 'NOT_ROUTED';
8
+ export declare class ContextStorageError extends Error {
9
+ readonly name = "ContextStorageError";
10
+ readonly code: ContextStorageErrorCode;
11
+ constructor(code: ContextStorageErrorCode);
12
+ }
13
+ /**
14
+ * The context of the route the current request reached: what its handler
15
+ * reads — the request, `set`, `reply`, and what every hook before it added.
16
+ * Throws a `ContextStorageError` outside a route declared after
17
+ * `contextStorage()`.
18
+ *
19
+ * `Ctx` types what the hooks added; prefer the typed `context()` of the plugin
20
+ * itself, typed by the app.
21
+ */
22
+ export declare function getContext<Ctx extends object = Empty>(): BaseContext & Ctx;
23
+ /** `getContext()`, or `undefined` where it would throw: code that runs in and out of requests. */
24
+ export declare function tryGetContext<Ctx extends object = Empty>(): (BaseContext & Ctx) | undefined;
25
+ /**
26
+ * The request as every global hook sees it — before routing, in a 404, in
27
+ * an `onResponse` — with the route it reached and the error it failed with.
28
+ */
29
+ export declare function getRequestContext(): RequestContext;
30
+ /** `getRequestContext()`, or `undefined` outside a request. */
31
+ export declare function tryGetRequestContext(): RequestContext | undefined;
32
+ /**
33
+ * Runs `work` with `ctx` as the current context: a job, a queue consumer,
34
+ * a test calling a service that reads `getContext()`.
35
+ */
36
+ export declare function runWithContext<T>(ctx: BaseContext, work: () => T): T;
37
+ /** The plugin, and its context typed by the app it follows. */
38
+ export type ContextStoragePlugin<App> = Alxia<Empty, Empty, '', never> & {
39
+ /** `getContext()`, typed by `App`. */
40
+ context(): ContextOf<App> extends never ? BaseContext : ContextOf<App>;
41
+ /** `tryGetContext()`, typed by `App`. */
42
+ tryContext(): (ContextOf<App> extends never ? BaseContext : ContextOf<App>) | undefined;
43
+ };
44
+ /**
45
+ * The request's context, anywhere it runs, as a plugin: from the routes
46
+ * declared after it, every function their handlers call — however deep,
47
+ * through every `await` and timer — reads it with `getContext()`, without
48
+ * it being passed down.
49
+ *
50
+ * Typed by the app it is used on: give the plugin that app's type, and its
51
+ * `context()` returns what its routes read — the `user` a session derived, the
52
+ * `db` decorated.
53
+ *
54
+ * ```ts
55
+ * const base = alxia().decorate({ db }).use(session(auth, { required: true }));
56
+ * export const requestContext = contextStorage<typeof base>();
57
+ * const app = base.use(requestContext).get('/orders', ({ reply }) => reply(200, listOrders()));
58
+ *
59
+ * // orders.ts — no context passed
60
+ * export const listOrders = () => {
61
+ * const { db, user } = requestContext.context();
62
+ * return db.orders.forUser(user.id);
63
+ * };
64
+ * ```
65
+ */
66
+ export declare function contextStorage<App = undefined>(...uncalled: readonly never[]): ContextStoragePlugin<App>;
67
+ //# sourceMappingURL=storage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AACA,OAAO,EACN,KAAK,KAAK,EAEV,KAAK,WAAW,EAChB,KAAK,SAAS,EACd,KAAK,KAAK,EACV,KAAK,cAAc,EACnB,MAAM,aAAa,CAAC;AAErB,uCAAuC;AACvC,MAAM,MAAM,uBAAuB;AAClC,4EAA4E;AAC1E,iBAAiB;AACnB,4GAA4G;GAC1G,YAAY,CAAC;AAEhB,qBAAa,mBAAoB,SAAQ,KAAK;IAC7C,SAAkB,IAAI,yBAAyB;IAC/C,QAAQ,CAAC,IAAI,EAAE,uBAAuB,CAAC;gBAE3B,IAAI,EAAE,uBAAuB;CAQzC;AAcD;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,GAAG,SAAS,MAAM,GAAG,KAAK,KAAK,WAAW,GAAG,GAAG,CAK1E;AAED,kGAAkG;AAClG,wBAAgB,aAAa,CAAC,GAAG,SAAS,MAAM,GAAG,KAAK,KACrD,CAAC,WAAW,GAAG,GAAG,CAAC,GACnB,SAAS,CAEX;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,cAAc,CAIlD;AAED,+DAA+D;AAC/D,wBAAgB,oBAAoB,IAAI,cAAc,GAAG,SAAS,CAEjE;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,GAAG,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,CAAC,CAEpE;AAED,+DAA+D;AAC/D,MAAM,MAAM,oBAAoB,CAAC,GAAG,IAAI,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,CAAC,GAAG;IACxE,sCAAsC;IACtC,OAAO,IAAI,SAAS,CAAC,GAAG,CAAC,SAAS,KAAK,GAAG,WAAW,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC;IACvE,yCAAyC;IACzC,UAAU,IACP,CAAC,SAAS,CAAC,GAAG,CAAC,SAAS,KAAK,GAAG,WAAW,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC,GAC7D,SAAS,CAAC;CACb,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,cAAc,CAAC,GAAG,GAAG,SAAS,EAC7C,GAAG,QAAQ,EAAE,SAAS,KAAK,EAAE,GAC3B,oBAAoB,CAAC,GAAG,CAAC,CAuB3B"}
package/docs/README.md ADDED
@@ -0,0 +1,12 @@
1
+ # @alxia/context-storage documentation
2
+
3
+ The [package README](../README.md) is the short version. This folder is
4
+ the long one: what the plugin stores and when, how to read it from a
5
+ service, a repository or a logger, how its type follows the app, where it
6
+ sits among an app's hooks, and what a timer or a detached callback sees.
7
+
8
+ | Page | Read it when |
9
+ | --- | --- |
10
+ | [Guide](guide.md) | reading the request's context from code the handler calls, typing it, placing the plugin among other hooks, running a job or a test with a context, or wondering what a timer sees |
11
+ | [Troubleshooting](troubleshooting.md) | `getContext()` threw a `ContextStorageError`, a service read the wrong context or none, `use(contextStorage)` threw a `TypeError`, or `tsc` refused the plugin or its type |
12
+ | [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
package/docs/guide.md ADDED
@@ -0,0 +1,418 @@
1
+ # Guide
2
+
3
+ This page covers what `contextStorage()` stores and when, how code outside
4
+ a handler reads it, how its type follows the app, where the plugin sits
5
+ among an app's hooks, and what `AsyncLocalStorage` does and does not carry.
6
+
7
+ ```ts
8
+ import { alxia } from '@alxia/core';
9
+ import { contextStorage } from '@alxia/context-storage';
10
+
11
+ const base = alxia()
12
+ .decorate({ greeting: 'Hello' })
13
+ .derive(({ request }) => ({ user: request.headers.get('x-user') ?? 'anonymous' }));
14
+
15
+ const requestContext = contextStorage<typeof base>();
16
+
17
+ // three calls down, no context passed
18
+ function greet(): string {
19
+ const { greeting, user } = requestContext.context(); // typed: greeting, user
20
+ return `${greeting}, ${user}`;
21
+ }
22
+
23
+ const app = base.use(requestContext).get('/hello', ({ reply }) => reply(200, greet()));
24
+
25
+ app.listen(3000);
26
+ ```
27
+
28
+ `curl -H 'x-user: Ada' localhost:3000/hello` answers `Hello, Ada`. `greet`
29
+ could live in another module, behind any number of `await`s: it reads the
30
+ context of the request that called it, and never another's.
31
+
32
+ ## The signature
33
+
34
+ ```ts
35
+ // `uncalled` takes nothing: it makes `use(contextStorage)` a compile error
36
+ function contextStorage<App = undefined>(...uncalled: readonly never[]): ContextStoragePlugin<App>;
37
+
38
+ type ContextStoragePlugin<App> = Alxia<Empty, Empty, '', never> & {
39
+ context(): ContextOf<App> extends never ? BaseContext : ContextOf<App>;
40
+ tryContext(): (ContextOf<App> extends never ? BaseContext : ContextOf<App>) | undefined;
41
+ };
42
+
43
+ function getContext<Ctx extends object = Empty>(): BaseContext & Ctx;
44
+ function tryGetContext<Ctx extends object = Empty>(): (BaseContext & Ctx) | undefined;
45
+ function getRequestContext(): RequestContext;
46
+ function tryGetRequestContext(): RequestContext | undefined;
47
+ function runWithContext<T>(ctx: BaseContext, work: () => T): T;
48
+
49
+ class ContextStorageError extends Error {
50
+ readonly name: 'ContextStorageError';
51
+ readonly code: ContextStorageErrorCode;
52
+ }
53
+
54
+ type ContextStorageErrorCode = 'OUTSIDE_REQUEST' | 'NOT_ROUTED';
55
+ ```
56
+
57
+ `contextStorage()` returns an app plugin: pass it to `use`, called — `use(contextStorage)` fails `tsc` with `TS2769` and throws a `TypeError` at startup ([troubleshooting](troubleshooting.md#typeerror-contextstorage-is-a-factory-usecontextstorage-not-usecontextstorage)). It adds
58
+ nothing to the app's type. `BaseContext`, `RequestContext` and `ContextOf`
59
+ come from `@alxia/core`.
60
+
61
+ | Export | Returns | Where it would have nothing |
62
+ | --- | --- | --- |
63
+ | `requestContext.context()` | the route's context, typed by `App` | throws a `ContextStorageError` |
64
+ | `requestContext.tryContext()` | the same | `undefined` |
65
+ | `getContext<Ctx>()` | the route's context, as `BaseContext & Ctx`: `Ctx` is yours to state | throws a `ContextStorageError` |
66
+ | `tryGetContext<Ctx>()` | the same | `undefined` |
67
+ | `getRequestContext()` | the request as global hooks see it: `request`, `url`, `ip`, `server`, and once routing has run, `route` and `error` | throws a `ContextStorageError` coded `OUTSIDE_REQUEST` |
68
+ | `tryGetRequestContext()` | the same | `undefined` |
69
+ | `runWithContext(ctx, work)` | what `work` returns, with `ctx` as the current context while it runs | — |
70
+
71
+ There is one store per copy of the package: every `contextStorage()` writes
72
+ to it, and `getContext()` reads it wherever it is called. Two plugins on one
73
+ app read the same context.
74
+
75
+ ## What it stores, and when
76
+
77
+ The plugin adds two hooks. A global `around` hook opens a store for every
78
+ request the app receives, wherever the plugin is used, holding the
79
+ request's `RequestContext`. A route hook records the route's context in
80
+ that store, for the routes declared after the plugin only. So what each
81
+ function reads depends on where the code runs:
82
+
83
+ | Code running in | `getRequestContext()` | `tryGetRequestContext()` | `getContext()` | `tryGetContext()` |
84
+ | --- | --- | --- | --- | --- |
85
+ | an `onRequest` hook | the request, `route` still `undefined` | the same | throws `NOT_ROUTED` | `undefined` |
86
+ | a route declared **before** the plugin, and its `onResponse` | the request | the same | throws `NOT_ROUTED` | `undefined` |
87
+ | a `derive` or `wrap` declared after the plugin | the request | the same | the route's context, **before validation**: no `params`, `query` or `body` yet | the same |
88
+ | the handler of a route declared after the plugin, and everything it calls | the request | the same | the context the handler receives — the same object | the same |
89
+ | that route's `onError` and `onResponse` hooks | the request, with `route` and `error` | the same | the same context | the same |
90
+ | an `onResponse` for a 404 | the request, `route` `undefined` | the same | throws `NOT_ROUTED` | `undefined` |
91
+ | a socket's handlers (`open`, `message`, `close`) | throws `OUTSIDE_REQUEST` | `undefined` | throws `OUTSIDE_REQUEST` | `undefined` |
92
+ | startup, a job, a timer started at startup | throws `OUTSIDE_REQUEST` | `undefined` | throws `OUTSIDE_REQUEST` | `undefined` |
93
+
94
+ The two errors carry these messages:
95
+
96
+ | `code` | `message` |
97
+ | --- | --- |
98
+ | `OUTSIDE_REQUEST` | `getContext(): called outside a request — use tryGetContext(), or runWithContext() in a job or a test` |
99
+ | `NOT_ROUTED` | `getContext(): this request reached no route declared after contextStorage() — use it earlier, or getRequestContext()` |
100
+
101
+ Thrown inside a route, either one answers a `500 {"error":"internal"}` and
102
+ is logged, like any other error. `code` tells them apart:
103
+
104
+ ```ts
105
+ import { ContextStorageError, getContext } from '@alxia/context-storage';
106
+
107
+ try {
108
+ getContext();
109
+ } catch (error) {
110
+ if (error instanceof ContextStorageError) console.log(error.code); // 'OUTSIDE_REQUEST'
111
+ }
112
+ ```
113
+
114
+ Code that runs both in and out of requests uses `tryContext()`,
115
+ `tryGetContext()` or `tryGetRequestContext()` instead of catching.
116
+
117
+ The context is the whole of what the handler reads: the request, `set` to
118
+ add a header or a cookie to the response, `reply` and `redirect`, and what
119
+ every hook added — so a service three calls down can set a response header,
120
+ as `listOrders` does below.
121
+
122
+ ## Reading it from outside the handler
123
+
124
+ Keep the app's base and the plugin in a module of their own, and import the
125
+ plugin from every service, repository or logger that reads it. The handlers
126
+ then call those without passing anything down.
127
+
128
+ ```ts
129
+ // context.ts
130
+ import { alxia } from '@alxia/core';
131
+ import { contextStorage } from '@alxia/context-storage';
132
+
133
+ export interface Order {
134
+ readonly id: number;
135
+ readonly userId: number;
136
+ readonly total: number;
137
+ }
138
+
139
+ const db = { orders: [{ id: 1, userId: 1, total: 42 }] as Order[] };
140
+ const users = new Map([['Bearer ada', { id: 1, name: 'Ada' }]]);
141
+
142
+ export const base = alxia()
143
+ .decorate({ db })
144
+ .derive(({ request, reply }) => {
145
+ const user = users.get(request.headers.get('authorization') ?? '');
146
+ return user ? { user } : reply(401, { error: 'unauthenticated' as const });
147
+ });
148
+
149
+ export const requestContext = contextStorage<typeof base>();
150
+ ```
151
+
152
+ ```ts
153
+ // orders.ts
154
+ import { requestContext } from './context';
155
+
156
+ export async function listOrders() {
157
+ const { db, user, set } = requestContext.context();
158
+ set.headers.set('cache-control', 'private');
159
+ return db.orders.filter((order) => order.userId === user.id);
160
+ }
161
+ ```
162
+
163
+ ```ts
164
+ // app.ts
165
+ import { base, requestContext } from './context';
166
+ import { listOrders } from './orders';
167
+
168
+ export const app = base
169
+ .use(requestContext)
170
+ .get('/orders', async ({ reply }) => reply(200, await listOrders()));
171
+ ```
172
+
173
+ A logger is the code that runs in and out of requests: at startup, in a 404,
174
+ in a route. `tryGetRequestContext()` gives it the request wherever there is
175
+ one, and `tryContext()` the user where a route was reached:
176
+
177
+ ```ts
178
+ // log.ts
179
+ import { tryGetRequestContext } from '@alxia/context-storage';
180
+ import { requestContext } from './context';
181
+
182
+ export function log(message: string): void {
183
+ const request = tryGetRequestContext(); // undefined outside a request
184
+ console.log(
185
+ JSON.stringify({
186
+ message,
187
+ method: request?.request.method,
188
+ route: request?.route,
189
+ user: requestContext.tryContext()?.user.id,
190
+ }),
191
+ );
192
+ }
193
+ ```
194
+
195
+ `log('listing orders')` from `listOrders` prints the method, `/orders` and
196
+ the user's id; from startup, the message alone.
197
+
198
+ ## Typing it
199
+
200
+ `contextStorage<App>()` takes the type of an app, and `context()` and `tryContext()`
201
+ return what a route declared next on that app would read: `ContextOf<App>`.
202
+
203
+ | `App` | `context()` returns |
204
+ | --- | --- |
205
+ | none: `contextStorage()` | `BaseContext`: the request, `set`, `reply`, `redirect`, `route`, `pathParams` |
206
+ | `typeof base` | `ContextOf<typeof base>`: `BaseContext` plus everything `base`'s `decorate`, `derive` and plugins added |
207
+
208
+ ```ts
209
+ import { alxia } from '@alxia/core';
210
+ import { contextStorage } from '@alxia/context-storage';
211
+
212
+ const base = alxia()
213
+ .decorate({ greeting: 'Hello' })
214
+ .derive(({ request }) => ({ user: request.headers.get('x-user') ?? 'anonymous' }));
215
+
216
+ const typed = contextStorage<typeof base>();
217
+ const untyped = contextStorage();
218
+
219
+ export function whoIsAsking(): string {
220
+ untyped.context().route; // string: BaseContext only
221
+ // untyped.context().user — error TS2339: Property 'user' does not exist on type 'BaseContext'.
222
+ return typed.context().user; // string
223
+ }
224
+ ```
225
+
226
+ Three rules follow from typing by an app:
227
+
228
+ - **Type it by the app before the plugin, never by the app that uses it.**
229
+ `const app = alxia().use(requestContext)…` with
230
+ `requestContext = contextStorage<typeof app>()` is a circular type, which
231
+ `tsc` refuses with `TS7022`. Declare `base` first, as above.
232
+ - **What a hook after the plugin adds is there at runtime, not in the
233
+ type.** `context()` knows `base`, so a `derive` added after
234
+ `base.use(requestContext)` is missing from its type. Put the hooks whose
235
+ values services read in `base`, or state the type with `getContext<Ctx>()`.
236
+ - **A route's own `params`, `query`, `body` and `headers` are not in it**:
237
+ they belong to one route's schema, not to the app. Read them in the
238
+ handler and pass them down, or state them:
239
+
240
+ ```ts
241
+ import { getContext } from '@alxia/context-storage';
242
+
243
+ // in code that only ever runs under GET /orders/:id, with a params schema
244
+ const { params } = getContext<{ params: { id: number } }>();
245
+ ```
246
+
247
+ `getContext<Ctx>()` and `tryGetContext<Ctx>()` believe what `Ctx` says,
248
+ as `hono/context-storage`'s do: nothing checks it against the route.
249
+
250
+ ## Where it sits
251
+
252
+ The plugin's `around` hook is global: it applies to every request, wherever
253
+ `use` is called. Its route hook applies to the routes declared after it, in
254
+ the same app or group. So **use it before the routes whose code reads the
255
+ context**, and after the hooks whose values services read:
256
+
257
+ ```ts
258
+ import { alxia } from '@alxia/core';
259
+ import { contextStorage, getContext } from '@alxia/context-storage';
260
+
261
+ const requestContext = contextStorage();
262
+
263
+ const app = alxia()
264
+ .get('/health', ({ reply }) => reply(200, 'ok')) // getContext() throws NOT_ROUTED here
265
+ .use(requestContext)
266
+ .derive(() => ({ startedAt: Date.now() })) // after the plugin: may call code that reads it
267
+ .get('/me', ({ reply }) => reply(200, getContext().route)); // '/me'
268
+ ```
269
+
270
+ A hook declared before the plugin that ends the request — a `derive`
271
+ answering `401` — ends it before the plugin's route hook runs: in that
272
+ request's `onResponse`, `getContext()` throws `NOT_ROUTED`, and
273
+ `getRequestContext()` still answers.
274
+
275
+ | Placement | Effect |
276
+ | --- | --- |
277
+ | at the top of the chain | every route can read it; `context()` is typed `BaseContext` unless typed by an app declared before it |
278
+ | after `decorate` and `derive` | the usual place: typed by them, and every route after reads it |
279
+ | inside a `group` | the group's routes read it; a route outside the group throws `NOT_ROUTED` |
280
+ | twice | harmless: one store, one context per request |
281
+
282
+ The plugin is an app like any other: `requestContext.get('/x', handler)` is the
283
+ route method, and the context is read with `context()` and `tryContext()`.
284
+ Declare routes on the app rather than on the plugin, and pass
285
+ `contextStorage()` to `use` called — the uncalled form is
286
+ [refused](troubleshooting.md#typeerror-contextstorage-is-a-factory-usecontextstorage-not-usecontextstorage).
287
+
288
+ ## What `AsyncLocalStorage` carries
289
+
290
+ The store follows the request's asynchronous work: every `await`, promise,
291
+ `setTimeout` and microtask started while the request runs reads the
292
+ context of that request, and concurrent requests never see each other's.
293
+
294
+ ```ts
295
+ async function greet(): Promise<string> {
296
+ await Bun.sleep(10);
297
+ await new Promise((resolve) => setTimeout(resolve, 1));
298
+ const { greeting, user } = requestContext.context(); // still this request's
299
+ return `${greeting} ${user}`;
300
+ }
301
+ ```
302
+
303
+ A server-sent event stream's generator runs while the response streams, and
304
+ reads the context too.
305
+
306
+ What it carries is decided where a callback is **scheduled**, not where it
307
+ runs:
308
+
309
+ | The callback | Sees |
310
+ | --- | --- |
311
+ | a `setTimeout` or promise started in the request, firing after the response is sent | that request's context, stale: `context()` does not throw, but `set` no longer reaches any response |
312
+ | a `setInterval`, queue consumer or pool started at startup | nothing: `context()` throws `OUTSIDE_REQUEST`, `tryContext()` is `undefined` |
313
+ | a function pushed into a queue in the request, and run by something started at startup | nothing, as above |
314
+ | an `EventEmitter` listener | the context of the code that called `emit`, since listeners run synchronously |
315
+ | a WebSocket's `open`, `message` and `close` | nothing: a socket's upgrade runs outside `around` |
316
+
317
+ So work that outlives the request — a write-behind, an e-mail, an audit
318
+ event — takes what it needs **before** it is detached, instead of reading
319
+ the context when it runs:
320
+
321
+ ```ts
322
+ import { requestContext } from './context';
323
+
324
+ const pending: (() => Promise<void>)[] = [];
325
+
326
+ export function auditLater(action: string): void {
327
+ const { user, route } = requestContext.context(); // read now, in the request
328
+ pending.push(async () => {
329
+ console.log(JSON.stringify({ action, user: user.id, route }));
330
+ });
331
+ }
332
+
333
+ setInterval(() => {
334
+ for (const job of pending.splice(0)) void job(); // runs outside any request
335
+ }, 1000);
336
+ ```
337
+
338
+ For a socket, read what a message needs from `socket.data`, which holds the
339
+ upgrade's context.
340
+
341
+ ## Jobs and tests: `runWithContext`
342
+
343
+ `runWithContext(ctx, work)` runs `work` with `ctx` as the current context,
344
+ for code that reads it outside any request: a queue consumer, a scheduled
345
+ job, a unit test of a service. `getContext()` and `getRequestContext()` both
346
+ return `ctx` while it runs, and `work`'s return value — a promise included
347
+ — comes back as it is.
348
+
349
+ `ctx` is a `BaseContext`. A job has no request to build one from, so give it
350
+ what the code reads, and say that is all it is:
351
+
352
+ ```ts
353
+ import type { ContextOf } from '@alxia/core';
354
+ import { runWithContext } from '@alxia/context-storage';
355
+ import { type base } from './context';
356
+ import { listOrders } from './orders';
357
+
358
+ type Ctx = ContextOf<typeof base>;
359
+
360
+ const job = {
361
+ db: { orders: [{ id: 7, userId: 0, total: 1 }] },
362
+ user: { id: 0, name: 'nightly' },
363
+ set: { headers: new Headers(), cookies: new Bun.CookieMap() },
364
+ } satisfies Partial<Ctx>;
365
+
366
+ const orders = await runWithContext(job as unknown as Ctx, () => listOrders());
367
+ ```
368
+
369
+ `satisfies` checks the fields you give against the app's context; the cast
370
+ admits the ones you left out, which the code must not read.
371
+
372
+ A test covers both sides — the service through a real request, and alone:
373
+
374
+ ```ts
375
+ import { describe, expect, test } from 'bun:test';
376
+ import type { ContextOf } from '@alxia/core';
377
+ import { runWithContext } from '@alxia/context-storage';
378
+ import { app } from './app';
379
+ import { type base } from './context';
380
+ import { listOrders } from './orders';
381
+
382
+ describe('listOrders', () => {
383
+ test('reads the user of the request it runs in', async () => {
384
+ const response = await app.request('/orders', {
385
+ headers: { authorization: 'Bearer ada' },
386
+ });
387
+ expect(response.status).toBe(200);
388
+ expect(response.headers.get('cache-control')).toBe('private');
389
+ expect(await response.json()).toEqual([{ id: 1, userId: 1, total: 42 }]);
390
+ });
391
+
392
+ test('concurrent requests never see each other', async () => {
393
+ const answers = await Promise.all(
394
+ ['Bearer ada', 'Bearer nobody', 'Bearer ada'].map(
395
+ async (authorization) =>
396
+ (await app.request('/orders', { headers: { authorization } })).status,
397
+ ),
398
+ );
399
+ expect(answers).toEqual([200, 401, 200]);
400
+ });
401
+
402
+ test('runs alone, with a context of its own', async () => {
403
+ const ctx = {
404
+ db: { orders: [{ id: 2, userId: 9, total: 5 }] },
405
+ user: { id: 9, name: 'test' },
406
+ set: { headers: new Headers(), cookies: new Bun.CookieMap() },
407
+ } satisfies Partial<ContextOf<typeof base>>;
408
+
409
+ const orders = await runWithContext(ctx as unknown as ContextOf<typeof base>, listOrders);
410
+
411
+ expect(orders).toEqual([{ id: 2, userId: 9, total: 5 }]);
412
+ expect(ctx.set.headers.get('cache-control')).toBe('private');
413
+ });
414
+ });
415
+ ```
416
+
417
+ When `getContext()` throws, or a service reads the wrong context,
418
+ [Troubleshooting](troubleshooting.md) starts from the message.
@@ -0,0 +1,50 @@
1
+ # Roadmap
2
+
3
+ What `@alxia/context-storage` gives an app, and what is coming. This page
4
+ is a direction, not a commitment: the version something shipped in is the
5
+ only number on it. Every release, with each change it made, is in
6
+ [`CHANGELOG.md`](https://github.com/softistx/alxia/blob/develop/packages/context-storage/CHANGELOG.md).
7
+
8
+ ## Now
9
+
10
+ Nothing scheduled yet.
11
+
12
+ ## Next
13
+
14
+ Nothing scheduled yet.
15
+
16
+ ## Later
17
+
18
+ Nothing scheduled yet.
19
+
20
+ ## Not planned
21
+
22
+ - **A runtime dependency.** `@alxia/context-storage` declares no
23
+ dependency, only peers: `@alxia/core` and `typescript`. It is built on
24
+ `@alxia/core`'s public API and the runtime's own `AsyncLocalStorage`.
25
+
26
+ ## Shipped
27
+
28
+ ### 0.1.0
29
+
30
+ - **The request's context anywhere it runs.** `alxia().use(contextStorage())`
31
+ lets a service, a repository or a logger read the context of the route
32
+ that called it with `getContext()`, without it being passed down, through
33
+ every `await`, timer and promise, and never another request's.
34
+ - **Typed by the app.** `contextStorage<typeof base>()` gives `context()` and
35
+ `tryContext()` what a route declared next on `base` reads: the values its
36
+ `decorate` and `derive` hooks added.
37
+ - **The request in global hooks.** `getRequestContext()` reads the request
38
+ before routing, in a 404 and in an `onResponse`, with the route it
39
+ reached and the error it failed with; `tryGetRequestContext()` returns
40
+ `undefined` outside a request instead of throwing.
41
+ - **Jobs and tests.** `runWithContext(ctx, work)` runs code that reads the
42
+ context outside a request.
43
+ - **A refusal that says why.** Where there is no context, `getContext()`
44
+ throws a `ContextStorageError` coded `OUTSIDE_REQUEST` or `NOT_ROUTED`,
45
+ and `tryGetContext()` returns `undefined`.
46
+ `use(contextStorage)`, the factory uncalled, fails `tsc` and throws a
47
+ `TypeError` at startup, rather than leaving the routes after it unserved.
48
+ - **Ported from `hono/context-storage`.** One store, and `getContext()`
49
+ read wherever it is called, as Hono's is: code written against one ports
50
+ to the other.
@@ -0,0 +1,276 @@
1
+ # Troubleshooting
2
+
3
+ Each entry is headed by the text you see: an error in the server log, a
4
+ behaviour that prints nothing, or an error from `tsc`. A
5
+ `ContextStorageError` thrown inside a route also answers the request with
6
+ `500 {"error":"internal"}`; the message is in the server log.
7
+
8
+ **Runtime**
9
+
10
+ - [`TypeError: contextStorage is a factory: use(contextStorage()), not use(contextStorage)`](#typeerror-contextstorage-is-a-factory-usecontextstorage-not-usecontextstorage)
11
+ - [`ContextStorageError: getContext(): called outside a request — use tryGetContext(), or runWithContext() in a job or a test`](#contextstorageerror-getcontext-called-outside-a-request--use-trygetcontext-or-runwithcontext-in-a-job-or-a-test)
12
+ - [`ContextStorageError: getContext(): this request reached no route declared after contextStorage() — use it earlier, or getRequestContext()`](#contextstorageerror-getcontext-this-request-reached-no-route-declared-after-contextstorage--use-it-earlier-or-getrequestcontext)
13
+ - [A header set from a timer never reaches the response](#a-header-set-from-a-timer-never-reaches-the-response)
14
+
15
+ **Types**
16
+
17
+ - [`'requestContext' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.`](#requestcontext-implicitly-has-type-any-because-it-does-not-have-a-type-annotation-and-is-referenced-directly-or-indirectly-in-its-own-initializer)
18
+ - [`Property 'user' does not exist on type 'BaseContext'.`](#property-user-does-not-exist-on-type-basecontext)
19
+ - [`Property 'params' does not exist on type 'BaseContext & …'.`](#property-params-does-not-exist-on-type-basecontext--)
20
+ - [`Object literal may only specify known properties, and 'db' does not exist in type 'BaseContext'.`](#object-literal-may-only-specify-known-properties-and-db-does-not-exist-in-type-basecontext)
21
+
22
+ ## Runtime
23
+
24
+ ### `TypeError: contextStorage is a factory: use(contextStorage()), not use(contextStorage)`
25
+
26
+ `tsc` reports the same mistake first:
27
+
28
+ ```text
29
+ error TS2769: No overload matches this call.
30
+ …
31
+ Argument of type '<App = undefined>(...uncalled: readonly never[]) => ContextStoragePlugin<App>' is not assignable to parameter of type '(app: Alxia<Empty, Empty, "", never>) => ContextStoragePlugin<undefined>'.
32
+ ```
33
+
34
+ **When:** at startup, on `.use(contextStorage)`: the factory given to `use`
35
+ without being called.
36
+
37
+ **Why:** `use` takes a function as a plugin and calls it with the app.
38
+ Called that way, `contextStorage` would return a new, empty plugin app,
39
+ and every route declared after it would land on an app nobody serves; it
40
+ refuses the argument instead.
41
+
42
+ **Fix:** call it, once, and keep the result:
43
+
44
+ ```ts
45
+ export const requestContext = contextStorage<typeof base>();
46
+
47
+ const app = base.use(requestContext);
48
+ ```
49
+
50
+ ### `ContextStorageError: getContext(): called outside a request — use tryGetContext(), or runWithContext() in a job or a test`
51
+
52
+ `error.code` is `'OUTSIDE_REQUEST'`. `getContext()`, `requestContext.context()`
53
+ and `getRequestContext()` all throw it.
54
+
55
+ **When:** the code that reads the context runs where no request is:
56
+
57
+ - at the top level of a module, or in code run at startup;
58
+ - in a job, a queue consumer, or a callback run by a `setInterval` started
59
+ at startup — including a function pushed onto a queue during a request
60
+ and run later by such a timer;
61
+ - in a WebSocket's `open`, `message` or `close`, since a socket's upgrade
62
+ runs outside the plugin's hook;
63
+ - with two copies of `@alxia/context-storage` installed: each has its own
64
+ store, so a plugin from one is invisible to `getContext()` from the other.
65
+
66
+ **Why:** the context lives in an `AsyncLocalStorage` that the plugin opens
67
+ for each request. A callback reads the store that was current where it was
68
+ **scheduled**; anything started outside a request has none.
69
+
70
+ **Fix:** in code that runs in and out of requests, use `tryContext()` —
71
+ or `tryGetRequestContext()` for the request alone:
72
+
73
+ ```ts
74
+ const user = requestContext.tryContext()?.user; // undefined outside a request
75
+ const method = tryGetRequestContext()?.request.method; // the same
76
+ ```
77
+
78
+ In a job, a consumer or a test, give the code a context to read:
79
+
80
+ ```ts
81
+ import type { ContextOf } from '@alxia/core';
82
+ import { runWithContext } from '@alxia/context-storage';
83
+
84
+ type Ctx = ContextOf<typeof base>;
85
+
86
+ const job = {
87
+ db,
88
+ user: { id: 0, name: 'nightly' },
89
+ set: { headers: new Headers(), cookies: new Bun.CookieMap() },
90
+ } satisfies Partial<Ctx>;
91
+
92
+ await runWithContext(job as unknown as Ctx, () => listOrders());
93
+ ```
94
+
95
+ For work that outlives the request, read the context in the request and
96
+ hand the values over:
97
+
98
+ ```ts
99
+ export function auditLater(action: string): void {
100
+ const { user } = requestContext.context(); // in the request
101
+ pending.push(() => audit(action, user.id)); // runs later, needs nothing
102
+ }
103
+ ```
104
+
105
+ In a socket handler, read what the upgrade's hooks added from
106
+ `socket.data`. For two copies, `bun pm ls --all | grep context-storage`
107
+ shows them; align the versions the app and its dependencies ask for.
108
+
109
+ ### `ContextStorageError: getContext(): this request reached no route declared after contextStorage() — use it earlier, or getRequestContext()`
110
+
111
+ `error.code` is `'NOT_ROUTED'`.
112
+
113
+ **When:** in a request, but not inside a route declared after the plugin:
114
+
115
+ - in a route declared **before** `use(requestContext)`, or outside the
116
+ `group` it is used in;
117
+ - in an `onRequest` hook, which runs before routing;
118
+ - in an `onResponse` hook, for a `404`, or for a request a hook declared
119
+ before the plugin refused — a `derive` answering `401`;
120
+ - in code those call.
121
+
122
+ **Why:** the plugin opens the store for every request, but records the
123
+ route's context only for the routes declared after it: until then there is
124
+ a request and no route.
125
+
126
+ **Fix:** use the plugin before the routes whose code reads it:
127
+
128
+ ```ts
129
+ const app = base
130
+ .use(requestContext) // before every route that reads it
131
+ .get('/orders', async ({ reply }) => reply(200, await listOrders()));
132
+ ```
133
+
134
+ In a global hook, or in code that also runs for a 404, read the request
135
+ instead, which holds wherever there is one:
136
+
137
+ ```ts
138
+ import { getRequestContext } from '@alxia/context-storage';
139
+
140
+ app.onResponse((response) => {
141
+ const { request, route } = getRequestContext(); // route is undefined for a 404
142
+ console.log(request.method, route ?? 'unmatched', response.status);
143
+ });
144
+ ```
145
+
146
+ ### A header set from a timer never reaches the response
147
+
148
+ **When:** a `setTimeout`, a promise that is not awaited, or a callback
149
+ started in a request calls `requestContext.context().set.headers.set(…)` — or
150
+ sets a cookie — after the handler has returned.
151
+
152
+ **Why:** the callback still reads the request's context — `context()` does not
153
+ throw — but the response was already sent. The context is stale, not gone.
154
+
155
+ **Fix:** await the work that shapes the response before replying, and
156
+ detach only what does not:
157
+
158
+ ```ts
159
+ const app = base.use(requestContext).get('/orders', async ({ reply }) => {
160
+ const orders = await listOrders(); // sets cache-control while the response is open
161
+ void sendReceipt(); // detached: must not touch `set`
162
+ return reply(200, orders);
163
+ });
164
+ ```
165
+
166
+
167
+ ## Types
168
+
169
+ ### `'requestContext' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.`
170
+
171
+ ```text
172
+ error TS7022: 'app' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
173
+ error TS7022: 'requestContext' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
174
+ ```
175
+
176
+ Often with `TS2448: Block-scoped variable 'requestContext' used before its declaration.`
177
+
178
+ **When:** the plugin is typed by the app that uses it:
179
+
180
+ ```ts
181
+ export const app = alxia().decorate({ db }).use(requestContext).get(/* … */);
182
+ export const requestContext = contextStorage<typeof app>(); // circular
183
+ ```
184
+
185
+ **Why:** `app`'s type depends on the plugin, and the plugin's on `app`.
186
+
187
+ **Fix:** type it by the app as it stands **before** the plugin:
188
+
189
+ ```ts
190
+ export const base = alxia().decorate({ db });
191
+ export const requestContext = contextStorage<typeof base>();
192
+ export const app = base.use(requestContext).get('/orders', ({ reply }) => reply(200, 'ok'));
193
+ ```
194
+
195
+ ### `Property 'user' does not exist on type 'BaseContext'.`
196
+
197
+ ```text
198
+ error TS2339: Property 'user' does not exist on type 'BaseContext'.
199
+ ```
200
+
201
+ Also as `Property 'user' does not exist on type 'BaseContext & Empty & { readonly db: … }'.`
202
+
203
+ **When:** reading from `context()` a value a hook adds, and either
204
+
205
+ - the plugin was made without an app type, `contextStorage()`; or
206
+ - it is typed by `base`, and the hook adding `user` comes after it:
207
+ `base.use(requestContext).derive(() => ({ user }))`.
208
+
209
+ **Why:** `context()` returns `ContextOf<App>`: what a route declared next on
210
+ `App` reads. With no `App`, that is `BaseContext`; a hook after `App` is
211
+ not in it. At runtime the value is there.
212
+
213
+ **Fix:** declare every hook whose values services read in `base`, then type
214
+ the plugin by it:
215
+
216
+ ```ts
217
+ const base = alxia()
218
+ .decorate({ db })
219
+ .derive(({ request }) => ({ user: request.headers.get('x-user') ?? 'anonymous' }));
220
+
221
+ export const requestContext = contextStorage<typeof base>();
222
+ // requestContext.context().user: string
223
+ ```
224
+
225
+ ### `Property 'params' does not exist on type 'BaseContext & …'.`
226
+
227
+ ```text
228
+ error TS2339: Property 'params' does not exist on type 'BaseContext & Empty & { readonly db: … }'.
229
+ ```
230
+
231
+ The same for `query`, `body` and `headers`.
232
+
233
+ **When:** reading a route's validated input from `requestContext.context()`.
234
+
235
+ **Why:** those belong to one route's schema, not to the app, so the app's
236
+ context does not have them. Hooks run before validation: a `derive` reads
237
+ them as `undefined` even at runtime.
238
+
239
+ **Fix:** pass them from the handler, or, in code that only runs under one
240
+ route, state them:
241
+
242
+ ```ts
243
+ import { getContext } from '@alxia/context-storage';
244
+
245
+ const { params } = getContext<{ params: { id: number } }>(); // nothing checks this
246
+ ```
247
+
248
+ ### `Object literal may only specify known properties, and 'db' does not exist in type 'BaseContext'.`
249
+
250
+ ```text
251
+ error TS2353: Object literal may only specify known properties, and 'db' does not exist in type 'BaseContext'.
252
+ ```
253
+
254
+ **When:** `runWithContext({ db, user }, work)`, with a context written by
255
+ hand for a job or a test.
256
+
257
+ **Why:** `runWithContext` takes a `BaseContext`: a whole request context.
258
+ A job has no request, so a literal has neither the fields it requires nor
259
+ room for the ones the app adds.
260
+
261
+ **Fix:** check the fields against the app's context with `satisfies`, and
262
+ cast — `work` must then read only what you gave:
263
+
264
+ ```ts
265
+ import type { ContextOf } from '@alxia/core';
266
+
267
+ type Ctx = ContextOf<typeof base>;
268
+
269
+ const ctx = {
270
+ db,
271
+ user: { id: 0, name: 'job' },
272
+ set: { headers: new Headers(), cookies: new Bun.CookieMap() }, // listOrders sets a header
273
+ } satisfies Partial<Ctx>;
274
+
275
+ runWithContext(ctx as unknown as Ctx, () => listOrders());
276
+ ```
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@alxia/context-storage",
3
+ "version": "0.1.0",
4
+ "description": "The request's context anywhere it runs — a service, a repository, a logger — through AsyncLocalStorage: alxia's hono/context-storage, typed by the app",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "docs",
12
+ "README.md",
13
+ "package.json",
14
+ "LICENSE"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/softistx/alxia.git",
27
+ "directory": "packages/context-storage"
28
+ },
29
+ "publishConfig": {
30
+ "registry": "https://registry.npmjs.org",
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "bun run ../../build.ts",
35
+ "test": "bun test src",
36
+ "typecheck": "tsc --noEmit"
37
+ },
38
+ "alxia": {
39
+ "entrypoints": [
40
+ "src/index.ts"
41
+ ]
42
+ },
43
+ "devDependencies": {
44
+ "@alxia/core": "^0.1.0",
45
+ "@types/bun": "^1.4.2",
46
+ "zod": "^4.6.5"
47
+ },
48
+ "peerDependencies": {
49
+ "@alxia/core": "^0.1.0",
50
+ "typescript": "^6.0.3 || ^7.0.0"
51
+ }
52
+ }