mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-22 00:13:45 -05:00
Frontend plugins Support [2/10]: Plugin service worker (#5784)
* Plugin service worker
This commit is contained in:
28
packages/plugins-service/bin/build-service-worker
Executable file
28
packages/plugins-service/bin/build-service-worker
Executable 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
|
||||
22
packages/plugins-service/package.json
Normal file
22
packages/plugins-service/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
159
packages/plugins-service/src/plugin-service-worker.ts
Normal file
159
packages/plugins-service/src/plugin-service-worker.ts
Normal 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';
|
||||
}
|
||||
17
packages/plugins-service/tsconfig.json
Normal file
17
packages/plugins-service/tsconfig.json
Normal 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"]
|
||||
}
|
||||
40
packages/plugins-service/vite.config.ts
Normal file
40
packages/plugins-service/vite.config.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user