diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 63a86bef1..c2f24bec8 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -279,10 +279,13 @@ export async function addStalkerPortal( await page.getByRole('menuitem', { name: 'Add Stalker Portal' }).click(); const dialog = page.locator('mat-dialog-container'); - await dialog.locator('#title').fill(name); - await dialog.locator('#portalUrl').fill(portalUrl); - await dialog.locator('#macAddress').fill(macAddress); - await dialog.getByRole('button', { name: 'Add', exact: true }).click(); + await setInputValue(dialog.locator('input#title'), name); + await setInputValue(dialog.locator('input#portalUrl'), portalUrl); + await setInputValue(dialog.locator('input#macAddress'), macAddress); + const addButton = dialog.getByRole('button', { name: 'Add', exact: true }); + + await expect(addButton).toBeEnabled({ timeout: 10000 }); + await addButton.click(); await page.waitForSelector('mat-dialog-container', { state: 'detached' }); } @@ -326,6 +329,7 @@ export async function waitForM3uCatalog(page: Page): Promise { export async function waitForXtreamCatalog(page: Page): Promise { await page.waitForURL(/\/workspace\/xtreams\/.+/); await waitForXtreamImportToFinish(page); + await waitForXtreamCategoryCounts(page); const categories = page.locator( 'app-workspace-context-panel .category-item' @@ -345,6 +349,23 @@ export async function waitForXtreamCatalog(page: Page): Promise { } } +async function waitForXtreamCategoryCounts(page: Page): Promise { + await expect + .poll( + async () => { + const texts = await page + .locator( + 'app-workspace-context-panel .category-item .item-count' + ) + .allInnerTexts(); + + return texts.some((text) => Number.parseInt(text, 10) > 0); + }, + { timeout: 30000 } + ) + .toBeTruthy(); +} + export async function waitForXtreamImportToFinish(page: Page): Promise { const overlay = page.locator('.workspace-loading-overlay'); @@ -464,9 +485,8 @@ async function startRendererFrameCapture(page: Page): Promise { const tick = () => { window.__rendererFrameCount = (window.__rendererFrameCount ?? 0) + 1; - window.__rendererFrameRequestId = window.requestAnimationFrame( - tick - ); + window.__rendererFrameRequestId = + window.requestAnimationFrame(tick); }; window.__rendererFrameRequestId = window.requestAnimationFrame(tick); @@ -505,16 +525,13 @@ export async function waitForPortalDebugEvent( timeoutMs?: number; } ): Promise { - const { - operation, - predicate, - provider, - timeoutMs = 20000, - } = options; + const { operation, predicate, provider, timeoutMs = 20000 } = options; const startedAt = Date.now(); while (Date.now() - startedAt <= timeoutMs) { - const events = await page.evaluate(() => window.__portalDebugEvents ?? []); + const events = await page.evaluate( + () => window.__portalDebugEvents ?? [] + ); const match = events.find((event) => { if ( event.provider !== provider || @@ -572,7 +589,9 @@ export async function waitForDbOperationEvent( const startedAt = Date.now(); while (Date.now() - startedAt <= timeoutMs) { - const events = await page.evaluate(() => window.__dbOperationEvents ?? []); + const events = await page.evaluate( + () => window.__dbOperationEvents ?? [] + ); const match = events.find((event) => { if (event.operation !== operation) { return false; @@ -628,10 +647,7 @@ export async function expectRendererFramesAdvance( sampleMs?: number; } = {} ): Promise { - const { - minimumDelta = 4, - sampleMs = 300, - } = options; + const { minimumDelta = 4, sampleMs = 300 } = options; const startCount = await getRendererFrameCount(page); await page.waitForTimeout(sampleMs); diff --git a/apps/electron-backend/src/app/events/epg.events.spec.ts b/apps/electron-backend/src/app/events/epg.events.spec.ts new file mode 100644 index 000000000..a16ccaed0 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg.events.spec.ts @@ -0,0 +1,131 @@ +import type EpgEventsType from './epg.events'; + +const mockWorkerInstances: any[] = []; +const resolveWorkerRuntimeBootstrap = jest.fn(); + +jest.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/mock/app.asar', + }, + BrowserWindow: { + getAllWindows: () => [], + }, + ipcMain: { + handle: jest.fn(), + }, +})); + +jest.mock('worker_threads', () => { + const { EventEmitter } = require('events'); + + class MockWorker extends EventEmitter { + postMessage = jest.fn(); + terminate = jest.fn().mockResolvedValue(0); + } + + return { + Worker: jest.fn().mockImplementation(() => { + const worker = new MockWorker(); + mockWorkerInstances.push(worker); + return worker; + }), + }; +}); + +jest.mock('../workers/worker-runtime-paths', () => ({ + resolveWorkerRuntimeBootstrap: (...args: unknown[]) => + resolveWorkerRuntimeBootstrap(...args), +})); + +jest.mock('../database/connection', () => ({ + getDatabase: jest.fn(), +})); + +describe('EpgEvents', () => { + let EpgEvents: typeof EpgEventsType; + let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(async () => { + jest.resetModules(); + mockWorkerInstances.length = 0; + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + resolveWorkerRuntimeBootstrap.mockReset(); + resolveWorkerRuntimeBootstrap.mockReturnValue({ + workerPath: '/mock/workers/epg-parser.worker.js', + workerPathCandidates: ['/mock/workers/epg-parser.worker.js'], + nativeModuleSearchPaths: [ + '/mock/resources/app.asar.unpacked/node_modules', + ], + }); + + ({ default: EpgEvents } = await import('./epg.events')); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + async function flushPromises(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + } + + it('uses the shared worker bootstrap and passes native module search paths to the EPG worker', async () => { + const fetchPromise = (EpgEvents as unknown as Record)[ + 'fetchEpgFromUrl' + ]('https://example.com/guide.xml'); + const worker = mockWorkerInstances[0]; + const { Worker } = jest.requireMock('worker_threads'); + + expect(resolveWorkerRuntimeBootstrap).toHaveBeenCalledWith( + expect.objectContaining({ + workerFilename: 'epg-parser.worker.js', + developmentWorkerDir: expect.stringContaining('workers'), + }) + ); + expect(Worker).toHaveBeenCalledWith(expect.any(URL), { + resourceLimits: { + maxOldGenerationSizeMb: 4096, + maxYoungGenerationSizeMb: 512, + }, + workerData: { + nativeModuleSearchPaths: [ + '/mock/resources/app.asar.unpacked/node_modules', + ], + }, + }); + + worker.emit('message', { type: 'READY' }); + await flushPromises(); + + expect(worker.postMessage).toHaveBeenCalledWith({ + type: 'FETCH_EPG', + url: 'https://example.com/guide.xml', + }); + + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 1, totalPrograms: 2 }, + }); + + await expect(fetchPromise).resolves.toBeUndefined(); + }); + + it('rejects with actionable worker path errors', async () => { + resolveWorkerRuntimeBootstrap.mockImplementation(() => { + const error = new Error( + 'Unable to resolve worker "epg-parser.worker.js".\nTried:\n- /missing/epg-parser.worker.js' + ); + error.name = 'WorkerPathResolutionError'; + throw error; + }); + + await expect(EpgEvents.clearEpgData()).rejects.toMatchObject({ + name: 'WorkerPathResolutionError', + message: expect.stringContaining('epg-parser.worker.js'), + }); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index a0a919f8e..0356c1a26 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -6,6 +6,7 @@ import { pathToFileURL } from 'url'; import { Worker } from 'worker_threads'; import { getDatabase } from '../database/connection'; import * as schema from '../database/schema'; +import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths'; /** * EPG Events Handler @@ -17,6 +18,29 @@ export default class EpgEvents { private static workers: Map = new Map(); private static readonly loggerLabel = '[EPG Events]'; + private static createEpgWorker(): Worker { + const bootstrap = resolveWorkerRuntimeBootstrap({ + isPackaged: app.isPackaged, + workerFilename: 'epg-parser.worker.js', + developmentWorkerDir: path.join(__dirname, 'workers'), + resourcesPath: ( + process as NodeJS.Process & { resourcesPath?: string } + ).resourcesPath, + appPath: app.getAppPath(), + }); + + const workerURL = pathToFileURL(bootstrap.workerPath); + return new Worker(workerURL, { + resourceLimits: { + maxOldGenerationSizeMb: 4096, + maxYoungGenerationSizeMb: 512, + }, + workerData: { + nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths, + }, + }); + } + /** * Send EPG progress to all renderer windows */ @@ -246,45 +270,9 @@ export default class EpgEvents { } return new Promise((resolve, reject) => { - let workerPath: string; - - if (app.isPackaged) { - const resourcesPath = path.dirname(app.getAppPath()); - workerPath = path.join( - resourcesPath, - 'dist', - 'apps', - 'electron-backend', - 'workers', - 'epg-parser.worker.js' - ); - } else { - workerPath = path.join( - __dirname, - 'workers', - 'epg-parser.worker.js' - ); - } - let worker: Worker; try { - const workerURL = pathToFileURL(workerPath); - // In packaged app, native modules are in app.asar.unpacked/node_modules - // which is separate from the worker location in extraResources - const nativeModulesPath = app.isPackaged - ? path.join( - path.dirname(app.getAppPath()), - 'app.asar.unpacked', - 'node_modules' - ) - : undefined; - worker = new Worker(workerURL, { - resourceLimits: { - maxOldGenerationSizeMb: 4096, - maxYoungGenerationSizeMb: 512, - }, - workerData: { nativeModulesPath }, - }); + worker = this.createEpgWorker(); } catch (error) { console.error( this.loggerLabel, @@ -575,40 +563,9 @@ export default class EpgEvents { */ static async clearEpgData(): Promise { return new Promise((resolve, reject) => { - let workerPath: string; - - if (app.isPackaged) { - const resourcesPath = path.dirname(app.getAppPath()); - workerPath = path.join( - resourcesPath, - 'dist', - 'apps', - 'electron-backend', - 'workers', - 'epg-parser.worker.js' - ); - } else { - workerPath = path.join( - __dirname, - 'workers', - 'epg-parser.worker.js' - ); - } - let worker: Worker; try { - const workerURL = pathToFileURL(workerPath); - // In packaged app, native modules are in app.asar.unpacked/node_modules - const nativeModulesPath = app.isPackaged - ? path.join( - path.dirname(app.getAppPath()), - 'app.asar.unpacked', - 'node_modules' - ) - : undefined; - worker = new Worker(workerURL, { - workerData: { nativeModulesPath }, - }); + worker = this.createEpgWorker(); } catch (error) { console.error( this.loggerLabel, diff --git a/apps/electron-backend/src/app/services/database-worker-client.spec.ts b/apps/electron-backend/src/app/services/database-worker-client.spec.ts index a0bfe35dd..429cb7e4f 100644 --- a/apps/electron-backend/src/app/services/database-worker-client.spec.ts +++ b/apps/electron-backend/src/app/services/database-worker-client.spec.ts @@ -1,6 +1,7 @@ import type { DatabaseWorkerClient as DatabaseWorkerClientType } from './database-worker-client'; const mockWorkerInstances: any[] = []; +const resolveWorkerRuntimeBootstrap = jest.fn(); jest.mock('electron', () => ({ app: { @@ -26,6 +27,11 @@ jest.mock('worker_threads', () => { }; }); +jest.mock('../workers/worker-runtime-paths', () => ({ + resolveWorkerRuntimeBootstrap: (...args: unknown[]) => + resolveWorkerRuntimeBootstrap(...args), +})); + describe('DatabaseWorkerClient', () => { let DatabaseWorkerClient: typeof DatabaseWorkerClientType; let clients: DatabaseWorkerClientType[]; @@ -33,6 +39,14 @@ describe('DatabaseWorkerClient', () => { beforeEach(async () => { jest.resetModules(); mockWorkerInstances.length = 0; + resolveWorkerRuntimeBootstrap.mockReset(); + resolveWorkerRuntimeBootstrap.mockReturnValue({ + workerPath: '/mock/workers/database.worker.js', + workerPathCandidates: ['/mock/workers/database.worker.js'], + nativeModuleSearchPaths: [ + '/mock/resources/app.asar.unpacked/node_modules', + ], + }); clients = []; ({ DatabaseWorkerClient } = await import('./database-worker-client')); }); @@ -59,6 +73,21 @@ describe('DatabaseWorkerClient', () => { searchTerm: 'matrix', }); const worker = mockWorkerInstances[0]; + const { Worker } = jest.requireMock('worker_threads'); + + expect(resolveWorkerRuntimeBootstrap).toHaveBeenCalledWith( + expect.objectContaining({ + workerFilename: 'database.worker.js', + developmentWorkerDir: expect.stringContaining('workers'), + }) + ); + expect(Worker).toHaveBeenCalledWith(expect.any(URL), { + workerData: { + nativeModuleSearchPaths: [ + '/mock/resources/app.asar.unpacked/node_modules', + ], + }, + }); worker.emit('message', { type: 'ready' }); await flushPromises(); @@ -243,4 +272,22 @@ describe('DatabaseWorkerClient', () => { await expect(secondRequest).resolves.toEqual([]); }); + + it('rejects requests with actionable worker path errors', async () => { + const client = createClient(); + resolveWorkerRuntimeBootstrap.mockImplementation(() => { + const error = new Error( + 'Unable to resolve worker "database.worker.js".\nTried:\n- /missing/database.worker.js' + ); + error.name = 'WorkerPathResolutionError'; + throw error; + }); + + await expect( + client.request('DB_GLOBAL_SEARCH', { searchTerm: 'matrix' }) + ).rejects.toMatchObject({ + name: 'WorkerPathResolutionError', + message: expect.stringContaining('database.worker.js'), + }); + }); }); diff --git a/apps/electron-backend/src/app/services/database-worker-client.ts b/apps/electron-backend/src/app/services/database-worker-client.ts index 7ae3aaf90..d09d76a04 100644 --- a/apps/electron-backend/src/app/services/database-worker-client.ts +++ b/apps/electron-backend/src/app/services/database-worker-client.ts @@ -8,6 +8,7 @@ import type { DbWorkerMessage, DbWorkerOperation, } from '../workers/database-worker.types'; +import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths'; type PendingRequest = { resolve: (value: unknown) => void; @@ -108,24 +109,33 @@ export class DatabaseWorkerClient { } private createWorker(): void { - const workerPath = this.resolveWorkerPath(); - const workerURL = pathToFileURL(workerPath); - const nativeModulesPath = app.isPackaged - ? path.join( - path.dirname(app.getAppPath()), - 'app.asar.unpacked', - 'node_modules' - ) - : undefined; + const bootstrap = resolveWorkerRuntimeBootstrap({ + isPackaged: app.isPackaged, + workerFilename: 'database.worker.js', + developmentWorkerDir: path.join(__dirname, 'workers'), + resourcesPath: ( + process as NodeJS.Process & { resourcesPath?: string } + ).resourcesPath, + appPath: app.getAppPath(), + }); this.readyPromise = new Promise((resolve, reject) => { this.readyResolve = resolve; this.readyReject = reject; }); - this.worker = new Worker(workerURL, { - workerData: { nativeModulesPath }, - }); + try { + const workerURL = pathToFileURL(bootstrap.workerPath); + this.worker = new Worker(workerURL, { + workerData: { + nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths, + }, + }); + } catch (error) { + this.readyReject?.(error); + this.resetWorkerState(); + throw error; + } this.worker.on('message', (message: DbWorkerMessage) => { this.handleMessage(message); @@ -138,22 +148,6 @@ export class DatabaseWorkerClient { }); } - private resolveWorkerPath(): string { - if (app.isPackaged) { - const resourcesPath = path.dirname(app.getAppPath()); - return path.join( - resourcesPath, - 'dist', - 'apps', - 'electron-backend', - 'workers', - 'database.worker.js' - ); - } - - return path.join(__dirname, 'workers', 'database.worker.js'); - } - private handleMessage(message: DbWorkerMessage): void { if (message.type === 'ready') { this.readyResolve?.(); diff --git a/apps/electron-backend/src/app/workers/database.worker-connection.ts b/apps/electron-backend/src/app/workers/database.worker-connection.ts index 03d87a4a3..ccf5ae97d 100644 --- a/apps/electron-backend/src/app/workers/database.worker-connection.ts +++ b/apps/electron-backend/src/app/workers/database.worker-connection.ts @@ -1,63 +1,55 @@ import type BetterSqlite3 from 'better-sqlite3'; import * as schema from 'database-schema'; import { getIptvnatorDatabasePath } from 'database-path-utils'; -import { drizzle } from 'drizzle-orm/better-sqlite3'; -import { existsSync } from 'fs'; -import { createRequire } from 'module'; -import { join } from 'path'; import { workerData } from 'worker_threads'; import type { AppDatabase } from '../database/database.types'; +import { + getNativeModuleSearchPaths, + getWorkerDataNativeModuleSearchPaths, + loadNativeModuleFromSearchPaths, + registerNativeModuleSearchPaths, +} from './worker-runtime-paths'; let Database: typeof BetterSqlite3; +let drizzleFactory: + | (typeof import('drizzle-orm/better-sqlite3'))['drizzle'] + | undefined; + +const nativeModuleSearchPaths = [ + ...getWorkerDataNativeModuleSearchPaths(workerData), + ...getNativeModuleSearchPaths({ + resourcesPath: ( + process as NodeJS.Process & { resourcesPath?: string } + ).resourcesPath, + }), +]; + +registerNativeModuleSearchPaths(nativeModuleSearchPaths); function loadBetterSqlite3(): typeof BetterSqlite3 { - if ( - workerData && - typeof workerData === 'object' && - 'nativeModulesPath' in workerData && - typeof workerData.nativeModulesPath === 'string' && - existsSync(workerData.nativeModulesPath) - ) { - try { - const nativeRequire = createRequire( - join(workerData.nativeModulesPath, 'index.js') - ); - return nativeRequire('better-sqlite3'); - } catch (error) { - console.error( - '[DB Worker] Failed to load better-sqlite3 from workerData path:', - error - ); - } - } - - if ( - (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath - ) { - const resourcesPath = ( - process as NodeJS.Process & { resourcesPath?: string } - ).resourcesPath!; - const unpackedPath = join( - resourcesPath, - 'app.asar.unpacked', - 'node_modules' - ); - - if (existsSync(unpackedPath)) { - try { - const nativeRequire = createRequire(join(unpackedPath, 'index.js')); - return nativeRequire('better-sqlite3'); - } catch (error) { - console.error( - '[DB Worker] Failed to load better-sqlite3 from resourcesPath:', - error - ); - } - } + return loadNativeModuleFromSearchPaths({ + moduleName: 'better-sqlite3', + loggerLabel: '[DB Worker]', + searchPaths: nativeModuleSearchPaths, + fallbackRequire: () => + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('better-sqlite3') as typeof BetterSqlite3, + }); +} + +function getDrizzleFactory(): (typeof import('drizzle-orm/better-sqlite3'))['drizzle'] { + if (drizzleFactory) { + return drizzleFactory; } + // Require drizzle only after native lookup paths have been registered. + // Its better-sqlite3 driver resolves the native package at module load time. // eslint-disable-next-line @typescript-eslint/no-require-imports - return require('better-sqlite3'); + drizzleFactory = require('drizzle-orm/better-sqlite3').drizzle as ( + typeof import('drizzle-orm/better-sqlite3') + )['drizzle']; + + return drizzleFactory; } Database = loadBetterSqlite3(); @@ -76,7 +68,7 @@ export async function getWorkerDatabase(): Promise { sqlite.pragma('journal_mode = WAL'); sqlite.pragma('busy_timeout = 5000'); - db = drizzle(sqlite, { schema }); + db = getDrizzleFactory()(sqlite, { schema }); return db; } diff --git a/apps/electron-backend/src/app/workers/epg-parser.worker.ts b/apps/electron-backend/src/app/workers/epg-parser.worker.ts index bec663665..5bb8d7eea 100644 --- a/apps/electron-backend/src/app/workers/epg-parser.worker.ts +++ b/apps/electron-backend/src/app/workers/epg-parser.worker.ts @@ -1,66 +1,39 @@ import type BetterSqlite3 from 'better-sqlite3'; import { existsSync, mkdirSync } from 'fs'; -import { getDatabasePath } from 'database'; -import { createRequire } from 'module'; -import { join } from 'path'; +import { getIptvnatorDatabasePath } from 'database-path-utils'; import { SaxesParser, SaxesTagPlain } from 'saxes'; import { Readable } from 'stream'; import { parentPort, workerData } from 'worker_threads'; import { createGunzip } from 'zlib'; +import { + getNativeModuleSearchPaths, + getWorkerDataNativeModuleSearchPaths, + loadNativeModuleFromSearchPaths, + registerNativeModuleSearchPaths, +} from './worker-runtime-paths'; -// In packaged app, native modules are in app.asar.unpacked/node_modules -// which is separate from the worker location in extraResources let Database: typeof BetterSqlite3; -function loadBetterSqlite3(): typeof BetterSqlite3 { - // Try workerData path first (passed from main process) - if ( - workerData?.nativeModulesPath && - existsSync(workerData.nativeModulesPath) - ) { - try { - const nativeRequire = createRequire( - join(workerData.nativeModulesPath, 'index.js') - ); - return nativeRequire('better-sqlite3'); - } catch (e) { - console.error( - '[EPG Worker] Failed to load from workerData path:', - e - ); - } - } - - // Try process.resourcesPath (available in packaged Electron apps) - if ( - (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath - ) { - const resourcesPath = ( +const nativeModuleSearchPaths = [ + ...getWorkerDataNativeModuleSearchPaths(workerData), + ...getNativeModuleSearchPaths({ + resourcesPath: ( process as NodeJS.Process & { resourcesPath?: string } - ).resourcesPath!; - const unpackedPath = join( - resourcesPath, - 'app.asar.unpacked', - 'node_modules' - ); - if (existsSync(unpackedPath)) { - try { - const nativeRequire = createRequire( - join(unpackedPath, 'index.js') - ); - return nativeRequire('better-sqlite3'); - } catch (e) { - console.error( - '[EPG Worker] Failed to load from resourcesPath:', - e - ); - } - } - } + ).resourcesPath, + }), +]; - // Fallback to regular require (development mode) - // eslint-disable-next-line @typescript-eslint/no-require-imports - return require('better-sqlite3'); +registerNativeModuleSearchPaths(nativeModuleSearchPaths); + +function loadBetterSqlite3(): typeof BetterSqlite3 { + return loadNativeModuleFromSearchPaths({ + moduleName: 'better-sqlite3', + loggerLabel: '[EPG Worker]', + searchPaths: nativeModuleSearchPaths, + fallbackRequire: () => + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('better-sqlite3') as typeof BetterSqlite3, + }); } Database = loadBetterSqlite3(); @@ -157,7 +130,7 @@ class EpgDatabase { private deleteChannelsStmt: BetterSqlite3.Statement; constructor() { - const dbPath = getDatabasePath(); + const dbPath = getIptvnatorDatabasePath(); this.db = new Database(dbPath); this.db.pragma('foreign_keys = ON'); this.db.pragma('journal_mode = WAL'); // Better concurrent write performance @@ -658,7 +631,7 @@ async function fetchAndParseEpgStreaming(url: string): Promise { * Runs in worker thread to avoid blocking main thread */ function clearAllEpgData(): void { - const dbPath = getDatabasePath(); + const dbPath = getIptvnatorDatabasePath(); const db = new Database(dbPath); try { diff --git a/apps/electron-backend/src/app/workers/worker-runtime-paths.spec.ts b/apps/electron-backend/src/app/workers/worker-runtime-paths.spec.ts new file mode 100644 index 000000000..70d7691d0 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-runtime-paths.spec.ts @@ -0,0 +1,180 @@ +import * as path from 'path'; +import { + getNativeModuleSearchPaths, + loadNativeModuleFromSearchPaths, + registerNativeModuleSearchPaths, + resolveWorkerRuntimeBootstrap, +} from './worker-runtime-paths'; + +describe('worker-runtime-paths', () => { + it('resolves the development worker path when the worker file exists', () => { + const bootstrap = resolveWorkerRuntimeBootstrap({ + isPackaged: false, + workerFilename: 'database.worker.js', + developmentWorkerDir: '/workspace/dist/apps/electron-backend/workers', + fileExists: (filePath) => + filePath === + '/workspace/dist/apps/electron-backend/workers/database.worker.js', + }); + + expect(bootstrap).toEqual({ + workerPath: + '/workspace/dist/apps/electron-backend/workers/database.worker.js', + workerPathCandidates: [ + '/workspace/dist/apps/electron-backend/workers/database.worker.js', + ], + }); + }); + + it('prefers process.resourcesPath for packaged worker resolution', () => { + const bootstrap = resolveWorkerRuntimeBootstrap({ + isPackaged: true, + workerFilename: 'epg-parser.worker.js', + developmentWorkerDir: '/unused', + resourcesPath: '/Applications/IPTVnator.app/Contents/Resources', + appPath: + '/Applications/IPTVnator.app/Contents/Resources/app.asar', + fileExists: (filePath) => + filePath === + '/Applications/IPTVnator.app/Contents/Resources/dist/apps/electron-backend/workers/epg-parser.worker.js', + }); + + expect(bootstrap.workerPath).toBe( + '/Applications/IPTVnator.app/Contents/Resources/dist/apps/electron-backend/workers/epg-parser.worker.js' + ); + expect(bootstrap.nativeModuleSearchPaths).toEqual([ + '/Applications/IPTVnator.app/Contents/Resources/app.asar.unpacked/node_modules', + '/Applications/IPTVnator.app/Contents/Resources/app.asar.unpacked/electron-backend/node_modules', + '/Applications/IPTVnator.app/Contents/Resources/app.asar.unpacked/dist/apps/electron-backend/node_modules', + ]); + }); + + it('falls back to appPath dirname when the resourcesPath candidate is missing', () => { + const bootstrap = resolveWorkerRuntimeBootstrap({ + isPackaged: true, + workerFilename: 'database.worker.js', + developmentWorkerDir: '/unused', + resourcesPath: '/tmp/runtime-resources', + appPath: '/opt/IPTVnator/resources/app.asar', + fileExists: (filePath) => + filePath === + '/opt/IPTVnator/resources/dist/apps/electron-backend/workers/database.worker.js', + }); + + expect(bootstrap.workerPath).toBe( + '/opt/IPTVnator/resources/dist/apps/electron-backend/workers/database.worker.js' + ); + expect(bootstrap.workerPathCandidates).toEqual([ + '/tmp/runtime-resources/dist/apps/electron-backend/workers/database.worker.js', + '/opt/IPTVnator/resources/dist/apps/electron-backend/workers/database.worker.js', + ]); + }); + + it('throws actionable errors when the worker file cannot be resolved', () => { + expect(() => + resolveWorkerRuntimeBootstrap({ + isPackaged: true, + workerFilename: 'database.worker.js', + developmentWorkerDir: '/unused', + resourcesPath: '/resources', + appPath: '/opt/IPTVnator/resources/app.asar', + fileExists: () => false, + }) + ).toThrow( + [ + 'Unable to resolve worker "database.worker.js".', + 'Tried:', + '- /resources/dist/apps/electron-backend/workers/database.worker.js', + '- /opt/IPTVnator/resources/dist/apps/electron-backend/workers/database.worker.js', + ].join('\n') + ); + }); + + it('de-duplicates native module search paths when resource roots match', () => { + expect( + getNativeModuleSearchPaths({ + resourcesPath: '/resources', + appPath: '/resources/app.asar', + }) + ).toEqual([ + '/resources/app.asar.unpacked/node_modules', + '/resources/app.asar.unpacked/electron-backend/node_modules', + '/resources/app.asar.unpacked/dist/apps/electron-backend/node_modules', + ]); + }); + + it('loads native modules from the first working candidate path', () => { + const requireFactory = jest.fn((entryPath: string) => { + if (entryPath === path.join('/second', 'index.js')) { + return ((moduleName: string) => ({ + moduleName, + entryPath, + })) as NodeRequire; + } + + throw new Error('bad path'); + }); + + const moduleValue = loadNativeModuleFromSearchPaths({ + moduleName: 'better-sqlite3', + loggerLabel: '[Worker]', + searchPaths: ['/first', '/second'], + fileExists: (searchPath) => searchPath !== '/first-missing', + requireFactory, + }); + + expect(moduleValue).toEqual({ + moduleName: 'better-sqlite3', + entryPath: path.join('/second', 'index.js'), + }); + }); + + it('registers native module search paths in NODE_PATH and module globalPaths', () => { + const env: NodeJS.ProcessEnv = {}; + const moduleApi = { + globalPaths: ['/global'], + }; + + const registeredPaths = registerNativeModuleSearchPaths( + ['/first', '/second', '/global'], + { + env, + moduleApi, + } + ); + + expect(registeredPaths).toEqual(['/first', '/second', '/global']); + expect(env.NODE_PATH).toBe( + ['/first', '/second', '/global'].join(path.delimiter) + ); + expect(moduleApi.globalPaths).toEqual(['/first', '/second', '/global']); + }); + + it('throws aggregated errors when no native module candidate works', () => { + expect(() => + loadNativeModuleFromSearchPaths({ + moduleName: 'better-sqlite3', + loggerLabel: '[Worker]', + searchPaths: ['/missing', '/broken'], + fileExists: (searchPath) => searchPath === '/broken', + requireFactory: () => { + throw new Error('Cannot find module'); + }, + fallbackRequire: () => { + throw new Error('fallback failed'); + }, + }) + ).toThrow( + [ + '[Worker] Unable to load native module "better-sqlite3".', + 'Tried:', + '- /missing', + '- /broken', + 'Failures:', + '- /missing (missing)', + '- /broken (Cannot find module)', + '- require(better-sqlite3) (fallback failed)', + ].join('\n') + ); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-runtime-paths.ts b/apps/electron-backend/src/app/workers/worker-runtime-paths.ts new file mode 100644 index 000000000..030edc0a7 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-runtime-paths.ts @@ -0,0 +1,261 @@ +import { existsSync } from 'fs'; +import { createRequire } from 'module'; +import * as path from 'path'; + +const WORKER_RELATIVE_PATH = [ + 'dist', + 'apps', + 'electron-backend', + 'workers', +] as const; + +const NATIVE_MODULE_RELATIVE_PATHS = [ + ['app.asar.unpacked', 'node_modules'], + ['app.asar.unpacked', 'electron-backend', 'node_modules'], + ['app.asar.unpacked', 'dist', 'apps', 'electron-backend', 'node_modules'], +] as const; + +type FileExists = (filePath: string) => boolean; +type ModuleRequireFactory = typeof createRequire; +type NodeModuleApi = { + globalPaths: string[]; + _initPaths?: () => void; +}; + +export interface WorkerBootstrapData { + nativeModuleSearchPaths?: string[]; +} + +export interface ResolveWorkerRuntimeBootstrapOptions { + isPackaged: boolean; + workerFilename: string; + developmentWorkerDir: string; + resourcesPath?: string; + appPath?: string; + fileExists?: FileExists; +} + +export interface WorkerRuntimeBootstrap { + workerPath: string; + workerPathCandidates: string[]; + nativeModuleSearchPaths?: string[]; +} + +export interface LoadNativeModuleOptions { + moduleName: string; + searchPaths: string[]; + loggerLabel: string; + fallbackRequire?: () => TModule; + fileExists?: FileExists; + requireFactory?: ModuleRequireFactory; +} + +function dedupePaths(paths: Array): string[] { + return [...new Set(paths.filter((value): value is string => Boolean(value)))]; +} + +export function registerNativeModuleSearchPaths( + searchPaths: string[], + options?: { + env?: NodeJS.ProcessEnv; + moduleApi?: NodeModuleApi; + } +): string[] { + const env = options?.env ?? process.env; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const moduleApi = + options?.moduleApi ?? + (require('module') as NodeModuleApi); + + const mergedPaths = dedupePaths([ + ...searchPaths, + ...(env.NODE_PATH?.split(path.delimiter) ?? []), + ...moduleApi.globalPaths, + ]); + + if (mergedPaths.length === 0) { + return []; + } + + env.NODE_PATH = mergedPaths.join(path.delimiter); + + if (typeof moduleApi._initPaths === 'function') { + moduleApi._initPaths(); + } else { + moduleApi.globalPaths.splice(0, moduleApi.globalPaths.length, ...mergedPaths); + } + + for (let index = mergedPaths.length - 1; index >= 0; index -= 1) { + const currentPath = mergedPaths[index]; + + if (!moduleApi.globalPaths.includes(currentPath)) { + moduleApi.globalPaths.unshift(currentPath); + } + } + + return mergedPaths; +} + +export function getPackagedResourceRoots(options: { + resourcesPath?: string; + appPath?: string; +}): string[] { + return dedupePaths([ + options.resourcesPath, + options.appPath ? path.dirname(options.appPath) : undefined, + ]); +} + +export function getNativeModuleSearchPaths(options: { + resourcesPath?: string; + appPath?: string; +}): string[] { + return dedupePaths( + getPackagedResourceRoots(options).flatMap((root) => + NATIVE_MODULE_RELATIVE_PATHS.map((segments) => + path.join(root, ...segments) + ) + ) + ); +} + +export function getPackagedWorkerPathCandidates(options: { + workerFilename: string; + resourcesPath?: string; + appPath?: string; +}): string[] { + return dedupePaths( + getPackagedResourceRoots(options).map((root) => + path.join(root, ...WORKER_RELATIVE_PATH, options.workerFilename) + ) + ); +} + +function createWorkerResolutionError( + workerFilename: string, + workerPathCandidates: string[] +): Error { + const error = new Error( + [ + `Unable to resolve worker "${workerFilename}".`, + 'Tried:', + ...workerPathCandidates.map((candidate) => `- ${candidate}`), + ].join('\n') + ); + error.name = 'WorkerPathResolutionError'; + return error; +} + +export function resolveWorkerRuntimeBootstrap( + options: ResolveWorkerRuntimeBootstrapOptions +): WorkerRuntimeBootstrap { + const fileExists = options.fileExists ?? existsSync; + + if (!options.isPackaged) { + const workerPath = path.join( + options.developmentWorkerDir, + options.workerFilename + ); + const workerPathCandidates = [workerPath]; + + if (!fileExists(workerPath)) { + throw createWorkerResolutionError( + options.workerFilename, + workerPathCandidates + ); + } + + return { workerPath, workerPathCandidates }; + } + + const workerPathCandidates = getPackagedWorkerPathCandidates(options); + const workerPath = workerPathCandidates.find((candidate) => + fileExists(candidate) + ); + + if (!workerPath) { + throw createWorkerResolutionError( + options.workerFilename, + workerPathCandidates + ); + } + + return { + workerPath, + workerPathCandidates, + nativeModuleSearchPaths: getNativeModuleSearchPaths(options), + }; +} + +export function getWorkerDataNativeModuleSearchPaths( + value: unknown +): string[] { + if (!value || typeof value !== 'object') { + return []; + } + + if (!('nativeModuleSearchPaths' in value)) { + return []; + } + + const searchPaths = value.nativeModuleSearchPaths; + if (!Array.isArray(searchPaths)) { + return []; + } + + return dedupePaths( + searchPaths.filter( + (searchPath): searchPath is string => typeof searchPath === 'string' + ) + ); +} + +export function loadNativeModuleFromSearchPaths( + options: LoadNativeModuleOptions +): TModule { + const fileExists = options.fileExists ?? existsSync; + const requireFactory = options.requireFactory ?? createRequire; + const attemptedPaths: string[] = []; + const failedPaths: string[] = []; + + for (const searchPath of dedupePaths(options.searchPaths)) { + attemptedPaths.push(searchPath); + + if (!fileExists(searchPath)) { + failedPaths.push(`${searchPath} (missing)`); + continue; + } + + try { + const nativeRequire = requireFactory( + path.join(searchPath, 'index.js') + ); + return nativeRequire(options.moduleName) as TModule; + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + failedPaths.push(`${searchPath} (${message})`); + } + } + + if (options.fallbackRequire) { + try { + return options.fallbackRequire(); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + failedPaths.push(`require(${options.moduleName}) (${message})`); + } + } + + const error = new Error( + [ + `${options.loggerLabel} Unable to load native module "${options.moduleName}".`, + 'Tried:', + ...attemptedPaths.map((searchPath) => `- ${searchPath}`), + ...(failedPaths.length > 0 ? ['Failures:', ...failedPaths.map((failure) => `- ${failure}`)] : []), + ].join('\n') + ); + error.name = 'NativeModuleResolutionError'; + throw error; +} diff --git a/apps/electron-backend/tsconfig.spec.json b/apps/electron-backend/tsconfig.spec.json index 0d3c604ea..976a7011d 100644 --- a/apps/electron-backend/tsconfig.spec.json +++ b/apps/electron-backend/tsconfig.spec.json @@ -2,6 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", + "esModuleInterop": true, "module": "commonjs", "moduleResolution": "node10", "types": ["jest", "node"]