mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
fix(worker): implement worker runtime path resolution and native module loading
This commit is contained in:
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;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"esModuleInterop": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
|
||||
Reference in new issue
Block a user