// React Native WebView cannot load external JS files, however it can load // arbitrary JS via the injectedJavaScript property. So we use this to load external // files: First here we convert the JS file to a plain string, and that string // is then loaded by eg. the Mermaid plugin, and finally injected in the WebView. import { dirname, extname, basename } from 'path'; // We need this to be transpiled to `const webpack = require('webpack')`. // As such, do a namespace import. See https://www.typescriptlang.org/tsconfig#esModuleInterop import * as webpack from 'webpack'; import copyJs from './copyJs'; export default class BundledFile { private readonly bundleOutputPath: string; private readonly bundleBaseName: string; private readonly rootFileDirectory: string; public constructor( public readonly bundleName: string, private readonly sourceFilePath: string, ) { this.rootFileDirectory = dirname(sourceFilePath); this.bundleBaseName = basename(sourceFilePath, extname(sourceFilePath)); this.bundleOutputPath = `${this.rootFileDirectory}/${this.bundleBaseName}.bundle.js`; } private getWebpackOptions(mode: 'production' | 'development'): webpack.Configuration { const config: webpack.Configuration = { mode, entry: this.sourceFilePath, // es5: Have Webpack's generated code target ES5. This doesn't apply to code not // generated by Webpack. target: ['web', 'es5'], output: { path: this.rootFileDirectory, filename: `${this.bundleBaseName}.bundle.js`, library: { type: 'window', name: this.bundleName, }, }, // See https://webpack.js.org/guides/typescript/ module: { rules: [ { // Include .tsx to include react components test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/, }, { test: (value) => { const isModuleFile = !!(/node_modules/.exec(value)); // Some libraries don't work with older browsers/WebViews. // Because Babel transpilation can be slow, we only transpile // these libraries. // For now, it's just Replit's CodeMirror-vim library. This library // uses `a?.b` syntax, which seems to be unsupported in iOS 12 Safari. const moduleNeedsTranspilation = !!(/.*node_modules.*replit.*\.[mc]?js$/.exec(value)); if (isModuleFile && !moduleNeedsTranspilation) { return false; } const isJsFile = !!(/\.[cm]?js$/.exec(value)); if (isJsFile) { console.log('Compiling with Babel:', value); } return isJsFile; }, use: { loader: 'babel-loader', options: { cacheDirectory: false, // Disable using babel.config.js to prevent conflicts with React Native's // Babel configuration. babelrc: false, configFile: false, presets: [ ['@babel/preset-env', { targets: { ios: 12, chrome: 80 } }], ], }, }, }, ], }, // Increase the minimum size required // to trigger warnings. // See https://stackoverflow.com/a/53517149/17055750 performance: { maxAssetSize: 2_000_000, // 2-ish MiB maxEntrypointSize: 2_000_000, }, resolve: { extensions: ['.tsx', '.ts', '.js'], }, cache: { type: 'filesystem', }, }; return config; } // Creates a file that can be imported by React native. This file contains the // bundled JS as a string. private async copyToImportableFile() { await copyJs(`${this.bundleBaseName}.bundle`, this.bundleOutputPath); } private handleErrors(error: Error | undefined | null, stats: webpack.Stats | undefined): boolean { let failed = false; if (error) { console.error(`Error (${this.bundleName}): ${error.name}`, error.message, error.stack); failed = true; } else if (stats?.hasErrors() || stats?.hasWarnings()) { const data = stats.toJson(); if (data.warnings && data.warningsCount) { console.warn(`Warnings (${this.bundleName}): `, data.warningsCount); for (const warning of data.warnings) { // Stack contains the message if (warning.stack) { console.warn(warning.stack); } else { console.warn(warning.message); } } } if (data.errors && data.errorsCount) { console.error(`Errors (${this.bundleName}): `, data.errorsCount); for (const error of data.errors) { if (error.stack) { console.error(error.stack); } else { console.error(error.message); } console.error(); } failed = true; } } return failed; } // Create a minified JS file in the same directory as `this.sourceFilePath` with // the same name. public build() { const compiler = webpack(this.getWebpackOptions('production')); return new Promise((resolve, reject) => { console.info(`Building bundle: ${this.bundleName}...`); compiler.run((buildError, stats) => { // Always output stats, even on success console.log(`Bundle ${this.bundleName} built: `, stats?.toString()); let failed = this.handleErrors(buildError, stats); // Clean up. compiler.close(async (closeError) => { if (closeError) { console.error('Error cleaning up:', closeError); failed = true; } let copyError; if (!failed) { try { await this.copyToImportableFile(); } catch (error) { console.error('Error copying', error); failed = true; copyError = error; } } if (!failed) { resolve(); } else { reject(closeError ?? buildError ?? copyError); } }); }); }); } public startWatching() { const compiler = webpack(this.getWebpackOptions('development')); const watchOptions = { ignored: '**/node_modules', }; console.info('Watching bundle: ', this.bundleName); compiler.watch(watchOptions, async (error, stats) => { const failed = this.handleErrors(error, stats); if (!failed) { await this.copyToImportableFile(); } }); } }