simplify, add pwa files during deploy

This commit is contained in:
Alexander Rose
2025-03-15 16:38:04 -07:00
parent 78aae8a2b4
commit 0f6fa5fe15
13 changed files with 224 additions and 215 deletions

15
data/pwa/README.md Normal file
View File

@@ -0,0 +1,15 @@
The files in this directory are used for deploying the Mol* Viewer as a PWA (Progressive Web App) at https://molstar.org/viewer/. They may serve as an example for creating your own PWA but wont work as-is. See `/script/deploy.js` for where these files are copied and how they are transformed during deployment.
## PWA features
- The Service Worker will cache static resources so the Viewer can be used without internet access. This works without installing, i.e., also in Firefox.
- Once installed, file types listed in the Manifest can be opened from, e.g., the Windows File Explorer.
## Notes for development
In Chrome you can see a list of installed PWAs at chrome://apps/. A right-click opens a menu with an option uninstall.
The Chrome Dev Tools have a section 'Application' to inspect and manage PWA aspects like the Manifest and Service Workers.

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,11 +1,11 @@
{
"id": "/molstar/build/viewer/index.html",
"id": "https://molstar.org/viewer/",
"name": "Mol* Viewer",
"short_name": "Mol*",
"description": "Mol* Viewer",
"description": "Mol* Viewer: a modern web app for 3D visualization and analysis of large biomolecular structures.",
"start_url": "./index.html",
"theme_color": "#eeffee",
"background_color": "#eeffee",
"theme_color": "#eeece7",
"background_color": "#eeece7",
"display": "standalone",
"icons": [
{

44
data/pwa/pwa.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Andy Turner <agdturner@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
window.addEventListener('molstarViewerCreated', e => {
const viewer = e.detail.viewer;
// Handle incoming files
if ('launchQueue' in window) {
launchQueue.setConsumer((launchParams) => {
if (!launchParams.files.length) return;
const files = [];
for (const fileHandle of launchParams.files) {
files.push(fileHandle.getFile());
}
Promise.all(files).then((files) => {
viewer.loadFiles(files);
});
});
}
});
// Register Progressive Web App service worker.
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('./sw.js')
.then(function (registration) {
// Registration was successful
if (molstar.isDebugMode) {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
}
}, function (err) {
// registration failed :(
if (molstar.isDebugMode) {
console.error('ServiceWorker registration failed: ', err);
}
});
});
}

60
data/pwa/sw.js Normal file
View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Andy Turner <agdturner@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
/** version from package.json, to be filled in during deployment */
const VERSION = '__MOLSTAR_VERSION__';
const CACHE_NAME = `molstar-viewer-${VERSION}`;
// The static resources that the app needs to function.
const APP_STATIC_RESOURCES = [
'favicon.ico',
'index.html',
'molstar.css',
'molstar.js',
'manifest.webmanifest',
'logo-144.png',
'pwa.js'
];
async function cacheStaticResources() {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(APP_STATIC_RESOURCES);
await self.skipWaiting(); // Ensures the new service worker takes control immediately.
}
async function deleteOldCaches() {
const keys = await caches.keys();
await Promise.all(
keys.map((key) => {
if (key !== CACHE_NAME) {
return caches.delete(key);
}
}),
);
await self.clients.claim(); // Ensures the new service worker takes control immediately.
}
async function respondWithCacheFirst(request) {
// Try to match the request with the cache
const cachedResponse = await caches.match(request);
return cachedResponse || fetch(request);
}
self.addEventListener('install', (event) => {
// console.log(`Service Worker version ${VERSION} installed.`);
event.waitUntil(cacheStaticResources());
});
self.addEventListener('activate', (event) => {
// console.log(`Service Worker version ${VERSION} activated.`);
event.waitUntil(deleteOldCaches());
});
self.addEventListener('fetch', (event) => {
event.respondWith(respondWithCacheFirst(event.request));
});

View File

