Archived
Merge pull request #49 from jmfederico/fix/plugin-svg-content-type
Serve plugin SVG assets with the correct content 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-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
|
## Plugin module shape
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ describe("buildApp PI WEB plugin routes", () => {
|
|||||||
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
|
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
|
||||||
expect(assetResponse.body).toBe("export default {};");
|
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" });
|
const missingResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/missing.js" });
|
||||||
expect(missingResponse.statusCode).toBe(404);
|
expect(missingResponse.statusCode).toBe(404);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function registerAppTestHooks(): void {
|
|||||||
piWebPlugins: {
|
piWebPlugins: {
|
||||||
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
|
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 }] }),
|
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,
|
clientDist: false,
|
||||||
logger: 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 {
|
export interface CapturedSessionDaemonRequest {
|
||||||
method: string;
|
method: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
|||||||
@@ -44,6 +44,44 @@ describe("PiWebPluginService", () => {
|
|||||||
expect(asset?.content.toString("utf8")).toContain("export default");
|
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": '<svg xmlns="http://www.w3.org/2000/svg"></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 = '<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 () => {
|
it("includes machine-specific preferences in plugin manifests", async () => {
|
||||||
await writePlugin(join(tempDir, "plugins", "updates"), {
|
await writePlugin(join(tempDir, "plugins", "updates"), {
|
||||||
packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } },
|
packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } },
|
||||||
|
|||||||
@@ -335,10 +335,12 @@ function isWithin(root: string, candidate: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function contentTypeFor(path: string): string {
|
function contentTypeFor(path: string): string {
|
||||||
if (path.endsWith(".js")) return "application/javascript; charset=utf-8";
|
const lowerPath = path.toLowerCase();
|
||||||
if (path.endsWith(".json")) return "application/json; charset=utf-8";
|
if (lowerPath.endsWith(".js")) return "application/javascript; charset=utf-8";
|
||||||
if (path.endsWith(".css")) return "text/css; charset=utf-8";
|
if (lowerPath.endsWith(".json")) return "application/json; charset=utf-8";
|
||||||
if (path.endsWith(".html")) return "text/html; 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";
|
return "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user