diff --git a/README.md b/README.md index a21a552..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,9 +37,25 @@ 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 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. -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. +```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/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 369c43b..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(); @@ -58,7 +62,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(); @@ -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 = [ { @@ -106,7 +112,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 +120,149 @@ describe("middleware", () => { } }); + it("applies per-rule headers", () => { + // given + 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(); + + // 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("drops undefined header values", () => { + // given + 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(); + + // 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 = { + 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 = { + rules: [ + { + 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); @@ -157,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(); @@ -180,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(); + }); }); diff --git a/packages/vite-plugin-serve-static/lib/config.ts b/packages/vite-plugin-serve-static/lib/config.ts index e8befb8..0d66db9 100644 --- a/packages/vite-plugin-serve-static/lib/config.ts +++ b/packages/vite-plugin-serve-static/lib/config.ts @@ -1,6 +1,36 @@ +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) + .filter(([, value]) => value !== undefined) + .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);