Frontend plugins Support [2/10]: Plugin service worker (#5784)

* Plugin service worker
This commit is contained in:
lelemm
2025-10-07 12:14:32 -03:00
committed by GitHub
parent 5ac29473f2
commit b034d5039f
21 changed files with 429 additions and 10 deletions

View File

@@ -0,0 +1,28 @@
#!/bin/bash -e
cd `dirname "$0"`
ROOT=`pwd -P`
VITE_ARGS=""
DESKTOP_DIR="$ROOT"/../../desktop-client
SERVICE_WORKER_DIR="$DESKTOP_DIR"/service-worker
mkdir -p "$SERVICE_WORKER_DIR"
# Clean out previous build files
rm -f ../dist/*
rm -rf "$DESKTOP_DIR"/service-worker/*
if [ $NODE_ENV == 'development' ]; then
if [ "$OSTYPE" == "msys" ]; then
# Ensure symlinks are created as native Windows symlinks.
export MSYS=winsymlinks:nativestrict
fi
ln -snf "$ROOT"/../dist "$DESKTOP_DIR"/service-worker
fi
yarn vite build --config ../vite.config.ts --mode $NODE_ENV $VITE_ARGS
if [ $NODE_ENV == 'production' ]; then
# In production, just copy the built files
cp -r ../dist/* "$DESKTOP_DIR"/service-worker
fi

View File

@@ -0,0 +1,22 @@
{
"name": "plugins-service",
"version": "0.0.1",
"description": "Plugin service worker for Actual",
"main": "plugin-sw.js",
"scripts": {
"build": "cross-env NODE_ENV=production ./bin/build-service-worker",
"build-dev": "cross-env NODE_ENV=development ./bin/build-service-worker",
"watch": "cross-env NODE_ENV=development ./bin/build-service-worker --watch"
},
"author": "",
"license": "ISC",
"dependencies": {
"workbox-precaching": "^7.0.0"
},
"devDependencies": {
"@types/node": "^22.17.0",
"cross-env": "^7.0.3",
"typescript": "^5.9.2",
"vite": "^6.3.6"
}
}

View File

@@ -0,0 +1,159 @@
/// <reference lib="WebWorker" />
import { precacheAndRoute } from 'workbox-precaching';
// Service Worker Global Types
declare const self: ServiceWorkerGlobalScope & {
__WB_DISABLE_DEV_LOGS: boolean;
};
type PluginFile = {
name: string;
content: string;
};
type PluginMessage = {
type: string;
eventData?: {
pluginUrl: string;
};
};
self.__WB_DISABLE_DEV_LOGS = true;
// Injected by VitePWA
precacheAndRoute(self.__WB_MANIFEST);
const fileList = new Map<string, string>();
// Log installation event
self.addEventListener('install', (_event: ExtendableEvent) => {
console.log('Plugins Worker installing...');
self.skipWaiting(); // Forces activation immediately
});
// Log activation event
self.addEventListener('activate', (_event: ExtendableEvent) => {
self.clients.claim();
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'service-worker-ready',
timestamp: Date.now(),
});
});
});
});
self.addEventListener('message', (event: ExtendableMessageEvent) => {
if (event.data && (event.data as PluginMessage).type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
self.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url);
const pathSegments = url.pathname.split('/').filter(Boolean); // Split and remove empty segments
const pluginsIndex = pathSegments.indexOf('plugin-data');
const slugIndex = pluginsIndex + 1;
if (pluginsIndex !== -1 && pathSegments[slugIndex]) {
const slug = pathSegments[slugIndex];
const fileName =
pathSegments.length > slugIndex + 1
? pathSegments[slugIndex + 1].split('?')[0]
: '';
event.respondWith(handlePlugin(slug, fileName.replace('?import', '')));
}
});
async function handlePlugin(slug: string, fileName: string): Promise<Response> {
for (const key of fileList.keys()) {
if (key.startsWith(`${slug}/`)) {
if (key.endsWith(`/${fileName}`)) {
const content = fileList.get(key);
const contentType = getContentType(fileName);
return new Response(content, {
headers: { 'Content-Type': contentType },
});
}
}
}
const clientsList = await self.clients.matchAll();
if (clientsList.length === 0) {
return new Response(
JSON.stringify({ error: 'No active clients to process' }),
{
status: 404,
headers: { 'Content-Type': 'application/json' },
},
);
}
const client = clientsList[0];
return new Promise<Response>(resolve => {
const channel = new MessageChannel();
channel.port1.onmessage = (messageEvent: MessageEvent<PluginFile[]>) => {
const responseData = messageEvent.data as PluginFile[];
if (responseData && Array.isArray(responseData)) {
responseData.forEach(({ name, content }) => {
fileList.set(`${slug}/${encodeURIComponent(name)}`, content);
});
}
const fileToCheck = fileName.length > 0 ? fileName : 'mf-manifest.json';
if (fileList.has(`${slug}/${fileToCheck}`)) {
let content = fileList.get(`${slug}/${fileToCheck}`)!;
const contentType = getContentType(fileToCheck);
const headers: Record<string, string> = { 'Content-Type': contentType };
if (fileToCheck === 'mf-manifest.json') {
try {
const manifest = JSON.parse(content);
if (manifest.metaData?.publicPath) {
manifest.metaData.publicPath = `/plugin-data/${slug}/`;
content = JSON.stringify(manifest);
}
} catch (error) {
console.error(
'Failed to parse manifest for publicPath rewrite:',
error,
);
}
headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
headers['Pragma'] = 'no-cache';
headers['Expires'] = '0';
}
resolve(new Response(content, { headers }));
} else {
resolve(new Response('File not found', { status: 404 }));
}
};
client.postMessage(
{ type: 'plugin-files', eventData: { pluginUrl: slug } },
[channel.port2],
);
});
}
function getContentType(fileName: string): string {
const extension = fileName.split('.').pop()?.toLowerCase() || '';
const mimeTypes: Record<string, string> = {
html: 'text/html',
css: 'text/css',
js: 'application/javascript',
json: 'application/json',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
};
return mimeTypes[extension] || 'application/octet-stream';
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "WebWorker", "DOM", "DOM.Iterable"],
"module": "ES2022",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"strict": false,
"types": ["vite/client"],
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,40 @@
// @ts-strict-ignore
import path from 'path';
import { defineConfig } from 'vite';
// eslint-disable-next-line import/no-default-export
export default defineConfig(({ mode }) => {
const isDev = mode === 'development';
const outDir = path.resolve(__dirname, 'dist');
return {
mode,
build: {
target: 'es2020',
outDir,
emptyOutDir: true,
lib: {
entry: path.resolve(__dirname, 'src/plugin-service-worker.ts'),
name: 'plugin_sw',
formats: ['iife'],
fileName: () => (isDev ? 'plugin-sw.dev.js' : 'plugin-sw.[hash].js'),
},
sourcemap: true,
minify: isDev ? false : 'terser',
terserOptions: {
compress: {
drop_debugger: false,
},
mangle: false,
},
},
resolve: {
extensions: ['.js', '.ts', '.json'],
},
define: {
'process.env': '{}',
'process.env.IS_DEV': JSON.stringify(isDev),
},
};
});