mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
perf(electron): compile the main-process bundle from a V8 code cache (#1696)
main.js is now a small entry that enables Node's on-disk V8 compile cache under userData/v8-compile-cache and then requires the application bundle main.app.js. Warm launches reach app.whenReady about 13 ms sooner at the median; IPTVNATOR_DISABLE_COMPILE_CACHE=1 turns it off and IPTVNATOR_COMPILE_CACHE_DIR relocates it. Packaging now ships main.app.js explicitly and verify:package-layout requires the main-process entry files in app.asar, because nx-electron copies the backend through an allowlist. The Xtream benchmark build identity hashes both the launcher and the bundle. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
0f1cad4b16
commit
587e19541f
12 files changed
+587
-20
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: perf
|
||||
area: electron
|
||||
---
|
||||
|
||||
The desktop app now keeps a compiled copy of its startup code next to its
|
||||
user data, so every launch after the first skips part of the JavaScript
|
||||
compilation and reaches the window a little sooner. Set
|
||||
`IPTVNATOR_DISABLE_COMPILE_CACHE=1` to turn this off.
|
||||
@@ -62,13 +62,23 @@ export const BUILD_IDENTITY: XtreamBenchmarkBuildIdentity = {
|
||||
'database-worker-source-map'
|
||||
),
|
||||
},
|
||||
main: {
|
||||
launcher: {
|
||||
javascript: buildFile(
|
||||
'dist/apps/electron-backend/main.js',
|
||||
'main-javascript'
|
||||
'launcher-javascript'
|
||||
),
|
||||
sourceMap: buildFile(
|
||||
'dist/apps/electron-backend/main.js.map',
|
||||
'launcher-source-map'
|
||||
),
|
||||
},
|
||||
main: {
|
||||
javascript: buildFile(
|
||||
'dist/apps/electron-backend/main.app.js',
|
||||
'main-javascript'
|
||||
),
|
||||
sourceMap: buildFile(
|
||||
'dist/apps/electron-backend/main.app.js.map',
|
||||
'main-source-map'
|
||||
),
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ const RENDERER_FILE =
|
||||
const BUILD_KEYS = ['electron', 'renderer'] as const;
|
||||
const ELECTRON_KEYS = [
|
||||
'databaseWorker',
|
||||
'launcher',
|
||||
'main',
|
||||
'playlistRefreshWorker',
|
||||
'preload',
|
||||
@@ -26,7 +27,8 @@ const RENDERER_KEYS = [
|
||||
] as const;
|
||||
const ELECTRON_PATHS = {
|
||||
databaseWorker: 'dist/apps/electron-backend/workers/database.worker.js',
|
||||
main: 'dist/apps/electron-backend/main.js',
|
||||
launcher: 'dist/apps/electron-backend/main.js',
|
||||
main: 'dist/apps/electron-backend/main.app.js',
|
||||
playlistRefreshWorker:
|
||||
'dist/apps/electron-backend/workers/playlist-refresh.worker.js',
|
||||
preload: 'dist/apps/electron-backend/main.preload.js',
|
||||
@@ -44,6 +46,7 @@ export function parseXtreamBenchmarkBuildIdentity(
|
||||
electronInput['databaseWorker'],
|
||||
ELECTRON_PATHS.databaseWorker
|
||||
),
|
||||
launcher: pair(electronInput['launcher'], ELECTRON_PATHS.launcher),
|
||||
main: pair(electronInput['main'], ELECTRON_PATHS.main),
|
||||
playlistRefreshWorker: pair(
|
||||
electronInput['playlistRefreshWorker'],
|
||||
|
||||
@@ -38,9 +38,14 @@ describe('Xtream benchmark build identity', () => {
|
||||
|
||||
assert.deepEqual(identity.electron.main.javascript, {
|
||||
bytes: 4,
|
||||
path: 'dist/apps/electron-backend/main.js',
|
||||
path: 'dist/apps/electron-backend/main.app.js',
|
||||
sha256: sha256('main'),
|
||||
});
|
||||
assert.deepEqual(identity.electron.launcher.javascript, {
|
||||
bytes: 8,
|
||||
path: 'dist/apps/electron-backend/main.js',
|
||||
sha256: sha256('launcher'),
|
||||
});
|
||||
assert.deepEqual(identity.electron.preload.sourceMap, {
|
||||
bytes: 11,
|
||||
path: 'dist/apps/electron-backend/main.preload.js.map',
|
||||
@@ -112,7 +117,7 @@ describe('Xtream benchmark build identity', () => {
|
||||
const symlinkRoot = await buildFixture();
|
||||
const mainPath = join(
|
||||
symlinkRoot,
|
||||
'dist/apps/electron-backend/main.js'
|
||||
'dist/apps/electron-backend/main.app.js'
|
||||
);
|
||||
const external = join(symlinkRoot, 'external-main.js');
|
||||
await writeFile(external, 'main');
|
||||
@@ -219,8 +224,10 @@ async function buildFixture(): Promise<string> {
|
||||
mkdir(join(renderer, 'assets'), { recursive: true }),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeFile(join(backend, 'main.js'), 'main'),
|
||||
writeFile(join(backend, 'main.js.map'), 'main-map'),
|
||||
writeFile(join(backend, 'main.js'), 'launcher'),
|
||||
writeFile(join(backend, 'main.js.map'), 'launcher-map'),
|
||||
writeFile(join(backend, 'main.app.js'), 'main'),
|
||||
writeFile(join(backend, 'main.app.js.map'), 'main-map'),
|
||||
writeFile(join(backend, 'main.preload.js'), 'preload'),
|
||||
writeFile(join(backend, 'main.preload.js.map'), 'preload-map'),
|
||||
writeFile(join(workers, 'database.worker.js'), 'database'),
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface XtreamBuildPairIdentity {
|
||||
export interface XtreamBenchmarkBuildIdentity {
|
||||
readonly electron: {
|
||||
readonly databaseWorker: XtreamBuildPairIdentity;
|
||||
readonly launcher: XtreamBuildPairIdentity;
|
||||
readonly main: XtreamBuildPairIdentity;
|
||||
readonly playlistRefreshWorker: XtreamBuildPairIdentity;
|
||||
readonly preload: XtreamBuildPairIdentity;
|
||||
@@ -35,7 +36,10 @@ const BACKEND_ROOT = 'dist/apps/electron-backend';
|
||||
const RENDERER_ROOT = 'dist/apps/web';
|
||||
const ELECTRON_PATHS = {
|
||||
databaseWorker: `${BACKEND_ROOT}/workers/database.worker.js`,
|
||||
main: `${BACKEND_ROOT}/main.js`,
|
||||
// main.js only enables the compile cache and requires main.app.js, but
|
||||
// it decides startup behavior, so both belong to the identity.
|
||||
launcher: `${BACKEND_ROOT}/main.js`,
|
||||
main: `${BACKEND_ROOT}/main.app.js`,
|
||||
playlistRefreshWorker: `${BACKEND_ROOT}/workers/playlist-refresh.worker.js`,
|
||||
preload: `${BACKEND_ROOT}/main.preload.js`,
|
||||
} as const;
|
||||
@@ -45,17 +49,25 @@ export async function captureXtreamBuildIdentity(
|
||||
): Promise<XtreamBenchmarkBuildIdentity> {
|
||||
try {
|
||||
if (!isAbsolute(workspaceRoot)) invalid();
|
||||
const [databaseWorker, main, playlistRefreshWorker, preload, renderer] =
|
||||
await Promise.all([
|
||||
readPair(workspaceRoot, ELECTRON_PATHS.databaseWorker),
|
||||
readPair(workspaceRoot, ELECTRON_PATHS.main),
|
||||
readPair(workspaceRoot, ELECTRON_PATHS.playlistRefreshWorker),
|
||||
readPair(workspaceRoot, ELECTRON_PATHS.preload),
|
||||
readRenderer(workspaceRoot),
|
||||
]);
|
||||
const [
|
||||
databaseWorker,
|
||||
launcher,
|
||||
main,
|
||||
playlistRefreshWorker,
|
||||
preload,
|
||||
renderer,
|
||||
] = await Promise.all([
|
||||
readPair(workspaceRoot, ELECTRON_PATHS.databaseWorker),
|
||||
readPair(workspaceRoot, ELECTRON_PATHS.launcher),
|
||||
readPair(workspaceRoot, ELECTRON_PATHS.main),
|
||||
readPair(workspaceRoot, ELECTRON_PATHS.playlistRefreshWorker),
|
||||
readPair(workspaceRoot, ELECTRON_PATHS.preload),
|
||||
readRenderer(workspaceRoot),
|
||||
]);
|
||||
return Object.freeze({
|
||||
electron: Object.freeze({
|
||||
databaseWorker,
|
||||
launcher,
|
||||
main,
|
||||
playlistRefreshWorker,
|
||||
preload,
|
||||
|
||||
@@ -51,7 +51,13 @@
|
||||
],
|
||||
"options": {
|
||||
"outputPath": "dist/apps/electron-backend",
|
||||
"main": "apps/electron-backend/src/main.ts",
|
||||
"main": "apps/electron-backend/src/main.entry.ts",
|
||||
"additionalEntryPoints": [
|
||||
{
|
||||
"entryName": "main.app",
|
||||
"entryPath": "apps/electron-backend/src/main.ts"
|
||||
}
|
||||
],
|
||||
"tsConfig": "apps/electron-backend/tsconfig.app.json",
|
||||
"assets": [
|
||||
"apps/electron-backend/src/assets",
|
||||
@@ -143,7 +149,13 @@
|
||||
],
|
||||
"options": {
|
||||
"outputPath": "dist/apps/electron-backend",
|
||||
"main": "apps/electron-backend/src/main.ts",
|
||||
"main": "apps/electron-backend/src/main.entry.ts",
|
||||
"additionalEntryPoints": [
|
||||
{
|
||||
"entryName": "main.app",
|
||||
"entryPath": "apps/electron-backend/src/main.ts"
|
||||
}
|
||||
],
|
||||
"tsConfig": "apps/electron-backend/tsconfig.app.json",
|
||||
"assets": [
|
||||
"apps/electron-backend/src/assets",
|
||||
@@ -197,6 +209,13 @@
|
||||
"options": {
|
||||
"name": "electron-backend",
|
||||
"frontendProject": "web",
|
||||
"files": [
|
||||
{
|
||||
"from": "electron-backend",
|
||||
"to": "electron-backend",
|
||||
"filter": ["main.app.js"]
|
||||
}
|
||||
],
|
||||
"sourcePath": "dist/apps",
|
||||
"outputPath": "dist/packages",
|
||||
"prepackageOnly": true
|
||||
@@ -211,6 +230,13 @@
|
||||
"options": {
|
||||
"name": "electron-backend",
|
||||
"frontendProject": "web",
|
||||
"files": [
|
||||
{
|
||||
"from": "electron-backend",
|
||||
"to": "electron-backend",
|
||||
"filter": ["main.app.js"]
|
||||
}
|
||||
],
|
||||
"sourcePath": "dist/apps",
|
||||
"outputPath": "dist/executables"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
COMPILE_CACHE_DIR_ENV,
|
||||
COMPILE_CACHE_DISABLE_ENV,
|
||||
enableStartupCompileCache,
|
||||
isCompileCacheDisabled,
|
||||
publishCompileCacheOutcome,
|
||||
readCompileCacheOutcome,
|
||||
resolveCompileCacheDirectory,
|
||||
type CompileCacheEnableResult,
|
||||
type CompileCacheModule,
|
||||
} from './compile-cache';
|
||||
|
||||
const OUTCOME_KEY = Symbol.for('iptvnator.compileCacheOutcome');
|
||||
const userData = join('/profiles', 'iptvnator');
|
||||
const defaultDirectory = join(userData, 'v8-compile-cache');
|
||||
|
||||
function createModule(
|
||||
result: CompileCacheEnableResult = {
|
||||
status: 1,
|
||||
directory: defaultDirectory,
|
||||
}
|
||||
): jest.Mocked<Required<CompileCacheModule>> {
|
||||
return { enableCompileCache: jest.fn().mockReturnValue(result) };
|
||||
}
|
||||
|
||||
function enable(
|
||||
env: NodeJS.ProcessEnv,
|
||||
module: CompileCacheModule = createModule(),
|
||||
userDataPath: () => string = () => userData
|
||||
) {
|
||||
return enableStartupCompileCache({ env, module, userDataPath });
|
||||
}
|
||||
|
||||
describe('startup compile cache guard', () => {
|
||||
afterEach(() => {
|
||||
delete (globalThis as Record<symbol, unknown>)[OUTCOME_KEY];
|
||||
});
|
||||
|
||||
describe('cache directory', () => {
|
||||
it('lives under userData/v8-compile-cache by default', () => {
|
||||
expect(resolveCompileCacheDirectory({}, () => userData)).toBe(
|
||||
defaultDirectory
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers IPTVNATOR_COMPILE_CACHE_DIR and never asks for userData then', () => {
|
||||
const userDataPath = jest.fn(() => userData);
|
||||
|
||||
const directory = resolveCompileCacheDirectory(
|
||||
{ [COMPILE_CACHE_DIR_ENV]: ' /tmp/iptvnator-cache ' },
|
||||
userDataPath
|
||||
);
|
||||
|
||||
expect(directory).toBe('/tmp/iptvnator-cache');
|
||||
expect(userDataPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a blank IPTVNATOR_COMPILE_CACHE_DIR', () => {
|
||||
expect(
|
||||
resolveCompileCacheDirectory(
|
||||
{ [COMPILE_CACHE_DIR_ENV]: ' ' },
|
||||
() => userData
|
||||
)
|
||||
).toBe(defaultDirectory);
|
||||
});
|
||||
|
||||
it('stays inside the E2E data directory, mirroring the userData override', () => {
|
||||
const directory = resolveCompileCacheDirectory(
|
||||
{ IPTVNATOR_E2E_DATA_DIR: '/tmp/e2e-run' },
|
||||
() => userData
|
||||
);
|
||||
|
||||
expect(directory).toBe(
|
||||
join('/tmp/e2e-run', 'user-data', 'v8-compile-cache')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kill switch', () => {
|
||||
it.each(['1', 'true', 'yes', 'ON', ' on '])(
|
||||
'honours IPTVNATOR_DISABLE_COMPILE_CACHE=%j without touching node:module',
|
||||
(value) => {
|
||||
const module = createModule();
|
||||
const userDataPath = jest.fn(() => userData);
|
||||
|
||||
const outcome = enableStartupCompileCache({
|
||||
env: { [COMPILE_CACHE_DISABLE_ENV]: value },
|
||||
module,
|
||||
userDataPath,
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
status: 'disabled',
|
||||
reason: COMPILE_CACHE_DISABLE_ENV,
|
||||
});
|
||||
expect(module.enableCompileCache).not.toHaveBeenCalled();
|
||||
expect(userDataPath).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['', '0', 'false', 'off'])(
|
||||
'keeps the cache on for IPTVNATOR_DISABLE_COMPILE_CACHE=%j',
|
||||
(value) => {
|
||||
expect(
|
||||
isCompileCacheDisabled({
|
||||
[COMPILE_CACHE_DISABLE_ENV]: value,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
enable({ [COMPILE_CACHE_DISABLE_ENV]: value }).status
|
||||
).toBe('enabled');
|
||||
}
|
||||
);
|
||||
|
||||
it('defaults to enabled when the variable is absent', () => {
|
||||
expect(isCompileCacheDisabled({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enabling', () => {
|
||||
it('enables the cache in the resolved directory', () => {
|
||||
const module = createModule();
|
||||
|
||||
const outcome = enable({}, module);
|
||||
|
||||
expect(module.enableCompileCache).toHaveBeenCalledWith(
|
||||
defaultDirectory
|
||||
);
|
||||
expect(outcome).toEqual({
|
||||
status: 'enabled',
|
||||
directory: defaultDirectory,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the directory Node settled on when it differs', () => {
|
||||
const module = createModule({
|
||||
status: 1,
|
||||
directory: '/resolved/elsewhere',
|
||||
});
|
||||
|
||||
expect(enable({}, module)).toEqual({
|
||||
status: 'enabled',
|
||||
directory: '/resolved/elsewhere',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps ALREADY_ENABLED without treating it as a failure', () => {
|
||||
expect(enable({}, createModule({ status: 2 }))).toEqual({
|
||||
status: 'already-enabled',
|
||||
directory: defaultDirectory,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps Node’s own DISABLED status and keeps its message', () => {
|
||||
expect(
|
||||
enable(
|
||||
{},
|
||||
createModule({
|
||||
status: 3,
|
||||
message: 'NODE_DISABLE_COMPILE_CACHE is set',
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
status: 'disabled',
|
||||
reason: 'NODE_DISABLE_COMPILE_CACHE is set',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps FAILED with the message Node gives', () => {
|
||||
expect(
|
||||
enable(
|
||||
{},
|
||||
createModule({
|
||||
status: 0,
|
||||
message: 'cannot create directory',
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
status: 'failed',
|
||||
directory: defaultDirectory,
|
||||
reason: 'cannot create directory',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('failure tolerance', () => {
|
||||
it('reports unavailable when Node has no enableCompileCache', () => {
|
||||
expect(enable({}, {})).toEqual({
|
||||
status: 'unavailable',
|
||||
reason: 'module.enableCompileCache is missing',
|
||||
});
|
||||
});
|
||||
|
||||
it('swallows an exception thrown by enableCompileCache', () => {
|
||||
const module: CompileCacheModule = {
|
||||
enableCompileCache: () => {
|
||||
throw new Error('EACCES: permission denied');
|
||||
},
|
||||
};
|
||||
|
||||
expect(enable({}, module)).toEqual({
|
||||
status: 'failed',
|
||||
directory: defaultDirectory,
|
||||
reason: 'EACCES: permission denied',
|
||||
});
|
||||
});
|
||||
|
||||
it('swallows a failing userData lookup', () => {
|
||||
const module = createModule();
|
||||
|
||||
const outcome = enable({}, module, () => {
|
||||
throw new Error('app is not ready');
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'app is not ready',
|
||||
});
|
||||
expect(module.enableCompileCache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stringifies non-Error throwables', () => {
|
||||
const module: CompileCacheModule = {
|
||||
enableCompileCache: () => {
|
||||
throw 'boom';
|
||||
},
|
||||
};
|
||||
|
||||
expect(enable({}, module).reason).toBe('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('outcome hand-over to the bundle', () => {
|
||||
it('reads back what the entry published', () => {
|
||||
publishCompileCacheOutcome({
|
||||
status: 'enabled',
|
||||
directory: defaultDirectory,
|
||||
});
|
||||
|
||||
expect(readCompileCacheOutcome()).toEqual({
|
||||
status: 'enabled',
|
||||
directory: defaultDirectory,
|
||||
});
|
||||
});
|
||||
|
||||
it('explains a missing outcome instead of tracing undefined', () => {
|
||||
expect(readCompileCacheOutcome()).toEqual({
|
||||
status: 'unavailable',
|
||||
reason: 'main.entry did not run',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* V8 compile cache for the Electron main process.
|
||||
*
|
||||
* `dist/apps/electron-backend/main.js` is built from `main.entry.ts`. It
|
||||
* enables Node's on-disk compile cache and only then requires the real
|
||||
* application bundle, `main.app.js` (built from `main.ts`), so the bundle and
|
||||
* the packages it pulls in are compiled from cached bytecode on every launch
|
||||
* after the first. V8 produces a code cache for the script it is compiling,
|
||||
* which is why the call cannot live inside the bundle it is meant to cache.
|
||||
*
|
||||
* This module holds the decision logic so the entry stays a few lines and the
|
||||
* guards are unit-testable: an environment kill switch, an explicit cache
|
||||
* directory, and failure tolerance (a broken cache must never block startup).
|
||||
*/
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const COMPILE_CACHE_DISABLE_ENV = 'IPTVNATOR_DISABLE_COMPILE_CACHE';
|
||||
export const COMPILE_CACHE_DIR_ENV = 'IPTVNATOR_COMPILE_CACHE_DIR';
|
||||
export const COMPILE_CACHE_DIR_NAME = 'v8-compile-cache';
|
||||
|
||||
const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||
const OUTCOME_KEY = Symbol.for('iptvnator.compileCacheOutcome');
|
||||
|
||||
/** `module.constants.compileCacheStatus` of Node 22.1+, by value. */
|
||||
const NODE_STATUS = {
|
||||
FAILED: 0,
|
||||
ENABLED: 1,
|
||||
ALREADY_ENABLED: 2,
|
||||
DISABLED: 3,
|
||||
} as const;
|
||||
|
||||
export type CompileCacheStatus =
|
||||
'enabled' | 'already-enabled' | 'disabled' | 'unavailable' | 'failed';
|
||||
|
||||
export interface CompileCacheOutcome {
|
||||
readonly status: CompileCacheStatus;
|
||||
readonly directory?: string;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface CompileCacheEnableResult {
|
||||
readonly status: number;
|
||||
readonly directory?: string;
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
/** The slice of `node:module` the entry relies on; injectable for tests. */
|
||||
export interface CompileCacheModule {
|
||||
enableCompileCache?: (directory?: string) => CompileCacheEnableResult;
|
||||
}
|
||||
|
||||
export interface EnableCompileCacheOptions {
|
||||
readonly env?: NodeJS.ProcessEnv;
|
||||
readonly module: CompileCacheModule;
|
||||
/** Electron's `userData`; only consulted without an explicit directory. */
|
||||
readonly userDataPath: () => string;
|
||||
}
|
||||
|
||||
export function isCompileCacheDisabled(
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): boolean {
|
||||
const value = env[COMPILE_CACHE_DISABLE_ENV]?.trim().toLowerCase();
|
||||
return value ? TRUE_VALUES.has(value) : false;
|
||||
}
|
||||
|
||||
export function resolveCompileCacheDirectory(
|
||||
env: NodeJS.ProcessEnv,
|
||||
userDataPath: () => string
|
||||
): string {
|
||||
const explicit = env[COMPILE_CACHE_DIR_ENV]?.trim();
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
// Mirrors getElectronUserDataPath() in @iptvnator/shared/database, which
|
||||
// profile bootstrap applies later. The entry cannot import that library
|
||||
// without loading the database stack ahead of the cache.
|
||||
const e2eDataDir = env.IPTVNATOR_E2E_DATA_DIR?.trim();
|
||||
const userData = e2eDataDir
|
||||
? join(e2eDataDir, 'user-data')
|
||||
: userDataPath();
|
||||
return join(userData, COMPILE_CACHE_DIR_NAME);
|
||||
}
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function enableStartupCompileCache(
|
||||
options: EnableCompileCacheOptions
|
||||
): CompileCacheOutcome {
|
||||
const env = options.env ?? process.env;
|
||||
if (isCompileCacheDisabled(env)) {
|
||||
return { status: 'disabled', reason: COMPILE_CACHE_DISABLE_ENV };
|
||||
}
|
||||
|
||||
const enable = options.module.enableCompileCache;
|
||||
if (typeof enable !== 'function') {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
reason: 'module.enableCompileCache is missing',
|
||||
};
|
||||
}
|
||||
|
||||
let directory: string | undefined;
|
||||
try {
|
||||
directory = resolveCompileCacheDirectory(env, options.userDataPath);
|
||||
const result = enable.call(options.module, directory);
|
||||
switch (result.status) {
|
||||
case NODE_STATUS.ENABLED:
|
||||
return {
|
||||
status: 'enabled',
|
||||
directory: result.directory ?? directory,
|
||||
};
|
||||
case NODE_STATUS.ALREADY_ENABLED:
|
||||
return {
|
||||
status: 'already-enabled',
|
||||
directory: result.directory ?? directory,
|
||||
};
|
||||
case NODE_STATUS.DISABLED:
|
||||
return {
|
||||
status: 'disabled',
|
||||
reason: result.message ?? 'NODE_DISABLE_COMPILE_CACHE',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
status: 'failed',
|
||||
directory,
|
||||
reason: result.message ?? `status ${result.status}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return { status: 'failed', directory, reason: describeError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Hands the entry's outcome to the bundle, which owns the startup trace. */
|
||||
export function publishCompileCacheOutcome(outcome: CompileCacheOutcome): void {
|
||||
(globalThis as Record<symbol, unknown>)[OUTCOME_KEY] = outcome;
|
||||
}
|
||||
|
||||
export function readCompileCacheOutcome(): CompileCacheOutcome {
|
||||
const outcome = (globalThis as Record<symbol, unknown>)[OUTCOME_KEY] as
|
||||
CompileCacheOutcome | undefined;
|
||||
return (
|
||||
outcome ?? { status: 'unavailable', reason: 'main.entry did not run' }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Process entry of the Electron main process. It becomes
|
||||
* `dist/apps/electron-backend/main.js`: the file Electron, `nx serve`, the E2E
|
||||
* fixtures and the packaged app launch.
|
||||
*
|
||||
* It enables the V8 compile cache and only then requires the application
|
||||
* bundle, `main.app.js` (built from `main.ts`). V8 writes a code cache for
|
||||
* the script being compiled, so a call inside the bundle would leave the
|
||||
* bundle itself uncached; only this small file pays the uncached compile.
|
||||
* Keep it free of imports beyond `electron`, Node built-ins and the guard
|
||||
* helper: anything imported here is compiled before the cache is on.
|
||||
*/
|
||||
import { app } from 'electron';
|
||||
import * as nodeModule from 'node:module';
|
||||
import {
|
||||
enableStartupCompileCache,
|
||||
publishCompileCacheOutcome,
|
||||
type CompileCacheModule,
|
||||
} from './app/services/compile-cache';
|
||||
|
||||
declare const __non_webpack_require__: NodeJS.Require;
|
||||
|
||||
publishCompileCacheOutcome(
|
||||
enableStartupCompileCache({
|
||||
module: nodeModule as CompileCacheModule,
|
||||
// Read before main.ts calls app.setName(), deliberately: renaming
|
||||
// here would move every path derived from the app name, including
|
||||
// the settings store's, for existing Linux profiles. On macOS and
|
||||
// Windows the directory is the same either way.
|
||||
userDataPath: () => app.getPath('userData'),
|
||||
})
|
||||
);
|
||||
|
||||
// Resolved next to this file at run time; webpack must not inline it.
|
||||
__non_webpack_require__('./main.app.js');
|
||||
@@ -31,6 +31,7 @@ import SharedEvents from './app/events/shared.events';
|
||||
import SquirrelEvents from './app/events/squirrel.events';
|
||||
import StalkerEvents from './app/events/stalker.events';
|
||||
import { isStartupTraceEnabled, trace } from './app/services/debug-trace';
|
||||
import { readCompileCacheOutcome } from './app/services/compile-cache';
|
||||
import { applyElectronNetworkDefaults } from './app/util/network-defaults';
|
||||
import { registerStaticHeaderShims } from './app/services/request-header-overrides.service';
|
||||
import { AppUpdateService } from './app/services/app-update.service';
|
||||
@@ -61,6 +62,10 @@ import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service';
|
||||
|
||||
app.setName('iptvnator');
|
||||
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'compile-cache', readCompileCacheOutcome());
|
||||
}
|
||||
|
||||
// Before the first portal, playlist or update request leaves this process.
|
||||
applyElectronNetworkDefaults((line) => {
|
||||
if (isStartupTraceEnabled()) {
|
||||
|
||||
@@ -32,6 +32,7 @@ IPTVNATOR_TRACE_STARTUP=1 pnpm nx serve electron-backend
|
||||
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal
|
||||
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
|
||||
- `IPTVNATOR_DISABLE_COMPILE_CACHE=1` disables the main-process V8 compile cache; `IPTVNATOR_COMPILE_CACHE_DIR=<dir>` relocates it. The startup trace reports the outcome as `compile-cache`
|
||||
|
||||
- Settings, portal request/response, and trace payloads must use
|
||||
`@iptvnator/shared/logging` or the redacting portal logger before reaching
|
||||
@@ -93,8 +94,17 @@ classifying a zero rendered-frame signal as an infrastructure flake.
|
||||
|
||||
## Main-process ownership
|
||||
|
||||
The entry point is `apps/electron-backend/src/main.ts`; it bootstraps the database,
|
||||
registers events and creates the main window. The preload is
|
||||
The process entry is `apps/electron-backend/src/main.entry.ts` (built to
|
||||
`dist/apps/electron-backend/main.js`): it enables the V8 compile cache under
|
||||
`userData/v8-compile-cache` and then requires the application bundle,
|
||||
`main.app.js`, built from `apps/electron-backend/src/main.ts`, which bootstraps
|
||||
the database, registers events and creates the main window. The cache is
|
||||
disposable; `IPTVNATOR_DISABLE_COMPILE_CACHE=1` turns it off and
|
||||
`IPTVNATOR_COMPILE_CACHE_DIR` relocates it (E2E runs keep it inside
|
||||
`IPTVNATOR_E2E_DATA_DIR`). nx-electron packages the backend through an
|
||||
allowlist, so `apps/electron-backend/project.json` lists `main.app.js` under the
|
||||
`files` option of the `package` and `make` targets, and `verify:package-layout`
|
||||
fails when either entry file is missing from `app.asar`. The preload is
|
||||
`apps/electron-backend/src/app/api/main.preload.ts`, with handlers under
|
||||
`apps/electron-backend/src/app/events/`. The window follows the saved startup mode
|
||||
(normal/maximized/fullscreen); `--fullscreen` overrides a single launch. Use
|
||||
|
||||
@@ -472,6 +472,52 @@ function verifyPackagedPackageMetadata(resourceDir, errors) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main-process entry is split: `main.js` enables the V8 compile cache and
|
||||
* requires `main.app.js`, the application bundle. nx-electron packages the
|
||||
* backend through an allowlist, so a missing bundle only surfaces as an
|
||||
* uncaught "Cannot find module" at launch; fail the layout check instead.
|
||||
*/
|
||||
const REQUIRED_MAIN_PROCESS_ENTRIES = [
|
||||
'/electron-backend/main.js',
|
||||
'/electron-backend/main.app.js',
|
||||
'/electron-backend/main.preload.js',
|
||||
];
|
||||
|
||||
function verifyPackagedMainProcessEntries(resourceDir, errors) {
|
||||
const asarPath = path.join(resourceDir, 'app.asar');
|
||||
if (!fileExists(asarPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
// @electron/asar lists entries with the host separator on Windows.
|
||||
entries = new Set(
|
||||
listPackage(asarPath).map((entry) => {
|
||||
const normalized = entry.replace(/\\/g, '/');
|
||||
return normalized.startsWith('/')
|
||||
? normalized
|
||||
: `/${normalized}`;
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`Unable to list main-process entries in ${asarPath}: ${error.message}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const missing = REQUIRED_MAIN_PROCESS_ENTRIES.filter(
|
||||
(entry) => !entries.has(entry)
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
errors.push(
|
||||
`Packaged app.asar is missing main-process entry files in ${asarPath}: ${missing.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyLinuxLauncher(resourceDir, targetNames, errors) {
|
||||
let launcherLayout;
|
||||
try {
|
||||
@@ -711,6 +757,7 @@ function verifyResourceDir(resourceDir) {
|
||||
let linuxTargetNames;
|
||||
|
||||
verifyPackagedPackageMetadata(resourceDir, errors);
|
||||
verifyPackagedMainProcessEntries(resourceDir, errors);
|
||||
verifyPackagedDependencyClosure(resourceDir, errors);
|
||||
verifyNoEmbeddedMpvNativeArchiveEntries(resourceDir, errors);
|
||||
|
||||
|
||||
Reference in new issue
Block a user