From 21c58fe6560db74af889610884a29ef9557ea4fc Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 12 Jul 2026 22:39:41 +0200 Subject: [PATCH] fix: serve plugin SVG assets with correct MIME type --- .changeset/serve-plugin-svg-assets.md | 5 +++++ docs/plugins.md | 10 +++++++++- src/server/app.plugins.test.ts | 5 +++++ src/server/app.testSupport.ts | 9 ++++++++- src/server/piWebPluginService.test.ts | 22 ++++++++++++++++++++++ src/server/piWebPluginService.ts | 15 +++++++++------ 6 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 .changeset/serve-plugin-svg-assets.md diff --git a/.changeset/serve-plugin-svg-assets.md b/.changeset/serve-plugin-svg-assets.md new file mode 100644 index 0000000..a17dea4 --- /dev/null +++ b/.changeset/serve-plugin-svg-assets.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Serve PI WEB plugin SVG assets with a browser-compatible content type and clarify module-relative asset packaging. diff --git a/docs/plugins.md b/docs/plugins.md index dd08ba6..77452eb 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -347,7 +347,15 @@ A plugin can fetch its own static assets with URLs under: /pi-web-plugins// ``` -PI WEB prevents asset path traversal outside the plugin root. JavaScript, JSON, CSS, and HTML get appropriate content types; other files are served as octet-stream. +Prefer module-relative asset URLs so they also work for remote machine plugins. For example, a built plugin module can reference an SVG shipped beside it: + +```js +const iconUrl = new URL("./assets/icon.svg", import.meta.url); +``` + +The final installed plugin package must contain `assets/icon.svg` at that path relative to the final built module. PI WEB serves files that already exist in the package; it does not copy a source `public/` directory or apply Vite-style public-directory semantics. Configure the plugin build and package contents to emit or copy the asset into its final module-relative location. + +PI WEB prevents asset path traversal outside the plugin root. JavaScript, JSON, CSS, HTML, and SVG files get appropriate content types; unknown file types are served as octet-stream. ## Plugin module shape diff --git a/src/server/app.plugins.test.ts b/src/server/app.plugins.test.ts index 1969335..d4e4d8e 100644 --- a/src/server/app.plugins.test.ts +++ b/src/server/app.plugins.test.ts @@ -24,6 +24,11 @@ describe("buildApp PI WEB plugin routes", () => { expect(assetResponse.headers["content-type"]).toContain("application/javascript"); expect(assetResponse.body).toBe("export default {};"); + const svgResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/assets/icon.svg" }); + expect(svgResponse.statusCode).toBe(200); + expect(svgResponse.headers["content-type"]).toContain("image/svg+xml"); + expect(svgResponse.body).toContain(" Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }), plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }), - readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined), + readAsset: fakePiWebPluginAsset, }, clientDist: false, logger: false, @@ -123,6 +123,13 @@ export function registerAppTestHooks(): void { }); } +function fakePiWebPluginAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> { + if (pluginId !== "fake") return Promise.resolve(undefined); + if (assetPath === "plugin.js") return Promise.resolve({ content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" }); + if (assetPath === "assets/icon.svg") return Promise.resolve({ content: Buffer.from(''), contentType: "image/svg+xml" }); + return Promise.resolve(undefined); +} + export interface CapturedSessionDaemonRequest { method: string; path: string; diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 733cfe0..7507aa7 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -44,6 +44,28 @@ describe("PiWebPluginService", () => { expect(asset?.content.toString("utf8")).toContain("export default"); }); + it("serves nested SVG assets with a browser-compatible content type", async () => { + const pluginDir = join(tempDir, "plugins", "icons"); + const svg = ''; + await writePlugin(pluginDir, { + packageJson: { piWeb: { plugins: [{ id: "icons", module: "pi-web-plugin.js" }] } }, + files: { + "pi-web-plugin.js": "export default {};", + "assets/icon.svg": svg, + "assets/uppercase.SVG": svg, + "assets/data.bin": "unknown", + }, + }); + + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + + const svgAsset = await service.readAsset("icons", "assets/icon.svg"); + expect(svgAsset?.contentType).toBe("image/svg+xml"); + expect(svgAsset?.content.toString("utf8")).toBe(svg); + await expect(service.readAsset("icons", "assets/uppercase.SVG")).resolves.toMatchObject({ contentType: "image/svg+xml" }); + await expect(service.readAsset("icons", "assets/data.bin")).resolves.toMatchObject({ contentType: "application/octet-stream" }); + }); + it("includes machine-specific preferences in plugin manifests", async () => { await writePlugin(join(tempDir, "plugins", "updates"), { packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } }, diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index 563315e..0566153 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { readdir, readFile, realpath, stat } from "node:fs/promises"; -import { dirname, join, relative, resolve, sep } from "node:path"; +import { dirname, extname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; import { loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js"; @@ -335,11 +335,14 @@ function isWithin(root: string, candidate: string): boolean { } function contentTypeFor(path: string): string { - if (path.endsWith(".js")) return "application/javascript; charset=utf-8"; - if (path.endsWith(".json")) return "application/json; charset=utf-8"; - if (path.endsWith(".css")) return "text/css; charset=utf-8"; - if (path.endsWith(".html")) return "text/html; charset=utf-8"; - return "application/octet-stream"; + switch (extname(path).toLowerCase()) { + case ".js": return "application/javascript; charset=utf-8"; + case ".json": return "application/json; charset=utf-8"; + case ".css": return "text/css; charset=utf-8"; + case ".html": return "text/html; charset=utf-8"; + case ".svg": return "image/svg+xml"; + default: return "application/octet-stream"; + } } function isRecord(value: unknown): value is Record {