fix(worker): implement worker runtime path resolution and native module loading

This commit is contained in:
4gray committed 2026-03-28 21:42:19 +01:00
1 parent 0c9fd18677
commit 688d9ec123
10 files changed
+771 -219

No files matched your search

@@ -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<void> {
export async function waitForXtreamCatalog(page: Page): Promise<void> {
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<void> {
}
}
async function waitForXtreamCategoryCounts(page: Page): Promise<void> {
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<void> {
const overlay = page.locator('.workspace-loading-overlay');
@@ -464,9 +485,8 @@ async function startRendererFrameCapture(page: Page): Promise<void> {
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<PortalDebugEvent> {
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<void> {
const {
minimumDelta = 4,
sampleMs = 300,
} = options;
const { minimumDelta = 4, sampleMs = 300 } = options;
const startCount = await getRendererFrameCount(page);
await page.waitForTimeout(sampleMs);
@@ -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<void> {
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<string, any>)[
'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'),
});
});
});
@@ -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<string, Worker> = 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<void> {
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,
@@ -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'),
});
});
});
@@ -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<void>((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?.();
@@ -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<AppDatabase> {
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('busy_timeout = 5000');
db = drizzle(sqlite, { schema });
db = getDrizzleFactory()(sqlite, { schema });
return db;
}
@@ -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<void> {
* Runs in worker thread to avoid blocking main thread
*/
function clearAllEpgData(): void {
const dbPath = getDatabasePath();
const dbPath = getIptvnatorDatabasePath();
const db = new Database(dbPath);
try {
@@ -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')
);
});
});
@@ -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<TModule> {
moduleName: string;
searchPaths: string[];
loggerLabel: string;
fallbackRequire?: () => TModule;
fileExists?: FileExists;
requireFactory?: ModuleRequireFactory;
}
function dedupePaths(paths: Array<string | undefined>): 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<TModule>(
options: LoadNativeModuleOptions<TModule>
): 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;
}
+1
View File
@@ -2,6 +2,7 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"esModuleInterop": true,
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]