diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts index e66abf5ad..c3c54b6eb 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -101,6 +101,42 @@ export class ServerPlugin extends Plugin { return config; } + private shouldSkipBodyParsing(urlPath: string): boolean { + const normalizedUrlPath = this.normalizePath(urlPath); + if (this.rawBodyPaths.has(normalizedUrlPath)) return true; + + for (const rawBodyPath of this.rawBodyPaths) { + const normalizedRawBodyPath = this.normalizePath(rawBodyPath); + if ( + normalizedRawBodyPath === normalizedUrlPath || + this.matchesRawBodyPath(normalizedRawBodyPath, normalizedUrlPath) + ) { + return true; + } + } + + return false; + } + + private normalizePath(urlPath: string): string { + return urlPath.length > 1 ? urlPath.replace(/\/+$/, "") : urlPath; + } + + private matchesRawBodyPath(routePath: string, urlPath: string): boolean { + const routeSegments = routePath.split("/"); + const urlSegments = urlPath.split("/"); + + if (routeSegments.length !== urlSegments.length) return false; + + return routeSegments.every((segment, index) => { + if (segment.startsWith(":") && segment.length > 1) { + return urlSegments[index].length > 0; + } + + return segment === urlSegments[index]; + }); + } + /** * Start the server. * @@ -126,7 +162,7 @@ export class ServerPlugin extends Plugin { // rawBodyPaths is populated by extendRoutes() below; the type // callback runs per-request so the set is already filled. const urlPath = req.url?.split("?")[0]; - if (urlPath && this.rawBodyPaths.has(urlPath)) return false; + if (urlPath && this.shouldSkipBodyParsing(urlPath)) return false; const ct = req.headers["content-type"] ?? ""; return ct.includes("json"); }, diff --git a/packages/appkit/src/plugins/server/tests/server.test.ts b/packages/appkit/src/plugins/server/tests/server.test.ts index bbc961723..6462cb0e0 100644 --- a/packages/appkit/src/plugins/server/tests/server.test.ts +++ b/packages/appkit/src/plugins/server/tests/server.test.ts @@ -409,7 +409,9 @@ describe("ServerPlugin", () => { }, }; - const plugin = new ServerPlugin({ plugins }); + const plugin = new ServerPlugin({ + context: createContextWithPlugins(plugins), + }); await plugin.start(); // Get the type function passed to express.json @@ -417,12 +419,20 @@ describe("ServerPlugin", () => { const typeFn = jsonCall.type; // Should skip body parsing for the declared path - expect(typeFn({ url: "/api/files/upload", headers: {} })).toBe(false); + expect( + typeFn({ + url: "/api/files/upload", + headers: { "content-type": "application/json" }, + }), + ).toBe(false); // Should skip body parsing for declared path with query string - expect(typeFn({ url: "/api/files/upload?path=foo", headers: {} })).toBe( - false, - ); + expect( + typeFn({ + url: "/api/files/upload?path=foo", + headers: { "content-type": "application/json" }, + }), + ).toBe(false); // Should NOT skip body parsing for other routes (no hardcoded /upload check) expect( @@ -441,6 +451,54 @@ describe("ServerPlugin", () => { ).toBe(true); }); + test("should skip body parsing for parameterized paths if plugin declares skipBodyParsing", async () => { + process.env.NODE_ENV = "production"; + + const plugins: any = { + files: { + name: "files", + injectRoutes: vi.fn(), + getEndpoints: vi.fn().mockReturnValue({}), + getSkipBodyParsingPaths: vi + .fn() + .mockReturnValue(new Set(["/api/files/:volumeKey/upload"])), + }, + }; + + const plugin = new ServerPlugin({ + context: createContextWithPlugins(plugins), + }); + await plugin.start(); + + const jsonCall = vi.mocked(express.json).mock.calls[0][0] as any; + const typeFn = jsonCall.type; + + expect( + typeFn({ + url: "/api/files/uploads/upload", + headers: { "content-type": "application/json" }, + }), + ).toBe(false); + expect( + typeFn({ + url: "/api/files/uploads/upload?path=foo", + headers: { "content-type": "application/json" }, + }), + ).toBe(false); + expect( + typeFn({ + url: "/api/files/uploads/upload/", + headers: { "content-type": "application/json" }, + }), + ).toBe(false); + expect( + typeFn({ + url: "/api/files/uploads/nested/upload", + headers: { "content-type": "application/json" }, + }), + ).toBe(true); + }); + test("extendRoutes registers plugin routes correctly", async () => { process.env.NODE_ENV = "production";