From 695f64a55a32aeff1ea5a6c47c80a51d12876f34 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Sun, 8 Sep 2024 14:09:57 -0700 Subject: [PATCH] Include code cache for preload bundle Co-authored-by: Scott Nonnenberg --- .eslintignore | 1 + .github/workflows/benchmark.yml | 2 + .github/workflows/ci.yml | 6 + .gitignore | 1 + .prettierignore | 1 + app/main.ts | 2 +- package.json | 7 +- preload.wrapper.ts | 126 +++++++++++++++++++ scripts/esbuild.js | 15 ++- ts/scripts/generate-preload-cache.html | 12 ++ ts/scripts/generate-preload-cache.preload.ts | 29 +++++ ts/scripts/generate-preload-cache.ts | 35 ++++++ ts/scripts/test-release.ts | 4 +- 13 files changed, 230 insertions(+), 11 deletions(-) create mode 100644 preload.wrapper.ts create mode 100644 ts/scripts/generate-preload-cache.html create mode 100644 ts/scripts/generate-preload-cache.preload.ts create mode 100644 ts/scripts/generate-preload-cache.ts diff --git a/.eslintignore b/.eslintignore index f5f3cc19211a..3e4e5a3b4a36 100644 --- a/.eslintignore +++ b/.eslintignore @@ -29,6 +29,7 @@ ts/**/*.js .eslintrc.js webpack.config.ts preload.bundle.* +preload.wrapper.* bundles/** # Sticker Creator has its own eslint config diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index f9779829cd36..064b967bb648 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -54,6 +54,8 @@ jobs: run: npm run generate - name: Bundle run: npm run build:esbuild:prod + - name: Create preload cache + run: xvfb-run --auto-servernum npm run build:preload-cache - name: Run startup benchmarks run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3249d91c600d..737280ddd88b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,6 +165,8 @@ jobs: - name: Create bundle run: npm run build:esbuild:prod + - name: Create preload cache + run: xvfb-run --auto-servernum npm run build:preload-cache - name: Build with packaging .deb file run: npm run build:release -- --publish=never @@ -249,6 +251,8 @@ jobs: - name: Create bundle run: npm run build:esbuild:prod + - name: Create preload cache + run: npm run build:preload-cache - name: Build with NSIS run: npm run build:release @@ -359,6 +363,8 @@ jobs: run: npm run generate - name: Bundle run: npm run build:esbuild:prod + - name: Create preload cache + run: xvfb-run --auto-servernum npm run build:preload-cache - name: Run mock server tests run: | diff --git a/.gitignore b/.gitignore index a09dfdeaeeb3..11154dfef755 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ stylesheets/*.css !stylesheets/webrtc_internals.css /storybook-static/ preload.bundle.* +preload.wrapper.js bundles/ ts/sql/mainWorker.bundle.js.LICENSE.txt build/ICUMessageParams.d.ts diff --git a/.prettierignore b/.prettierignore index 74630351cf73..33edf55ffc70 100644 --- a/.prettierignore +++ b/.prettierignore @@ -52,6 +52,7 @@ js/calling-tools/** stylesheets/_intlTelInput.scss preload.bundle.* +preload.wrapper.js bundles/** # Sticker Creator has its own prettier config diff --git a/app/main.ts b/app/main.ts index 246dc34179f0..587fd87c930c 100644 --- a/app/main.ts +++ b/app/main.ts @@ -709,7 +709,7 @@ async function createWindow() { preload: join( __dirname, usePreloadBundle - ? '../preload.bundle.js' + ? '../preload.wrapper.js' : '../ts/windows/main/preload.js' ), spellcheck: await getSpellCheckSetting(), diff --git a/package.json b/package.json index b231c14e0291..540cc597a977 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "svgo": "svgo --multipass images/**/*.svg", "transpile": "run-p check:types build:esbuild", "check:types": "tsc --noEmit", - "clean-transpile-once": "rimraf sticker-creator/dist app/**/*.js app/*.js ts/**/*.js ts/*.js bundles tsconfig.tsbuildinfo", + "clean-transpile-once": "rimraf sticker-creator/dist app/**/*.js app/*.js ts/**/*.js ts/*.js bundles tsconfig.tsbuildinfo preload.bundle.js preload.bundle.cache", "clean-transpile": "run-s clean-transpile-once clean-transpile-once", "ready": "npm-run-all --print-label clean-transpile generate --parallel lint lint-deps lint-intl test-node test-electron", "dev": "npm run build-protobuf && cross-env SIGNAL_ENV=storybook storybook dev --port 6006", @@ -74,7 +74,7 @@ "test:storybook": "npm run build:storybook && run-p --race test:storybook:*", "test:storybook:serve": "http-server storybook-static --port 6006 --silent", "test:storybook:test": "wait-on http://127.0.0.1:6006/ --timeout 5000 && test-storybook", - "build": "run-s --print-label generate build:esbuild:prod build:release", + "build": "run-s --print-label generate build:esbuild:prod build:preload-cache build:release", "build-linux": "run-s generate build:esbuild:prod && npm run build:release -- --publish=never", "build:acknowledgments": "node scripts/generate-acknowledgments.js", "build:dns-fallback": "node ts/scripts/generate-dns-fallback.js", @@ -85,6 +85,7 @@ "build:esbuild:prod": "node scripts/esbuild.js --prod", "build:electron": "electron-builder --config.extraMetadata.environment=$SIGNAL_ENV", "build:release": "cross-env SIGNAL_ENV=production npm run build:electron -- --config.directories.output=release", + "build:preload-cache": "electron --js-args=\"--predictable --random-seed 1\" ts/scripts/generate-preload-cache.js", "verify": "run-p --print-label verify:*", "verify:ts": "tsc --noEmit", "electron:install-app-deps": "electron-builder install-app-deps" @@ -534,6 +535,8 @@ "app/*", "!app/*.ts", "preload.bundle.js", + "preload.wrapper.js", + "preload.bundle.cache", "preload_utils.js", "main.js", "images/**", diff --git a/preload.wrapper.ts b/preload.wrapper.ts new file mode 100644 index 000000000000..7773a54cea2e --- /dev/null +++ b/preload.wrapper.ts @@ -0,0 +1,126 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// Based on: +// https://github.com/zertosh/v8-compile-cache/blob/b6bc035d337fbda0e6e3ec7936499048fc9deafc/v8-compile-cache.js + +import { Module } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { Script } from 'node:vm'; + +const srcPath = join(__dirname, 'preload.bundle.js'); +const cachePath = join(__dirname, 'preload.bundle.cache'); + +let cachedData: Buffer | undefined; +try { + cachedData = readFileSync(cachePath); +} catch (error) { + // No cache - no big deal + if (error.code !== 'ENOENT') { + throw error; + } +} + +function compile( + filename: string, + content: string +): (..._args: Array) => void { + // https://github.com/nodejs/node/blob/v7.5.0/lib/module.js#L511 + + // create wrapper function + const wrapper = Module.wrap(content); + + const script = new Script(wrapper, { + filename, + lineOffset: 0, + cachedData, + }); + + const compiledWrapper = script.runInThisContext({ + filename, + lineOffset: 0, + columnOffset: 0, + displayErrors: true, + }); + + return compiledWrapper; +} + +const ModuleInternals = Module as unknown as { + prototype: { + _compile( + this: typeof ModuleInternals, + content: string, + filename: string + ): unknown; + }; + require(id: string): unknown; + exports: unknown; + _resolveFilename( + request: unknown, + mod: unknown, + _: false, + options: unknown + ): unknown; + _resolveLookupPaths(request: unknown, mod: unknown, _: true): unknown; + _cache: unknown; + _extensions: unknown; +}; + +const previousModuleCompile = ModuleInternals.prototype._compile; +ModuleInternals.prototype._compile = function _compile( + content: string, + filename: string +) { + if (filename !== srcPath) { + throw new Error(`Unexpected filename: ${filename}`); + } + + // Immediately restore + ModuleInternals.prototype._compile = previousModuleCompile; + + const require = (id: string) => { + return this.require(id); + }; + + // https://github.com/nodejs/node/blob/v10.15.3/lib/internal/modules/cjs/helpers.js#L28 + const resolve = (request: unknown, options: unknown) => { + return ModuleInternals._resolveFilename(request, this, false, options); + }; + require.resolve = resolve; + + resolve.paths = (request: unknown) => { + return ModuleInternals._resolveLookupPaths(request, this, true); + }; + + require.main = process.mainModule; + + // Enable support to add extra extension types + require.extensions = ModuleInternals._extensions; + require.cache = ModuleInternals._cache; + + const dir = dirname(filename); + + const compiledWrapper = compile(filename, content); + + // We skip the debugger setup because by the time we run, node has already + // done that itself. + + // `Buffer` is included for Electron. + // See https://github.com/zertosh/v8-compile-cache/pull/10#issuecomment-518042543 + const args = [ + this.exports, + require, + this, + filename, + dir, + process, + global, + Buffer, + ]; + return compiledWrapper.apply(this.exports, args); +}; + +// eslint-disable-next-line import/no-dynamic-require +require(srcPath); diff --git a/scripts/esbuild.js b/scripts/esbuild.js index 5cd771718bad..d91441980da5 100644 --- a/scripts/esbuild.js +++ b/scripts/esbuild.js @@ -103,12 +103,15 @@ async function main() { ...nodeDefaults, format: 'cjs', mainFields: ['browser', 'main'], - entryPoints: glob - .sync('{app,ts}/**/*.{ts,tsx}', { - nodir: true, - root: ROOT_DIR, - }) - .filter(file => !file.endsWith('.d.ts')), + entryPoints: [ + 'preload.wrapper.ts', + ...glob + .sync('{app,ts}/**/*.{ts,tsx}', { + nodir: true, + root: ROOT_DIR, + }) + .filter(file => !file.endsWith('.d.ts')), + ], outdir: path.join(ROOT_DIR), }, preloadConfig: { diff --git a/ts/scripts/generate-preload-cache.html b/ts/scripts/generate-preload-cache.html new file mode 100644 index 000000000000..9e17b3325f2e --- /dev/null +++ b/ts/scripts/generate-preload-cache.html @@ -0,0 +1,12 @@ + + + + + + + Generating cache... + + + Generating cache... + + diff --git a/ts/scripts/generate-preload-cache.preload.ts b/ts/scripts/generate-preload-cache.preload.ts new file mode 100644 index 000000000000..51122245f557 --- /dev/null +++ b/ts/scripts/generate-preload-cache.preload.ts @@ -0,0 +1,29 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { Module } from 'node:module'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { Script } from 'node:vm'; +import { ipcRenderer } from 'electron'; + +ipcRenderer.on('compile', async () => { + try { + const sourceFile = join(__dirname, '..', '..', 'preload.bundle.js'); + const outFile = sourceFile.replace(/\.js$/, ''); + + const source = await readFile(sourceFile, 'utf8'); + const script = new Script(Module.wrap(source), { + filename: 'preload.bundle.js', + produceCachedData: true, + }); + if (!script.cachedDataProduced || !script.cachedData) { + throw new Error('Cached data not produced'); + } + + await writeFile(`${outFile}.cache`, script.cachedData); + await ipcRenderer.invoke('done'); + } catch (error) { + await ipcRenderer.invoke('error', error); + } +}); diff --git a/ts/scripts/generate-preload-cache.ts b/ts/scripts/generate-preload-cache.ts new file mode 100644 index 000000000000..fdd25fde9904 --- /dev/null +++ b/ts/scripts/generate-preload-cache.ts @@ -0,0 +1,35 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { pathToFileURL } from 'node:url'; +import { join } from 'node:path'; +import { app, BrowserWindow, ipcMain } from 'electron'; + +app.on('ready', async () => { + ipcMain.handle('done', () => { + app.quit(); + }); + + ipcMain.handle('error', (_event, err) => { + console.error(err); + process.exit(1); + }); + + const window = new BrowserWindow({ + show: false, + webPreferences: { + devTools: true, + nodeIntegration: false, + sandbox: false, + contextIsolation: true, + preload: join(__dirname, 'generate-preload-cache.preload.js'), + }, + }); + + await window.loadURL( + pathToFileURL(join(__dirname, 'generate-preload-cache.html')).toString() + ); + + window.webContents.openDevTools(); + window.webContents.send('compile', process.argv[2], process.argv[3]); +}); diff --git a/ts/scripts/test-release.ts b/ts/scripts/test-release.ts index 97f5442fdab5..b891943af319 100644 --- a/ts/scripts/test-release.ts +++ b/ts/scripts/test-release.ts @@ -15,14 +15,14 @@ let archive: string; let exe: string; if (process.platform === 'darwin') { archive = join( - 'mac', + 'mac-arm64', `${packageJson.productName}.app`, 'Contents', 'Resources', 'app.asar' ); exe = join( - 'mac', + 'mac-arm64', `${packageJson.productName}.app`, 'Contents', 'MacOS',