Skip to content

Commit 182169f

Browse files
authored
feat: add caching control (#85)
1 parent 126f32a commit 182169f

File tree

6 files changed

+186
-24
lines changed

6 files changed

+186
-24
lines changed

README-ru.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,32 @@ const config: Partial<AppConfig> = {
5353

5454
export default config;
5555
```
56+
57+
## Управление кешированием
58+
59+
По умолчанию ExpressKit устанавливает `no-cache` заголовки на все ответы. Вы можете управлять этим поведением глобально или на уровне маршрута.
60+
61+
### Глобальная конфигурация
62+
63+
```typescript
64+
const config: Partial<AppConfig> = {
65+
expressEnableCaching: true, // Разрешить кеширование по умолчанию
66+
};
67+
```
68+
69+
### Конфигурация на уровне маршрута
70+
71+
```typescript
72+
const app = new ExpressKit(nodekit, {
73+
'GET /api/cached': {
74+
enableCaching: true, // Разрешить кеширование для этого маршрута
75+
handler: (req, res) => res.json({data: 'кешируемые'}),
76+
},
77+
'GET /api/fresh': {
78+
enableCaching: false, // Принудительно no-cache
79+
handler: (req, res) => res.json({data: 'всегда свежие'}),
80+
},
81+
});
82+
```
83+
84+
Настройка `enableCaching` на уровне маршрута переопределяет глобальную. Состояние доступно в `req.routeInfo.enableCaching`.

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,32 @@ const app = new ExpressKit(nodekit, {
128128
},
129129
});
130130
```
131+
132+
## Caching Control
133+
134+
By default, ExpressKit sets `no-cache` headers on all responses. You can control this behavior globally or per-route.
135+
136+
### Global configuration
137+
138+
```typescript
139+
const config: Partial<AppConfig> = {
140+
expressEnableCaching: true, // Allow caching by default
141+
};
142+
```
143+
144+
### Per-route configuration
145+
146+
```typescript
147+
const app = new ExpressKit(nodekit, {
148+
'GET /api/cached': {
149+
enableCaching: true, // Allow caching for this route
150+
handler: (req, res) => res.json({data: 'cacheable'}),
151+
},
152+
'GET /api/fresh': {
153+
enableCaching: false, // Force no-cache
154+
handler: (req, res) => res.json({data: 'always fresh'}),
155+
},
156+
});
157+
```
158+
159+
Route-level `enableCaching` overrides the global setting. The caching state is available in `req.routeInfo.enableCaching`.

src/base-middleware.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,6 @@ export function setupBaseMiddleware(ctx: AppContext, expressApp: Express) {
1111
req.id = requestId;
1212
res.setHeader(DEFAULT_REQUEST_ID_HEADER, requestId);
1313

14-
res.setHeader('Surrogate-Control', 'no-store');
15-
res.setHeader(
16-
'Cache-Control',
17-
'no-store, max-age=0, must-revalidate, proxy-revalidate',
18-
);
19-
2014
req.routeInfo = {};
2115

2216
const startTime = Date.now();

src/router.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -91,17 +91,25 @@ export function setupRoutes(ctx: AppContext, expressApp: Express, routes: AppRou
9191
typeof rawRoute === 'function' ? {handler: rawRoute} : rawRoute;
9292

9393
const {
94+
handlerName: routeHandlerName,
9495
authPolicy: routeAuthPolicy,
96+
enableCaching: routeEnableCaching,
9597
handler: _h,
9698
beforeAuth: _beforeAuth,
9799
afterAuth: _afterAuth,
98100
cspPresets,
99101
...restRouteInfo
100102
} = route;
103+
104+
const handlerName = routeHandlerName || route.handler.name || UNNAMED_CONTROLLER;
101105
const authPolicy = routeAuthPolicy || ctx.config.appAuthPolicy || AuthPolicy.disabled;
102-
const handlerName = restRouteInfo.handlerName || route.handler.name || UNNAMED_CONTROLLER;
106+
const enableCaching =
107+
typeof routeEnableCaching === 'boolean'
108+
? routeEnableCaching
109+
: Boolean(ctx.config.expressEnableCaching);
110+
103111
const routeInfoMiddleware: AppMiddleware = function routeInfoMiddleware(req, res, next) {
104-
Object.assign(req.routeInfo, restRouteInfo, {authPolicy, handlerName});
112+
Object.assign(req.routeInfo, restRouteInfo, {handlerName, authPolicy, enableCaching});
105113

106114
res.on('finish', () => {
107115
if (req.ctx.config.appTelemetryChEnableSelfStats) {
@@ -129,22 +137,34 @@ export function setupRoutes(ctx: AppContext, expressApp: Express, routes: AppRou
129137
next();
130138
};
131139

132-
const routeMiddleware: AppMiddleware[] = [
133-
routeInfoMiddleware,
134-
...(ctx.config.expressCspEnable
135-
? [
136-
cspMiddleware({
137-
appPresets,
138-
routPresets: cspPresets,
139-
reportOnly: ctx.config.expressCspReportOnly,
140-
reportTo: ctx.config.expressCspReportTo,
141-
reportUri: ctx.config.expressCspReportUri,
142-
}),
143-
]
144-
: []),
145-
...(ctx.config.appBeforeAuthMiddleware || []),
146-
...(route.beforeAuth || []),
147-
];
140+
const routeMiddleware: AppMiddleware[] = [routeInfoMiddleware];
141+
142+
if (!enableCaching) {
143+
const cacheMiddleware: AppMiddleware = (_req, res, next) => {
144+
res.setHeader('Surrogate-Control', 'no-store');
145+
res.setHeader(
146+
'Cache-Control',
147+
'no-store, max-age=0, must-revalidate, proxy-revalidate',
148+
);
149+
next();
150+
};
151+
routeMiddleware.push(cacheMiddleware);
152+
}
153+
154+
if (ctx.config.expressCspEnable) {
155+
routeMiddleware.push(
156+
cspMiddleware({
157+
appPresets,
158+
routPresets: cspPresets,
159+
reportOnly: ctx.config.expressCspReportOnly,
160+
reportTo: ctx.config.expressCspReportTo,
161+
reportUri: ctx.config.expressCspReportUri,
162+
}),
163+
);
164+
}
165+
166+
routeMiddleware.push(...(ctx.config.appBeforeAuthMiddleware || []));
167+
routeMiddleware.push(...(route.beforeAuth || []));
148168

149169
const authHandler =
150170
authPolicy === AuthPolicy.disabled

src/tests/caching.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import {ExpressKit, Request, Response} from '..';
2+
import {NodeKit} from '@gravity-ui/nodekit';
3+
import request from 'supertest';
4+
5+
describe('Caching Control', () => {
6+
it('should set no-cache headers by default', async () => {
7+
const nodekit = new NodeKit({config: {}});
8+
const app = new ExpressKit(nodekit, {
9+
'GET /test': (_req: Request, res: Response) => {
10+
res.json({ok: true});
11+
},
12+
});
13+
14+
const res = await request.agent(app.express).get('/test');
15+
expect(res.headers['cache-control']).toBe(
16+
'no-store, max-age=0, must-revalidate, proxy-revalidate',
17+
);
18+
});
19+
20+
it('should allow caching when expressEnableCaching is true', async () => {
21+
const nodekit = new NodeKit({config: {expressEnableCaching: true}});
22+
const app = new ExpressKit(nodekit, {
23+
'GET /test': (_req: Request, res: Response) => {
24+
res.json({ok: true});
25+
},
26+
});
27+
28+
const res = await request.agent(app.express).get('/test');
29+
expect(res.headers['cache-control']).toBeUndefined();
30+
});
31+
32+
it('should respect route-level enableCaching flag', async () => {
33+
const nodekit = new NodeKit({config: {}});
34+
const app = new ExpressKit(nodekit, {
35+
'GET /cached': {
36+
enableCaching: true,
37+
handler: (_req: Request, res: Response) => {
38+
res.json({ok: true});
39+
},
40+
},
41+
'GET /not-cached': {
42+
enableCaching: false,
43+
handler: (_req: Request, res: Response) => {
44+
res.json({ok: true});
45+
},
46+
},
47+
});
48+
49+
const cached = await request.agent(app.express).get('/cached');
50+
expect(cached.headers['cache-control']).toBeUndefined();
51+
52+
const notCached = await request.agent(app.express).get('/not-cached');
53+
expect(notCached.headers['cache-control']).toBe(
54+
'no-store, max-age=0, must-revalidate, proxy-revalidate',
55+
);
56+
});
57+
58+
it('should allow route override of global config', async () => {
59+
const nodekit = new NodeKit({config: {expressEnableCaching: true}});
60+
const app = new ExpressKit(nodekit, {
61+
'GET /override': {
62+
enableCaching: false,
63+
handler: (_req: Request, res: Response) => {
64+
res.json({ok: true});
65+
},
66+
},
67+
});
68+
69+
const res = await request.agent(app.express).get('/override');
70+
expect(res.headers['cache-control']).toBe(
71+
'no-store, max-age=0, must-revalidate, proxy-revalidate',
72+
);
73+
});
74+
75+
it('should set enableCaching in routeInfo', async () => {
76+
const nodekit = new NodeKit({config: {expressEnableCaching: true}});
77+
const app = new ExpressKit(nodekit, {
78+
'GET /info': (req: Request, res: Response) => {
79+
res.json({enableCaching: req.routeInfo.enableCaching});
80+
},
81+
});
82+
83+
const res = await request.agent(app.express).get('/info');
84+
expect(res.body.enableCaching).toBe(true);
85+
});
86+
});

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ declare module '@gravity-ui/nodekit' {
6565
expressCspReportTo?: CSPMiddlewareParams['reportTo'];
6666
expressCspReportUri?: CSPMiddlewareParams['reportUri'];
6767

68+
expressEnableCaching?: boolean;
69+
6870
appCsrfSecret?: string | string[];
6971
appCsrfLifetime?: number;
7072
appCsrfHeaderName?: string;
@@ -75,6 +77,7 @@ declare module '@gravity-ui/nodekit' {
7577
appLangQueryParamName?: string;
7678
appLangByTld?: Record<string, string | undefined>;
7779
appGetLangByHostname?: (hostname: string) => string | undefined;
80+
7881
appValidationErrorHandler?: (ctx: AppContext) => AppErrorHandler;
7982
}
8083

@@ -95,6 +98,7 @@ export interface AppRouteParams {
9598
handlerName?: string;
9699
disableSelfStats?: boolean;
97100
disableCsrf?: boolean;
101+
enableCaching?: boolean;
98102
}
99103

100104
export interface AppRouteDescription extends AppRouteParams {

0 commit comments

Comments
 (0)