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

2
.gitignore vendored
View File

@@ -26,6 +26,8 @@ packages/desktop-electron/build
packages/desktop-electron/.electron-symbols
packages/desktop-electron/dist
packages/desktop-electron/loot-core
packages/desktop-client/service-worker
packages/plugins-service/dist
bundle.desktop.js
bundle.desktop.js.map
bundle.mobile.js

View File

@@ -16,6 +16,7 @@ packages/desktop-client/bin/remove-untranslated-languages
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace plugins-service build
yarn workspace loot-core build:browser
yarn workspace @actual-app/web build:browser

View File

@@ -41,6 +41,7 @@ packages/desktop-client/bin/remove-untranslated-languages
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace plugins-service build
yarn workspace loot-core build:node
yarn workspace @actual-app/web build --mode=desktop # electron specific build

View File

@@ -83,6 +83,7 @@ export default pluginTypescript.config(
'packages/component-library/src/icons/**/*',
'packages/desktop-client/bundle.browser.js',
'packages/desktop-client/build/',
'packages/desktop-client/service-worker/*',
'packages/desktop-client/build-electron/',
'packages/desktop-client/build-stats/',
'packages/desktop-client/public/kcab/',
@@ -98,6 +99,7 @@ export default pluginTypescript.config(
'packages/loot-core/**/lib-dist/*',
'packages/loot-core/**/proto/*',
'packages/sync-server/build/',
'packages/plugins-service/dist/',
'.yarn/*',
'.github/*',
],

View File

@@ -23,18 +23,20 @@
"start:server-monitor": "yarn workspace @actual-app/sync-server start-monitor",
"start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'",
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
"start:desktop-node": "yarn workspace loot-core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
"start:desktop-electron": "yarn workspace desktop-electron watch",
"start:browser": "npm-run-all --parallel 'start:browser-*'",
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"start:service-plugins": "yarn workspace plugins-service watch",
"start:browser-backend": "yarn workspace loot-core watch:browser",
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
"build:browser-backend": "yarn workspace loot-core build:browser",
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
"build:browser": "./bin/package-browser",
"build:desktop": "./bin/package-electron",
"build:plugins-service": "yarn workspace plugins-service build",
"build:api": "yarn workspace @actual-app/api build",
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",

View File

@@ -14,6 +14,9 @@ build-electron
build-stats
stats.json
# generated service worker
service-worker/
# misc
.DS_Store
.env

View File

@@ -9,6 +9,7 @@ rm -fr build
export IS_GENERIC_BROWSER=1
export REACT_APP_BACKEND_WORKER_HASH=`ls "$ROOT"/../public/kcab/kcab.worker.*.js | sed 's/.*kcab\.worker\.\(.*\)\.js/\1/'`
export REACT_APP_PLUGIN_SERVICE_WORKER_HASH=`ls "$ROOT"/../service-worker/plugin-sw.*.js | sed 's/.*plugin-sw\.\(.*\)\.js/\1/'`
yarn build

View File

@@ -6,5 +6,6 @@ cd "$ROOT/.."
export IS_GENERIC_BROWSER=1
export PORT=3001
export REACT_APP_BACKEND_WORKER_HASH="dev"
export REACT_APP_PLUGIN_SERVICE_WORKER_HASH="dev"
yarn start

View File

@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "./service-worker",
"target": "ES2022",
"lib": ["ES2022", "WebWorker", "DOM", "DOM.Iterable"],
"module": "ES2022",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"strict": false,
"types": ["vite/client"]
},
"include": ["src/plugin-service-worker.ts"],
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
}

View File

