Skip to content

Commit 03034db

Browse files
svozzasdangol
andauthored
feat(event-handler): add type-safe Store API for request and shared state (#5081)
Co-authored-by: Swopnil Dangol <swopnildangol@gmail.com>
1 parent a72f66c commit 03034db

24 files changed

Lines changed: 1349 additions & 88 deletions

docs/features/event-handler/http.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,94 @@ When necessary, you can set a prefix when including a `Router` instance. This me
922922
--8<-- "examples/snippets/event-handler/http/split_route_prefix_index.ts"
923923
```
924924

925+
### Store
926+
927+
The Store API provides a way to share data between middleware and route handlers without relying on closures or module-level variables. There are two types of store: **request** and **shared**.
928+
929+
#### Request store
930+
931+
The request store is scoped to a single invocation. Each time your Lambda function handles a request, a fresh store is created. This is ideal for passing values like authenticated user IDs or parsed tokens from middleware to route handlers.
932+
933+
You can interact with the request store directly on the request context using `set`, `get`, `has`, and `delete`.
934+
935+
=== "index.ts"
936+
937+
```ts hl_lines="12 18"
938+
--8<-- "examples/snippets/event-handler/http/advanced_store_request.ts:8"
939+
```
940+
941+
1. Middleware stores the authenticated user ID in the request store.
942+
2. Route handler retrieves the user ID set by middleware.
943+
944+
!!! note "Request store lifecycle"
945+
The request store is created at the beginning of each invocation and is not shared across concurrent or subsequent requests. You don't need to clean up after yourself — the store is discarded when the invocation completes.
946+
947+
#### Shared store
948+
949+
The shared store lives on the `Router` instance and persists across invocations for the lifetime of the execution environment. This makes it suitable for values that are expensive to compute and don't change between requests, such as database clients, configuration, or feature flags.
950+
951+
You can access the shared store via `app.shared` at the module level (e.g., during cold start), or via `reqCtx.shared` inside middleware and route handlers.
952+
953+
=== "index.ts"
954+
955+
```ts hl_lines="7-8 11-12"
956+
--8<-- "examples/snippets/event-handler/http/advanced_store_shared.ts"
957+
```
958+
959+
1. Set shared values at the module level during cold start.
960+
2. Access shared values inside route handlers via `reqCtx.shared`.
961+
962+
#### Typed stores
963+
964+
By default, both stores accept any string key and return `unknown` values. When you know the shape of your data ahead of time, you can define an `Env` type to get full type safety on store keys and values.
965+
966+
=== "index.ts"
967+
968+
```ts hl_lines="4-9 14 20-21 27-28 31"
969+
--8<-- "examples/snippets/event-handler/http/advanced_store_typed.ts:8"
970+
```
971+
972+
1. Define the shape of the request store — keys and their value types.
973+
2. Define the shape of the shared store.
974+
3. `app.shared.set` only accepts keys defined in `AppEnv['store']['shared']` with matching value types.
975+
4. `reqCtx.set` only accepts keys defined in `AppEnv['store']['request']` with matching value types.
976+
5. `reqCtx.get('userId')` returns `string | undefined` instead of `unknown`.
977+
6. `reqCtx.shared.get('db')` returns the typed database client or `undefined`.
978+
7. Accessing a key not defined in `AppEnv` is a type error — the compiler only allows `'userId'` and `'isAdmin'`.
979+
980+
!!! tip "Store values are always `T | undefined`"
981+
Even with typed stores, `get` returns `T[K] | undefined`. This ensures you handle the case where a value hasn't been set yet, for example if a middleware hasn't run or a shared value wasn't initialized during cold start.
982+
983+
#### Typed stores with split routers
984+
985+
When using [split routers](#split-routers), each sub-router can declare its own `Env` type with only the store keys it needs. You can then chain `includeRouter` calls to merge the store types together, so the parent router sees all keys from all sub-routers.
986+
987+
=== "index.ts"
988+
989+
```ts hl_lines="5-9 12-16 32 35-36 38-39"
990+
--8<-- "examples/snippets/event-handler/http/advanced_store_include_router.ts"
991+
```
992+
993+
1. Chaining `includeRouter` calls merges the store types from each sub-router.
994+
2. Inferred as `string | undefined` — from `AuthEnv`.
995+
3. Inferred as `number | undefined` — from `FeatureEnv`.
996+
4. Assigning `maxResults` to a `string` variable produces a type error — the compiler knows it's a `number`.
997+
998+
Alternatively, you can declare the full environment type upfront on the parent router using the `MergeEnv` utility type.
999+
`MergeEnv` takes a tuple of `Env` types and intersects their request and shared stores into a single `Env`,
1000+
so you don't have to manually write out the combined type.
1001+
This is useful when you prefer to define the parent's type in one place rather than relying on chaining to infer it.
1002+
1003+
=== "index.ts"
1004+
1005+
```ts hl_lines="18 32-34"
1006+
--8<-- "examples/snippets/event-handler/http/advanced_store_declare_upfront.ts"
1007+
```
1008+
1009+
1. `MergeEnv` computes the intersection of the sub-router store types into a single `Env`.
1010+
2. The parent router is created with the merged `AppEnv` type — all store keys are known upfront.
1011+
3. `includeRouter` accepts any sub-router whose `Env` is a subset of the parent's.
1012+
9251013
### Considerations
9261014

9271015
This utility is optimized for AWS Lambda computing model and prioritizes fast startup, minimal feature set, and quick onboarding for triggers supported by Lambda.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Router } from '@aws-lambda-powertools/event-handler/http';
2+
import type { MergeEnv } from '@aws-lambda-powertools/event-handler/types';
3+
4+
type AuthEnv = {
5+
store: {
6+
request: { userId: string };
7+
shared: { db: { query: (sql: string) => Promise<unknown> } };
8+
};
9+
};
10+
11+
type FeatureEnv = {
12+
store: {
13+
request: { maxResults: number };
14+
shared: { cache: Map<string, unknown> };
15+
};
16+
};
17+
18+
type AppEnv = MergeEnv<[AuthEnv, FeatureEnv]>; // (1)!
19+
20+
const authRouter = new Router<AuthEnv>();
21+
authRouter.use(async ({ reqCtx, next }) => {
22+
reqCtx.set('userId', 'user-123');
23+
await next();
24+
});
25+
26+
const featureRouter = new Router<FeatureEnv>();
27+
featureRouter.use(async ({ reqCtx, next }) => {
28+
reqCtx.set('maxResults', 50);
29+
await next();
30+
});
31+
32+
const app = new Router<AppEnv>(); // (2)!
33+
app.includeRouter(authRouter); // (3)!
34+
app.includeRouter(featureRouter);
35+
36+
app.get('/dashboard', (reqCtx) => {
37+
const userId = reqCtx.get('userId'); // string | undefined
38+
const maxResults = reqCtx.get('maxResults'); // number | undefined
39+
40+
if (!userId || !maxResults) return { ready: false };
41+
return { userId, maxResults };
42+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Router } from '@aws-lambda-powertools/event-handler/http';
2+
3+
// Each sub-router declares only the store keys it needs
4+
5+
type AuthEnv = {
6+
store: {
7+
request: { userId: string };
8+
shared: { db: { query: (sql: string) => Promise<unknown> } };
9+
};
10+
};
11+
12+
type FeatureEnv = {
13+
store: {
14+
request: { maxResults: number };
15+
shared: { cache: Map<string, unknown> };
16+
};
17+
};
18+
19+
const authRouter = new Router<AuthEnv>();
20+
authRouter.use(async ({ reqCtx, next }) => {
21+
reqCtx.set('userId', 'user-123');
22+
await next();
23+
});
24+
25+
const featureRouter = new Router<FeatureEnv>();
26+
featureRouter.use(async ({ reqCtx, next }) => {
27+
reqCtx.set('maxResults', 50);
28+
await next();
29+
});
30+
31+
// Chain includeRouter to merge store types // (1)!
32+
const app = new Router().includeRouter(authRouter).includeRouter(featureRouter);
33+
34+
app.get('/dashboard', (reqCtx) => {
35+
const userId = reqCtx.get('userId'); // (2)!
36+
const maxResults = reqCtx.get('maxResults'); // (3)!
37+
38+
// @ts-expect-error - maxResults is number | undefined, not string
39+
const _wrong: string = reqCtx.get('maxResults'); // (4)!
40+
41+
if (!userId || !maxResults) return { ready: false };
42+
return { userId, maxResults };
43+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
declare const getUserProfile: (
2+
userId: string
3+
) => Promise<{ name: string; email: string }>;
4+
declare const jwt: {
5+
verify(token: string, secret: string): { sub: string };
6+
};
7+
8+
import { Router } from '@aws-lambda-powertools/event-handler/http';
9+
import type { Context } from 'aws-lambda';
10+
11+
const app = new Router();
12+
13+
// Middleware sets a value in the request store
14+
app.use(async ({ reqCtx, next }) => {
15+
const auth = reqCtx.req.headers.get('Authorization') ?? '';
16+
const token = auth.replace('Bearer ', '');
17+
const { sub } = jwt.verify(token, 'secret');
18+
19+
reqCtx.set('userId', sub); // (1)!
20+
21+
await next();
22+
});
23+
24+
app.get('/profile', async (reqCtx) => {
25+
const userId = reqCtx.get('userId'); // (2)!
26+
if (!userId) return { error: 'Not authenticated' };
27+
28+
const profile = await getUserProfile(userId as string);
29+
return { profile };
30+
});
31+
32+
export const handler = async (event: unknown, context: Context) =>
33+
app.resolve(event, context);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Router } from '@aws-lambda-powertools/event-handler/http';
2+
import type { Context } from 'aws-lambda';
3+
4+
const app = new Router();
5+
6+
// Set shared values during cold start
7+
app.shared.set('region', process.env.AWS_REGION ?? 'us-east-1'); // (1)!
8+
app.shared.set('startedAt', Date.now());
9+
10+
app.get('/health', (reqCtx) => {
11+
const region = reqCtx.shared.get('region') as string; // (2)!
12+
const startedAt = reqCtx.shared.get('startedAt') as number;
13+
const uptime = Date.now() - startedAt;
14+
15+
return { status: 'ok', region, uptime };
16+
});
17+
18+
export const handler = async (event: unknown, context: Context) =>
19+
app.resolve(event, context);
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
declare const jwt: {
2+
verify(token: string, secret: string): { sub: string; isAdmin: boolean };
3+
};
4+
declare const createDbClient: () => {
5+
query: (sql: string) => Promise<unknown>;
6+
};
7+
8+
import { Router } from '@aws-lambda-powertools/event-handler/http';
9+
import type { Context } from 'aws-lambda';
10+
11+
type AppEnv = {
12+
store: {
13+
request: { userId: string; isAdmin: boolean }; // (1)!
14+
shared: { db: { query: (sql: string) => Promise<unknown> } }; // (2)!
15+
};
16+
};
17+
18+
const app = new Router<AppEnv>();
19+
20+
// Shared store is typed — only accepts keys defined in AppEnv
21+
app.shared.set('db', createDbClient()); // (3)!
22+
23+
app.use(async ({ reqCtx, next }) => {
24+
const auth = reqCtx.req.headers.get('Authorization') ?? '';
25+
const { sub, isAdmin } = jwt.verify(auth.replace('Bearer ', ''), 'secret');
26+
27+
reqCtx.set('userId', sub); // (4)!
28+
reqCtx.set('isAdmin', isAdmin);
29+
30+
await next();
31+
});
32+
33+
app.get('/profile', async (reqCtx) => {
34+
const userId = reqCtx.get('userId'); // (5)!
35+
const db = reqCtx.shared.get('db'); // (6)!
36+
37+
// @ts-expect-error - 'email' is not a key defined in AppEnv
38+
reqCtx.get('email'); // (7)!
39+
40+
if (!userId || !db) return { error: 'not ready' };
41+
return { userId };
42+
});
43+
44+
export const handler = async (event: unknown, context: Context) =>
45+
app.resolve(event, context);

packages/event-handler/package.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"test": "vitest --run",
1414
"test:unit": "vitest --run tests/unit",
1515
"test:unit:coverage": "vitest --run tests/unit --coverage.enabled --coverage.thresholds.100 --coverage.include='src/**'",
16-
"test:unit:types": "echo 'Not Implemented'",
16+
"test:unit:types": "vitest --typecheck --run tests/types",
1717
"test:e2e:nodejs20x": "RUNTIME=nodejs20x vitest run tests/e2e",
1818
"test:e2e:nodejs22x": "RUNTIME=nodejs22x vitest run tests/e2e",
1919
"test:e2e:nodejs24x": "RUNTIME=nodejs24x vitest run tests/e2e",
@@ -70,6 +70,16 @@
7070
"default": "./lib/esm/types/index.js"
7171
}
7272
},
73+
"./store": {
74+
"require": {
75+
"types": "./lib/cjs/store/index.d.ts",
76+
"default": "./lib/cjs/store/index.js"
77+
},
78+
"import": {
79+
"types": "./lib/esm/store/index.d.ts",
80+
"default": "./lib/esm/store/index.js"
81+
}
82+
},
7383
"./http": {
7484
"require": {
7585
"types": "./lib/cjs/http/index.d.ts",

packages/event-handler/src/http/Route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {
2+
Env,
23
HandlerResponse,
34
HttpMethod,
45
Middleware,
@@ -17,13 +18,13 @@ class Route<
1718
readonly id: string;
1819
readonly method: string;
1920
readonly path: Path;
20-
readonly handler: RouteHandler | TypedRouteHandler<TReq, TResBody, TRes>;
21+
readonly handler: RouteHandler | TypedRouteHandler<Env, TReq, TResBody, TRes>;
2122
readonly middleware: Middleware[];
2223

2324
constructor(
2425
method: HttpMethod,
2526
path: Path,
26-
handler: RouteHandler | TypedRouteHandler<TReq, TResBody, TRes>,
27+
handler: RouteHandler | TypedRouteHandler<Env, TReq, TResBody, TRes>,
2728
middleware: Middleware[] = []
2829
) {
2930
this.id = `${method}:${path}`;

0 commit comments

Comments
 (0)