Skip to content

Commit d037526

Browse files
fix(core): Prevent route-scoped beforeListen parser from disabling body parsing (#5029)
1 parent cf5b2cc commit d037526

12 files changed

Lines changed: 296 additions & 14 deletions

File tree

docs/docs/reference/typescript-api/common/bootstrap.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ generated: true
44
---
55
## bootstrap
66

7-
<GenerationInfo sourceFile="packages/core/src/bootstrap.ts" sourceLine="190" packageName="@vendure/core" />
7+
<GenerationInfo sourceFile="packages/core/src/bootstrap.ts" sourceLine="191" packageName="@vendure/core" />
88

99
Bootstraps the Vendure server. This is the entry point to the application.
1010

@@ -76,7 +76,7 @@ Parameters
7676

7777
## BootstrapOptions
7878

79-
<GenerationInfo sourceFile="packages/core/src/bootstrap.ts" sourceLine="45" packageName="@vendure/core" since="2.2.0" />
79+
<GenerationInfo sourceFile="packages/core/src/bootstrap.ts" sourceLine="46" packageName="@vendure/core" since="2.2.0" />
8080

8181
Additional options that can be used to configure the bootstrap process of the
8282
Vendure server.

docs/docs/reference/typescript-api/common/middleware.mdx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: "Middleware"
33
generated: true
44
---
5-
<GenerationInfo sourceFile="packages/core/src/common/types/common-types.ts" sourceLine="213" packageName="@vendure/core" />
5+
<GenerationInfo sourceFile="packages/core/src/common/types/common-types.ts" sourceLine="228" packageName="@vendure/core" />
66

77
Defines API middleware, set in the [ApiOptions](/reference/typescript-api/configuration/api-options#apioptions). Middleware can be either
88
[Express middleware](https://expressjs.com/en/guide/using-middleware.html) or [NestJS middleware](https://docs.nestjs.com/middleware).
@@ -32,6 +32,22 @@ export const config: VendureConfig = {
3232
};
3333
```
3434

35+
The `route` may also be scoped to a single API path if you only want to raise the limit (or
36+
otherwise customize body parsing) for that route, leaving the default parser in place everywhere
37+
else:
38+
39+
*Example*
40+
41+
```ts
42+
apiOptions: {
43+
middleware: [{
44+
handler: json({ limit: '10mb' }),
45+
route: '/admin-api',
46+
beforeListen: true,
47+
}],
48+
},
49+
```
50+
3551
```ts title="Signature"
3652
interface Middleware {
3753
handler: MiddlewareHandler;

docs/docs/reference/typescript-api/services/administrator-service.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: "AdministratorService"
33
generated: true
44
---
5-
<GenerationInfo sourceFile="packages/core/src/service/services/administrator.service.ts" sourceLine="42" packageName="@vendure/core" />
5+
<GenerationInfo sourceFile="packages/core/src/service/services/administrator.service.ts" sourceLine="43" packageName="@vendure/core" />
66

77
Contains methods relating to [Administrator](/reference/typescript-api/entities/administrator#administrator) entities.
88

docs/docs/reference/typescript-api/testing/test-server.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: "TestServer"
33
generated: true
44
---
5-
<GenerationInfo sourceFile="packages/testing/src/test-server.ts" sourceLine="17" packageName="@vendure/testing" />
5+
<GenerationInfo sourceFile="packages/testing/src/test-server.ts" sourceLine="18" packageName="@vendure/testing" />
66

77
A real Vendure server against which the e2e tests should be run.
88

docs/docs/reference/typescript-api/worker/bootstrap-worker.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ generated: true
44
---
55
## bootstrapWorker
66

7-
<GenerationInfo sourceFile="packages/core/src/bootstrap.ts" sourceLine="256" packageName="@vendure/core" />
7+
<GenerationInfo sourceFile="packages/core/src/bootstrap.ts" sourceLine="264" packageName="@vendure/core" />
88

99
Bootstraps a Vendure worker. Resolves to a [VendureWorker](/reference/typescript-api/worker/vendure-worker#vendureworker) object containing a reference to the underlying
1010
NestJs [standalone application](https://docs.nestjs.com/standalone-applications) as well as convenience
@@ -41,7 +41,7 @@ Parameters
4141

4242
## BootstrapWorkerOptions
4343

44-
<GenerationInfo sourceFile="packages/core/src/bootstrap.ts" sourceLine="115" packageName="@vendure/core" since="2.2.0" />
44+
<GenerationInfo sourceFile="packages/core/src/bootstrap.ts" sourceLine="116" packageName="@vendure/core" since="2.2.0" />
4545

4646
Additional options that can be used to configure the bootstrap process of the
4747
Vendure worker.

docs/manifest.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1192,7 +1192,7 @@
11921192
"title": "Bootstrap",
11931193
"slug": "bootstrap",
11941194
"file": "docs/reference/typescript-api/common/bootstrap.mdx",
1195-
"lastModified": "2026-04-20T16:08:16+02:00"
1195+
"lastModified": "2026-07-24T07:56:00Z"
11961196
},
11971197
{
11981198
"title": "CurrencyCode",
@@ -1252,7 +1252,7 @@
12521252
"title": "Middleware",
12531253
"slug": "middleware",
12541254
"file": "docs/reference/typescript-api/common/middleware.mdx",
1255-
"lastModified": "2026-05-22T16:47:46+02:00"
1255+
"lastModified": "2026-07-24T08:15:19Z"
12561256
},
12571257
{
12581258
"title": "PaginatedList",
@@ -2892,7 +2892,7 @@
28922892
"title": "AdministratorService",
28932893
"slug": "administrator-service",
28942894
"file": "docs/reference/typescript-api/services/administrator-service.mdx",
2895-
"lastModified": "2026-07-23T08:59:41+02:00"
2895+
"lastModified": "2026-07-24T07:56:00Z"
28962896
},
28972897
{
28982898
"title": "AssetService",
@@ -3432,7 +3432,7 @@
34323432
"title": "TestServer",
34333433
"slug": "test-server",
34343434
"file": "docs/reference/typescript-api/testing/test-server.mdx",
3435-
"lastModified": "2026-04-20T16:08:16+02:00"
3435+
"lastModified": "2026-07-24T08:47:32Z"
34363436
},
34373437
{
34383438
"title": "TestServerOptions",
@@ -3452,7 +3452,7 @@
34523452
"title": "BootstrapWorker",
34533453
"slug": "bootstrap-worker",
34543454
"file": "docs/reference/typescript-api/worker/bootstrap-worker.mdx",
3455-
"lastModified": "2026-04-20T16:08:16+02:00"
3455+
"lastModified": "2026-07-24T07:56:00Z"
34563456
},
34573457
{
34583458
"title": "VendureWorker",
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { mergeConfig } from '@vendure/core';
2+
import { createTestEnvironment } from '@vendure/testing';
3+
import { json } from 'express';
4+
import gql from 'graphql-tag';
5+
import path from 'path';
6+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
7+
8+
import { initialData } from '../../../e2e-common/e2e-initial-data';
9+
import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
10+
11+
// https://github.com/vendurehq/vendure/issues/5028
12+
//
13+
// A route-scoped `beforeListen` body-parser must not disable body parsing on other routes.
14+
// body-parser's `json()` is named `jsonParser`, which is the exact name NestJS's ExpressAdapter
15+
// scans for (ignoring the mount path) when deciding whether to register its own global parser.
16+
// Without the guard in `wrapEarlyMiddlewareHandler`, mounting `json()` on `/admin-api` makes NestJS
17+
// skip its global parser, leaving `/shop-api` unable to parse JSON request bodies.
18+
describe('route-scoped beforeListen middleware (#5028)', () => {
19+
const config = mergeConfig(testConfig(), {
20+
apiOptions: {
21+
middleware: [
22+
{
23+
handler: json({ limit: '10mb' }),
24+
route: '/admin-api',
25+
beforeListen: true,
26+
},
27+
],
28+
},
29+
});
30+
const { server, adminClient, shopClient } = createTestEnvironment(config);
31+
32+
// A JSON body over Express's default 100kb limit but well under the scoped 10mb limit.
33+
const oversizedBody = JSON.stringify({
34+
query: '{ me { identifier } }',
35+
padding: 'x'.repeat(200 * 1024),
36+
});
37+
const postJson = (apiPath: string) =>
38+
fetch(`http://localhost:${config.apiOptions.port}${apiPath}`, {
39+
method: 'POST',
40+
headers: { 'content-type': 'application/json' },
41+
body: oversizedBody,
42+
});
43+
44+
beforeAll(async () => {
45+
await server.init({
46+
initialData,
47+
productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
48+
customerCount: 1,
49+
});
50+
}, TEST_SETUP_TIMEOUT_MS);
51+
52+
afterAll(async () => {
53+
await server.destroy();
54+
});
55+
56+
it('parses JSON bodies on the shop API', async () => {
57+
const { activeChannel } = await shopClient.query(gql`
58+
query {
59+
activeChannel {
60+
id
61+
code
62+
}
63+
}
64+
`);
65+
expect(activeChannel.code).toBe('__default_channel__');
66+
});
67+
68+
it('parses JSON bodies on the route-scoped admin API', async () => {
69+
await adminClient.asSuperAdmin();
70+
const { me } = await adminClient.query(gql`
71+
query {
72+
me {
73+
identifier
74+
}
75+
}
76+
`);
77+
expect(me.identifier).toBe('superadmin');
78+
});
79+
80+
// The scoped 10mb parser must still take effect on its own route...
81+
it('honours the raised body-size limit on the route-scoped admin API', async () => {
82+
const response = await postJson('/admin-api');
83+
expect(response.status).toBe(200);
84+
});
85+
86+
// ...while the raised limit does not leak to other routes, which keep the default 100kb parser
87+
// and therefore reject the same oversized body (Vendure surfaces the PayloadTooLargeError as a
88+
// 5xx, so we only assert that the request was rejected rather than pinning an exact status).
89+
it('does not leak the raised body-size limit to other routes', async () => {
90+
const response = await postJson('/shop-api');
91+
expect(response.status).toBeGreaterThanOrEqual(400);
92+
});
93+
});

packages/core/src/bootstrap.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { setProcessContext } from './process-context/process-context';
3030
import { isTelemetryDisabled } from './telemetry/helpers/is-telemetry-disabled.helper';
3131
import { VENDURE_VERSION } from './version';
3232
import { VendureWorker } from './worker/vendure-worker';
33+
import { wrapEarlyMiddlewareHandler } from './wrap-early-middleware-handler';
3334

3435
export type VendureBootstrapFunction = (config: VendureConfig) => Promise<INestApplication>;
3536

@@ -219,7 +220,14 @@ export async function bootstrap(
219220
}
220221
const earlyMiddlewares = middleware.filter(mid => mid.beforeListen);
221222
earlyMiddlewares.forEach(mid => {
222-
app.use(mid.route, mid.handler);
223+
const handler = wrapEarlyMiddlewareHandler(mid);
224+
if (handler !== mid.handler) {
225+
Logger.info(
226+
`Wrapped route-scoped "beforeListen" middleware on route "${mid.route}" to avoid ` +
227+
'suppressing the global body-parser on other routes.',
228+
);
229+
}
230+
app.use(mid.route, handler);
223231
});
224232
await options?.onBeforeAppListen?.(app);
225233
await app.listen(port, hostname || '');

packages/core/src/common/types/common-types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,21 @@ export type MiddlewareHandler = Type<any> | Function;
208208
* };
209209
* ```
210210
*
211+
* The `route` may also be scoped to a single API path if you only want to raise the limit (or
212+
* otherwise customize body parsing) for that route, leaving the default parser in place everywhere
213+
* else:
214+
*
215+
* @example
216+
* ```ts
217+
* apiOptions: {
218+
* middleware: [{
219+
* handler: json({ limit: '10mb' }),
220+
* route: '/admin-api',
221+
* beforeListen: true,
222+
* }],
223+
* },
224+
* ```
225+
*
211226
* @docsCategory Common
212227
*/
213228
export interface Middleware {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import { Middleware } from './common/types/common-types';
4+
import { isGlobalRoute, wrapEarlyMiddlewareHandler } from './wrap-early-middleware-handler';
5+
6+
/**
7+
* Reproduces the shape of body-parser's `json()`/`urlencoded()` return values, whose function
8+
* names (`jsonParser`/`urlencodedParser`) are what NestJS's ExpressAdapter checks for.
9+
*/
10+
function makeParser(name: 'jsonParser' | 'urlencodedParser') {
11+
const fn = vi.fn();
12+
Object.defineProperty(fn, 'name', { value: name });
13+
return fn;
14+
}
15+
16+
// https://github.com/vendurehq/vendure/issues/5028
17+
describe('wrapEarlyMiddlewareHandler', () => {
18+
it('wraps a route-scoped jsonParser so its name no longer collides', () => {
19+
const handler = makeParser('jsonParser');
20+
const mid: Middleware = { handler, route: '/admin-api', beforeListen: true };
21+
22+
const wrapped = wrapEarlyMiddlewareHandler(mid);
23+
24+
expect(wrapped).not.toBe(handler);
25+
expect((wrapped as any).name).not.toBe('jsonParser');
26+
});
27+
28+
it('wraps a route-scoped urlencodedParser', () => {
29+
const handler = makeParser('urlencodedParser');
30+
const mid: Middleware = { handler, route: '/admin-api', beforeListen: true };
31+
32+
expect(wrapEarlyMiddlewareHandler(mid)).not.toBe(handler);
33+
});
34+
35+
it('the wrapped handler delegates to the original with the same arguments', () => {
36+
const handler = makeParser('jsonParser');
37+
const mid: Middleware = { handler, route: '/admin-api', beforeListen: true };
38+
const wrapped = wrapEarlyMiddlewareHandler(mid) as (...args: any[]) => void;
39+
40+
const req = {} as any;
41+
const res = {} as any;
42+
const next = vi.fn();
43+
wrapped(req, res, next);
44+
45+
expect(handler).toHaveBeenCalledWith(req, res, next);
46+
});
47+
48+
it.each(['/', '', '*', '*splat', '/*splat', '{*splat}', '/{*splat}', '(.*)', '/(.*)'])(
49+
'leaves a parser on the catch-all route "%s" untouched (preserves global-parser replacement)',
50+
route => {
51+
const handler = makeParser('jsonParser');
52+
const mid: Middleware = { handler, route, beforeListen: true };
53+
54+
expect(wrapEarlyMiddlewareHandler(mid)).toBe(handler);
55+
},
56+
);
57+
58+
it('does not wrap a handler whose name is not a parser name', () => {
59+
const handler = vi.fn();
60+
Object.defineProperty(handler, 'name', { value: 'myCustomMiddleware' });
61+
const mid: Middleware = { handler, route: '/admin-api', beforeListen: true };
62+
63+
expect(wrapEarlyMiddlewareHandler(mid)).toBe(handler);
64+
});
65+
66+
it('leaves a NestJS middleware class untouched (its name does not collide)', () => {
67+
class SomeNestMiddleware {}
68+
const mid: Middleware = { handler: SomeNestMiddleware, route: '/admin-api', beforeListen: true };
69+
70+
expect(wrapEarlyMiddlewareHandler(mid)).toBe(SomeNestMiddleware);
71+
});
72+
});
73+
74+
describe('isGlobalRoute', () => {
75+
it.each(['/', '', ' ', '*', '/*', '*splat', '/*splat', '{*splat}', '/{*splat}', '(.*)', '/(.*)'])(
76+
'treats "%s" as global',
77+
route => {
78+
expect(isGlobalRoute(route)).toBe(true);
79+
},
80+
);
81+
82+
it.each(['/admin-api', '/shop-api', '/webhooks/stripe', '/api/*', '/admin'])(
83+
'treats "%s" as scoped',
84+
route => {
85+
expect(isGlobalRoute(route)).toBe(false);
86+
},
87+
);
88+
});

0 commit comments

Comments
 (0)