feat(electron): bundle worker assets and resolve worker path

Add webpack config and include it in the electron backend build so 
worker scripts are correctly bundled for packaged apps. Move worker 
files to be packaged as extraResources instead of asar unpack patterns 
so the packaged app can load worker JS from Resources/dist/... at 
runtime. Fix worker path resolution in epg.events: compute resourcesPath
from app.getAppPath() and point to dist/apps/electron-backend/workers 
when app.isPackaged. Remove verbose app path logging and update comments
to reflect the actual packaged layout.
Why: ensure webworker entry points are included in the renderer/backend
bundle and accessible when the Electron app is packaged, preventing
worker loading failures in production builds.
This commit is contained in:
4gray committed 2025-11-24 20:46:15 +01:00
1 parent b7109c16a2
commit a948ce0265
7 files changed
+1636 -219

No files matched your search

+6 -3
View File
@@ -5,13 +5,16 @@
"WebSearch", "WebSearch",
"WebFetch(domain:www.electronjs.org)", "WebFetch(domain:www.electronjs.org)",
"Bash(npm run build:frontend:*)", "Bash(npm run build:frontend:*)",
"Bash(cat:*)" "Bash(cat:*)",
"Bash(npx asar:*)",
"Bash(npm run package:app:*)",
"Bash(npm run build:backend:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []
}, },
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [ "enabledMcpjsonServers": [
"nx-mcp" "nx-mcp"
], ]
"enableAllProjectMcpServers": true
} }
+44
View File
@@ -0,0 +1,44 @@
const esbuild = require('esbuild');
const path = require('path');
// Node.js built-in modules that should be externalized
const nodeBuiltins = [
'assert', 'async_hooks', 'buffer', 'child_process', 'cluster',
'console', 'constants', 'crypto', 'dgram', 'dns', 'domain',
'events', 'fs', 'http', 'http2', 'https', 'inspector', 'module',
'net', 'os', 'path', 'perf_hooks', 'process', 'punycode',
'querystring', 'readline', 'repl', 'stream', 'string_decoder',
'sys', 'timers', 'tls', 'trace_events', 'tty', 'url', 'util',
'v8', 'vm', 'worker_threads', 'zlib'
];
const isProduction = process.env.NODE_ENV === 'production';
async function buildWorker() {
try {
console.log(`Building EPG parser worker with esbuild (${isProduction ? 'production' : 'development'})...`);
await esbuild.build({
entryPoints: [path.join(__dirname, 'src/app/workers/epg-parser.worker.ts')],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: path.join(__dirname, '../../dist/apps/electron-backend/workers/epg-parser.worker.js'),
external: nodeBuiltins.map(m => `node:${m}`).concat(nodeBuiltins),
sourcemap: !isProduction,
minify: isProduction,
// Resolve workspace libraries from tsconfig paths
alias: {
'shared-interfaces': path.join(__dirname, '../../libs/shared/interfaces/src/index.ts'),
},
});
console.log('✅ Worker built successfully!');
} catch (error) {
console.error('❌ Worker build failed:', error);
process.exit(1);
}
}
buildWorker();
+9 -15
View File
@@ -4,8 +4,16 @@
"sourceRoot": "apps/electron-backend/src", "sourceRoot": "apps/electron-backend/src",
"projectType": "application", "projectType": "application",
"targets": { "targets": {
"build-worker": {
"executor": "nx:run-commands",
"outputs": ["{workspaceRoot}/dist/apps/electron-backend/workers"],
"options": {
"command": "node apps/electron-backend/build-worker.js"
}
},
"build": { "build": {
"dependsOn": [ "dependsOn": [
"electron-backend:build-worker",
{ {
"projects": ["web", "remote-control-web"], "projects": ["web", "remote-control-web"],
"target": "build" "target": "build"
@@ -17,20 +25,7 @@
"outputPath": "dist/apps/electron-backend", "outputPath": "dist/apps/electron-backend",
"main": "apps/electron-backend/src/main.ts", "main": "apps/electron-backend/src/main.ts",
"tsConfig": "apps/electron-backend/tsconfig.app.json", "tsConfig": "apps/electron-backend/tsconfig.app.json",
"assets": [ "assets": ["apps/electron-backend/src/assets"]
"apps/electron-backend/src/assets",
{
"input": "apps/electron-backend/src/app/workers",
"glob": "*.worker.ts",
"output": "workers"
}
],
"additionalEntryPoints": [
{
"entryName": "workers/epg-parser.worker",
"entryPath": "apps/electron-backend/src/app/workers/epg-parser.worker.ts"
}
]
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -54,7 +49,6 @@
"nx run electron-backend:serve-electron" "nx run electron-backend:serve-electron"
] ]
} }
}, },
"serve-electron": { "serve-electron": {
"executor": "nx-electron:execute", "executor": "nx-electron:execute",
@@ -24,8 +24,6 @@ export default class EpgEvents {
* Bootstrap EPG events * Bootstrap EPG events
*/ */
static bootstrapEpgEvents(): Electron.IpcMain { static bootstrapEpgEvents(): Electron.IpcMain {
console.log('[EPG Events] Bootstrapping EPG events...');
// Fetch EPG from URLs // Fetch EPG from URLs
ipcMain.handle('FETCH_EPG', async (event, args: { url: string[] }) => { ipcMain.handle('FETCH_EPG', async (event, args: { url: string[] }) => {
return await this.handleFetchEpg(args.url); return await this.handleFetchEpg(args.url);
@@ -79,7 +77,7 @@ export default class EpgEvents {
await Promise.all(promises); await Promise.all(promises);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('[EPG Events] Error fetching EPG:', error); console.error(this.loggerLabel, 'Error fetching EPG:', error);
return { return {
success: false, success: false,
message: error instanceof Error ? error.message : String(error), message: error instanceof Error ? error.message : String(error),
@@ -93,21 +91,19 @@ export default class EpgEvents {
private static async fetchEpgFromUrl(url: string): Promise<void> { private static async fetchEpgFromUrl(url: string): Promise<void> {
// Skip if already fetched // Skip if already fetched
if (this.fetchedUrls.has(url)) { if (this.fetchedUrls.has(url)) {
console.log(this.loggerLabel, 'URL already fetched:', url);
return; return;
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Get the proper worker path following Electron's recommended approach
// https://www.electronjs.org/docs/latest/tutorial/multithreading
let workerPath: string; let workerPath: string;
if (app.isPackaged) { if (app.isPackaged) {
// In packaged app: Resources/app.asar.unpacked/electron-backend/workers/epg-parser.worker.js // In packaged app: Resources/dist/apps/electron-backend/workers/epg-parser.worker.js
const appPath = app.getAppPath(); const resourcesPath = path.dirname(app.getAppPath());
const unpackedPath = appPath.replace('app.asar', 'app.asar.unpacked');
workerPath = path.join( workerPath = path.join(
unpackedPath, resourcesPath,
'dist',
'apps',
'electron-backend', 'electron-backend',
'workers', 'workers',
'epg-parser.worker.js' 'epg-parser.worker.js'
@@ -117,17 +113,11 @@ export default class EpgEvents {
workerPath = path.join(__dirname, 'workers', 'epg-parser.worker.js'); workerPath = path.join(__dirname, 'workers', 'epg-parser.worker.js');
} }
console.log(this.loggerLabel, 'Creating worker for:', url);
console.log(this.loggerLabel, 'Worker path:', workerPath);
console.log(this.loggerLabel, 'App path:', app.getAppPath());
console.log(this.loggerLabel, 'Is packaged:', app.isPackaged);
let worker: Worker; let worker: Worker;
try { try {
// Worker threads require file:// URLs wrapped in URL object for packaged apps // Worker threads require file:// URLs wrapped in URL object for packaged apps
const workerURL = pathToFileURL(workerPath); const workerURL = pathToFileURL(workerPath);
worker = new Worker(workerURL); worker = new Worker(workerURL);
console.log(this.loggerLabel, 'Worker created successfully');
} catch (error) { } catch (error) {
console.error( console.error(
this.loggerLabel, this.loggerLabel,
@@ -149,16 +139,8 @@ export default class EpgEvents {
url?: string; url?: string;
}) => { }) => {
if (message.type === 'READY') { if (message.type === 'READY') {
console.log(
this.loggerLabel,
'Worker ready, sending fetch command'
);
worker.postMessage({ type: 'FETCH_EPG', url }); worker.postMessage({ type: 'FETCH_EPG', url });
} else if (message.type === 'EPG_PARSED') { } else if (message.type === 'EPG_PARSED') {
console.log(
this.loggerLabel,
'EPG parsed in worker, merging data...'
);
if (message.data) { if (message.data) {
this.mergeEpgData(message.data); this.mergeEpgData(message.data);
this.fetchedUrls.add(url); this.fetchedUrls.add(url);
@@ -201,11 +183,6 @@ export default class EpgEvents {
* Merge EPG data (optimized for large datasets) * Merge EPG data (optimized for large datasets)
*/ */
private static mergeEpgData(newData: EpgData): void { private static mergeEpgData(newData: EpgData): void {
console.log(
this.loggerLabel,
`Merging ${newData.channels.length} channels and ${newData.programs.length} programs...`
);
// Merge channels (avoid duplicates) // Merge channels (avoid duplicates)
const existingChannelIds = new Set( const existingChannelIds = new Set(
this.epgData.channels.map((c) => c.id) this.epgData.channels.map((c) => c.id)
@@ -226,11 +203,6 @@ export default class EpgEvents {
// Rebuild merged data structure // Rebuild merged data structure
this.rebuildMergedData(); this.rebuildMergedData();
console.log(
this.loggerLabel,
`Merged EPG data: ${this.epgData.channels.length} channels, ${this.epgData.programs.length} programs`
);
} }
/** /**
@@ -238,7 +210,6 @@ export default class EpgEvents {
* Optimized for large datasets * Optimized for large datasets
*/ */
private static rebuildMergedData(): void { private static rebuildMergedData(): void {
console.log(this.loggerLabel, 'Rebuilding merged data structure...');
this.epgDataMerged.clear(); this.epgDataMerged.clear();
// Create channel lookup map for O(1) access // Create channel lookup map for O(1) access
@@ -265,31 +236,17 @@ export default class EpgEvents {
}); });
} }
} }
console.log(
this.loggerLabel,
`Rebuild complete. ${this.epgDataMerged.size} channels with programs`
);
} }
/** /**
* Get programs for a specific channel * Get programs for a specific channel
*/ */
private static handleGetChannelPrograms(channelId: string): EpgProgram[] { private static handleGetChannelPrograms(channelId: string): EpgProgram[] {
console.log('[EPG Events] Getting programs for channel:', channelId);
console.log(
'[EPG Events] Total merged channels:',
this.epgDataMerged.size
);
// First try exact ID match // First try exact ID match
let channelData = this.epgDataMerged.get(channelId); let channelData = this.epgDataMerged.get(channelId);
// If not found, try to find by display name // If not found, try to find by display name
if (!channelData) { if (!channelData) {
console.log(
'[EPG Events] No exact match, searching by display name...'
);
for (const [id, channel] of this.epgDataMerged.entries()) { for (const [id, channel] of this.epgDataMerged.entries()) {
const displayNames = channel.displayName.map((d) => const displayNames = channel.displayName.map((d) =>
d.value.toLowerCase() d.value.toLowerCase()
@@ -302,22 +259,12 @@ export default class EpgEvents {
channelId.toLowerCase().includes(name) channelId.toLowerCase().includes(name)
) )
) { ) {
console.log(
'[EPG Events] Found match by name:',
id,
'→',
channel.displayName[0]?.value
);
channelData = channel; channelData = channel;
break; break;
} }
} }
} }
console.log(
'[EPG Events] Found programs:',
channelData?.programs?.length || 0
);
return channelData?.programs || []; return channelData?.programs || [];
} }
@@ -343,7 +290,5 @@ export default class EpgEvents {
// Terminate all workers // Terminate all workers
this.workers.forEach((worker) => worker.terminate()); this.workers.forEach((worker) => worker.terminate());
this.workers.clear(); this.workers.clear();
console.log(this.loggerLabel, 'EPG data cleared');
} }
} }
@@ -1,4 +1,3 @@
import axios from 'axios';
import { parse as parseEpg } from 'epg-parser'; import { parse as parseEpg } from 'epg-parser';
import { EpgData, EpgProgram } from 'shared-interfaces'; import { EpgData, EpgProgram } from 'shared-interfaces';
import { parentPort } from 'worker_threads'; import { parentPort } from 'worker_threads';
@@ -29,32 +28,28 @@ const loggerLabel = '[EPG Worker]';
*/ */
async function fetchAndParseEpg(url: string): Promise<EpgData> { async function fetchAndParseEpg(url: string): Promise<EpgData> {
try { try {
console.log(loggerLabel, 'Fetching EPG from:', url);
const isGzipped = url.endsWith('.gz'); const isGzipped = url.endsWith('.gz');
const axiosConfig = isGzipped
? { responseType: 'arraybuffer' as const }
: {};
const response = await axios.get(url.trim(), axiosConfig); // Use native fetch (available in Node.js 18+)
console.log( const response = await fetch(url.trim());
loggerLabel,
'EPG data fetched, size:', if (!response.ok) {
response.data.length throw new Error(`HTTP error! status: ${response.status}`);
); }
let xmlString: string; let xmlString: string;
if (isGzipped) { if (isGzipped) {
console.log(loggerLabel, 'Unzipping...'); // For gzipped files, get as ArrayBuffer
const buffer = Buffer.from(response.data); const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const decompressed = gunzipSync(buffer); const decompressed = gunzipSync(buffer);
xmlString = decompressed.toString('utf-8'); xmlString = decompressed.toString('utf-8');
} else { } else {
xmlString = response.data; // For regular files, get as text
xmlString = await response.text();
} }
console.log(loggerLabel, 'Parsing EPG XML...');
const parsed = parseEpg(xmlString); const parsed = parseEpg(xmlString);
// Normalize data to match our interface // Normalize data to match our interface
@@ -89,11 +84,6 @@ async function fetchAndParseEpg(url: string): Promise<EpgData> {
) as EpgProgram[], ) as EpgProgram[],
}; };
console.log(
loggerLabel,
`Parsed ${normalized.channels.length} channels and ${normalized.programs.length} programs`
);
return normalized; return normalized;
} catch (error) { } catch (error) {
console.error( console.error(
@@ -109,20 +99,12 @@ async function fetchAndParseEpg(url: string): Promise<EpgData> {
* Worker message handler * Worker message handler
*/ */
if (parentPort) { if (parentPort) {
console.log(loggerLabel, 'Worker initialized and ready');
parentPort.on('message', async (message: WorkerMessage) => { parentPort.on('message', async (message: WorkerMessage) => {
console.log(loggerLabel, 'Received message:', message.type);
try { try {
if ( if (
message.type === 'FETCH_EPG' || message.type === 'FETCH_EPG' ||
message.type === 'FORCE_FETCH' message.type === 'FORCE_FETCH'
) { ) {
console.log(
loggerLabel,
'Starting EPG fetch for:',
message.url
);
const parsedData = await fetchAndParseEpg(message.url); const parsedData = await fetchAndParseEpg(message.url);
const response: WorkerResponse = { const response: WorkerResponse = {
@@ -131,10 +113,6 @@ if (parentPort) {
url: message.url, url: message.url,
}; };
console.log(
loggerLabel,
'Sending parsed data back to main thread'
);
parentPort?.postMessage(response); parentPort?.postMessage(response);
} }
} catch (error) { } catch (error) {
@@ -150,7 +128,6 @@ if (parentPort) {
}); });
// Notify parent that worker is ready // Notify parent that worker is ready
console.log(loggerLabel, 'Sending READY message');
parentPort.postMessage({ type: 'READY' }); parentPort.postMessage({ type: 'READY' });
} else { } else {
console.error(loggerLabel, 'parentPort is not available!'); console.error(loggerLabel, 'parentPort is not available!');
+1556 -103
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -36,8 +36,8 @@
"extraMetadata": { "extraMetadata": {
"main": "electron-backend/main.js" "main": "electron-backend/main.js"
}, },
"asarUnpack": [ "extraResources": [
"**/workers/**" "dist/apps/electron-backend/workers"
], ],
"mac": { "mac": {
"target": { "target": {
@@ -201,6 +201,7 @@
"drizzle-kit": "0.31.5", "drizzle-kit": "0.31.5",
"electron": "^39.0.0", "electron": "^39.0.0",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"esbuild": "0.27.0",
"eslint": "^9.8.0", "eslint": "^9.8.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "2.32.0", "eslint-plugin-import": "2.32.0",