fix: serve plugin SVG assets with correct MIME type

This commit is contained in:
Federico Jaramillo Martinez
2026-07-12 22:39:41 +02:00
parent 16b801b8cd
commit 21c58fe656
6 changed files with 58 additions and 8 deletions
+5
View File
@@ -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.
+9 -1
View File
@@ -347,7 +347,15 @@ A plugin can fetch its own static assets with URLs under:
/pi-web-plugins/<plugin-id>/<path-inside-plugin-root>
```
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
+5
View File
@@ -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("<svg");
const missingResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/missing.js" });
expect(missingResponse.statusCode).toBe(404);
});
+8 -1
View File
@@ -100,7 +100,7 @@ export function registerAppTestHooks(): void {
piWebPlugins: {
manifest: () => 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('<svg xmlns="http://www.w3.org/2000/svg"></svg>'), contentType: "image/svg+xml" });
return Promise.resolve(undefined);
}
export interface CapturedSessionDaemonRequest {
method: string;
path: string;
+22
View File
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></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 }] } },
+9 -6
View File
@@ -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<string, unknown> {