From e8c25e7ec3a61b144b43b3c32692e671effdd84e Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 19 Jan 2026 10:26:01 -0500 Subject: [PATCH 01/11] chore: remove colorette dependency and update minimum webpack version to 5.101.0 (#2238) --- .cspell.json | 1 - package-lock.json | 4 ++-- package.json | 3 +-- src/utils/setupHooks.js | 16 ++-------------- test/utils/setupHooks.test.js | 16 ++++++++++++++++ 5 files changed, 21 insertions(+), 19 deletions(-) diff --git a/.cspell.json b/.cspell.json index 7722ca977..fb32a57f0 100644 --- a/.cspell.json +++ b/.cspell.json @@ -3,7 +3,6 @@ "language": "en,en-gb", "words": [ "memfs", - "colorette", "noextension", "fullhash", "execa", diff --git a/package-lock.json b/package-lock.json index 0d7a4edc9..5c93b171b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "7.4.5", "license": "MIT", "dependencies": { - "colorette": "^2.0.10", "memfs": "^4.43.1", "mime-types": "^3.0.1", "on-finished": "^2.4.1", @@ -64,7 +63,7 @@ "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "^5.0.0" + "webpack": "^5.101.0" }, "peerDependenciesMeta": { "webpack": { @@ -7795,6 +7794,7 @@ "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, "license": "MIT" }, "node_modules/combined-stream": { diff --git a/package.json b/package.json index 4ec83b262..0d0b7d3b4 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "release": "standard-version" }, "dependencies": { - "colorette": "^2.0.10", "memfs": "^4.43.1", "mime-types": "^3.0.1", "on-finished": "^2.4.1", @@ -95,7 +94,7 @@ "webpack": "^5.101.0" }, "peerDependencies": { - "webpack": "^5.0.0" + "webpack": "^5.101.0" }, "peerDependenciesMeta": { "webpack": { diff --git a/src/utils/setupHooks.js b/src/utils/setupHooks.js index f9b14c8de..50c55a0f8 100644 --- a/src/utils/setupHooks.js +++ b/src/utils/setupHooks.js @@ -113,14 +113,8 @@ function setupHooks(context) { /** @type {MultiCompiler} */ (compiler).compilers; - // TODO remove `colorette` and set minimum supported webpack version is `5.101.0` childStatsOptions.colors = - typeof firstCompiler.webpack !== "undefined" && - typeof firstCompiler.webpack.cli !== "undefined" && - typeof firstCompiler.webpack.cli.isColorSupported === - "function" - ? firstCompiler.webpack.cli.isColorSupported() - : require("colorette").isColorSupported; + firstCompiler.webpack.cli.isColorSupported(); } return childStatsOptions; @@ -133,13 +127,7 @@ function setupHooks(context) { if (typeof statsOptions.colors === "undefined") { const { compiler } = /** @type {{ compiler: Compiler }} */ (context); - // TODO remove `colorette` and set minimum supported webpack version is `5.101.0` - statsOptions.colors = - typeof compiler.webpack !== "undefined" && - typeof compiler.webpack.cli !== "undefined" && - typeof compiler.webpack.cli.isColorSupported === "function" - ? compiler.webpack.cli.isColorSupported() - : require("colorette").isColorSupported; + statsOptions.colors = compiler.webpack.cli.isColorSupported(); } } diff --git a/test/utils/setupHooks.test.js b/test/utils/setupHooks.test.js index 8aaaa263f..92f437aef 100644 --- a/test/utils/setupHooks.test.js +++ b/test/utils/setupHooks.test.js @@ -12,6 +12,7 @@ describe("setupHooks", () => { const loggerInfo = jest.fn(); const loggerWarn = jest.fn(); const loggerError = jest.fn(); + const colorSupport = jest.fn(); let nextTick; const cb1 = jest.fn(); @@ -22,6 +23,11 @@ describe("setupHooks", () => { context = { options: {}, compiler: { + webpack: { + cli: { + isColorSupported: colorSupport, + }, + }, hooks: { watchRun: { tap: watchRunHook, @@ -126,12 +132,22 @@ describe("setupHooks", () => { it("handles multi compiler", () => { context.compiler.compilers = [ { + webpack: { + cli: { + isColorSupported: colorSupport, + }, + }, options: { name: "comp1", stats: {}, }, }, { + webpack: { + cli: { + isColorSupported: colorSupport, + }, + }, options: { name: "comp2", stats: {}, From 4927c277ea8130ffb3885d8a7fc75b6b8399c579 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Tue, 20 Jan 2026 06:36:41 -0500 Subject: [PATCH 02/11] feat!: drop support for node <20.9 (#2248) --- .github/workflows/nodejs.yml | 3 ++- package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 81075edac..71c36036d 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -64,9 +64,10 @@ jobs: name: Test - ${{ matrix.os }} - Node v${{ matrix.node-version }}, Webpack ${{ matrix.webpack-version }} strategy: + fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - node-version: [18.x, 20.x, 22.x, 24.x] + node-version: [20.x, 22.x, 24.x] webpack-version: [latest] runs-on: ${{ matrix.os }} diff --git a/package.json b/package.json index 0d0b7d3b4..e5db22d89 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,6 @@ } }, "engines": { - "node": ">= 18.12.0" + "node": ">= 20.9.0" } } From 47a43b0e912a7c809d6ced4c0f562ef849f02b82 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 26 Jan 2026 13:03:19 -0500 Subject: [PATCH 03/11] feat: enable cacheImmutable by default (#2247) * feat: enable cacheImmutable by default * fix: thoses files shouldn't immutable * fix: update cache control logic becauses undefined !== false (true) * test: add tests for cacheControl option behavior with cacheImmutable set to false * refactor: normalize cacheControl handling in response headers * refactor: simplify maxAge calculation in cacheControl handling --- src/middleware.js | 58 ++++--- test/middleware.test.js | 329 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 354 insertions(+), 33 deletions(-) diff --git a/src/middleware.js b/src/middleware.js index 7b38ea99b..ba47666a9 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -571,38 +571,34 @@ function wrapper(context) { } if (!getResponseHeader(res, "Cache-Control")) { - // TODO enable the `cacheImmutable` by default for the next major release - const cacheControl = - context.options.cacheImmutable && extra.immutable - ? { immutable: true } - : context.options.cacheControl; - - if (cacheControl) { - let cacheControlValue; - - if (typeof cacheControl === "boolean") { - cacheControlValue = "public, max-age=31536000"; - } else if (typeof cacheControl === "number") { - const maxAge = Math.floor( - Math.min(Math.max(0, cacheControl), MAX_MAX_AGE) / 1000, - ); + const hasCacheImmutable = + context.options.cacheImmutable === undefined + ? true + : context.options.cacheImmutable; + + let { cacheControl } = context.options; + + // Normalize cacheControl to object + if (typeof cacheControl === "string") { + setResponseHeader(res, "Cache-Control", cacheControl); + } else if (hasCacheImmutable && extra.immutable) { + cacheControl = { immutable: true }; + } else if (typeof cacheControl === "boolean") { + cacheControl = { maxAge: MAX_MAX_AGE }; + } else if (typeof cacheControl === "number") { + cacheControl = { maxAge: cacheControl }; + } - cacheControlValue = `public, max-age=${maxAge}`; - } else if (typeof cacheControl === "string") { - cacheControlValue = cacheControl; - } else { - const maxAge = cacheControl.maxAge - ? Math.floor( - Math.min(Math.max(0, cacheControl.maxAge), MAX_MAX_AGE) / - 1000, - ) - : MAX_MAX_AGE / 1000; - - cacheControlValue = `public, max-age=${maxAge}`; - - if (cacheControl.immutable) { - cacheControlValue += ", immutable"; - } + if (cacheControl && typeof cacheControl === "object") { + const maxAge = + cacheControl.maxAge !== undefined + ? Math.min(Math.max(0, cacheControl.maxAge), MAX_MAX_AGE) + : MAX_MAX_AGE; + + let cacheControlValue = `public, max-age=${Math.floor(maxAge / 1000)}`; + + if (cacheControl.immutable && hasCacheImmutable) { + cacheControlValue += ", immutable"; } setResponseHeader(res, "Cache-Control", cacheControlValue); diff --git a/test/middleware.test.js b/test/middleware.test.js index 9400380c9..7bf3ea687 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -5964,7 +5964,7 @@ describe.each([ await close(server, instance); }); - it('should return the "200" code for the "GET" request to the bundle file and don\'t generate `Cache-Control` header', async () => { + it('should return the "200" code for the "GET" request to the bundle file and generate `Cache-Control` header', async () => { const response = await req.get("/bundle.js"); expect(response.statusCode).toBe(200); @@ -6129,7 +6129,6 @@ describe.each([ name, framework, compiler, - { cacheImmutable: true }, ); }); @@ -6235,6 +6234,332 @@ describe.each([ ); }); }); + + describe("should not generate `Cache-Control` header for immutable assets when cacheImmutable is false", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: false }, + ); + }); + + afterEach(async () => { + await close(server, instance); + }); + + it('should return the "200" code for the "GET" request to the bundle file and don\'t generate `Cache-Control` header', async () => { + const response = await req.get("/main.js"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeUndefined(); + }); + + it('should return the "200" code for the "GET" request to the immutable asset and don\'t generate `Cache-Control` header', async () => { + const response = await req.get("/6076fc274f403ebb2d09.svg"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeUndefined(); + }); + }); + + describe("should use cacheControl option when cacheImmutable is false even for immutable assets", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: false, cacheControl: 1000000 }, + ); + }); + + afterEach(async () => { + await close(server, instance); + }); + + it('should return the "200" code for the "GET" request to the bundle file and generate `Cache-Control` header from cacheControl option', async () => { + const response = await req.get("/main.js"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=1000", + ); + }); + + it('should return the "200" code for the "GET" request to the immutable asset and generate `Cache-Control` header from cacheControl option (not immutable)', async () => { + const response = await req.get("/6076fc274f403ebb2d09.svg"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=1000", + ); + }); + }); + + describe("should use cacheControl string option when cacheImmutable is false", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: false, cacheControl: "max-age=500" }, + ); + }); + + afterEach(async () => { + await close(server, instance); + }); + + it('should return the "200" code for the "GET" request to the bundle file and generate `Cache-Control` header from cacheControl string option without immutable', async () => { + const response = await req.get("/main.js"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe("max-age=500"); + }); + + it('should return the "200" code for the "GET" request to the immutable asset and generate `Cache-Control` header from cacheControl string option without immutable', async () => { + const response = await req.get("/6076fc274f403ebb2d09.svg"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe("max-age=500"); + }); + }); + + describe("should use cacheControl object option when cacheImmutable is false", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: false, cacheControl: { maxAge: 2000000 } }, + ); + }); + + afterEach(async () => { + await close(server, instance); + }); + + it('should return the "200" code for the "GET" request to the bundle file and generate `Cache-Control` header from cacheControl object option without immutable', async () => { + const response = await req.get("/main.js"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=2000", + ); + }); + + it('should return the "200" code for the "GET" request to the immutable asset and generate `Cache-Control` header from cacheControl object option without immutable', async () => { + const response = await req.get("/6076fc274f403ebb2d09.svg"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=2000", + ); + }); + }); + + describe("should use cacheControl object option (with only immutable: true) when cacheImmutable is false, and not add 'immutable' to Cache-Control header", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: false, cacheControl: { immutable: true } }, + ); + }); + + afterEach(async () => { + await close(server, instance); + }); + + it('should return the "200" code for the "GET" request to the bundle file and generate `Cache-Control` header from cacheControl object option without immutable', async () => { + const response = await req.get("/main.js"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=31536000", + ); + }); + + it('should return the "200" code for the "GET" request to the immutable asset and generate `Cache-Control` header from cacheControl object option without immutable', async () => { + const response = await req.get("/6076fc274f403ebb2d09.svg"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=31536000", + ); + }); + }); + + describe("should use cacheControl object option with explicit immutable false", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheControl: { maxAge: 3000000, immutable: false } }, + ); + }); + + afterEach(async () => { + await close(server, instance); + }); + + it('should return the "200" code for the "GET" request to the bundle file and generate `Cache-Control` header without immutable when explicitly set to false', async () => { + const response = await req.get("/main.js"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=3000", + ); + }); + + it('should return the "200" code for the "GET" request to the immutable asset and generate `Cache-Control` header without immutable when explicitly set to false', async () => { + const response = await req.get("/6076fc274f403ebb2d09.svg"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=31536000, immutable", + ); + }); + }); + + describe("should use cacheControl boolean option when cacheImmutable is false", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: false, cacheControl: true }, + ); + }); + + afterEach(async () => { + await close(server, instance); + }); + + it('should return the "200" code for the "GET" request to the bundle file and generate `Cache-Control` header from cacheControl boolean option without immutable', async () => { + const response = await req.get("/main.js"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=31536000", + ); + }); + + it('should return the "200" code for the "GET" request to the immutable asset and generate `Cache-Control` header from cacheControl boolean option without immutable', async () => { + const response = await req.get("/6076fc274f403ebb2d09.svg"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=31536000", + ); + }); + }); + + describe("should use cacheControl number option when cacheImmutable is false without immutable", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: false, cacheControl: 5000000 }, + ); + }); + + afterEach(async () => { + await close(server, instance); + }); + + it('should return the "200" code for the "GET" request to the bundle file and generate `Cache-Control` header from cacheControl number option without immutable', async () => { + const response = await req.get("/main.js"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=5000", + ); + }); + + it('should return the "200" code for the "GET" request to the immutable asset and generate `Cache-Control` header from cacheControl number option without immutable', async () => { + const response = await req.get("/6076fc274f403ebb2d09.svg"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=5000", + ); + }); + }); }); }); }); From 063213e543ea7b70a9eb116ac33a29fe506e4305 Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:39:34 +0300 Subject: [PATCH 04/11] refactor: logic for cacheControl and cacheImmutable (#2254) --- README.md | 2 +- src/middleware.js | 38 +++++++++++++++++++------------------- test/middleware.test.js | 6 +++--- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index ce8a7feda..4a7cfbbf2 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ See [below](#other-servers) for an example of use with fastify. | **[`etag`](#tag)** | `boolean\| "weak"\| "strong"` | `undefined` | Enable or disable etag generation. | | **[`lastModified`](#lastmodified)** | `boolean` | `undefined` | Enable or disable `Last-Modified` header. Uses the file system's last modified value. | | **[`cacheControl`](#cachecontrol)** | `boolean\|number\|string\|Object` | `undefined` | Enable or disable setting `Cache-Control` response header. | -| **[`cacheImmutable`](#cacheimmutable)** | `boolean\` | `undefined` | Enable or disable setting `Cache-Control: public, max-age=31536000, immutable` response header for immutable assets. | +| **[`cacheImmutable`](#cacheimmutable)** | `boolean` | `undefined` | Enable or disable setting `Cache-Control: public, max-age=31536000, immutable` response header for immutable assets. | | **[`publicPath`](#publicpath)** | `string` | `undefined` | The public path that the middleware is bound to. | | **[`stats`](#stats)** | `boolean\|string\|Object` | `stats` (from a configuration) | Stats options object or preset name. | | **[`serverSideRender`](#serversiderender)** | `boolean` | `undefined` | Instructs the module to enable or disable the server-side rendering mode. | diff --git a/src/middleware.js b/src/middleware.js index ba47666a9..3441f3285 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -571,36 +571,36 @@ function wrapper(context) { } if (!getResponseHeader(res, "Cache-Control")) { - const hasCacheImmutable = - context.options.cacheImmutable === undefined - ? true - : context.options.cacheImmutable; - - let { cacheControl } = context.options; - - // Normalize cacheControl to object - if (typeof cacheControl === "string") { - setResponseHeader(res, "Cache-Control", cacheControl); - } else if (hasCacheImmutable && extra.immutable) { - cacheControl = { immutable: true }; + const { cacheControl, cacheImmutable } = context.options; + + let cacheControlValue; + + if ( + (cacheImmutable === undefined || cacheImmutable) && + extra.immutable + ) { + cacheControlValue = `public, max-age=${Math.floor(MAX_MAX_AGE / 1000)}, immutable`; } else if (typeof cacheControl === "boolean") { - cacheControl = { maxAge: MAX_MAX_AGE }; + cacheControlValue = `public, max-age=${Math.floor(MAX_MAX_AGE / 1000)}`; } else if (typeof cacheControl === "number") { - cacheControl = { maxAge: cacheControl }; - } - - if (cacheControl && typeof cacheControl === "object") { + const maxAge = Math.min(Math.max(0, cacheControl), MAX_MAX_AGE); + cacheControlValue = `public, max-age=${Math.floor(maxAge / 1000)}`; + } else if (typeof cacheControl === "string") { + cacheControlValue = cacheControl; + } else if (cacheControl) { const maxAge = cacheControl.maxAge !== undefined ? Math.min(Math.max(0, cacheControl.maxAge), MAX_MAX_AGE) : MAX_MAX_AGE; - let cacheControlValue = `public, max-age=${Math.floor(maxAge / 1000)}`; + cacheControlValue = `public, max-age=${Math.floor(maxAge / 1000)}`; - if (cacheControl.immutable && hasCacheImmutable) { + if (cacheControl.immutable) { cacheControlValue += ", immutable"; } + } + if (cacheControlValue) { setResponseHeader(res, "Cache-Control", cacheControlValue); } } diff --git a/test/middleware.test.js b/test/middleware.test.js index 7bf3ea687..4e076031b 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -6393,7 +6393,7 @@ describe.each([ }); }); - describe("should use cacheControl object option (with only immutable: true) when cacheImmutable is false, and not add 'immutable' to Cache-Control header", () => { + describe("should use cacheControl object option (with only immutable: true) when cacheImmutable is false, and add 'immutable' to Cache-Control header", () => { beforeEach(async () => { const compiler = getCompiler({ ...webpackConfigImmutable, @@ -6420,7 +6420,7 @@ describe.each([ expect(response.statusCode).toBe(200); expect(response.headers["cache-control"]).toBeDefined(); expect(response.headers["cache-control"]).toBe( - "public, max-age=31536000", + "public, max-age=31536000, immutable", ); }); @@ -6430,7 +6430,7 @@ describe.each([ expect(response.statusCode).toBe(200); expect(response.headers["cache-control"]).toBeDefined(); expect(response.headers["cache-control"]).toBe( - "public, max-age=31536000", + "public, max-age=31536000, immutable", ); }); }); From 35dd70b33ebedc1bd653dc8964be2416a0e22dd6 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Tue, 27 Jan 2026 14:26:15 -0500 Subject: [PATCH 05/11] feat!: update `getFilenameFromUrl` to throw errors and return `undefined` when not found --- .cspell.json | 3 +- README.md | 13 +++++- src/index.js | 14 +++--- src/middleware.js | 46 ++++++++++++-------- src/utils/getFilenameFromUrl.js | 67 ++++++++++++++++------------- test/middleware.test.js | 48 ++++++++++++--------- types/index.d.ts | 21 ++++----- types/middleware.d.ts | 4 ++ types/utils/getFilenameFromUrl.d.ts | 52 ++++++++++++---------- 9 files changed, 157 insertions(+), 111 deletions(-) diff --git a/.cspell.json b/.cspell.json index fb32a57f0..ecd7d2e23 100644 --- a/.cspell.json +++ b/.cspell.json @@ -23,7 +23,8 @@ "cachable", "finalhandler", "hono", - "rspack" + "rspack", + "malformed" ], "ignorePaths": [ "CHANGELOG.md", diff --git a/README.md b/README.md index 4a7cfbbf2..113fdc2e5 100644 --- a/README.md +++ b/README.md @@ -459,7 +459,18 @@ const app = new express(); app.use(instance); instance.waitUntilValid(() => { - const filename = instance.getFilenameFromUrl("/bundle.js"); + let resolver; + + try { + resolved = instance.getFilenameFromUrl("/bundle.js"); + } catch (err) { + console.log(`Error: ${err}`); + } + + if (!resolved) { + console.log("Not found"); + return; + } console.log(`Filename is ${filename}`); }); diff --git a/src/index.js b/src/index.js index ccdc8fedc..de1403f83 100644 --- a/src/index.js +++ b/src/index.js @@ -131,9 +131,9 @@ const noop = () => {}; * @template {IncomingMessage} [RequestInternal=IncomingMessage] * @template {ServerResponse} [ResponseInternal=ServerResponse] * @callback Middleware - * @param {RequestInternal} req - * @param {ResponseInternal} res - * @param {NextFunction} next + * @param {RequestInternal} req request + * @param {ResponseInternal} res response + * @param {NextFunction} next next function * @returns {Promise} */ @@ -141,9 +141,8 @@ const noop = () => {}; /** * @callback GetFilenameFromUrl - * @param {string} url - * @param {Extra=} extra - * @returns {string | undefined} + * @param {string} url request URL + * @returns {{ filename: string, extra: Extra } | undefined} a filename with additional information, or `undefined` if nothing is found */ /** @@ -278,8 +277,7 @@ function wdm(compiler, options = {}) { (middleware(filledContext)); // API - instance.getFilenameFromUrl = (url, extra) => - getFilenameFromUrl(filledContext, url, extra); + instance.getFilenameFromUrl = (url) => getFilenameFromUrl(filledContext, url); instance.waitUntilValid = (callback = noop) => { ready(filledContext, callback); diff --git a/src/middleware.js b/src/middleware.js index 3441f3285..078bbead7 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -31,6 +31,8 @@ const ready = require("./utils/ready"); /** @typedef {import("./index.js").IncomingMessage} IncomingMessage */ /** @typedef {import("./index.js").ServerResponse} ServerResponse */ /** @typedef {import("./index.js").NormalizedHeaders} NormalizedHeaders */ +/** @typedef {import("./utils/getFilenameFromUrl.js").FilenameError} FilenameError */ +/** @typedef {import("./utils/getFilenameFromUrl.js").Extra} Extra */ /** @typedef {import("fs").ReadStream} ReadStream */ const BYTES_RANGE_REGEXP = /^ *bytes/i; @@ -498,22 +500,29 @@ function wrapper(context) { */ async function processRequest() { // Pipe and SendFile - /** @type {import("./utils/getFilenameFromUrl").Extra} */ - const extra = {}; - const filename = getFilenameFromUrl( - context, - /** @type {string} */ (getRequestURL(req)), - extra, - ); - - if (extra.errorCode) { - if (extra.errorCode === 403) { - context.logger.error(`Malicious path "${filename}".`); + /** @type {{ filename: string, extra: Extra } | undefined} */ + let resolved; + + const requestUrl = /** @type {string} */ (getRequestURL(req)); + + try { + resolved = getFilenameFromUrl(context, requestUrl); + } catch (err) { + // Fallback to 403 for unknown errors + const errorCode = + typeof err === "object" && + err !== null && + typeof (/** @type {FilenameError} */ (err).code) !== "undefined" + ? /** @type {FilenameError} */ (err).code + : 403; + + if (errorCode === 403) { + context.logger.error(`Malicious path "${requestUrl}".`); } await sendError( - extra.errorCode === 400 ? "Bad Request" : "Forbidden", - extra.errorCode, + errorCode === 400 ? "Bad Request" : "Forbidden", + errorCode, { modifyResponseData: context.options.modifyResponseData, }, @@ -521,7 +530,7 @@ function wrapper(context) { return; } - if (!filename) { + if (!resolved) { await goNext(); return; } @@ -531,7 +540,8 @@ function wrapper(context) { return; } - const { size } = /** @type {import("fs").Stats} */ (extra.stats); + const { extra, filename } = resolved; + const { size } = extra.stats; let len = size; let offset = 0; @@ -609,9 +619,7 @@ function wrapper(context) { context.options.lastModified && !getResponseHeader(res, "Last-Modified") ) { - const modified = - /** @type {import("fs").Stats} */ - (extra.stats).mtime.toUTCString(); + const modified = extra.stats.mtime.toUTCString(); setResponseHeader(res, "Last-Modified", modified); } @@ -667,7 +675,7 @@ function wrapper(context) { const result = await getETag()( isStrongETag ? /** @type {Buffer | ReadStream} */ (bufferOrStream) - : /** @type {import("fs").Stats} */ (extra.stats), + : extra.stats, ); // Because we already read stream, we can cache buffer to avoid extra read from fs diff --git a/src/utils/getFilenameFromUrl.js b/src/utils/getFilenameFromUrl.js index 44c7b7bd6..d54bd749a 100644 --- a/src/utils/getFilenameFromUrl.js +++ b/src/utils/getFilenameFromUrl.js @@ -1,7 +1,5 @@ const path = require("node:path"); const querystring = require("node:querystring"); -// eslint-disable-next-line n/no-deprecated-api -const { parse } = require("node:url"); const getPaths = require("./getPaths"); const memorize = require("./memorize"); @@ -17,20 +15,18 @@ function decode(input) { return querystring.unescape(input); } -const memoizedParse = memorize(parse, undefined, (value) => { - if (value.pathname) { - value.pathname = decode(value.pathname); - } +const memoizedParse = memorize((url) => { + const urlObject = new URL(url, "http://localhost"); - return value; -}); + // We can't change pathname in URL object directly because don't decode correctly + return { ...urlObject, pathname: decode(urlObject.pathname) }; +}, undefined); const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/; /** * @typedef {object} Extra - * @property {import("fs").Stats=} stats stats - * @property {number=} errorCode error code + * @property {import("fs").Stats} stats stats * @property {boolean=} immutable true when immutable, otherwise false */ @@ -42,43 +38,55 @@ const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/; * @returns {string} */ -// TODO refactor me in the next major release, this function should return `{ filename, stats, error }` +class FilenameError extends Error { + /** + * @param {string} message message + * @param {number=} code error code + */ + constructor(message, code) { + super(message); + this.name = "FilenameError"; + this.code = code; + } +} + // TODO fix redirect logic when `/` at the end, like https://github.com/pillarjs/send/blob/master/index.js#L586 /** * @template {IncomingMessage} Request * @template {ServerResponse} Response * @param {import("../index.js").FilledContext} context context * @param {string} url url - * @param {Extra=} extra extra - * @returns {string | undefined} filename + * @returns {{ filename: string, extra: Extra } | undefined} result of get filename from url */ -function getFilenameFromUrl(context, url, extra = {}) { - const { options } = context; - const paths = getPaths(context); +function getFilenameFromUrl(context, url) { + /** @type {URL} */ + let urlObject; /** @type {string | undefined} */ let foundFilename; - /** @type {import("node:url").Url} */ - let urlObject; try { // The `url` property of the `request` is contains only `pathname`, `search` and `hash` - urlObject = memoizedParse(url, false, true); + urlObject = memoizedParse(url); } catch { return; } + const { options } = context; + const paths = getPaths(context); + + /** @type {Extra} */ + const extra = {}; + for (const { publicPath, outputPath, assetsInfo } of paths) { /** @type {string | undefined} */ let filename; - /** @type {import("node:url").Url} */ + /** @type {URL} */ let publicPathObject; try { publicPathObject = memoizedParse( publicPath !== "auto" && publicPath ? publicPath : "/", - false, - true, ); } catch { continue; @@ -94,16 +102,12 @@ function getFilenameFromUrl(context, url, extra = {}) { ) { // Null byte(s) if (pathname.includes("\0")) { - extra.errorCode = 400; - - return; + throw new FilenameError("Bad Request", 400); } // ".." is malicious if (UP_PATH_REGEXP.test(path.normalize(`./${pathname}`))) { - extra.errorCode = 403; - - return; + throw new FilenameError("Forbidden", 403); } // Strip the `pathname` property from the `publicPath` option from the start of requested url @@ -161,7 +165,12 @@ function getFilenameFromUrl(context, url, extra = {}) { } } - return foundFilename; + if (!foundFilename) { + return; + } + + return { filename: foundFilename, extra }; } module.exports = getFilenameFromUrl; +module.exports.FilenameError = FilenameError; diff --git a/test/middleware.test.js b/test/middleware.test.js index 4e076031b..3c0836a8a 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -573,16 +573,16 @@ describe.each([ it("should work", (done) => { instance.waitUntilValid(() => { - expect(instance.getFilenameFromUrl("/bundle.js")).toBe( + expect(instance.getFilenameFromUrl("/bundle.js").filename).toBe( path.join(webpackConfig.output.path, "/bundle.js"), ); - expect(instance.getFilenameFromUrl("/")).toBe( + expect(instance.getFilenameFromUrl("/").filename).toBe( path.join(webpackConfig.output.path, "/index.html"), ); - expect(instance.getFilenameFromUrl("/index.html")).toBe( + expect(instance.getFilenameFromUrl("/index.html").filename).toBe( path.join(webpackConfig.output.path, "/index.html"), ); - expect(instance.getFilenameFromUrl("/svg.svg")).toBe( + expect(instance.getFilenameFromUrl("/svg.svg").filename).toBe( path.join(webpackConfig.output.path, "/svg.svg"), ); expect( @@ -617,15 +617,15 @@ describe.each([ it("should work", (done) => { instance.waitUntilValid(() => { - expect(instance.getFilenameFromUrl("/bundle.js")).toBe( + expect(instance.getFilenameFromUrl("/bundle.js").filename).toBe( path.join(webpackConfig.output.path, "/bundle.js"), ); expect(instance.getFilenameFromUrl("/")).toBeUndefined(); - expect(instance.getFilenameFromUrl("/index.html")).toBe( + expect(instance.getFilenameFromUrl("/index.html").filename).toBe( path.join(webpackConfig.output.path, "/index.html"), ); - expect(instance.getFilenameFromUrl("/svg.svg")).toBe( + expect(instance.getFilenameFromUrl("/svg.svg").filename).toBe( path.join(webpackConfig.output.path, "/svg.svg"), ); expect( @@ -658,19 +658,23 @@ describe.each([ it("should work", (done) => { instance.waitUntilValid(() => { expect( - instance.getFilenameFromUrl("/public/path/bundle.js"), + instance.getFilenameFromUrl("/public/path/bundle.js").filename, ).toBe( path.join(webpackPublicPathConfig.output.path, "/bundle.js"), ); - expect(instance.getFilenameFromUrl("/public/path/")).toBe( + expect( + instance.getFilenameFromUrl("/public/path/").filename, + ).toBe( path.join(webpackPublicPathConfig.output.path, "/index.html"), ); expect( - instance.getFilenameFromUrl("/public/path/index.html"), + instance.getFilenameFromUrl("/public/path/index.html").filename, ).toBe( path.join(webpackPublicPathConfig.output.path, "/index.html"), ); - expect(instance.getFilenameFromUrl("/public/path/svg.svg")).toBe( + expect( + instance.getFilenameFromUrl("/public/path/svg.svg").filename, + ).toBe( path.join(webpackPublicPathConfig.output.path, "/svg.svg"), ); @@ -704,20 +708,22 @@ describe.each([ it("should work", (done) => { instance.waitUntilValid(() => { - expect(instance.getFilenameFromUrl("/static-one/bundle.js")).toBe( + expect( + instance.getFilenameFromUrl("/static-one/bundle.js").filename, + ).toBe( path.join(webpackMultiConfig[0].output.path, "/bundle.js"), ); - expect(instance.getFilenameFromUrl("/static-one/")).toBe( + expect(instance.getFilenameFromUrl("/static-one/").filename).toBe( path.join(webpackMultiConfig[0].output.path, "/index.html"), ); expect( - instance.getFilenameFromUrl("/static-one/index.html"), + instance.getFilenameFromUrl("/static-one/index.html").filename, ).toBe( path.join(webpackMultiConfig[0].output.path, "/index.html"), ); - expect(instance.getFilenameFromUrl("/static-one/svg.svg")).toBe( - path.join(webpackMultiConfig[0].output.path, "/svg.svg"), - ); + expect( + instance.getFilenameFromUrl("/static-one/svg.svg").filename, + ).toBe(path.join(webpackMultiConfig[0].output.path, "/svg.svg")); expect( instance.getFilenameFromUrl("/static-one/unknown.unknown"), ).toBeUndefined(); @@ -727,7 +733,9 @@ describe.each([ ), ).toBeUndefined(); - expect(instance.getFilenameFromUrl("/static-two/bundle.js")).toBe( + expect( + instance.getFilenameFromUrl("/static-two/bundle.js").filename, + ).toBe( path.join(webpackMultiConfig[1].output.path, "/bundle.js"), ); expect( @@ -1294,7 +1302,7 @@ describe.each([ }); }); - describe('should not work with the broken "publicPath" option', () => { + describe('should work with the broken "publicPath" option (malformed URI parsed as "/")', () => { let compiler; const outputPath = path.resolve(__dirname, "./outputs/basic"); @@ -1305,7 +1313,7 @@ describe.each([ output: { filename: "bundle.js", path: outputPath, - publicPath: "https://test:malfor%5Med@test.example.com", + publicPath: "http/s://test:malformed%5Med@test.example.com", }, }); diff --git a/types/index.d.ts b/types/index.d.ts index 140425b45..1ba81cd6c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -99,17 +99,16 @@ export = wdm; * @template {IncomingMessage} [RequestInternal=IncomingMessage] * @template {ServerResponse} [ResponseInternal=ServerResponse] * @callback Middleware - * @param {RequestInternal} req - * @param {ResponseInternal} res - * @param {NextFunction} next + * @param {RequestInternal} req request + * @param {ResponseInternal} res response + * @param {NextFunction} next next function * @returns {Promise} */ /** @typedef {import("./utils/getFilenameFromUrl").Extra} Extra */ /** * @callback GetFilenameFromUrl - * @param {string} url - * @param {Extra=} extra - * @returns {string | undefined} + * @param {string} url request URL + * @returns {{ filename: string, extra: Extra } | undefined} a filename with additional information, or `undefined` if nothing is found */ /** * @callback WaitUntilValid @@ -460,10 +459,12 @@ type Middleware< next: NextFunction, ) => Promise; type Extra = import("./utils/getFilenameFromUrl").Extra; -type GetFilenameFromUrl = ( - url: string, - extra?: Extra | undefined, -) => string | undefined; +type GetFilenameFromUrl = (url: string) => + | { + filename: string; + extra: Extra; + } + | undefined; type WaitUntilValid = (callback: Callback) => any; type Invalidate = (callback: Callback) => any; type Close = (callback: (err: Error | null | undefined) => void) => any; diff --git a/types/middleware.d.ts b/types/middleware.d.ts index cce5820ac..2be44b1de 100644 --- a/types/middleware.d.ts +++ b/types/middleware.d.ts @@ -25,6 +25,8 @@ declare namespace wrapper { IncomingMessage, ServerResponse, NormalizedHeaders, + FilenameError, + Extra, ReadStream, }; } @@ -50,4 +52,6 @@ type NextFunction = import("./index.js").NextFunction; type IncomingMessage = import("./index.js").IncomingMessage; type ServerResponse = import("./index.js").ServerResponse; type NormalizedHeaders = import("./index.js").NormalizedHeaders; +type FilenameError = import("./utils/getFilenameFromUrl.js").FilenameError; +type Extra = import("./utils/getFilenameFromUrl.js").Extra; type ReadStream = import("fs").ReadStream; diff --git a/types/utils/getFilenameFromUrl.d.ts b/types/utils/getFilenameFromUrl.d.ts index be28a0ae0..2385f8646 100644 --- a/types/utils/getFilenameFromUrl.d.ts +++ b/types/utils/getFilenameFromUrl.d.ts @@ -1,24 +1,10 @@ export = getFilenameFromUrl; -/** - * @typedef {object} Extra - * @property {import("fs").Stats=} stats stats - * @property {number=} errorCode error code - * @property {boolean=} immutable true when immutable, otherwise false - */ -/** - * decodeURIComponent. - * - * Allows V8 to only deoptimize this fn instead of all of send(). - * @param {string} input - * @returns {string} - */ /** * @template {IncomingMessage} Request * @template {ServerResponse} Response * @param {import("../index.js").FilledContext} context context * @param {string} url url - * @param {Extra=} extra extra - * @returns {string | undefined} filename + * @returns {{ filename: string, extra: Extra } | undefined} result of get filename from url */ declare function getFilenameFromUrl< Request extends IncomingMessage, @@ -26,10 +12,34 @@ declare function getFilenameFromUrl< >( context: import("../index.js").FilledContext, url: string, - extra?: Extra | undefined, -): string | undefined; +): + | { + filename: string; + extra: Extra; + } + | undefined; declare namespace getFilenameFromUrl { - export { IncomingMessage, ServerResponse, Extra }; + export { FilenameError, IncomingMessage, ServerResponse, Extra }; +} +/** + * @typedef {object} Extra + * @property {import("fs").Stats} stats stats + * @property {boolean=} immutable true when immutable, otherwise false + */ +/** + * decodeURIComponent. + * + * Allows V8 to only deoptimize this fn instead of all of send(). + * @param {string} input + * @returns {string} + */ +declare class FilenameError extends Error { + /** + * @param {string} message message + * @param {number=} code error code + */ + constructor(message: string, code?: number | undefined); + code: number | undefined; } type IncomingMessage = import("../index.js").IncomingMessage; type ServerResponse = import("../index.js").ServerResponse; @@ -37,11 +47,7 @@ type Extra = { /** * stats */ - stats?: import("fs").Stats | undefined; - /** - * error code - */ - errorCode?: number | undefined; + stats: import("fs").Stats; /** * true when immutable, otherwise false */ From fe678c407d70b37f9c64fd1be31f762c21a52400 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Tue, 27 Jan 2026 20:02:05 +0000 Subject: [PATCH 06/11] fix: update redirect logic for URLs ending with '/' --- src/utils/getFilenameFromUrl.js | 11 ++++++++++- test/middleware.test.js | 20 +++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/utils/getFilenameFromUrl.js b/src/utils/getFilenameFromUrl.js index d54bd749a..efad5b0f5 100644 --- a/src/utils/getFilenameFromUrl.js +++ b/src/utils/getFilenameFromUrl.js @@ -50,7 +50,6 @@ class FilenameError extends Error { } } -// TODO fix redirect logic when `/` at the end, like https://github.com/pillarjs/send/blob/master/index.js#L586 /** * @template {IncomingMessage} Request * @template {ServerResponse} Response @@ -120,6 +119,16 @@ function getFilenameFromUrl(context, url) { ); try { + if (filename[filename.length - 1] === "/") { + if (options.index === false) { + return; + } else if (options.index === "string") { + filename = path.join(filename, options.index); + } else { + filename = path.join(filename, "index.html"); + } + } + extra.stats = context.outputFileSystem.statSync(filename); } catch { continue; diff --git a/test/middleware.test.js b/test/middleware.test.js index 3c0836a8a..02969c2ee 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -1736,6 +1736,10 @@ describe.each([ value: "noextension", code: 200, }, + { + value: "noextension/", + code: 404, + }, ], }, { @@ -1780,6 +1784,11 @@ describe.each([ contentType: "text/plain; charset=utf-8", code: 200, }, + { + value: "windows%202.txt/", + contentType: get404ContentTypeHeader(name), + code: 404, + }, ], }, { @@ -1945,7 +1954,7 @@ describe.each([ expect(response.statusCode).toEqual(code); - if (data) { + if (data && code !== 404) { expect(response.headers["content-length"]).toEqual( String(data.length), ); @@ -5187,6 +5196,15 @@ describe.each([ "text/html; charset=utf-8", ); }); + + it('should return the "404" code for the "GET" request with a non-existent file', async () => { + const response = await req.get("/default.html/"); + + expect(response.statusCode).toBe(404); + expect(response.headers["content-type"]).toBe( + get404ContentTypeHeader(name), + ); + }); }); describe('should work with "string" value with a custom extension', () => { From 6a8465e66e887ce4427227cfbc81ead153226239 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Tue, 27 Jan 2026 20:42:48 +0000 Subject: [PATCH 07/11] test: add 404 response check for GET request to "index.html" --- test/middleware.test.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/middleware.test.js b/test/middleware.test.js index 02969c2ee..cbc48b703 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -5109,6 +5109,15 @@ describe.each([ ); }); + it('should return the "404" code for the "GET" request to the "index.html" file', async () => { + const response = await req.get("/index.html/"); + + expect(response.statusCode).toBe(404); + expect(response.headers["content-type"]).toEqual( + get404ContentTypeHeader(name), + ); + }); + it('should return the "200" code for the "GET" request to the "index.html" file', async () => { const response = await req.get("/index.html"); From 4b0b8982a35e2e5ad6958095358537da29fa757a Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Wed, 28 Jan 2026 17:23:47 +0000 Subject: [PATCH 08/11] test: add 200 and 404 response checks for GET requests to "/slug/" and "/slug" paths --- src/utils/getFilenameFromUrl.js | 2 +- test/middleware.test.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/utils/getFilenameFromUrl.js b/src/utils/getFilenameFromUrl.js index efad5b0f5..6cb4366db 100644 --- a/src/utils/getFilenameFromUrl.js +++ b/src/utils/getFilenameFromUrl.js @@ -122,7 +122,7 @@ function getFilenameFromUrl(context, url) { if (filename[filename.length - 1] === "/") { if (options.index === false) { return; - } else if (options.index === "string") { + } else if (typeof options.index === "string") { filename = path.join(filename, options.index); } else { filename = path.join(filename, "index.html"); diff --git a/test/middleware.test.js b/test/middleware.test.js index cbc48b703..ba7d38f44 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -5187,10 +5187,22 @@ describe.each([ instance.context.outputFileSystem.mkdirSync(outputPath, { recursive: true, }); + + instance.context.outputFileSystem.mkdirSync( + path.resolve(outputPath, "slug"), + { + recursive: true, + }, + ); instance.context.outputFileSystem.writeFileSync( path.resolve(outputPath, "default.html"), "hello", ); + + instance.context.outputFileSystem.writeFileSync( + path.resolve(outputPath, "slug", "default.html"), + "hello", + ); }); afterAll(async () => { @@ -5206,6 +5218,24 @@ describe.each([ ); }); + it('should return the "200" code for the "GET" request to the "/slug/" path', async () => { + const response = await req.get("/slug/"); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toBe( + "text/html; charset=utf-8", + ); + }); + + it('should return the "404" code for the "GET" request to the "/slug" path', async () => { + const response = await req.get("/slug"); + + expect(response.statusCode).toBe(404); + expect(response.headers["content-type"]).toEqual( + get404ContentTypeHeader(name), + ); + }); + it('should return the "404" code for the "GET" request with a non-existent file', async () => { const response = await req.get("/default.html/"); From 7f2a51b86e184db0759c3d5c82b3cd08f4c0a592 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Wed, 28 Jan 2026 17:29:47 +0000 Subject: [PATCH 09/11] test: update response code for GET request to "/slug" path from 404 to 200 --- test/middleware.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/middleware.test.js b/test/middleware.test.js index ba7d38f44..63cb8e90b 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -5227,12 +5227,12 @@ describe.each([ ); }); - it('should return the "404" code for the "GET" request to the "/slug" path', async () => { + it('should return the "200" code for the "GET" request to the "/slug" path', async () => { const response = await req.get("/slug"); - expect(response.statusCode).toBe(404); - expect(response.headers["content-type"]).toEqual( - get404ContentTypeHeader(name), + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toBe( + "text/html; charset=utf-8", ); }); From 8f5f508b212d080871de87c614e7a3dc55adf383 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Wed, 28 Jan 2026 17:38:26 +0000 Subject: [PATCH 10/11] fix: correct variable reference from filename to pathname in getFilenameFromUrl function --- src/utils/getFilenameFromUrl.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/getFilenameFromUrl.js b/src/utils/getFilenameFromUrl.js index 6cb4366db..dc892cb8c 100644 --- a/src/utils/getFilenameFromUrl.js +++ b/src/utils/getFilenameFromUrl.js @@ -119,7 +119,7 @@ function getFilenameFromUrl(context, url) { ); try { - if (filename[filename.length - 1] === "/") { + if (pathname[pathname.length - 1] === "/") { if (options.index === false) { return; } else if (typeof options.index === "string") { From ee8725daac4326cc048078ce7695152e55bae181 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Wed, 28 Jan 2026 17:50:37 +0000 Subject: [PATCH 11/11] refactor: logic --- src/utils/getFilenameFromUrl.js | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/utils/getFilenameFromUrl.js b/src/utils/getFilenameFromUrl.js index dc892cb8c..a71f9a94a 100644 --- a/src/utils/getFilenameFromUrl.js +++ b/src/utils/getFilenameFromUrl.js @@ -109,26 +109,29 @@ function getFilenameFromUrl(context, url) { throw new FilenameError("Forbidden", 403); } - // Strip the `pathname` property from the `publicPath` option from the start of requested url - // `/complex/foo.js` => `foo.js` - // and add outputPath - // `foo.js` => `/home/user/my-project/dist/foo.js` + let index; + + if (pathname && pathname.endsWith("/")) { + if (options.index === false) { + return; + } + index = + typeof options.index === "string" ? options.index : "index.html"; + } + + // Builds the absolute path of the file to serve: + // - If the URL ends with '/', appends the index file (index.html or custom) to the directory path. + // - If the URL does not end with '/', only joins the relative path to outputPath. + // Example: + // URL: /complex/foo.js => outputPath/complex/foo.js + // URL: /complex/ => outputPath/complex/index.html (or the configured index file) filename = path.join( outputPath, pathname.slice(publicPathPathname.length), + index || "", ); try { - if (pathname[pathname.length - 1] === "/") { - if (options.index === false) { - return; - } else if (typeof options.index === "string") { - filename = path.join(filename, options.index); - } else { - filename = path.join(filename, "index.html"); - } - } - extra.stats = context.outputFileSystem.statSync(filename); } catch { continue;