diff --git a/packages/ghost/.gitignore b/packages/ghost/.gitignore
new file mode 100644
index 00000000..a547bf36
--- /dev/null
+++ b/packages/ghost/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/packages/ghost/index.html b/packages/ghost/index.html
new file mode 100644
index 00000000..72ba3a8b
--- /dev/null
+++ b/packages/ghost/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite App
+
+
+
+
+
+
diff --git a/packages/ghost/package.json b/packages/ghost/package.json
new file mode 100644
index 00000000..f2719f49
--- /dev/null
+++ b/packages/ghost/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@galaxyproject/ghost",
+ "private": false,
+ "version": "0.0.4",
+ "type": "module",
+ "files": [
+ "static"
+ ],
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "prettier": "prettier --write 'package.json' '*.js' 'src/*.js' 'public/*.js'"
+ },
+ "devDependencies": {
+ "jszip": "^3.10.1",
+ "prettier": "^3.6.2",
+ "vite": "^7.0.4"
+ }
+}
diff --git a/packages/ghost/prettier.config.js b/packages/ghost/prettier.config.js
new file mode 100644
index 00000000..6934b044
--- /dev/null
+++ b/packages/ghost/prettier.config.js
@@ -0,0 +1,5 @@
+export default {
+ tabWidth: 4,
+ printWidth: 120,
+ bracketSameLine: true,
+};
diff --git a/packages/ghost/public/ghost-worker.js b/packages/ghost/public/ghost-worker.js
new file mode 100644
index 00000000..85302562
--- /dev/null
+++ b/packages/ghost/public/ghost-worker.js
@@ -0,0 +1,127 @@
+const DESTROY = 5000;
+const TIMEOUT = 100;
+
+const virtualFS = new Map();
+
+let ready = false;
+let scope = "";
+
+const getMimeType = (path) => {
+ const mimeMap = {
+ ".css": "text/css",
+ ".js": "application/javascript",
+ ".json": "application/json",
+ ".jsonp": "application/javascript",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".html": "text/html",
+ ".svg": "image/svg+xml",
+ ".woff": "font/woff",
+ ".woff2": "font/woff2",
+ ".ttf": "font/ttf",
+ };
+ const ext = path.slice(path.lastIndexOf(".")).split("?")[0];
+ return mimeMap[ext] || "text/plain";
+};
+
+self.addEventListener("install", (event) => {
+ console.log("[GHOST] Installing...");
+ event.waitUntil(self.skipWaiting());
+});
+
+self.addEventListener("activate", (event) => {
+ console.log("[GHOST] Activating...");
+ event.waitUntil(clients.claim());
+});
+
+self.addEventListener("message", (event) => {
+ if (event.data.type === "CREATE") {
+ scope = event.data.scope;
+ const files = Object.entries(event.data.files);
+ for (const [path, content] of files) {
+ virtualFS.set(path, content);
+ }
+ console.log(`[GHOST] Serving ${files.length} files from ${scope}`);
+ ready = true;
+ }
+ if (event.data.type === "DESTROY") {
+ virtualFS.clear();
+ console.log("[GHOST] Destroyed filesystem");
+ ready = false;
+ }
+});
+
+self.addEventListener("fetch", (event) => {
+ console.log("[GHOST] Intercepting...");
+ const url = new URL(event.request.url);
+ const isSameOrigin = url.origin === self.location.origin;
+ const scoped = scope.endsWith("/") ? scope : scope + "/";
+
+ // Only handle same-origin requests
+ if (isSameOrigin) {
+ if (event.request.method === "GET" && url.pathname.startsWith(scoped)) {
+ event.respondWith(
+ (async () => {
+ const start = Date.now();
+ while (!ready) {
+ if (Date.now() - start > TIMEOUT * TIMEOUT) {
+ return new Response("Filesystem not ready", {
+ status: 503,
+ headers: { "Cache-Control": "no-store" },
+ });
+ }
+ await new Promise((r) => setTimeout(r, TIMEOUT));
+ }
+ const path = decodeURIComponent(url.pathname).split("?")[0];
+ if (virtualFS.has(path)) {
+ const mime = getMimeType(path);
+ const content = virtualFS.get(path);
+ return new Response(content, { headers: { "Content-Type": mime } });
+ }
+ return new Response("Not Found", { status: 404, statusText: "Not Found" });
+ })(),
+ );
+ } else {
+ // Block other same-origin requests (outside our scope)
+ console.error("[GHOST] Blocking same-origin request:", url.href);
+ event.respondWith(
+ new Response("Blocked by Service Worker", {
+ status: 403,
+ statusText: "Forbidden",
+ headers: { "Content-Type": "text/plain" },
+ }),
+ );
+ }
+ }
+});
+
+// Function to check if there are any active clients within the same scope
+const checkClientsAndCleanup = async () => {
+ // Get all clients that are within the same scope
+ const clientsList = await clients.matchAll({
+ type: "window",
+ includeUncontrolled: true,
+ });
+
+ // Filter clients by scope
+ const scopedClients = clientsList.filter((client) => {
+ const clientUrl = new URL(client.url);
+ return clientUrl.pathname.startsWith(scope);
+ });
+
+ // If no scoped clients are present, start cleanup
+ if (scopedClients.length === 0) {
+ // Clear interval
+ clearInterval(clientCheckInterval);
+
+ // Cleanup logic: unregister service worker or any other resource cleanup
+ await self.registration.unregister();
+
+ // Clear filesystem
+ virtualFS.clear();
+ console.log("[GHOST] Service worker unregistered");
+ }
+};
+
+// Start checking for active clients
+const clientCheckInterval = setInterval(checkClientsAndCleanup, DESTROY);
diff --git a/packages/ghost/public/ghost.xml b/packages/ghost/public/ghost.xml
new file mode 100644
index 00000000..042a3e4a
--- /dev/null
+++ b/packages/ghost/public/ghost.xml
@@ -0,0 +1,56 @@
+
+
+
+ Virtual Webserver for HTML archives
+
+
+ HistoryDatasetAssociation
+ qzv
+
+
+
+ dataset_id
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/ghost/public/logo.svg b/packages/ghost/public/logo.svg
new file mode 100644
index 00000000..1e60c1c6
--- /dev/null
+++ b/packages/ghost/public/logo.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/packages/ghost/src/main.css b/packages/ghost/src/main.css
new file mode 100644
index 00000000..da2c6db8
--- /dev/null
+++ b/packages/ghost/src/main.css
@@ -0,0 +1,25 @@
+:root {
+ --message-background: rgba(255, 255, 255, 0.9);
+ --message-color: rgba(0, 0, 0, 0.9);
+}
+
+#message {
+ background: var(--message-background);
+ border-radius: 4px;
+ color: var(--message-color);
+ display: none;
+ font-family: sans-serif;
+ font-weight: 100;
+ font-size: 0.85rem;
+ left: 1rem;
+ padding: 0.7rem;
+ position: absolute;
+ right: 1rem;
+ top: 1rem;
+ word-break: break-all;
+ z-index: 9999;
+}
+
+body {
+ margin: 0;
+}
\ No newline at end of file
diff --git a/packages/ghost/src/main.js b/packages/ghost/src/main.js
new file mode 100644
index 00000000..3cedb83d
--- /dev/null
+++ b/packages/ghost/src/main.js
@@ -0,0 +1,206 @@
+/*
+GHOST is a Galaxy visualization that securely loads and displays static websites packaged
+as ZIP-based QIIME 2 visualization (.qzv) files or similar archive formats. The archive is
+fetched at runtime, unzipped in memory, and its contents are served to an embedded iframe
+via a service worker. HTML files are automatically rebased so all relative paths resolve
+through the service worker scope.
+
+The service worker enforces a strict same-origin policy:
+- Only GET requests within the defined virtual scope are served.
+- Files are only returned if explicitly loaded into the in-memory virtual file system.
+- Any other same-origin requests (for example, /api/version) are blocked with a 403
+ Forbidden response to prevent malicious HTML content from interacting with the Galaxy API.
+
+This enables arbitrary client-side visualizations to run isolated from the main Galaxy UI,
+while still loading all assets locally from the extracted archive.
+
+Some example archives you can load:
+https://raw.githubusercontent.com/qiime2/q2-fmt/master/demo/raincloud-baseline0.qzv
+https://docs.qiime2.org/2024.2/data/tutorials/moving-pictures/core-metrics-results/faith-pd-group-significance.qzv
+https://raw.githubusercontent.com/caporaso-lab/q2view-visualizations/main/uu-fasttree-empire.qzv
+*/
+
+// Import styles
+import "./main.css";
+
+// Load JSZip to unzip files
+import JSZip from "jszip";
+
+// Access container element
+const appElement = document.getElementById("app");
+
+// Attach mock data for development
+if (import.meta.env.DEV) {
+ // Build the incoming data object
+ const dataIncoming = {
+ visualization_config: {
+ dataset_id: process.env.dataset_id,
+ },
+ };
+
+ // Attach config to the data-incoming attribute
+ appElement?.setAttribute("data-incoming", JSON.stringify(dataIncoming));
+}
+
+// Access attached data
+const incoming = JSON.parse(appElement?.dataset.incoming || "{}");
+
+// Collect dataset identifier
+const DATASET_ID = incoming.visualization_config.dataset_id;
+
+// Collect root
+const ROOT = incoming.root;
+
+// Set index file name
+const INDEX_NAME = "index.html";
+
+// Set static path within GALAXY
+const STATIC_PATH = "static/plugins/visualizations/ghost/static/";
+
+// Set service worker script name
+const SCRIPT_NAME = "ghost-worker.js";
+
+// Locate service worker script
+const SCRIPT_PATH = ROOT ? `${new URL(ROOT).pathname}${STATIC_PATH}` : "/";
+
+// Determine scope
+const SCOPE = `${SCRIPT_PATH}virtual/${DATASET_ID}/`;
+
+// Test dataset
+const TEST_DATA = "https://raw.githubusercontent.com/caporaso-lab/q2view-visualizations/main/uu-fasttree-empire.qzv";
+
+// Determine dataset url
+const ZIP = DATASET_ID ? `${ROOT}api/datasets/${DATASET_ID}/display` : TEST_DATA;
+
+// Ignore list for zip loader
+const IGNORE = ["__MACOSX/", ".DS_Store"];
+
+// Add message container
+const messageElement = document.createElement("div");
+messageElement.id = "message";
+messageElement.style.display = "none";
+appElement.appendChild(messageElement);
+
+// Unzip and write files to virtual file system
+async function loadZip() {
+ const response = await fetch(ZIP);
+ const zip = await JSZip.loadAsync(await response.arrayBuffer());
+ const files = {};
+
+ // Detect index path from the index inside the zip
+ const candidates = [];
+ for (const [path, file] of Object.entries(zip.files)) {
+ if (!file.dir) {
+ const canon = "/" + path.replace(/\\/g, "/").replace(/^\.\//, "");
+ if (canon.toLowerCase().endsWith(`/${INDEX_NAME}`)) {
+ const dir = canon.slice(0, -(INDEX_NAME.length + 1));
+ const depth = dir.split("/").filter(Boolean).length;
+ candidates.push({ dir, depth, len: dir.length });
+ }
+ }
+ }
+ if (!candidates.length) {
+ throw new Error(`No ${INDEX_NAME} found in ZIP`);
+ }
+ candidates.sort((a, b) => a.depth - b.depth || a.len - b.len);
+ const indexPath = candidates[0].dir;
+ console.log("[GHOST] Detected index path:", indexPath);
+
+ // Process files
+ for (const [path, file] of Object.entries(zip.files)) {
+ if (!file.dir && !IGNORE.some((pattern) => path.includes(pattern))) {
+ const normalizedPath = "/" + path.replace(/\\/g, "/").replace(/^\.\//, "");
+ // Only process files under index path
+ if (normalizedPath.startsWith(indexPath)) {
+ // Map to root by removing index prefix
+ const rootPath = normalizedPath.slice(indexPath.length);
+ const content = await file.async("uint8array");
+ files[SCOPE + rootPath.slice(1)] = content;
+ }
+ }
+ }
+ console.log("[GHOST] Registered files:", Object.keys(files).length);
+ return files;
+}
+
+// Register service worker
+async function registerWorker() {
+ if (navigator.serviceWorker) {
+ try {
+ let registration = await navigator.serviceWorker.getRegistration(SCOPE);
+ if (!registration) {
+ console.log("[GHOST] Creating service worker...");
+ // Create service worker
+ registration = await navigator.serviceWorker.register(`${SCRIPT_PATH}${SCRIPT_NAME}`, {
+ scope: SCOPE,
+ updateViaCache: "none",
+ });
+ }
+
+ // Wait for service to become active
+ if (!registration.active) {
+ await new Promise((resolve) => {
+ const sw = registration.installing || registration.waiting;
+ if (sw) {
+ sw.addEventListener("statechange", function onStateChange(e) {
+ if (e.target.state === "activated") {
+ sw.removeEventListener("statechange", onStateChange);
+ resolve();
+ }
+ });
+ } else {
+ resolve();
+ }
+ });
+ }
+
+ // Initialize and populate contents
+ console.log("[GHOST] Loading ZIP content from", ZIP);
+ const files = await loadZip();
+ registration.active.postMessage({ type: "CREATE", scope: SCOPE, files: files });
+
+ // Return service worker handle
+ return registration;
+ } catch (e) {
+ console.error(e);
+ throw new Error("[GHOST] Service activation failed.", e);
+ }
+ } else {
+ throw new Error("[GHOST] Service workers not supported.");
+ }
+}
+
+// Show error message
+function showMessage(title, details = null) {
+ details = details ? `: ${details}` : "";
+ messageElement.innerHTML = `${title}${details}`;
+ messageElement.style.display = "inline";
+ console.debug(`${title}${details}`);
+}
+
+// Mount website
+function showWebsite() {
+ const iframe = document.createElement("iframe");
+ iframe.style.width = "100%";
+ iframe.style.height = "100vh";
+ iframe.style.border = "none";
+ iframe.src = `${SCOPE}${INDEX_NAME}`;
+ document.getElementById("app").innerHTML = "";
+ document.getElementById("app").appendChild(iframe);
+}
+
+// Render content
+async function startApp() {
+ try {
+ console.log("[GHOST] Registering service worker...");
+ await registerWorker();
+ console.log("[GHOST] Mounting website...");
+ showWebsite();
+ } catch (err) {
+ console.error("[GHOST] Error:", err);
+ showMessage("Loading Error", err.message);
+ }
+}
+
+// Start the app
+startApp();
diff --git a/packages/ghost/vite.config.charts.js b/packages/ghost/vite.config.charts.js
new file mode 100644
index 00000000..415b0a2f
--- /dev/null
+++ b/packages/ghost/vite.config.charts.js
@@ -0,0 +1,56 @@
+import { defineConfig } from "vite";
+
+const env = {
+ GALAXY_DATASET_ID: "",
+ GALAXY_KEY: "",
+ GALAXY_ROOT: "http://127.0.0.1:8080",
+};
+
+Object.keys(env).forEach((key) => {
+ if (process.env[key]) {
+ env[key] = process.env[key];
+ } else {
+ console.log(`${key} not available. Please provide as environment variable.`);
+ }
+});
+
+// https://vitejs.dev/config/
+export const viteConfigCharts = defineConfig({
+ build: {
+ outDir: "./static",
+ emptyOutDir: true,
+ rollupOptions: {
+ output: {
+ manualChunks: () => "app.js",
+ entryFileNames: "[name].js",
+ chunkFileNames: "[name].js",
+ assetFileNames: "[name][extname]",
+ },
+ },
+ },
+ define: {
+ "process.env.credentials": JSON.stringify(env.GALAXY_KEY ? "omit" : "include"),
+ "process.env.dataset_id": JSON.stringify(env.GALAXY_DATASET_ID),
+ },
+ resolve: {
+ alias: {
+ "@": "/src",
+ },
+ },
+ server: {
+ proxy: {
+ "/api": {
+ changeOrigin: true,
+ rewrite: (path) => {
+ if (env.GALAXY_KEY) {
+ const separator = path.includes("?") ? "&" : "?";
+ return `${path}${separator}key=${env.GALAXY_KEY}`;
+ } else {
+ return path;
+ }
+ },
+ target: env.GALAXY_ROOT,
+ },
+ },
+ },
+});
diff --git a/packages/ghost/vite.config.js b/packages/ghost/vite.config.js
new file mode 100644
index 00000000..b994df7c
--- /dev/null
+++ b/packages/ghost/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from "vite";
+import { viteConfigCharts } from "./vite.config.charts";
+
+export default defineConfig({
+ ...viteConfigCharts,
+ /* Insert your existing vite.config settings here. */
+});