@@ -26,6 +26,15 @@ const addWatchers = (): Plugin => ({
},
});
// Get service worker filename from environment variable
function getServiceWorkerFilename(): string {
const hash = process.env.REACT_APP_PLUGIN_SERVICE_WORKER_HASH;
if (hash) {
return `plugin-sw.${hash}.js`;
}
return 'plugin-sw.js'; // fallback
}
// Inject build shims using the inject plugin
const injectShims = (): Plugin[] => {
const buildShims = path.resolve('./src/build-shims.js');
@@ -159,18 +168,41 @@ export default defineConfig(async ({ mode }) => {
? undefined
: VitePWA({
registerType: 'prompt',
strategies: 'injectManifest',
srcDir: 'service-worker',
filename: getServiceWorkerFilename(),
manifest: {
name: 'Actual',
short_name: 'Actual',
description: 'A local-first personal finance tool',
theme_color: '#8812E1',
background_color: '#8812E1',
display: 'standalone',
start_url: './',
},
injectManifest: {
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB
swSrc: `service-worker/${getServiceWorkerFilename()}`,
},
devOptions: {
enabled: true, // We need service worker in dev mode to work with plugins
type: 'module',
},
workbox: {
globPatterns: [
'**/*.{js,css,html,txt,wasm,sql,sqlite,ico,png,woff2,webmanifest}',
],
ignoreURLParametersMatching: [/^v$/],
navigateFallback: '/index.html',
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB
navigateFallbackDenylist: [
/^\/account\/.*$/,
/^\/admin\/.*$/,
/^\/secret\/.*$/,
/^\/openid\/.*$/,
/^\/plugins\/.*$/,
/^\/kcab\/.*$/,
/^\/plugin-data\/.*$/,
],
},
}),

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),
},
};
});

View File

@@ -16,6 +16,7 @@ COPY packages/desktop-electron/package.json packages/desktop-electron/package.js
COPY packages/eslint-plugin-actual/package.json packages/eslint-plugin-actual/package.json
COPY packages/loot-core/package.json packages/loot-core/package.json
COPY packages/sync-server/package.json packages/sync-server/package.json
COPY packages/plugins-service/package.json packages/plugins-service/package.json
# Avoiding memory issues with ARMv7
RUN if [ "$(uname -m)" = "armv7l" ]; then yarn config set taskPoolConcurrency 2; yarn config set networkConcurrency 5; fi

View File

@@ -16,6 +16,7 @@ COPY packages/desktop-electron/package.json packages/desktop-electron/package.js
COPY packages/eslint-plugin-actual/package.json packages/eslint-plugin-actual/package.json
COPY packages/loot-core/package.json packages/loot-core/package.json
COPY packages/sync-server/package.json packages/sync-server/package.json
COPY packages/plugins-service/package.json packages/plugins-service/package.json
# Avoiding memory issues with ARMv7
RUN if [ "$(uname -m)" = "armv7l" ]; then yarn config set taskPoolConcurrency 2; yarn config set networkConcurrency 5; fi

View File

@@ -16,6 +16,7 @@ COPY packages/desktop-electron/package.json packages/desktop-electron/package.js
COPY packages/eslint-plugin-actual/package.json packages/eslint-plugin-actual/package.json
COPY packages/loot-core/package.json packages/loot-core/package.json
COPY packages/sync-server/package.json packages/sync-server/package.json
COPY packages/plugins-service/package.json packages/plugins-service/package.json
COPY ./bin/package-browser ./bin/package-browser

View File

