Archived
fix: serve plugin SVG assets with correct MIME type
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }] } },
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user