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..6b00d34 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -44,6 +44,44 @@ describe("PiWebPluginService", () => { expect(asset?.content.toString("utf8")).toContain("export default"); }); + it("preserves content types for extension-only asset names", async () => { + const pluginDir = join(tempDir, "plugins", "extension-only"); + await writePlugin(pluginDir, { + packageJson: { piWeb: { plugins: [{ id: "extension-only", module: ".js" }] } }, + files: { + ".js": "export default {};", + ".svg": '', + }, + }); + + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + + await expect(service.readAsset("extension-only", ".js")).resolves.toMatchObject({ contentType: "application/javascript; charset=utf-8" }); + await expect(service.readAsset("extension-only", ".svg")).resolves.toMatchObject({ contentType: "image/svg+xml" }); + }); + + 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..45938ea 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -335,10 +335,12 @@ 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"; + const lowerPath = path.toLowerCase(); + if (lowerPath.endsWith(".js")) return "application/javascript; charset=utf-8"; + if (lowerPath.endsWith(".json")) return "application/json; charset=utf-8"; + if (lowerPath.endsWith(".css")) return "text/css; charset=utf-8"; + if (lowerPath.endsWith(".html")) return "text/html; charset=utf-8"; + if (lowerPath.endsWith(".svg")) return "image/svg+xml"; return "application/octet-stream"; }