Skip to content

Commit

Permalink
feat(plugin-webpack): improve native asset relocation without forking…
Browse files Browse the repository at this point in the history
… Vercel loader (#2320)

Co-authored-by: Mark Lee <electronjs@lazymalevolence.com>
  • Loading branch information
timfish and malept authored Jul 11, 2021
1 parent 43cbb0a commit db8a3f3
Show file tree
Hide file tree
Showing 21 changed files with 436 additions and 97 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ packages/plugin/**/README.md
packages/api/cli/README.md
!packages/**/base/README.md
!/README.md
.webpack
11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@
"build:quick": "bolt ws exec -- node_modules/.bin/babel src -d dist --quiet --extensions \".ts\" --config-file ../../../.babelrc",
"postbuild": "ts-node tools/test-dist",
"commit": "git-cz",
"coverage:fast": "cross-env INTEGRATION_TESTS=0 TS_NODE_FILES=true nyc mocha './tools/test-globber.ts' && nyc report --reporter=text-lcov > coverage.lcov",
"coverage:slow": "cross-env TS_NODE_FILES=true nyc mocha './tools/test-globber.ts' --integration && nyc report --reporter=text-lcov > coverage.lcov",
"coverage:fast": "xvfb-maybe cross-env INTEGRATION_TESTS=0 TS_NODE_FILES=true nyc mocha './tools/test-globber.ts' && nyc report --reporter=text-lcov > coverage.lcov",
"coverage:slow": "xvfb-maybe cross-env TS_NODE_FILES=true nyc mocha './tools/test-globber.ts' --integration && nyc report --reporter=text-lcov > coverage.lcov",
"docs": "bolt docs:generate && bolt docs:position",
"docs:generate": "bolt ws exec -- node_modules/.bin/typedoc --out doc --excludeExternals --ignoreCompilerErrors --mode file --excludePrivate --excludeProtected --hideGenerator",
"docs:position": "ts-node tools/position-docs.ts",
"docs:deploy": "ts-node tools/sync-readmes.ts && bolt docs && ts-node tools/copy-now.ts && cd docs && now && now alias",
"docs:deploy:ci": "ts-node tools/sync-readmes.ts && bolt docs && ts-node tools/copy-now.ts && cd docs && now --token $NOW_TOKEN && now alias --token $NOW_TOKEN",
"lint": "eslint --ext .ts .",
"test": "cross-env TS_NODE_FILES=true yarn run mocha './tools/test-globber.ts'",
"test:fast": "cross-env TEST_FAST_ONLY=1 TS_NODE_FILES=true yarn run mocha './tools/test-globber.ts'",
"test": "xvfb-maybe cross-env TS_NODE_FILES=true mocha './tools/test-globber.ts'",
"test:fast": "xvfb-maybe cross-env TEST_FAST_ONLY=1 TS_NODE_FILES=true mocha './tools/test-globber.ts'",
"postinstall": "rimraf node_modules/.bin/*.ps1 && ts-node tools/link-ts.ts"
},
"dependencies": {
Expand Down Expand Up @@ -143,7 +143,8 @@
"sinon-chai": "^3.6.0",
"ts-node": "^10.0.0",
"typedoc": "^0.21.0",
"typescript": "~4.0.2"
"typescript": "~4.0.2",
"xvfb-maybe": "^0.2.1"
},
"optionalDependencies": {
"@malept/electron-installer-flatpak": "^0.11.2",
Expand Down
6 changes: 4 additions & 2 deletions packages/plugin/webpack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
"main": "dist/WebpackPlugin.js",
"typings": "dist/WebpackPlugin.d.ts",
"scripts": {
"test": "mocha --config ../../../.mocharc.js test/**/*_spec.ts test/**/**/*_spec.ts"
"test": "xvfb-maybe mocha --config ../../../.mocharc.js test/**/*_spec.ts test/**/**/*_spec.ts"
},
"devDependencies": {
"@malept/cross-spawn-promise": "^2.0.0",
"@types/node": "^16.3.1",
"chai": "^4.3.3",
"mocha": "^9.0.1",
"sinon": "^11.1.1"
"sinon": "^11.1.1",
"xvfb-maybe": "^0.2.1"
},
"engines": {
"node": ">= 12.13.0"
Expand Down
23 changes: 5 additions & 18 deletions packages/plugin/webpack/src/WebpackConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'path';
import webpack, { Configuration, WebpackPluginInstance } from 'webpack';
import { merge as webpackMerge } from 'webpack-merge';
import { WebpackPluginConfig, WebpackPluginEntryPoint, WebpackPreloadEntryPoint } from './Config';
import AssetRelocatorPatch from './util/AssetRelocatorPatch';

type EntryType = string | string[] | Record<string, string | string[]>;

Expand Down Expand Up @@ -88,24 +89,8 @@ export default class WebpackConfigGenerator {
return 'undefined';
}

assetRelocatorBaseDir(inRendererDir = true) {
if (this.isProd) {
return `process.resourcesPath + "/" + (__filename.includes(".asar") ? "app.asar" : "app") + "/.webpack/${inRendererDir ? 'main' : 'renderer/any_folder'}"`;
}

return JSON.stringify(
path.resolve(
this.webpackDir,
inRendererDir ? 'main' : 'renderer',
inRendererDir ? '.' : 'any_folder',
),
);
}

getDefines(inRendererDir = true) {
const defines: { [key: string]: string; } = {
ASSET_RELOCATOR_BASE_DIR: this.assetRelocatorBaseDir(inRendererDir),
};
const defines: Record<string, string> = {};
if (
!this.pluginConfig.renderer.entryPoints
|| !Array.isArray(this.pluginConfig.renderer.entryPoints)
Expand Down Expand Up @@ -215,7 +200,9 @@ export default class WebpackConfigGenerator {
template: entryPoint.html,
filename: `${entryPoint.name}/index.html`,
chunks: [entryPoint.name].concat(entryPoint.additionalChunks || []),
}) as WebpackPluginInstance).concat([new webpack.DefinePlugin(defines)]);
}) as WebpackPluginInstance).concat(
[new webpack.DefinePlugin(defines), new AssetRelocatorPatch(this.isProd)],
);
return webpackMerge({
entry,
devtool: this.rendererSourceMapOption,
Expand Down
59 changes: 59 additions & 0 deletions packages/plugin/webpack/src/util/AssetRelocatorPatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Chunk, Compiler } from 'webpack';

export default class AssetRelocatorPatch {
private readonly isProd: boolean;

constructor(isProd: boolean) {
this.isProd = isProd;
}

public apply(compiler: Compiler) {
compiler.hooks.compilation.tap(
'asset-relocator-forge-patch',
(compilation) => {
// We intercept the Vercel loader code injection and replace __dirname with
// code that works with Electron Forge
//
// Here is where the injection occurs:
// https://github.com/vercel/webpack-asset-relocator-loader/blob/4710a018fc8fb64ad51efb368759616fb273618f/src/asset-relocator.js#L331-L339
compilation.mainTemplate.hooks.requireExtensions.intercept({
register: (tapInfo) => {
if (tapInfo.name === 'asset-relocator-loader') {
const originalFn = tapInfo.fn as (source: string, chunk: Chunk) => string;

tapInfo.fn = (source: string, chunk: Chunk) => {
const originalInjectCode = originalFn(source, chunk);

// Since this is not a public API of the Vercel loader, it could
// change on patch versions and break things.
//
// If the injected code changes substantially, we throw an error
if (!originalInjectCode.includes('__webpack_require__.ab = __dirname + ')) {
throw new Error('The installed version of @vercel/webpack-asset-relocator-loader does not appear to be compatible with Forge');
}

return originalInjectCode.replace(
'__dirname',
this.isProd
// In production the assets are found one directory up from
// __dirname
//
// __dirname cannot be used directly until this PR lands
// https://github.com/jantimon/html-webpack-plugin/pull/1650
? 'require("path").resolve(require("path").dirname(__filename), "..")'
// In development, the app is loaded via webpack-dev-server
// so __dirname is useless because it points to Electron
// internal code. Instead we hard-code the absolute path to
// the webpack output.
: JSON.stringify(compiler.options.output.path),
);
};
}

return tapInfo;
},
});
},
);
}
}
228 changes: 228 additions & 0 deletions packages/plugin/webpack/test/AssetRelocatorPatch_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { Configuration, webpack } from 'webpack';
import path from 'path';
import { expect } from 'chai';
import http from 'http';
import { pathExists, readFile } from 'fs-extra';
import { spawn } from '@malept/cross-spawn-promise';
import { WebpackPluginConfig } from '../src/Config';
import WebpackConfigGenerator from '../src/WebpackConfig';

type Closeable = {
close: () => void;
}

let servers: Closeable[] = [];

const nativePathSuffix = 'build/Release/hello_world.node';
const appPath = path.join(__dirname, 'fixtures', 'apps', 'native-modules');

async function asyncWebpack(config: Configuration): Promise<void> {
return new Promise((resolve, reject) => {
webpack(config, (err, stats) => {
if (err) {
reject(err);
return;
}

if (stats?.compilation?.errors?.length) {
reject(stats.compilation.errors);
return;
}

if (stats?.compilation?.warnings?.length) {
reject(stats.compilation.warnings);
return;
}

resolve();
});
});
}

/**
* Webpack dev server doesn't like to exit, outputs logs so instead we just create a
* basic server.
*/
function createSimpleDevServer(rendererOut: string): http.Server {
return http.createServer(async (req, res) => {
const url = (req.url || '');
const file = url.endsWith('main_window') ? path.join(url, '/index.html') : url;
const fullPath = path.join(rendererOut, file);
try {
const data = await readFile(fullPath);
res.writeHead(200);
res.end(data);
} catch (err) {
res.writeHead(404);
res.end(JSON.stringify(err));
}
}).listen(3000);
}

type ExpectNativeModulePathOptions = {
outDir: string,
jsPath: string,
nativeModulesString: string,
nativePathString: string
};

async function expectOutputFileToHaveTheCorrectNativeModulePath({
outDir,
jsPath,
nativeModulesString,
nativePathString,
}: ExpectNativeModulePathOptions): Promise<void> {
const nativePath = `native_modules/${nativePathSuffix}`;
expect(await pathExists(path.join(outDir, nativePath))).to.equal(true);

const jsContents = await readFile(jsPath, { encoding: 'utf8' });
expect(jsContents).to.contain(nativeModulesString);
expect(jsContents).to.contain(nativePathString);
}

async function yarnStart(): Promise<string> {
return spawn('yarn', ['start'], {
cwd: appPath,
shell: true,
env: {
...process.env,
ELECTRON_ENABLE_LOGGING: 'true',
},
});
}

describe('AssetRelocatorPatch', () => {
const rendererOut = path.join(appPath, '.webpack/renderer');
const mainOut = path.join(appPath, '.webpack/main');

before(async () => {
await spawn('yarn', [], { cwd: appPath, shell: true });
});

after(() => {
for (const server of servers) {
server.close();
}
servers = [];
});

const config = {
mainConfig: './webpack.main.config.js',
renderer: {
config: './webpack.renderer.config.js',
entryPoints: [
{
name: 'main_window',
html: './src/index.html',
js: './src/renderer.js',
preload: {
js: './src/preload.js',
},
},
],
},
} as WebpackPluginConfig;

describe('Development', () => {
const generator = new WebpackConfigGenerator(config, appPath, false, 3000);

it('builds main', async () => {
await asyncWebpack(generator.getMainConfig());

await expectOutputFileToHaveTheCorrectNativeModulePath({
outDir: mainOut,
jsPath: path.join(mainOut, 'index.js'),
nativeModulesString: '__webpack_require__.ab = __dirname + "/native_modules/"',
nativePathString: `require(__webpack_require__.ab + "${nativePathSuffix}")`,
});
});

it('builds preload', async () => {
const entryPoint = config.renderer.entryPoints[0];
const preloadConfig = await generator.getPreloadRendererConfig(
entryPoint, entryPoint.preload!,
);
await asyncWebpack(preloadConfig);

await expectOutputFileToHaveTheCorrectNativeModulePath({
outDir: path.join(rendererOut, 'main_window'),
jsPath: path.join(rendererOut, 'main_window/preload.js'),
nativeModulesString: '__webpack_require__.ab = __dirname + "/native_modules/"',
nativePathString: `require(__webpack_require__.ab + \\"${nativePathSuffix}\\")`,
});
});

it('builds renderer', async () => {
const rendererConfig = await generator.getRendererConfig(config.renderer.entryPoints);
await asyncWebpack(rendererConfig);

await expectOutputFileToHaveTheCorrectNativeModulePath({
outDir: rendererOut,
jsPath: path.join(rendererOut, 'main_window/index.js'),
nativeModulesString: `__webpack_require__.ab = ${JSON.stringify(rendererOut)} + "/native_modules/"`,
nativePathString: `require(__webpack_require__.ab + \\"${nativePathSuffix}\\")`,
});
});

it('runs the app with the native module', async () => {
servers.push(createSimpleDevServer(rendererOut));

const output = await yarnStart();

expect(output).to.contain('Hello, world! from the main');
expect(output).to.contain('Hello, world! from the preload');
expect(output).to.contain('Hello, world! from the renderer');
});
});

describe('Production', () => {
const generator = new WebpackConfigGenerator(config, appPath, true, 3000);

it('builds main', async () => {
const mainConfig = generator.getMainConfig();
await asyncWebpack(mainConfig);

await expectOutputFileToHaveTheCorrectNativeModulePath({
outDir: mainOut,
jsPath: path.join(mainOut, 'index.js'),
nativeModulesString: '.ab=__dirname+"/native_modules/"',
nativePathString: `.ab+"${nativePathSuffix}"`,
});
});

it('builds preload', async () => {
const entryPoint = config.renderer.entryPoints[0];
const preloadConfig = await generator.getPreloadRendererConfig(
entryPoint, entryPoint.preload!,
);
await asyncWebpack(preloadConfig);

await expectOutputFileToHaveTheCorrectNativeModulePath({
outDir: path.join(rendererOut, 'main_window'),
jsPath: path.join(rendererOut, 'main_window/preload.js'),
nativeModulesString: '.ab=__dirname+"/native_modules/"',
nativePathString: `.ab+"${nativePathSuffix}"`,
});
});

it('builds renderer', async () => {
const rendererConfig = await generator.getRendererConfig(config.renderer.entryPoints);
await asyncWebpack(rendererConfig);

await expectOutputFileToHaveTheCorrectNativeModulePath({
outDir: rendererOut,
jsPath: path.join(rendererOut, 'main_window/index.js'),
nativeModulesString: '.ab=require("path").resolve(require("path").dirname(__filename),"..")+"/native_modules/"',
nativePathString: `.ab+"${nativePathSuffix}"`,
});
});

it('runs the app with the native module', async () => {
const output = await yarnStart();

expect(output).to.contain('Hello, world! from the main');
expect(output).to.contain('Hello, world! from the preload');
expect(output).to.contain('Hello, world! from the renderer');
});
});
});
Loading

0 comments on commit db8a3f3

Please sign in to comment.