diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0987b5f..d75743c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -39,6 +39,11 @@ jobs: mkdir -p site/app cp -r ./docs/.vitepress/dist/* ./site cp -r ./dist/* ./site/app + - name: Make the app cross-origin isolated + # GitHub Pages can't send the COOP/COEP headers that libOpenCOR needs, + # so load the docs' service worker registration script + # (see docs/public/coi-register.js), whose scope also covers the app. + run: sed -i 's##\n #' site/app/index.html - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 0db4827..cae913f 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -1,14 +1,22 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitepress'; import Components from 'unplugin-vue-components/vite'; import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'; +import libopencor from '../../vite-plugin-libopencor.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const versionNumber = process.env.npm_package_version; +const base = '/simulationvuer/'; // https://vitepress.dev/reference/site-config export default defineConfig({ title: 'SimulationVuer', description: 'API documentation for SimulationVuer', - base: '/simulationvuer/', + base, + // Make the pages cross-origin isolated on hosts that can't send the COOP/COEP headers (e.g., GitHub Pages). + head: [['script', { src: `${base}coi-register.js` }]], themeConfig: { // https://vitepress.dev/reference/default-theme-config nav: [ @@ -55,6 +63,26 @@ export default defineConfig({ }, markdown: { attrs: { disable: true } }, vite: { + // Load the .env files (e.g., VITE_API_LOCATION for the demos) from the project root, like the app does, rather than + // from VitePress' root (i.e., docs). + + envDir: path.resolve(__dirname, '../..'), + + // Some dependencies (e.g., has-hover, used by Plotly.js through PlotVuer) reference Node's `global`, + // which doesn't exist in browsers. + // Note: VitePress runs on Vite 5, whose `define` doesn't reach pre-bundled dependencies in dev, + // so it is also passed to esbuild through `optimizeDeps`. + + define: { + global: 'globalThis', + }, + optimizeDeps: { + esbuildOptions: { + define: { + global: 'globalThis', + }, + }, + }, css: { preprocessorOptions: { scss: { @@ -74,6 +102,17 @@ export default defineConfig({ }), ], }), + libopencor(), + { + name: 'cross-origin-isolation', + configureServer(server) { + server.middlewares.use((_req, res, next) => { + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); + next(); + }); + }, + }, ], }, }); diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index a39de35..67e6992 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -61,3 +61,7 @@ } } } + +.Layout * { + box-sizing: border-box; +} diff --git a/docs/public/coi-register.js b/docs/public/coi-register.js new file mode 100644 index 0000000..7bd2846 --- /dev/null +++ b/docs/public/coi-register.js @@ -0,0 +1,47 @@ +// Register the cross-origin isolation service worker (see coi-serviceworker.js) +// when the host doesn't send the COOP/COEP headers (e.g., GitHub Pages), +// and reload once so that the page is served through it. +// Note: this is loaded as a classic (i.e., non-module) script from the docs' head and, on GitHub Pages, from the app's +// head (see .github/workflows/deploy.yml). The service worker is located relative to this script, so its scope +// (i.e., the docs' base) also covers the app deployed under it. +// Note: the reload is only done once per session to avoid a reload loop if isolation still can't be achieved. + +(() => { + if (window.crossOriginIsolated || !window.isSecureContext || !('serviceWorker' in navigator)) { + try { + sessionStorage.removeItem('coiReloaded'); + } catch { + // Ignore storage errors (e.g., in a private window). + } + + return; + } + + const reload = () => { + try { + if (sessionStorage.getItem('coiReloaded')) { + return; + } + + sessionStorage.setItem('coiReloaded', '1'); + } catch { + // Ignore storage errors (e.g., in a private window). + } + + location.reload(); + }; + + const serviceWorkerUrl = new URL('coi-serviceworker.js', document.currentScript.src); + + navigator.serviceWorker.register(serviceWorkerUrl).then((registration) => { + if (navigator.serviceWorker.controller) { + return; + } + + if (registration.active) { + reload(); + } else { + navigator.serviceWorker.addEventListener('controllerchange', reload); + } + }); +})(); diff --git a/docs/public/coi-serviceworker.js b/docs/public/coi-serviceworker.js new file mode 100644 index 0000000..b78cedf --- /dev/null +++ b/docs/public/coi-serviceworker.js @@ -0,0 +1,46 @@ +// Cross-origin isolation service worker for the docs. +// +// libOpenCOR is built with Emscripten pthreads, which need SharedArrayBuffer (i.e., a shared WebAssembly.Memory), +// which browsers only allow on cross-origin isolated pages. That requires the Cross-Origin-Opener-Policy and +// Cross-Origin-Embedder-Policy response headers, which the dev server sends, but static hosts like GitHub Pages can't. +// So this service worker adds them to every response in its scope (see the registration script in +// docs/.vitepress/config.js). + +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('fetch', (event) => { + const request = event.request; + + // Work around a Chrome bug where "only-if-cached" requests fail unless they are same-origin. + + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return; + } + + event.respondWith( + fetch(request).then((response) => { + // Opaque responses (from no-cors cross-origin requests) can't be modified, so return them as is. + + if (response.status === 0) { + return response; + } + + const headers = new Headers(response.headers); + + headers.set('Cross-Origin-Opener-Policy', 'same-origin'); + headers.set('Cross-Origin-Embedder-Policy', 'require-corp'); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + }), + ); +}); diff --git a/vite-plugin-libopencor.js b/vite-plugin-libopencor.js new file mode 100644 index 0000000..3d32858 --- /dev/null +++ b/vite-plugin-libopencor.js @@ -0,0 +1,148 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const libopencorDir = path.resolve(__dirname, 'node_modules/@opencor/opencor/dist/libopencor'); + +// Serve libOpenCOR for OpenCOR's runtime probe. +// +// In @opencor/opencor, opencor.es.js locates its glue code at runtime: +// new URL("libopencor/downloads/wasm//libopencor.js", document.baseURI).href +// and fetches that same-origin path (HEAD) before falling back to opencor.ws. The WASM is always loaded from a +// hardcoded opencor.ws base via locateFile. The probe is evaluated at runtime and the glue is imported +// dynamically (with @vite-ignore), so there is no static URL for Vite to rewrite. +// +// The probe is satisfied by: +// - Dev (serve): serving "libopencor/*" from the installed package via the middleware below, so the probe +// resolves same-origin (required for pthread Workers). +// - Build: copying the package's libOpenCOR dir to /libopencor via closeBundle, so the document-relative +// probe hits when the page is served from the output root (otherwise OpenCOR falls back to opencor.ws). +// +// The base and outDir come from the resolved config, so the same plugin works for both the app/library (base "/", +// outDir "dist") and the VitePress docs (base "/simulationvuer/", outDir "docs/.vitepress/dist"). + +export default function libopencor() { + let resolvedConfig; + + return { + name: 'opencor-libopencor', + configResolved(config) { + resolvedConfig = config; + }, + configureServer(server) { + // Serve libopencor files from @opencor/opencor during development so the dynamic import in opencor.es.js + // resolves same-origin (required for pthread Workers). + // Note: a relative base (e.g., "./" in app mode) is served from "/" in dev. + + const base = resolvedConfig.base.startsWith('/') ? resolvedConfig.base : '/'; + const prefix = `${base.endsWith('/') ? base : `${base}/`}libopencor/`; + + // Resolve symlinks in libopencorDir once so the per-request guard compares real paths on both sides. + + let realLibopencorDir; + + try { + realLibopencorDir = fs.realpathSync(libopencorDir); + } catch { + realLibopencorDir = libopencorDir; + } + + server.middlewares.use((req, res, next) => { + // Parse the URL to strip any query string, and decode it safely (malformed sequences are rejected). + + let pathname; + + try { + pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); + } catch { + next(); + + return; + } + + if (!pathname.startsWith(prefix)) { + next(); + + return; + } + + // Resolve the requested subpath and verify the result stays within libopencorDir, guarding against + // directory traversal (e.g., "/libopencor/../../..." or percent-encoded ".." segments) and symlink + // escapes (a symlink inside libopencorDir pointing outside would pass a purely lexical check). + // Note: realpathSync() resolves symlinks, so a symlink pointing outside libopencorDir resolves to a path + // outside it, which the relative check below rejects. It also throws if the path doesn't exist. + + let filePath; + + try { + filePath = fs.realpathSync(path.resolve(libopencorDir, pathname.slice(prefix.length))); + } catch { + next(); + + return; + } + + // Reject paths escaping the directory: exactly "..", starting with "../", or absolute (e.g., a + // different drive on Windows). + // Note: a plain startsWith("..") check would wrongly reject legitimate files like "..foo.js". + + const relative = path.relative(realLibopencorDir, filePath); + + if ( + relative === '' || + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + next(); + + return; + } + + // Read the file and serve it with the correct headers. + + let content; + + try { + if (!fs.statSync(filePath).isFile()) { + next(); + + return; + } + + content = fs.readFileSync(filePath); + } catch { + next(); + + return; + } + + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Resource-Policy': 'same-origin', + }); + res.end(content); + }); + }, + closeBundle() { + // Copy libOpenCOR to the output root (/libopencor) so that OpenCOR's document-relative probe + // (`/libopencor/downloads/wasm//libopencor.js`) resolves: in app mode and for the docs, the + // output dir is the deployment root, and in lib mode the probed files ship in the package for consumers that + // serve them at the page root (everyone else falls back to opencor.ws). + // Note: SSR builds (e.g., VitePress' server-side render pass) don't produce a deployable output, so skip them. + + if (resolvedConfig.build.ssr || !fs.existsSync(libopencorDir)) { + return; + } + + const targetDir = path.join( + path.resolve(resolvedConfig.root, resolvedConfig.build.outDir), + 'libopencor', + ); + + fs.cpSync(libopencorDir, targetDir, { recursive: true, force: true }); + }, + }; +} diff --git a/vite.config.js b/vite.config.js index fde0d9c..6fd46c9 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,5 @@ import vue from '@vitejs/plugin-vue'; -import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import AutoImport from 'unplugin-auto-import/vite'; @@ -8,9 +7,10 @@ import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'; import Components from 'unplugin-vue-components/vite'; import { defineConfig } from 'vite'; +import libopencor from './vite-plugin-libopencor.js'; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const pathSrc = path.resolve(__dirname, './src'); -const libopencorDir = path.resolve(__dirname, 'node_modules/@opencor/opencor/dist/libopencor'); export default defineConfig(({ command, mode }) => { const config = { @@ -40,132 +40,7 @@ export default defineConfig(({ command, mode }) => { dts: 'src/components.d.ts', }), - // Serve libOpenCOR for OpenCOR's runtime probe. - // - // In @opencor/opencor, opencor.es.js locates its glue code at runtime: - // new URL("libopencor/downloads/wasm//libopencor.js", document.baseURI).href - // and fetches that same-origin path (HEAD) before falling back to opencor.ws. The WASM is always loaded from a - // hardcoded opencor.ws base via locateFile. The probe is evaluated at runtime and the glue is imported - // dynamically (with @vite-ignore), so there is no static URL for Vite to rewrite. - // - // The probe is satisfied by: - // - Dev (serve): serving "/libopencor/*" from the installed package via the middleware below, so the probe - // resolves same-origin (required for pthread Workers). - // - Build (lib): copying the package's libOpenCOR dir to dist/libopencor via closeBundle, so the - // document-relative probe hits when the page is served from the dist root (otherwise OpenCOR - // falls back to opencor.ws). - - { - name: 'opencor-libopencor', - configureServer(server) { - // Serve libopencor files from @opencor/opencor during development so the dynamic import in opencor.es.js - // resolves same-origin (required for pthread Workers). - - // Resolve symlinks in libopencorDir once so the per-request guard compares real paths on both sides. - - let realLibopencorDir; - - try { - realLibopencorDir = fs.realpathSync(libopencorDir); - } catch { - realLibopencorDir = libopencorDir; - } - - server.middlewares.use((req, res, next) => { - // Parse the URL to strip any query string, and decode it safely (malformed sequences are rejected). - - let pathname; - - try { - pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); - } catch { - next(); - - return; - } - - if (!pathname.startsWith('/libopencor/')) { - next(); - - return; - } - - // Resolve the requested subpath and verify the result stays within libopencorDir, guarding against - // directory traversal (e.g., "/libopencor/../../..." or percent-encoded ".." segments) and symlink - // escapes (a symlink inside libopencorDir pointing outside would pass a purely lexical check). - // Note: realpathSync() resolves symlinks, so a symlink pointing outside libopencorDir resolves to a path - // outside it, which the relative check below rejects. It also throws if the path doesn't exist. - - let filePath; - - try { - filePath = fs.realpathSync( - path.resolve(libopencorDir, pathname.slice('/libopencor/'.length)), - ); - } catch { - next(); - - return; - } - - // Reject paths escaping the directory: exactly "..", starting with "../", or absolute (e.g., a - // different drive on Windows). - // Note: a plain startsWith("..") check would wrongly reject legitimate files like "..foo.js". - - const relative = path.relative(realLibopencorDir, filePath); - - if ( - relative === '' || - relative === '..' || - relative.startsWith(`..${path.sep}`) || - path.isAbsolute(relative) - ) { - next(); - - return; - } - - // Read the file and serve it with the correct headers. - - let content; - - try { - if (!fs.statSync(filePath).isFile()) { - next(); - - return; - } - - content = fs.readFileSync(filePath); - } catch { - next(); - - return; - } - - res.writeHead(200, { - 'Content-Type': 'application/javascript', - 'Cross-Origin-Embedder-Policy': 'require-corp', - 'Cross-Origin-Resource-Policy': 'same-origin', - }); - res.end(content); - }); - }, - closeBundle() { - // Copy liboOpenCOR to the dist root (dist/libopencor) so that OpenCOR's document-relative probe - // (`/libopencor/downloads/wasm//libopencor.js`) resolves: in app mode, dist is the - // deployment root, and in lib mode the probed files ship in the package for consumers that serve them at the - // page root (everyone else falls back to opencor.ws). - - if (!fs.existsSync(libopencorDir)) { - return; - } - - const targetDir = path.join(path.resolve(__dirname, 'dist'), 'libopencor'); - - fs.cpSync(libopencorDir, targetDir, { recursive: true, force: true }); - }, - }, + libopencor(), ], base: mode === 'app' ? './' : '/', build: @@ -213,6 +88,12 @@ export default defineConfig(({ command, mode }) => { 'process.env.HTTP_PROXY': 8081, global: 'globalThis', }; + } else if (mode === 'app') { + // Some dependencies (e.g., has-hover, used by Plotly.js through PlotVuer) reference Node's `global`, + // which doesn't exist in browsers. (In lib mode, it's up to the consumer's bundler.) + config.define = { + global: 'globalThis', + }; } return config; diff --git a/vuese-generator.js b/vuese-generator.js index 649624f..1ad5a8e 100644 --- a/vuese-generator.js +++ b/vuese-generator.js @@ -65,7 +65,7 @@ function transformData(data = []) { prop.describe = [prop.description.replaceAll('\n', ' ')]; } - if (prop.type) { + if (prop.type?.name) { // Handle multiple types separated by '|' // Convert to array to avoid markdown table issues if (prop.type.name.indexOf('|') !== -1) {