Implement webpack build for web interface

Set up webpack for the master server's web interface and fix the issues
from the imported interface source files.  The .jsx files in
package/master/web are bundled together into package/master/static and
served by the master server via express.
This commit is contained in:
Hornwitser committed 2020-09-28 04:23:45 +02:00
1 parent 2382f4bbdc
commit 701ff87bcd
25 files changed
+219 -191

No files matched your search

-1
View File
@@ -41,7 +41,6 @@ Factorio*
package-lock.json
mod_cache.json
static/export
packages/master/static/external
saves
/temp/
+2
View File
@@ -0,0 +1,2 @@
# Webpack output
static
+13 -1
View File
@@ -1553,7 +1553,9 @@ async function startServer() {
})
.demandCommand(1, "You need to specify a command to run");
})
.command("run", "Run master server")
.command("run", "Run master server", yargs => {
yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 });
})
.demandCommand(1, "You need to specify a command to run")
.strict()
.argv
@@ -1634,6 +1636,16 @@ async function startServer() {
// If we get here the command was run
if (args.dev) {
console.log("Development mode enabled");
const webpack = require("webpack");
const middleware = require("webpack-dev-middleware");
const webpackConfig = require("./webpack.config");
const compiler = webpack(webpackConfig({}));
app.use(middleware(compiler, {}));
}
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
+20
View File
@@ -6,6 +6,7 @@
"version": "2.0.0-alpha0",
"main": "master.js",
"scripts": {
"build": "webpack --env.production",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
@@ -32,5 +33,24 @@
"set-blocking": "^2.0.0",
"ws": "^7.2.3",
"yargs": "^14.2.0"
},
"devDependencies": {
"@ant-design/icons": "^4.2.2",
"@babel/core": "^7.11.6",
"@babel/preset-react": "^7.10.4",
"antd": "^4.6.5",
"babel-loader": "^8.1.0",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^4.3.0",
"fast-deep-equal": "^3.1.3",
"html-webpack-plugin": "^4.5.0",
"null-loader": "^4.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"style-loader": "^1.2.1",
"webpack": "^4.44.2",
"webpack-cli": "^3.3.12",
"webpack-dev-middleware": "^3.7.2"
}
}
+19 -27
View File
@@ -1,20 +1,9 @@
import React, { Component } from "react";
import {
Collapse, Row, Col, Modal, Table,
Form,
Input,
Button,
Radio,
Select,
Cascader,
DatePicker,
InputNumber,
TreeSelect,
Switch,
Popover
} from "antd";
import { Collapse, Row, Col, Modal, Table, Form, Input, Button, Popover } from "antd";
import PlusOutlined from "@ant-design/icons/PlusOutlined"
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import notify from "../util/notify";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
const { Panel } = Collapse;
@@ -45,10 +34,12 @@ class DataTable extends Component {
record: {},
}
}
componentDidMount() {
this.getData()
}
getData = async (pagination, filters, sorter) => {
async getData(pagination, filters, sorter) {
/*
pagination = {
pageSize: 10, // items per page
@@ -84,21 +75,22 @@ class DataTable extends Component {
filters,
})
}
showEditModal = (record, index) => {
showEditModal(record, index) {
if (!this.state.showEditModal)
this.setState({
record: record,
showEditModal: true,
})
}
hideEditModal = () => {
hideEditModal() {
this.setState({
showEditModal: false,
inserting: false,
record: {}
})
}
onEditFinish = async values => {
async onEditFinish(values) {
console.log("Finished editing", values)
if (this.state.inserting) { // Create new record
@@ -116,7 +108,7 @@ class DataTable extends Component {
// Force refresh of data
this.getData()
}
deleteRecord = async record => {
async deleteRecord(record) {
delete record.key
// Delete "null" values as they seem to cause deletes to fail silently when the value is null and field type is float
for (let key in record) {
@@ -148,7 +140,7 @@ class DataTable extends Component {
object. This way the end user isn't affected by our event handlers.
*/
let externalOnRow = {
...this.props.TableProps?.onRow?.(record, rowIndex),
...(this.props.TableProps && this.props.TableProps.onRow && this.props.TableProps.onRow(record, rowIndex)),
}
return {
onClick: event => {
@@ -219,7 +211,7 @@ class DataTable extends Component {
})}
dataSource={this.state.tableData}
pagination={this.state.pagination.total > 10 ? this.state.pagination : false}
onChange={this.getData}
onChange={this.getData.bind(this)}
/>
{this.props.AddRecord && <Button type="primary" onClick={() => this.setState({
showEditModal: true,
@@ -244,18 +236,18 @@ class DataTable extends Component {
span: 12,
}}
layout="horizontal"
onFinish={this.onEditFinish}
onFinish={this.onEditFinish.bind(this)}
initialValues={this.state.record}
>
{
(
(this.state.inserting && this.props.AddRecord?.fields) // Use externally supplied form input fields
|| this.props.columns?.filter(x => x.dataIndex !== "notInUse") // Generate input fields from external columns
(this.state.inserting && this.props.AddRecord && this.props.AddRecord.fields) // Use externally supplied form input fields
|| this.props.columns.filter && this.props.columns.filter(x => x.dataIndex !== "notInUse") // Generate input fields from external columns
|| this.inferColumns(this.state.tableData)).map(col => { // Generate input fields from autogenerated columns (only works with existing data)
console.log(this.state.record)
return <Form.Item key={col.dataIndex} label={col.title} name={col.dataIndex}>
{col.renderEdit?.(this.state.record[col.dataIndex], this.state.record, undefined /* Non spec compliant, is supposed to be index of record */, this.state.tableData) || <Input
{col.renderEdit && col.renderEdit(this.state.record[col.dataIndex], this.state.record, undefined /* Non spec compliant, is supposed to be index of record */, this.state.tableData) || <Input
placeholder={this.state.record[col.dataIndex]}
/>}
</Form.Item>
@@ -271,7 +263,7 @@ class DataTable extends Component {
</Row>
</>
}
inferColumns = data => {
inferColumns(data) {
// Look at the data and find columns and how to display them. Can be overridden by props.Columns if more customization is needed
let columns = []
data.forEach(row => {
+2
View File
@@ -1,3 +1,5 @@
@import '~antd/dist/antd.dark.css';
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Clusterio</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
-29
View File
@@ -1,29 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import Layout from './layout'
import * as serviceWorker from './serviceWorker';
import { BrowserRouter } from 'react-router-dom';
import moment from "moment";
import locale from "moment/locale/nb";
moment.updateLocale("nb", locale);
// ReactDOM.render(
// <React.StrictMode>
// <Layout />
// </React.StrictMode>,
// document.getElementById('root')
// );
ReactDOM.render(
<BrowserRouter>
<Layout />
</BrowserRouter>,
document.getElementById('root')
)
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
+13
View File
@@ -0,0 +1,13 @@
import React from "react"
import ReactDOM from "react-dom"
import { BrowserRouter } from "react-router-dom"
import "./index.css"
import Layout from "./layout"
ReactDOM.render(
<BrowserRouter>
<Layout />
</BrowserRouter>,
document.getElementById('root')
);
+2 -26
View File
@@ -1,23 +1,9 @@
import {
Layout,
Menu,
Avatar,
} from "antd";
import 'antd/dist/antd.dark.css';
import {
UnorderedListOutlined,
UserOutlined,
SettingOutlined,
GoldOutlined,
FileTextOutlined,
ScheduleOutlined,
} from "@ant-design/icons";
import { Layout, Menu } from "antd";
import { withRouter } from "react-router-dom";
import { BrowserRouter as Router, Route, Link } from "react-router-dom";
import React, { Component } from "react";
import { Helmet } from "react-helmet";
import { Dashboard } from "../pages/dashboard";
import { InstancesTable } from "../pages/instances/instancesTable";
import { SlavesTable } from "../pages/slaves/slavesTable"
@@ -40,8 +26,6 @@ class SiteLayout extends Component {
// this.history = useHistory();
this.onCollapse = this.onCollapse.bind(this);
this.state = {
collapsed: false,
setcollapsed: false,
@@ -76,19 +60,11 @@ class SiteLayout extends Component {
return (
<Router>
<Layout style={{ minHeight: "100vh" }}>
<Helmet>
<meta charSet="utf-8" />
<title>Clusterio 2.0</title>
<link
href="https://fonts.googleapis.com/css?family=Titillium+Web:200,400&display=swap"
rel="stylesheet"
></link>
</Helmet>
{document.location.search !== "?hideExtras" ? (
<Sider
collapsible
collapsed={this.state.collapsed}
onCollapse={this.onCollapse}
onCollapse={this.onCollapse.bind(this)}
width={250}
breakpoint="lg"
>
@@ -1,5 +1,5 @@
import React, { Component } from "react"
import { Card, Table, Button, List, Popover, Select, Divider, Input, Form, Col, Row, Checkbox, Space } from "antd"
import { Card, Button, Popover, Select, Divider, Input, Form, Col, Row, Checkbox, Space } from "antd"
import {
listSlaves,
listInstances,
@@ -15,7 +15,9 @@ import {
} from "../../util/wslink"
import DataTable from "../../components/data-table"
import notify from "../../util/notify"
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons"
import DeleteOutlined from "@ant-design/icons/DeleteOutlined"
import EditOutlined from "@ant-design/icons/EditOutlined"
import PlusOutlined from "@ant-design/icons/PlusOutlined"
const { Option } = Select
@@ -27,8 +29,8 @@ export class InstanceView extends Component {
slaves: [],
logLines: [{ message: "Logviewer. Follows the latest logs if you scroll to the bottom. Does not store logs past the lifetime of this tab" }],
}
this.messagesEndRef = React.createRef()
}
messagesEndRef = React.createRef()
navigate(url) {
this.props.history.push(url);
}
@@ -50,7 +52,7 @@ export class InstanceView extends Component {
// this.messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}
getData = async () => {
async getData() {
let id = this.props.match.params.id
let instances = await listInstances()
@@ -67,7 +69,7 @@ export class InstanceView extends Component {
config
})
}
start = async () => {
async start() {
this.setState({
starting: true,
})
@@ -88,7 +90,7 @@ export class InstanceView extends Component {
})
this.getData()
}
stop = async () => {
async stop() {
this.setState({
stopping: true,
})
@@ -108,7 +110,7 @@ export class InstanceView extends Component {
})
this.getData()
}
createSave = async () => {
async createSave() {
this.setState({
creatingSave: true,
})
@@ -127,7 +129,7 @@ export class InstanceView extends Component {
creatingSave: false,
})
}
onConfigEditFinish = async values => {
async onConfigEditFinish(values) {
console.log("Completed config edit with", values)
for (let k in values) {
if (values[k] !== this.getInstanceConfigAsKvObject(this.state.config)[k]) {
@@ -150,28 +152,29 @@ export class InstanceView extends Component {
render() {
console.log(this.state)
let { instance, slaves } = this.state
let slave = slaves && slaves.find(x => x.id === instance.assigned_slave);
return <Card>
<h2>{instance.name}</h2>
<p>ID: {instance.id}</p>
<p>Slave: {slaves?.find(x => x.id === instance.assigned_slave)?.name}</p>
<p>Slave: {slave && slave.name}</p>
{instance.status !== "running" && <Button
loading={this.state.starting}
type="primary"
onClick={this.start}
onClick={this.start.bind(this)}
>
Start server
</Button>}
{instance.status !== "stopped" && <Button
loading={this.state.stopping}
type="primary"
onClick={this.stop}
onClick={this.stop.bind(this)}
>
Stop server
</Button>}
{instance.status !== "running" && <Button
loading={this.state.creatingSave}
type="primary"
onClick={this.createSave}
onClick={this.createSave.bind(this)}
>
Create save
</Button>}
@@ -283,7 +286,7 @@ export class InstanceView extends Component {
<Form
{...layout}
initialValues={this.getInstanceConfigAsKvObject(config)}
onFinish={this.onConfigEditFinish}
onFinish={this.onConfigEditFinish.bind(this)}
>
{this.state.editingConfig ?
<Form.Item>
@@ -1,8 +1,8 @@
import React, { Component } from "react"
import { Card, Table } from "antd"
import { Card } from "antd"
import { listSlaves, listInstances, createInstance } from "../../util/wslink"
import DataTable from "../../components/data-table"
const config = require("../../lib/config")
const config = require("@clusterio/lib/config")
export class InstancesTable extends Component {
constructor(props) {
@@ -1,5 +1,5 @@
import React, { Component } from "react"
import { Card, Table } from "antd"
import { Card } from "antd"
import { listSlaves, listInstances, listPermissions } from "../../util/wslink"
import DataTable from "../../components/data-table"
+9 -7
View File
@@ -1,5 +1,8 @@
import React, { Component } from "react"
import { Card, Table, Button, List, Popover, Select, Divider, Input, Form, Col, Row, Checkbox, Space, Transfer } from "antd"
import { Card, Button, Popover, Select, Transfer } from "antd"
import DeleteOutlined from "@ant-design/icons/DeleteOutlined"
import {
listSlaves,
listInstances,
@@ -21,7 +24,6 @@ import {
} from "../../util/wslink"
import DataTable from "../../components/data-table"
import notify from "../../util/notify"
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons"
const { Option } = Select
@@ -31,15 +33,15 @@ export class RoleView extends Component {
this.state = {
role: {}
}
this.messagesEndRef = React.createRef()
}
messagesEndRef = React.createRef()
navigate(url) {
this.props.history.push(url);
}
async componentDidMount() {
await this.getData()
}
getData = async () => {
async getData() {
let id = this.props.match.params.id
let roles = await listRoles()
@@ -50,7 +52,7 @@ export class RoleView extends Component {
permissions,
})
}
handleChange = async targetKeys => {
async handleChange(targetKeys) {
this.setState({
role: {
...this.state.role,
@@ -62,7 +64,7 @@ export class RoleView extends Component {
permissions: targetKeys,
})
console.log("Updated user roles", response)
};
}
render() {
console.log(this.state)
let { role, permissions } = this.state
@@ -103,7 +105,7 @@ export class RoleView extends Component {
showSearch
filterOption={(inputValue, option) => option.name.toLowerCase().indexOf(inputValue.toLowerCase()) > -1}
targetKeys={role.permissions}
onChange={this.handleChange}
onChange={this.handleChange.bind(this)}
render={item => item.name}
/>
</Card>
@@ -1,5 +1,5 @@
import React, { Component } from "react"
import { Card, Table } from "antd"
import { Card } from "antd"
import { listSlaves, listInstances, listRoles, createRole } from "../../util/wslink"
import DataTable from "../../components/data-table"
@@ -1,5 +1,5 @@
import React, { Component } from "react"
import { Card, Button, Select, Input, Form } from "antd"
import { Card, Button, Input, Form } from "antd"
const layout = {
labelCol: {
@@ -1,5 +1,5 @@
import React, { Component } from "react"
import { Card, Table } from "antd"
import { Card } from "antd";
import { listSlaves, listInstances } from "../../util/wslink"
import DataTable from "../../components/data-table"
+6 -17
View File
@@ -1,17 +1,6 @@
import React, { Component } from "react"
import { Card, Table, Button, List, Popover, Select, Divider, Input, Form, Col, Row, Checkbox, Space, Transfer } from "antd"
import { Card, Button, Popover, Select, Transfer } from "antd";
import {
listSlaves,
listInstances,
startInstance,
stopInstance,
setInstanceOutputSubscriptions,
deleteInstance,
assignInstance,
createSave,
getInstanceConfig,
setInstanceConfigField,
sendRcon,
deleteUser,
listUsers,
listRoles,
@@ -19,7 +8,7 @@ import {
} from "../../util/wslink"
import DataTable from "../../components/data-table"
import notify from "../../util/notify"
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons"
import DeleteOutlined from "@ant-design/icons/DeleteOutlined"
const { Option } = Select
@@ -29,15 +18,15 @@ export class UserView extends Component {
this.state = {
user: {}
}
this.messagesEndRef = React.createRef();
}
messagesEndRef = React.createRef()
navigate(url) {
this.props.history.push(url);
}
async componentDidMount() {
await this.getData()
}
getData = async () => {
async getData() {
let name = this.props.match.params.id
let users = await listUsers()
@@ -48,7 +37,7 @@ export class UserView extends Component {
roles,
})
}
handleChange = async targetKeys => {
async handleChange(targetKeys) {
this.setState({
user: {
...this.state.user,
@@ -73,7 +62,7 @@ export class UserView extends Component {
showSearch
filterOption={(inputValue, option) => option.name.toLowerCase().indexOf(inputValue.toLowerCase()) > -1}
targetKeys={user.roles}
onChange={this.handleChange}
onChange={this.handleChange.bind(this)}
render={item => item.name}
/>
<Popover
@@ -1,5 +1,5 @@
import React, { Component } from "react"
import { Card, Table } from "antd"
import { Card } from "antd"
import { listSlaves, listInstances, listUsers, createUser } from "../../util/wslink"
import DataTable from "../../components/data-table"
File renamed without changes.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

+23 -37
View File
@@ -1,49 +1,41 @@
const link = require("./../lib/link");
const plugin = require("./../lib/plugin");
const errors = require("./../lib/errors");
const chalk = require("chalk");
const link = require("@clusterio/lib/link");
const plugin = require("@clusterio/lib/plugin");
const errors = require("@clusterio/lib/errors");
/**
* Format a parsed Factorio output message with colors
* Format a parsed Factorio output message
*
* Formats a parsed Factorio output from lib/factorio into a readable
* colorized output using terminal escape codes that can be printed.
* Formats a parsed Factorio output from lib/factorio back into the
* text string it was parsed from.
*
* @param {Object} output - Factorio server output.
* @returns {string} terminal colorized message.
* @returns {string} origial output text.
* @private
*/
function formatOutputColored(output) {
function formatOutput(output) {
let time = "";
if (output.format === "seconds") {
time = chalk.yellow(output.time.padStart(8)) + " ";
time = `${output.time.padStart(8)} `;
} else if (output.format === "date") {
time = chalk.yellow(output.time) + " ";
time = `${output.time} `;
}
let info = "";
if (output.type === "log") {
let level = output.level;
if (level === "Info") {
level = chalk.bold.blueBright(level);
} else if (output.level === "Warning") {
level = chalk.bold.yellowBright(level);
} else if (output.level === "Error") {
level = chalk.bold.redBright(level);
}
info = level + " " + chalk.gray(output.file) + ": ";
info = `${output.level} ${output.file}: `;
} else if (output.type === "action") {
info = "[" + chalk.yellow(output.action) + "] ";
info = `[${output.action}] `;
}
return time + info + output.message;
return `${time}${info}${output.message}`;
}
/**
* Connector for control connection to master server
* @private
*/
class ControlConnector extends link.WebSocketClientConnector {
export class ControlConnector extends link.WebSocketClientConnector {
constructor(url, reconnectDelay, token) {
super(url, reconnectDelay);
this._token = token;
@@ -53,7 +45,7 @@ class ControlConnector extends link.WebSocketClientConnector {
console.log("SOCKET | registering control");
this.sendHandshake("register_control", {
token: this._token,
agent: "clusterctl",
agent: "web",
version: "2.0.0-alpha",
});
}
@@ -65,16 +57,15 @@ class ControlConnector extends link.WebSocketClientConnector {
* Connects to the master server over WebSocket and sends commands to it.
* @static
*/
class Control extends link.Link {
export class Control extends link.Link {
constructor(connector, controlPlugins) {
super("control", "master", connector);
link.attachAllMessages(this);
/**
* Mapping of plugin names to their instance for loaded plugins.
* @type {Map<string, module:lib/plugin.BaseControlPlugin>}
*/
/**
* Mapping of plugin names to their instance for loaded plugins.
* @type {Map<string, module:lib/plugin.BaseControlPlugin>}
*/
this.plugins = controlPlugins;
for (let controlPlugin of controlPlugins.values()) {
plugin.attachPluginMessages(this, controlPlugin.info, controlPlugin);
@@ -83,7 +74,7 @@ class Control extends link.Link {
async instanceOutputEventHandler(message) {
let { instance_id, output } = message.data;
console.log(formatOutputColored(output));
console.log(formatOutput(output));
window.instanceOutputEventHandler && window.instanceOutputEventHandler({instance_id, output})
}
@@ -105,8 +96,3 @@ class Control extends link.Link {
await this.connector.close(1001, "Control Quit");
}
}
export {
ControlConnector,
Control
}
+4 -26
View File
@@ -1,11 +1,13 @@
import { Control, ControlConnector } from "./../util/websocket"
import * as errors from "./../lib/errors"
import * as link from "./../lib/link"
import * as errors from "@clusterio/lib/errors"
import * as link from "@clusterio/lib/link"
import config from "@clusterio/lib/config"
let control, controlConnector
// Create WS connection to master. Used by all components through the interface exported here.
(async () => {
config.finalizeConfigs();
controlConnector = new ControlConnector(
localStorage.getItem("master_url"),
10, // Reconnect delay
@@ -16,31 +18,7 @@ let control, controlConnector
await controlConnector.connect();
} catch (err) {
console.log(err)
if (err instanceof errors.AuthenticationFailed) {
throw new errors.StartupError(err.message);
}
throw err;
}
let response = await link.messages.listSlaves.send(control);
// console.log(response.list);
console.log(link.messages)
let data = {}
// for (let key in {
// ...link.messages,
// }) {
// if (link.messages[key].constructor.name === "Request") {
// let resp, error
// try{
// resp = await link.messages[key].send(control)
// } catch(e){
// error = e
// }
// data[key] = resp
// data[key].error = error
// }
// }
// console.log(data)
})()
let listSlaves = createListFunction("listSlaves")
+71
View File
@@ -0,0 +1,71 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
module.exports = (env = {}) => ({
entry: "./web/index.jsx",
mode: env.production ? "production" : "development",
devtool: env.production ? "source-map" : "eval-source-map",
devServer: {
contentBase: "./static",
},
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "static"),
},
plugins: [
new CleanWebpackPlugin(),
new webpack.DefinePlugin({
"process.env.APP_ENV": JSON.stringify("browser"),
}),
new HtmlWebpackPlugin({
template: "web/index.html",
}),
// Make sure ant-design icons use the ES variant
new webpack.NormalModuleReplacementPlugin(
/@ant-design\/icons\/[A-Z]/,
function(resource) {
resource.request = resource.request.replace(/@ant-design\/icons/, "$&/es/icons");
}
),
],
module: {
rules: [
{
test: /node_modules.fs-extra/,
use: "null-loader",
},
{
test: /\.css$/,
use: [
"style-loader",
{
loader: "css-loader",
options: {
sourceMap: !env.production,
},
},
],
},
{
test: /\.jsx$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-react"],
},
},
},
],
},
resolve: {
extensions: [".js", ".json", ".jsx"],
},
node: {
"crypto": "empty",
},
});