@@ -20,7 +20,7 @@
"rebuild": "npm run clean && npm run build",
"build-viewer": "npm run build-tsc && npm run build-extra && npm run build-webpack-viewer",
"build-tsc": "concurrently \"tsc --incremental\" \"tsc --build tsconfig.commonjs.json --incremental\"",
"build-extra": "cpx \"src/**/*.{scss,html,ico,jpg,webmanifest,png}\" lib/ && cpx \"src/**/sw.js\" lib/",
"build-extra": "cpx \"src/**/*.{scss,html,ico,jpg}\" lib/",
"build-webpack": "webpack --mode production --config ./webpack.config.production.js",
"build-webpack-viewer": "webpack --mode production --config ./webpack.config.viewer.js",
"watch": "concurrently -c \"green,green,gray,gray\" --names \"tsc,srv,ext,wpc\" --kill-others \"npm:watch-tsc\" \"npm:watch-servers\" \"npm:watch-extra\" \"npm:watch-webpack\"",
@@ -28,7 +28,7 @@
"watch-viewer-debug": "concurrently -c \"green,gray,gray\" --names \"tsc,ext,wpc\" --kill-others \"npm:watch-tsc\" \"npm:watch-extra\" \"npm:watch-webpack-viewer-debug\"",
"watch-tsc": "tsc --watch --incremental",
"watch-servers": "tsc --build tsconfig.commonjs.json --watch --incremental",
"watch-extra": "cpx \"src/**/*.{scss,html,ico,jpg,webmanifest,png}\" lib/ --watch && cpx \"src/**/sw.js\" lib/ --watch",
"watch-extra": "cpx \"src/**/*.{scss,html,ico,jpg}\" lib/ --watch",
"watch-webpack": "webpack -w --mode development --stats minimal",
"watch-webpack-viewer": "webpack -w --mode development --stats minimal --config ./webpack.config.viewer.js",
"watch-webpack-viewer-debug": "webpack -w --mode development --stats minimal --config ./webpack.config.viewer.debug.js",
@@ -134,7 +134,6 @@
"@typescript-eslint/parser": "^7.18.0",
"benchmark": "^2.1.4",
"concurrently": "^9.1.2",
"copy-webpack-plugin": "^13.0.0",
"cpx2": "^8.0.0",
"crypto-browserify": "^3.12.1",
"css-loader": "^7.1.2",

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2019-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2019-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
@@ -9,7 +9,10 @@ const path = require('path');
const fs = require("fs");
const fse = require("fs-extra");
const VERSION = require(path.resolve(__dirname, '../package.json')).version;
const remoteUrl = "https://github.com/molstar/molstar.github.io.git";
const dataDir = path.resolve(__dirname, '../data/');
const buildDir = path.resolve(__dirname, '../build/');
const deployDir = path.resolve(buildDir, 'deploy/');
const localPath = path.resolve(deployDir, 'molstar.github.io/');
@@ -17,6 +20,12 @@ const localPath = path.resolve(deployDir, 'molstar.github.io/');
const analyticsTag = /<!-- __MOLSTAR_ANALYTICS__ -->/g;
const analyticsCode = `<!-- Cloudflare Web Analytics --><script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "c414cbae2d284ea995171a81e4a3e721"}'></script><!-- End Cloudflare Web Analytics --><iframe src="https://web3dsurvey.com/collector-iframe.html" style="width: 1px; height: 1px;"></iframe>`;
const manifestTag = /<!-- __MOLSTAR_MANIFEST__ -->/g;
const manifestCode = `<link rel="manifest" href="./manifest.webmanifest">`;
const pwaTag = /<!-- __MOLSTAR_PWA__ -->/g;
const pwaCode = `<script src='./pwa.js'></script>`;
function log(command, stdout, stderr) {
if (command) {
console.log('\n###', command);
@@ -31,17 +40,41 @@ function addAnalytics(path) {
fs.writeFileSync(path, result, 'utf8');
}
function addManifest(path) {
const data = fs.readFileSync(path, 'utf8');
const result = data.replace(manifestTag, manifestCode);
fs.writeFileSync(path, result, 'utf8');
}
function addPwa(path) {
const data = fs.readFileSync(path, 'utf8');
const result = data.replace(pwaTag, pwaCode);
fs.writeFileSync(path, result, 'utf8');
}
function addVersion(path) {
const data = fs.readFileSync(path, 'utf8');
const result = data.replace('__MOLSTAR_VERSION__', VERSION);
fs.writeFileSync(path, result, 'utf8');
}
function copyViewer() {
console.log('\n###', 'copy viewer files');
const viewerBuildPath = path.resolve(buildDir, '../build/viewer/');
const viewerBuildPath = path.resolve(buildDir, 'viewer/');
const viewerDeployPath = path.resolve(localPath, 'viewer/');
fse.copySync(viewerBuildPath, viewerDeployPath, { overwrite: true });
addAnalytics(path.resolve(viewerDeployPath, 'index.html'));
addManifest(path.resolve(viewerDeployPath, 'index.html'));
addPwa(path.resolve(viewerDeployPath, 'index.html'));
const pwaDataPath = path.resolve(dataDir, 'pwa/');
fse.copySync(pwaDataPath, viewerDeployPath, { overwrite: true });
addVersion(path.resolve(viewerDeployPath, 'sw.js'));
}
function copyMe() {
console.log('\n###', 'copy me files');
const meBuildPath = path.resolve(buildDir, '../build/mesoscale-explorer/');
const meBuildPath = path.resolve(buildDir, 'mesoscale-explorer/');
const meDeployPath = path.resolve(localPath, 'me/viewer/');
fse.copySync(meBuildPath, meDeployPath, { overwrite: true });
addAnalytics(path.resolve(meDeployPath, 'index.html'));
@@ -49,12 +82,12 @@ function copyMe() {
function copyDemos() {
console.log('\n###', 'copy demos files');
const lightingBuildPath = path.resolve(buildDir, '../build/examples/lighting/');
const lightingBuildPath = path.resolve(buildDir, 'examples/lighting/');
const lightingDeployPath = path.resolve(localPath, 'demos/lighting/');
fse.copySync(lightingBuildPath, lightingDeployPath, { overwrite: true });
addAnalytics(path.resolve(lightingDeployPath, 'index.html'));
const orbitalsBuildPath = path.resolve(buildDir, '../build/examples/alpha-orbitals/');
const orbitalsBuildPath = path.resolve(buildDir, 'examples/alpha-orbitals/');
const orbitalsDeployPath = path.resolve(localPath, 'demos/alpha-orbitals/');
fse.copySync(orbitalsBuildPath, orbitalsDeployPath, { overwrite: true });
addAnalytics(path.resolve(orbitalsDeployPath, 'index.html'));

View File

@@ -61,7 +61,7 @@ import { ObjectKeys } from '../../mol-util/type-helpers';
import { OpenFiles } from '../../mol-plugin-state/actions/file';
export { PLUGIN_VERSION as version } from '../../mol-plugin/version';
export { consoleStats, setDebugMode, setProductionMode, setTimingMode } from '../../mol-util/debug';
export { consoleStats, setDebugMode, setProductionMode, setTimingMode, isProductionMode, isDebugMode, isTimingMode } from '../../mol-util/debug';
const CustomFormats = [
['g3d', G3dProvider] as const
@@ -561,14 +561,14 @@ export class Viewer {
}
}
async loadFiles(files: File[]) {
loadFiles(files: File[]) {
const sessions = files.filter(f => {
const fn = f.name.toLowerCase();
return fn.endsWith('.molx') || fn.endsWith('.molj');
});
if (sessions.length > 0) {
PluginCommands.State.Snapshots.OpenFile(this.plugin, { file: sessions[0] });
return PluginCommands.State.Snapshots.OpenFile(this.plugin, { file: sessions[0] });
} else {
return this.plugin.runTask(this.plugin.state.data.applyAction(OpenFiles, {
files: files.map(f => Asset.File(f)),

View File

@@ -35,7 +35,7 @@
}
</style>
<link rel="stylesheet" type="text/css" href="molstar.css" />
<link rel="manifest" href="./manifest.webmanifest">
<!-- __MOLSTAR_MANIFEST__ -->
</head>
<body>
<div id="app"></div>
@@ -132,37 +132,11 @@
viewer.dispose();
});
// Handle incoming files
if ('launchQueue' in window) {
launchQueue.setConsumer((launchParams) => {
if (!launchParams.files.length) return;
const files = [];
for (const fileHandle of launchParams.files) {
files.push(fileHandle.getFile());
}
Promise.all(files).then((files) => {
viewer.loadFiles(files);
});
});
}
const event = new CustomEvent("molstarViewerCreated", { detail: { viewer } });
window.dispatchEvent(event);
});
// Register Progressive Web App service worker.
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('./sw.js')
.then(function (registration) {
// Registration was successful
console.log('ServiceWorker registration successful with scope: ', registration.scope);
}, function (err) {
// registration failed :(
console.log('ServiceWorker registration failed: ', err);
});
});
}
</script>
<!-- __MOLSTAR_PWA__ -->
<!-- __MOLSTAR_ANALYTICS__ -->
</body>
</html>

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -8,7 +8,5 @@
import './embedded.html';
import './favicon.ico';
import './index.html';
import './manifest.webmanifest';
import './logo-144.png';
import '../../mol-plugin-ui/skin/light.scss';
export * from './app';

View File

@@ -1,95 +0,0 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Andy Turner <agdturner@gmail.com>
*
* @description Service worker for the viewer app.
* The service worker:
* - caches the static resources that the app needs to function
* - intercepts server requests and responds with cached responses instead of going to the network
* - deletes old caches on activation
* - responds with cached resources on fetch
* - responds with a network error if fetching fails
* - responds with a cache error if the cache match fails
*
* To not complicate the build process, this file is not transpiled but written in JavaScript.
* It is coppied to the build directory as is and no imports must be used.
*/
/** version from package.json, to be filled in at build time */
const VERSION = '';
const CACHE_NAME = `molstar-viewer-${VERSION}`;
// The static resources that the app needs to function.
const APP_STATIC_RESOURCES = [
'favicon.ico',
'index.html',
'molstar.css',
'molstar.js',
'manifest.webmanifest',
'logo-144.png'
];
// On install, cache the static resources.
self.addEventListener('install', (event) => {
console.log(`Service Worker version ${VERSION} installed.`);
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(APP_STATIC_RESOURCES);
await self.skipWaiting(); // Ensures the new service worker takes control immediately.
})(),
);
});
// On activate, delete old caches.
self.addEventListener('activate', (event) => {
console.log(`Service Worker version ${VERSION} activated.`);
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys.map((key) => {
if (key !== CACHE_NAME) {
return caches.delete(key);
}
}),
);
await self.clients.claim(); // Ensures the new service worker takes control immediately.
})(),
);
});
// On fetch, respond with cached resources.
self.addEventListener('fetch', (event) => {
event.respondWith(
(async () => {
try {
// Try to match the request with the cache
const cachedResponse = await caches.match(event.request);
if (cachedResponse) {
return cachedResponse;
}
// Fetch from network if not found in cache
const networkResponse = await fetch(event.request);
// Check if the network response is valid
if (!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') {
return networkResponse;
}
// Clone the network response
const responseToCache = networkResponse.clone();
// Open the cache and put the network response in it
const cache = await caches.open(CACHE_NAME);
await cache.put(event.request, responseToCache);
return networkResponse;
} catch (error) {
console.error('Fetching failed:', error);
return new Response('Network error occurred', {
status: 408,
statusText: 'Network error occurred'
});
}
})()
);
});

