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. */ +});