@@ -52,7 +52,8 @@
"**/dist/*",
"**/lib-dist/*",
"**/test-results/*",
"**/playwright-report/*"
"**/playwright-report/*",
"**/service-worker/*"
],
"ts-node": {
"compilerOptions": {

View File

@@ -0,0 +1,7 @@
---
category: Features
authors: [lelemm]
---
Introduce a Workbox-based service worker for enhanced plugin support and caching functionality.

View File

@@ -6717,7 +6717,7 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:^22.18.8":
"@types/node@npm:^22.17.0, @types/node@npm:^22.18.8":
version: 22.18.8
resolution: "@types/node@npm:22.18.8"
dependencies:
@@ -9479,6 +9479,18 @@ __metadata:
languageName: node
linkType: hard
"cross-env@npm:^7.0.3":
version: 7.0.3
resolution: "cross-env@npm:7.0.3"
dependencies:
cross-spawn: "npm:^7.0.1"
bin:
cross-env: src/bin/cross-env.js
cross-env-shell: src/bin/cross-env-shell.js
checksum: 10/e99911f0d31c20e990fd92d6fd001f4b01668a303221227cc5cb42ed155f086351b1b3bd2699b200e527ab13011b032801f8ce638e6f09f854bdf744095e604c
languageName: node
linkType: hard
"cross-spawn@npm:^6.0.5":
version: 6.0.5
resolution: "cross-spawn@npm:6.0.5"
@@ -16670,6 +16682,18 @@ __metadata:
languageName: node
linkType: hard
"plugins-service@workspace:packages/plugins-service":
version: 0.0.0-use.local
resolution: "plugins-service@workspace:packages/plugins-service"
dependencies:
"@types/node": "npm:^22.17.0"
cross-env: "npm:^7.0.3"
typescript: "npm:^5.9.2"
vite: "npm:^6.3.6"
workbox-precaching: "npm:^7.0.0"
languageName: unknown
linkType: soft
"possible-typed-array-names@npm:^1.0.0":
version: 1.0.0
resolution: "possible-typed-array-names@npm:1.0.0"
@@ -16677,7 +16701,7 @@ __metadata:
languageName: node
linkType: hard
"postcss@npm:^8.5.5, postcss@npm:^8.5.6":
"postcss@npm:^8.5.3, postcss@npm:^8.5.5, postcss@npm:^8.5.6":
version: 8.5.6
resolution: "postcss@npm:8.5.6"
dependencies:
@@ -19661,7 +19685,7 @@ __metadata:
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15":
"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.13, tinyglobby@npm:^0.2.15":
version: 0.2.15
resolution: "tinyglobby@npm:0.2.15"
dependencies:
@@ -20144,7 +20168,7 @@ __metadata:
languageName: node
linkType: hard
"typescript@npm:^5.9.3":
"typescript@npm:^5.9.2, typescript@npm:^5.9.3":
version: 5.9.3
resolution: "typescript@npm:5.9.3"
bin:
@@ -20174,7 +20198,7 @@ __metadata:
languageName: node
linkType: hard
"typescript@patch:typescript@npm%3A^5.9.3#optional!builtin<compat/typescript>":
"typescript@patch:typescript@npm%3A^5.9.2#optional!builtin<compat/typescript>, typescript@patch:typescript@npm%3A^5.9.3#optional!builtin<compat/typescript>":
version: 5.9.3
resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin<compat/typescript>::version=5.9.3&hash=5786d5"
bin:
@@ -20940,6 +20964,61 @@ __metadata:
languageName: node
linkType: hard
"vite@npm:^6.3.6":
version: 6.3.6
resolution: "vite@npm:6.3.6"
dependencies:
esbuild: "npm:^0.25.0"
fdir: "npm:^6.4.4"
fsevents: "npm:~2.3.3"
picomatch: "npm:^4.0.2"
postcss: "npm:^8.5.3"
rollup: "npm:^4.34.9"
tinyglobby: "npm:^0.2.13"
peerDependencies:
"@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0
jiti: ">=1.21.0"
less: "*"
lightningcss: ^1.21.0
sass: "*"
sass-embedded: "*"
stylus: "*"
sugarss: "*"
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
dependenciesMeta:
fsevents:
optional: true
peerDependenciesMeta:
"@types/node":
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
bin:
vite: bin/vite.js
checksum: 10/8b8b6fe12318ca457396bf2053df7056cf4810f1d4a43b36b6afe59860e32b749c0685a290fe8a973b0d3da179ceec4c30cebbd3c91d0c47fbcf6436b17bdeef
languageName: node
linkType: hard
"vite@npm:^7.1.9":
version: 7.1.9
resolution: "vite@npm:7.1.9"
@@ -21460,7 +21539,7 @@ __metadata:
languageName: node
linkType: hard
"workbox-precaching@npm:7.3.0":
"workbox-precaching@npm:7.3.0, workbox-precaching@npm:^7.0.0":
version: 7.3.0
resolution: "workbox-precaching@npm:7.3.0"
dependencies: