@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 +21 -0
- package/README.md +75 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +70 -0
- package/dist/index.js.map +10 -0
- package/dist/storage.d.ts +67 -0
- package/dist/storage.d.ts.map +1 -0
- package/docs/README.md +12 -0
- package/docs/guide.md +418 -0
- package/docs/roadmap.md +50 -0
- package/docs/troubleshooting.md +276 -0
- package/package.json +52 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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.
|
package/docs/roadmap.md
ADDED
|
@@ -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
|
+
}
|