View File

@@ -3,7 +3,6 @@ const fs = require('fs');
const webpack = require('webpack');
const ExtraWatchWebpackPlugin = require('extra-watch-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const VERSION = require('./package.json').version;
class VersionFilePlugin {
@@ -14,8 +13,31 @@ class VersionFilePlugin {
}
}
function getSharedConfig(copyPluginPatterns) {
const plugins = [
const sharedConfig = {
module: {
rules: [
{
test: /\.(html|ico)$/,
use: [{
loader: 'file-loader',
options: { name: '[name].[ext]' }
}]
},
{
test: /\.(s*)css$/,
use: [
MiniCssExtractPlugin.loader,
{ loader: 'css-loader', options: { sourceMap: false } },
{ loader: 'sass-loader', options: { sourceMap: false } },
]
},
{
test: /\.(jpg)$/i,
type: 'asset/resource',
},
]
},
plugins: [
new ExtraWatchWebpackPlugin({
files: [
'./lib/**/*.scss',
@@ -28,73 +50,40 @@ function getSharedConfig(copyPluginPatterns) {
}),
new MiniCssExtractPlugin({ filename: 'molstar.css' }),
new VersionFilePlugin(),
];
if (copyPluginPatterns && copyPluginPatterns.length > 0) {
plugins.push(new CopyPlugin({
patterns: copyPluginPatterns,
}));
}
return {
module: {
rules: [
{
test: /\.(html|ico|webmanifest|png)$/,
use: [{
loader: 'file-loader',
options: { name: '[name].[ext]' }
}]
},
{
test: /\.(s*)css$/,
use: [
MiniCssExtractPlugin.loader,
{ loader: 'css-loader', options: { sourceMap: false } },
{ loader: 'sass-loader', options: { sourceMap: false } },
]
},
{
test: /\.(jpg)$/i,
type: 'asset/resource',
},
]
},
plugins: plugins,
resolve: {
modules: [
'node_modules',
path.resolve(__dirname, 'lib/')
],
fallback: {
fs: false,
vm: false,
buffer: false,
crypto: require.resolve('crypto-browserify'),
path: require.resolve('path-browserify'),
stream: require.resolve('stream-browserify'),
}
},
watchOptions: {
aggregateTimeout: 750
],
resolve: {
modules: [
'node_modules',
path.resolve(__dirname, 'lib/')
],
fallback: {
fs: false,
vm: false,
buffer: false,
crypto: require.resolve('crypto-browserify'),
path: require.resolve('path-browserify'),
stream: require.resolve('stream-browserify'),
}
};
}
},
watchOptions: {
aggregateTimeout: 750
}
};
function createEntry(src, outFolder, outFilename, isNode) {
return {
target: isNode ? 'node' : void 0,
entry: path.resolve(__dirname, `lib/${src}.js`),
output: { filename: `${outFilename}.js`, path: path.resolve(__dirname, `build/${outFolder}`) },
...getSharedConfig()
...sharedConfig
};
}
function createEntryPoint(name, dir, out, library, copyPluginPatterns) {
function createEntryPoint(name, dir, out, library) {
return {
entry: path.resolve(__dirname, `lib/${dir}/${name}.js`),
output: { filename: `${library || name}.js`, path: path.resolve(__dirname, `build/${out}`), library: library || out, libraryTarget: 'umd', assetModuleFilename: 'images/[hash][ext][query]', 'publicPath': '' },
...getSharedConfig(copyPluginPatterns)
...sharedConfig
};
}
@@ -109,11 +98,11 @@ function createNodeEntryPoint(name, dir, out) {
'util.promisify': 'require("util.promisify")',
xhr2: 'require("xhr2")',
},
...getSharedConfig()
...sharedConfig
};
}
function createApp(name, library, copyPluginPatterns) { return createEntryPoint('index', `apps/${name}`, name, library, copyPluginPatterns); }
function createApp(name, library) { return createEntryPoint('index', `apps/${name}`, name, library); }
function createExample(name) { return createEntry(`examples/${name}/index`, `examples/${name}`, 'index'); }
function createBrowserTest(name) { return createEntryPoint(name, 'tests/browser', 'tests'); }
function createNodeApp(name) { return createNodeEntryPoint('index', `apps/${name}`, name); }
@@ -125,4 +114,4 @@ module.exports = {
createBrowserTest,
createNodeEntryPoint,
createNodeApp
};
};

View File

@@ -1,14 +1,6 @@
const common = require('./webpack.config.common.js');
const VERSION = require('./package.json').version;
const createApp = common.createApp;
module.exports = (env, argv) => {
return [
createApp('viewer', 'molstar', [{
from: 'lib/apps/viewer/sw.js',
transform: (content) => {
return content.toString().replace('const VERSION = \'\';', `const VERSION = '${VERSION}${env.WEBPACK_WATCH ? '-' + new Date().valueOf() : ''}';`);
}
}]),
createApp('mesoscale-explorer', 'molstar'),
];
};
module.exports = [
createApp('viewer', 'molstar'),
createApp('mesoscale-explorer', 'molstar'),
];