Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(plugin-webpack): improve native asset relocation without forking Vercel loader #2320

Merged
merged 24 commits into from
Jul 11, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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',
),
);
}

timfish marked this conversation as resolved.
Show resolved Hide resolved
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