From e5c78b84617e24f6a94c16d9ac53c2fb08ea916b Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Mon, 2 Mar 2026 01:33:25 -0800 Subject: [PATCH 1/3] feat: allow customization of response headers --- README.md | 23 +++- .../lib/__tests__/middleware.test.ts | 114 +++++++++++++++++- .../vite-plugin-serve-static/lib/config.ts | 32 ++++- .../lib/middleware.ts | 19 ++- 4 files changed, 176 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a21a552..f55e6bf 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,28 @@ export default defineConfig({ ## Config -The configuration is defined as an array of objects defining which patterns to intercept and how to resolve them. +The configuration can be provided as one of the following formats. -Each `pattern` is defined as a [regular expression]. The `resolve` property can either be a string containing the path to a single file or a function that returns a string given the result of executing the `pattern` against the request path. +- An array of rules (legacy mode) +- An object with `rules`, plus an optional global `contentType` + +Each rule defines which patterns to intercept and how to resolve them. Each `pattern` is defined as a [regular expression]. The `resolve` property can either be a string containing the path to a single file or a function that returns a string given the result of executing the `pattern` against the request path. Rules can also specify `headers` to apply per match. + +```typescript +const serveStaticPlugin = serveStatic({ + contentType: "text/plain", + rules: [ + { + pattern: /^\/metadata\.json/, + resolve: path.join(".", "metadata.json"), + headers: { + "Cache-Control": "no-store", + "X-Static-File": "true", + }, + }, + ], +}); +``` ## License diff --git a/packages/vite-plugin-serve-static/lib/__tests__/middleware.test.ts b/packages/vite-plugin-serve-static/lib/__tests__/middleware.test.ts index 369c43b..fd6e62e 100644 --- a/packages/vite-plugin-serve-static/lib/__tests__/middleware.test.ts +++ b/packages/vite-plugin-serve-static/lib/__tests__/middleware.test.ts @@ -58,7 +58,7 @@ describe("middleware", () => { // then expect(res.writeHead).toHaveBeenCalledWith( 200, - expect.objectContaining({ "Content-Length": 50, "Content-Type": "application/octet-stream" }), + expect.objectContaining({ "content-length": 50, "content-type": "application/octet-stream" }), ); expect(mockCreateReadStream).toHaveBeenCalledWith(path.join(".", "hello")); expect(mockPipe).toHaveBeenCalled(); @@ -106,7 +106,7 @@ describe("middleware", () => { // then expect(res.writeHead).toHaveBeenCalledWith( 200, - expect.objectContaining({ "Content-Length": test.size, "Content-Type": test.type }), + expect.objectContaining({ "content-length": test.size, "content-type": test.type }), ); expect(mockCreateReadStream).toHaveBeenCalledWith(test.file); expect(mockPipe).toHaveBeenCalled(); @@ -114,6 +114,116 @@ describe("middleware", () => { } }); + it("applies per-rule headers", () => { + // given + const config: Config = [ + { + pattern: /^\/hello/, + resolve: path.join(".", "hello"), + headers: { + "Cache-Control": "no-store", + "X-Static-File": "true", + }, + }, + ]; + const middleware = createMiddleware(config, mockLogger); + const req = createMockReq({ url: "/hello" }); + const res = createMockRes(); + + // when + middleware(req, res, mockNext); + + // then + expect(res.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ + "content-length": 1, + "content-type": "application/octet-stream", + "cache-control": "no-store", + "x-static-file": "true", + }), + ); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it("uses the content type from headers when provided", () => { + // given + const config: Config = { + contentType: "text/plain", + rules: [ + { + pattern: /^\/profile/, + resolve: path.join("..", "profile.json"), + headers: { "Content-Type": "text/plain" }, + }, + ], + }; + const middleware = createMiddleware(config, mockLogger); + const req = createMockReq({ url: "/profile" }); + const res = createMockRes(); + + // when + middleware(req, res, mockNext); + + // then + expect(res.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ + "content-type": "text/plain", + }), + ); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it("uses global content type when no header override is provided", () => { + // given + const config: Config = { + contentType: "text/plain", + rules: [ + { + pattern: /^\/profile/, + resolve: path.join("..", "profile.json"), + }, + ], + }; + const middleware = createMiddleware(config, mockLogger); + const req = createMockReq({ url: "/profile" }); + const res = createMockRes(); + + // when + middleware(req, res, mockNext); + + // then + expect(res.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ "content-length": 1, "content-type": "text/plain" }), + ); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it("uses octet-stream as the content type fallback when there is no MIME type match", () => { + // given + const config: Config = [ + { + pattern: /^\/binary/, + resolve: path.join("..", "file.unknown"), + }, + ]; + const middleware = createMiddleware(config, mockLogger); + const req = createMockReq({ url: "/binary" }); + const res = createMockRes(); + + // when + middleware(req, res, mockNext); + + // then + expect(res.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ "content-type": "application/octet-stream" }), + ); + expect(mockNext).not.toHaveBeenCalled(); + }); + it("returns a 404 if the resolved path cannot be opened", () => { // given mockStatSync.mockReturnValue(undefined); diff --git a/packages/vite-plugin-serve-static/lib/config.ts b/packages/vite-plugin-serve-static/lib/config.ts index e8befb8..fc489a1 100644 --- a/packages/vite-plugin-serve-static/lib/config.ts +++ b/packages/vite-plugin-serve-static/lib/config.ts @@ -1,6 +1,34 @@ +import http from "http"; + export type ResolveFn = (match: RegExpExecArray) => string; -export type Config = { +type RuleConfig = { readonly pattern: RegExp; readonly resolve: string | ResolveFn; -}[]; + readonly headers?: http.OutgoingHttpHeaders; +}; + +export type Config = + | RuleConfig[] + | { + readonly rules: RuleConfig[]; + readonly contentType?: string; + }; + +export function normalizeConfig(config: Config) { + const { rules, ...rest } = Array.isArray(config) ? { rules: config } : config; + + const normalizedRules = rules.map((rule) => ({ + ...rule, + headers: normalizeHeaders(rule.headers), + })); + + return { rules: normalizedRules, ...rest }; +} + +function normalizeHeaders(headers?: http.OutgoingHttpHeaders): http.OutgoingHttpHeaders { + if (!headers) return {}; + + const entries = Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]); + return Object.fromEntries(entries); +} diff --git a/packages/vite-plugin-serve-static/lib/middleware.ts b/packages/vite-plugin-serve-static/lib/middleware.ts index 48bddf1..7d5fcfe 100644 --- a/packages/vite-plugin-serve-static/lib/middleware.ts +++ b/packages/vite-plugin-serve-static/lib/middleware.ts @@ -5,21 +5,22 @@ import corsMiddleware from "cors"; import * as mime from "mime-types"; import { Connect, Logger, PreviewServer, ViteDevServer } from "vite"; -import { Config as PluginConfig } from "./config.ts"; +import { normalizeConfig, Config as PluginConfig } from "./config.ts"; import { isDevServer, setupLogger } from "./utils.ts"; export function createMiddleware( - config: PluginConfig, + pluginConfig: PluginConfig, rawLogger: Logger, ): Connect.NextHandleFunction { const log = setupLogger(rawLogger); + const config = normalizeConfig(pluginConfig); return function serveStaticMiddleware(req, res, next) { if (!req.url) { return next(); } - for (const { pattern, resolve } of config) { + for (const { pattern, resolve, headers } of config.rules) { const match = pattern.exec(req.url); if (match) { @@ -33,10 +34,16 @@ export function createMiddleware( return; } - const type = mime.contentType(path.basename(filePath)); + const contentType = + headers["content-type"] || + config.contentType || + mime.contentType(path.basename(filePath)) || + "application/octet-stream"; + res.writeHead(200, { - "Content-Length": stats.size, - "Content-Type": type || "application/octet-stream", + "content-length": stats.size, + "content-type": contentType, + ...headers, }); const stream = fs.createReadStream(filePath); From c798afa7cdab3ee2db984d8a37b1ef152bc0ec8a Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Mon, 2 Mar 2026 10:44:07 -0800 Subject: [PATCH 2/3] filter out undefined header values --- .../lib/__tests__/middleware.test.ts | 27 +++++++++++++++++++ .../vite-plugin-serve-static/lib/config.ts | 4 ++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/vite-plugin-serve-static/lib/__tests__/middleware.test.ts b/packages/vite-plugin-serve-static/lib/__tests__/middleware.test.ts index fd6e62e..953c713 100644 --- a/packages/vite-plugin-serve-static/lib/__tests__/middleware.test.ts +++ b/packages/vite-plugin-serve-static/lib/__tests__/middleware.test.ts @@ -146,6 +146,33 @@ describe("middleware", () => { expect(mockNext).not.toHaveBeenCalled(); }); + it("drops undefined header values", () => { + // given + const config: Config = [ + { + pattern: /^\/hello/, + resolve: path.join(".", "hello"), + headers: { + "Cache-Control": undefined, + "X-Static-File": "true", + }, + }, + ]; + const middleware = createMiddleware(config, mockLogger); + const req = createMockReq({ url: "/hello" }); + const res = createMockRes(); + + // when + middleware(req, res, mockNext); + + // then + const [status, headers] = vi.mocked(res.writeHead).mock.calls[0]!; + expect(status).toBe(200); + expect(headers).toMatchObject({ "x-static-file": "true" }); + expect(headers).not.toHaveProperty("cache-control"); + expect(mockNext).not.toHaveBeenCalled(); + }); + it("uses the content type from headers when provided", () => { // given const config: Config = { diff --git a/packages/vite-plugin-serve-static/lib/config.ts b/packages/vite-plugin-serve-static/lib/config.ts index fc489a1..0d66db9 100644 --- a/packages/vite-plugin-serve-static/lib/config.ts +++ b/packages/vite-plugin-serve-static/lib/config.ts @@ -29,6 +29,8 @@ export function normalizeConfig(config: Config) { function normalizeHeaders(headers?: http.OutgoingHttpHeaders): http.OutgoingHttpHeaders { if (!headers) return {}; - const entries = Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]); + const entries = Object.entries(headers) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key.toLowerCase(), value]); return Object.fromEntries(entries); } From 34b4e20af9eff81e9dc302d065b623de6232a9c4 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Mon, 2 Mar 2026 19:39:16 -0800 Subject: [PATCH 3/3] update documentation --- README.md | 35 +++-- apps/example/vite.config.ts | 30 ++-- packages/vite-plugin-serve-static/README.md | 34 ++--- .../lib/__tests__/middleware.test.ts | 131 +++++++++++------- 4 files changed, 135 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index f55e6bf..130214c 100644 --- a/README.md +++ b/README.md @@ -13,20 +13,22 @@ import path from "path"; import { defineConfig } from "vite"; import serveStatic from "vite-plugin-serve-static"; -const serveStaticPlugin = serveStatic([ - { - pattern: /^\/metadata\.json/, - resolve: path.join(".", "metadata.json"), - }, - { - pattern: /^\/dog-photos\/.*/, - resolve: ([match]) => path.join("..", "dog-photos", match), - }, - { - pattern: /^\/author-photos\/(.*)/, - resolve: (groups) => path.join("..", "authors", groups[1]) + ".jpg", - }, -]); +const serveStaticPlugin = serveStatic({ + rules: [ + { + pattern: /^\/metadata\.json/, + resolve: path.join(".", "metadata.json"), + }, + { + pattern: /^\/dog-photos\/.*/, + resolve: ([match]) => path.join("..", "dog-photos", match), + }, + { + pattern: /^\/author-photos\/(.*)/, + resolve: (groups) => path.join("..", "authors", groups[1]) + ".jpg", + }, + ], +}); export default defineConfig({ plugins: [serveStaticPlugin], @@ -35,10 +37,7 @@ export default defineConfig({ ## Config -The configuration can be provided as one of the following formats. - -- An array of rules (legacy mode) -- An object with `rules`, plus an optional global `contentType` +The configuration is provided as an object with `rules`, plus an optional global `contentType`. Each rule defines which patterns to intercept and how to resolve them. Each `pattern` is defined as a [regular expression]. The `resolve` property can either be a string containing the path to a single file or a function that returns a string given the result of executing the `pattern` against the request path. Rules can also specify `headers` to apply per match. diff --git a/apps/example/vite.config.ts b/apps/example/vite.config.ts index fbe13a2..78e5ee4 100644 --- a/apps/example/vite.config.ts +++ b/apps/example/vite.config.ts @@ -9,19 +9,21 @@ const staticDir = path.join(__dirname, "static"); export default defineConfig({ plugins: [ - serveStatic([ - { - pattern: /^\/metadata\.json$/, - resolve: path.join(staticDir, "metadata.json"), - }, - { - pattern: /^\/data\/(.*)$/, - resolve: (match: RegExpExecArray) => path.join(staticDir, "data", `${match[1]!}.csv`), - }, - { - pattern: /^\/pages\/(.*)$/, - resolve: (match: RegExpExecArray) => path.join(staticDir, "pages", `${match[1]!}.html`), - }, - ]), + serveStatic({ + rules: [ + { + pattern: /^\/metadata\.json$/, + resolve: path.join(staticDir, "metadata.json"), + }, + { + pattern: /^\/data\/(.*)$/, + resolve: (match: RegExpExecArray) => path.join(staticDir, "data", `${match[1]!}.csv`), + }, + { + pattern: /^\/pages\/(.*)$/, + resolve: (match: RegExpExecArray) => path.join(staticDir, "pages", `${match[1]!}.html`), + }, + ], + }), ], }); diff --git a/packages/vite-plugin-serve-static/README.md b/packages/vite-plugin-serve-static/README.md index e442ed6..67080b5 100644 --- a/packages/vite-plugin-serve-static/README.md +++ b/packages/vite-plugin-serve-static/README.md @@ -13,20 +13,22 @@ import path from "path"; import { defineConfig } from "vite"; import serveStatic from "vite-plugin-serve-static"; -const serveStaticPlugin = serveStatic([ - { - pattern: /^\/metadata\.json/, - resolve: path.join(".", "metadata.json"), - }, - { - pattern: /^\/dog-photos\/.*/, - resolve: ([match]) => path.join("..", "dog-photos", match), - }, - { - pattern: /^\/author-photos\/(.*)/, - resolve: (groups) => path.join("..", "authors", groups[1]) + ".jpg", - }, -]); +const serveStaticPlugin = serveStatic({ + rules: [ + { + pattern: /^\/metadata\.json/, + resolve: path.join(".", "metadata.json"), + }, + { + pattern: /^\/dog-photos\/.*/, + resolve: ([match]) => path.join("..", "dog-photos", match), + }, + { + pattern: /^\/author-photos\/(.*)/, + resolve: (groups) => path.join("..", "authors", groups[1]) + ".jpg", + }, + ], +}); export default defineConfig({ plugins: [serveStaticPlugin], @@ -35,9 +37,9 @@ export default defineConfig({ ## Config -The configuration is defined as an array of objects defining which patterns to intercept and how to resolve them. +The configuration is provided as an object with `rules`, plus an optional global `contentType`. -Each `pattern` is defined as a [regular expression]. The `resolve` property can either be a string containing the path to a single file or a function that returns a string given the result of executing the `pattern` against the request path. +Each rule defines which patterns to intercept and how to resolve them. Each `pattern` is defined as a [regular expression]. The `resolve` property can either be a string containing the path to a single file or a function that returns a string given the result of executing the `pattern` against the request path. ## License diff --git a/packages/vite-plugin-serve-static/lib/__tests__/middleware.test.ts b/packages/vite-plugin-serve-static/lib/__tests__/middleware.test.ts index 953c713..859e0a0 100644 --- a/packages/vite-plugin-serve-static/lib/__tests__/middleware.test.ts +++ b/packages/vite-plugin-serve-static/lib/__tests__/middleware.test.ts @@ -14,12 +14,14 @@ const mockCreateReadStream = vi.mocked(fs.createReadStream); const mockStatSync = vi.mocked(fs.statSync); const mockPipe = vi.fn(); -const testConfig: Config = [ - { - pattern: /\/test-data\/(.*)/, - resolve: (groups) => path.join("..", "test-data", groups[1] ?? ""), - }, -]; +const testConfig: Config = { + rules: [ + { + pattern: /\/test-data\/(.*)/, + resolve: (groups) => path.join("..", "test-data", groups[1] ?? ""), + }, + ], +}; function expectYield(res: ServerResponse) { expect(mockNext).toHaveBeenCalledOnce(); @@ -42,12 +44,14 @@ describe("middleware", () => { it("works with string resolvers", () => { // given mockStatSync.mockReturnValue({ size: 50, isFile: () => true } as Stats); - const config = [ - { - pattern: /^\/hello/, - resolve: path.join(".", "hello"), - }, - ]; + const config: Config = { + rules: [ + { + pattern: /^\/hello/, + resolve: path.join(".", "hello"), + }, + ], + }; const middleware = createMiddleware(config, mockLogger); const req = createMockReq({ url: "/hello" }); const res = createMockRes(); @@ -66,16 +70,18 @@ describe("middleware", () => { }); it("works with function resolvers", () => { - const config: Config = [ - { - pattern: /^\/profile/, - resolve: () => path.join("..", "profile.json"), - }, - { - pattern: /^\/images\/.*/, - resolve: ([match]) => path.join("..", match), - }, - ]; + const config: Config = { + rules: [ + { + pattern: /^\/profile/, + resolve: () => path.join("..", "profile.json"), + }, + { + pattern: /^\/images\/.*/, + resolve: ([match]) => path.join("..", match), + }, + ], + }; const tests = [ { @@ -116,16 +122,18 @@ describe("middleware", () => { it("applies per-rule headers", () => { // given - const config: Config = [ - { - pattern: /^\/hello/, - resolve: path.join(".", "hello"), - headers: { - "Cache-Control": "no-store", - "X-Static-File": "true", + const config: Config = { + rules: [ + { + pattern: /^\/hello/, + resolve: path.join(".", "hello"), + headers: { + "Cache-Control": "no-store", + "X-Static-File": "true", + }, }, - }, - ]; + ], + }; const middleware = createMiddleware(config, mockLogger); const req = createMockReq({ url: "/hello" }); const res = createMockRes(); @@ -148,16 +156,18 @@ describe("middleware", () => { it("drops undefined header values", () => { // given - const config: Config = [ - { - pattern: /^\/hello/, - resolve: path.join(".", "hello"), - headers: { - "Cache-Control": undefined, - "X-Static-File": "true", + const config: Config = { + rules: [ + { + pattern: /^\/hello/, + resolve: path.join(".", "hello"), + headers: { + "Cache-Control": undefined, + "X-Static-File": "true", + }, }, - }, - ]; + ], + }; const middleware = createMiddleware(config, mockLogger); const req = createMockReq({ url: "/hello" }); const res = createMockRes(); @@ -230,12 +240,14 @@ describe("middleware", () => { it("uses octet-stream as the content type fallback when there is no MIME type match", () => { // given - const config: Config = [ - { - pattern: /^\/binary/, - resolve: path.join("..", "file.unknown"), - }, - ]; + const config: Config = { + rules: [ + { + pattern: /^\/binary/, + resolve: path.join("..", "file.unknown"), + }, + ], + }; const middleware = createMiddleware(config, mockLogger); const req = createMockReq({ url: "/binary" }); const res = createMockRes(); @@ -294,7 +306,7 @@ describe("middleware", () => { it("yields if the config is empty", () => { // given - const middleware = createMiddleware([], mockLogger); + const middleware = createMiddleware({ rules: [] }, mockLogger); const req = createMockReq(); const res = createMockRes(); @@ -317,4 +329,29 @@ describe("middleware", () => { // then expectYield(res); }); + + it("supports the legacy array config format", () => { + // given + const config: Config = [ + { + pattern: /^\/hello/, + resolve: path.join(".", "hello"), + }, + ]; + const middleware = createMiddleware(config, mockLogger); + const req = createMockReq({ url: "/hello" }); + const res = createMockRes(); + + // when + middleware(req, res, mockNext); + + // then + expect(res.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ "content-length": 1, "content-type": "application/octet-stream" }), + ); + expect(mockCreateReadStream).toHaveBeenCalledWith(path.join(".", "hello")); + expect(mockPipe).toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + }); });