Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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#<head>#<head>\n <script src="../coi-register.js"></script>#' site/app/index.html
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
Expand Down
41 changes: 40 additions & 1 deletion docs/.vitepress/config.js
Original file line number Diff line number Diff line change
@@ -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: [
Expand Down Expand Up @@ -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: {
Expand All @@ -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();
});
},
},
],
},
});
4 changes: 4 additions & 0 deletions docs/.vitepress/theme/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,7 @@
}
}
}

.Layout * {
box-sizing: border-box;
}
47 changes: 47 additions & 0 deletions docs/public/coi-register.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
})();
46 changes: 46 additions & 0 deletions docs/public/coi-serviceworker.js
Original file line number Diff line number Diff line change
@@ -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,
});
}),
);
});
148 changes: 148 additions & 0 deletions vite-plugin-libopencor.js
Original file line number Diff line number Diff line change
@@ -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/<version>/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 "<base>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 <outDir>/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 (<outDir>/libopencor) so that OpenCOR's document-relative probe
// (`<baseURI>/libopencor/downloads/wasm/<version>/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 });
},
};
}
Loading
Loading