diff --git a/.vscode/launch.json b/.vscode/launch.json index d909a57b..67db700d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,9 +8,9 @@ "name": "Attach Chrome", "request": "attach", "outFiles": [ - "${workspaceFolder}/public/**/*.js", - // "!${workspaceFolder}/public/**/*vendors*", - "!${workspaceFolder}/public/**/*minecraftData*", + "${workspaceFolder}/dist/**/*.js", + // "!${workspaceFolder}/dist/**/*vendors*", + "!${workspaceFolder}/dist/**/*minecraftData*", "!**/node_modules/**" ], "skipFiles": [ @@ -26,9 +26,9 @@ "request": "launch", "url": "http://localhost:8080/", "outFiles": [ - "${workspaceFolder}/public/**/*.js", - // "!${workspaceFolder}/public/**/*vendors*", - "!${workspaceFolder}/public/**/*minecraftData*", + "${workspaceFolder}/dist/**/*.js", + // "!${workspaceFolder}/dist/**/*vendors*", + "!${workspaceFolder}/dist/**/*minecraftData*", "!**/node_modules/**" ], "skipFiles": [ diff --git a/esbuild.mjs b/esbuild.mjs new file mode 100644 index 00000000..b1671ce5 --- /dev/null +++ b/esbuild.mjs @@ -0,0 +1,125 @@ +//@ts-check +import * as esbuild from 'esbuild' +import fs from 'fs' +// import htmlPlugin from '@chialab/esbuild-plugin-html' +import server from './server.js' +import { analyzeMetafile } from 'esbuild' +import { clients, plugins } from './scripts/esbuildPlugins.mjs' +import { generateSW } from 'workbox-build' +import { getSwAdditionalEntries } from './scripts/build.js' + +/** @type {import('esbuild').BuildOptions} */ +let baseConfig = {} + +// // legacy html config +// baseConfig = { +// entryPoints: ['index.html'], +// assetNames: 'assets/[name]', +// chunkNames: '[ext]/[name]', +// outdir: 'dist', +// outfile: undefined, +// plugins: [ +// htmlPlugin({ +// scriptsTarget: 'esnext', +// }) +// ], +// } + +// // testing config +// baseConfig = { +// entryPoints: ['files/index.js'], +// outfile: 'out.js', +// outdir: undefined, +// } + +fs.copyFileSync('index.html', 'dist/index.html') +fs.writeFileSync('dist/index.html', fs.readFileSync('dist/index.html', 'utf8').replace('', ''), 'utf8') + +const banner = [ + 'window.global = globalThis;', + // auto-reload + '(() => new EventSource("/esbuild").onmessage = ({ data: _data }) => {const data = JSON.parse(_data);if (!data.update)return;sessionStorage.lastReload = JSON.stringify({buildTime:data.update.time, reloadStart:Date.now()});location.reload()})();' +] + +const buildingVersion = new Date().toISOString().split(':')[0] + +const ctx = await esbuild.context({ + bundle: true, + entryPoints: ['src/index.js'], + logLevel: 'info', + platform: 'browser', + sourcemap: true, + outdir: 'dist', + mainFields: [ + 'browser', 'module', 'main' + ], + keepNames: true, + ...baseConfig, + banner: { + js: banner.join('\n') + }, + alias: { + events: 'events', // make explicit + buffer: 'buffer', + 'fs': 'browserfs/dist/shims/fs.js', + http: 'http-browserify', + perf_hooks: './src/perf_hooks_replacement.js', + crypto: './src/crypto.js', + stream: 'stream-browserify', + net: 'net-browserify', + dns: './src/dns.js' + }, + inject: [ + './src/shims.js' + ], + metafile: true, + plugins: [ + ...plugins, + ...baseConfig.plugins ?? [], + ], + minify: process.argv.includes('--minify'), + define: { + 'process.env.BUILD_VERSION': process.argv.includes('--watch') ? undefined : JSON.stringify(buildingVersion), + 'process.env.GITHUB_URL': + JSON.stringify(`https://github.com/${process.env.GITHUB_REPOSITORY || `${process.env.VERCEL_GIT_REPO_OWNER}/${process.env.VERCEL_GIT_REPO_SLUG}`}`) + }, + // chunkNames: '[name]', +}) + +if (process.argv.includes('--watch')) { + await ctx.watch() + server.app.get('/esbuild', (req, res, next) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + // Send a comment to keep the connection alive + res.write(': ping\n\n') + + // Add the client response to the clients array + clients.push(res) + + // Handle any client disconnection logic + res.on('close', () => { + const index = clients.indexOf(res) + if (index !== -1) { + clients.splice(index, 1) + } + }) + }) +} else { + fs.writeFileSync('dist/version.txt', buildingVersion, 'utf-8') + const result = await ctx.rebuild() + // console.log(await analyzeMetafile(result.metafile)) + + const { count, size, warnings } = await generateSW({ + // dontCacheBustURLsMatching: [new RegExp('...')], + globDirectory: 'dist', + additionalManifestEntries: getSwAdditionalEntries(), + swDest: 'dist/service-worker.js', + }) + + await ctx.dispose() +} diff --git a/index.html b/index.html index 54cf0acb..f409c427 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ Prismarine Web Client - + @@ -30,5 +30,6 @@ + diff --git a/package.json b/package.json index b0167853..eccf63c8 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,9 @@ "version": "1.5.0", "description": "A minecraft client running in a browser", "scripts": { - "start": "NODE_OPTIONS=--max-old-space-size=8192 run-p watch prod-start", - "build": "NODE_OPTIONS=--max-old-space-size=8192 rimraf public && node scripts/build.js copyFiles && webpack --config webpack.prod.js --progress", + "start": "node scripts/build.js copyFilesDev && node esbuild.mjs --minify --watch", + "start-watch-script": "nodemon -w esbuild.mjs esbuild.mjs", + "build": "node scripts/build.js copyFiles && node esbuild.mjs --minify --build-only", "watch": "node scripts/build.js copyFilesDev && webpack serve --config webpack.dev.js --progress", "postinstall": "node scripts/patchPackages.js", "test:cypress": "cypress run", @@ -32,12 +33,14 @@ "compression": "^1.7.4", "cypress-plugin-snapshots": "^1.4.4", "diamond-square": "^1.2.0", + "esbuild": "^0.19.2", "esbuild-loader": "^4.0.0", "esbuild-plugin-polyfill-node": "^0.3.0", "express": "^4.18.2", "fs-extra": "^11.1.1", "iconify-icon": "^1.0.8", "lit": "^2.8.0", + "minecraft-data": "^3.0.0", "net-browserify": "github:PrismarineJS/net-browserify", "prismarine-world": "^3.6.2", "querystring": "^0.2.1", @@ -48,7 +51,8 @@ "speed-measure-webpack-plugin": "^1.5.0", "stats.js": "^0.17.0", "url": "^0.11.1", - "valtio": "^1.11.1" + "valtio": "^1.11.1", + "workbox-build": "^7.0.0" }, "devDependencies": { "@types/three": "0.128.0", @@ -62,6 +66,7 @@ "cypress": "^9.5.4", "cypress-esbuild-preprocessor": "^1.0.2", "events": "^3.3.0", + "filesize": "^10.0.12", "html-webpack-plugin": "^5.5.3", "http-browserify": "^1.7.0", "http-server": "^14.1.1", diff --git a/prismarine-viewer/viewer/lib/utils.js b/prismarine-viewer/viewer/lib/utils.js index e91ff69f..382c0573 100644 --- a/prismarine-viewer/viewer/lib/utils.js +++ b/prismarine-viewer/viewer/lib/utils.js @@ -10,7 +10,12 @@ const THREE = require('three') const path = require('path') const textureCache = {} +// todo not ideal, export different functions for browser and node function loadTexture (texture, cb) { + if (process.platform === 'browser') { + return require('./utils.web').loadTexture(texture, cb) + } + if (textureCache[texture]) { cb(textureCache[texture]) } else { @@ -22,6 +27,9 @@ function loadTexture (texture, cb) { } function loadJSON (json, cb) { + if (process.platform === 'browser') { + return require('./utils.web').loadJSON(json, cb) + } cb(require(path.resolve(__dirname, '../../public/' + json))) } diff --git a/scripts/build.js b/scripts/build.js index 9b9d8908..ffd1c8dc 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -11,22 +11,25 @@ const filesAlwaysToCopy = [ ] // these files could be copied at build time eg with copy plugin, but copy plugin slows down the config (2x in my testing, sometimes with too many open files error) is slow so we also copy them there const webpackFilesToCopy = [ - { from: './node_modules/prismarine-viewer2/public/blocksStates/', to: 'public/blocksStates/' }, - { from: './node_modules/prismarine-viewer2/public/textures/', to: 'public/textures/' }, - { from: './node_modules/prismarine-viewer2/public/worker.js', to: 'public/worker.js' }, - { from: './node_modules/prismarine-viewer2/public/supportedVersions.json', to: 'public/supportedVersions.json' }, - { from: './assets/', to: './public/' }, - { from: './config.json', to: 'public/config.json' } + { from: './node_modules/prismarine-viewer2/public/blocksStates/', to: 'dist/blocksStates/' }, + { from: './node_modules/prismarine-viewer2/public/textures/', to: 'dist/textures/' }, + { from: './node_modules/prismarine-viewer2/public/worker.js', to: 'dist/worker.js' }, + { from: './node_modules/prismarine-viewer2/public/supportedVersions.json', to: 'dist/supportedVersions.json' }, + { from: './assets/', to: './dist/' }, + { from: './config.json', to: 'dist/config.json' } ] exports.webpackFilesToCopy = webpackFilesToCopy exports.copyFiles = (isDev = false) => { + console.time('copy files'); [...filesAlwaysToCopy, ...webpackFilesToCopy].forEach(file => { fsExtra.copySync(file.from, file.to) }) + + console.timeEnd('copy files') } exports.copyFilesDev = () => { - if (fsExtra.existsSync('public/config.json')) return + if (fsExtra.existsSync('dist/config.json')) return exports.copyFiles(true) } @@ -34,6 +37,9 @@ exports.getSwAdditionalEntries = () => { // need to be careful with this const singlePlayerVersion = defaultLocalServerOptions.version const filesToCachePatterns = [ + 'index.js', + 'index.css', + 'favicon.ico', `blocksStates/${singlePlayerVersion}.json`, 'extra-textures/**', // todo-low copy from assets @@ -59,9 +65,9 @@ exports.getSwAdditionalEntries = () => { const output = [] console.log('Generating sw additional entries...') for (const pattern of filesToCachePatterns) { - const files = glob.sync(pattern, { cwd: 'public' }) + const files = glob.sync(pattern, { cwd: 'dist' }) for (const file of files) { - const fullPath = path.join('public', file) + const fullPath = path.join('dist', file) if (!fs.lstatSync(fullPath).isFile()) continue let revision = null const url = './' + file.replace(/\\/g, '/') diff --git a/scripts/esbuildPlugins.mjs b/scripts/esbuildPlugins.mjs new file mode 100644 index 00000000..9a11c827 --- /dev/null +++ b/scripts/esbuildPlugins.mjs @@ -0,0 +1,229 @@ +//@ts-check + +import { polyfillNode } from 'esbuild-plugin-polyfill-node' +import { join, dirname } from 'path' +import * as fs from 'fs' +import { filesize } from 'filesize' + +let clients = [] + +/** @type {import('esbuild').Plugin[]} */ +const plugins = [ + { + name: 'strict-aliases', + setup (build) { + build.onResolve({ + filter: /^minecraft-protocol$/, + }, async ({ kind, resolveDir }) => { + return { + path: (await build.resolve('minecraft-protocol/src/index.js', { kind, resolveDir })).path, + } + }) + // build.onResolve({ + // filter: /^\.\/data.js$/, + // }, ({ resolveDir, path }) => { + // if (!resolveDir.endsWith('minecraft-data')) return + // return { + // namespace: 'load-global-minecraft-data', + // path + // } + // }) + // build.onLoad({ + // filter: /.+/, + // namespace: 'load-global-minecraft-data', + // }, () => { + // return { + // contents: 'module.exports = window.minecraftData', + // loader: 'js', + // } + // }) + // build.onResolve({ + // filter: /^minecraft-assets$/, + // }, ({ resolveDir, path }) => { + // // if (!resolveDir.endsWith('minecraft-data')) return + // return { + // namespace: 'load-global-minecraft-assets', + // path + // } + // }) + // build.onLoad({ + // filter: /.+/, + // namespace: 'load-global-minecraft-assets', + // }, async () => { + // const resolvedPath = await build.resolve('minecraft-assets/index.js', { kind: 'require-call', resolveDir: process.cwd() }) + // let contents = (await fs.promises.readFile(resolvedPath.path, 'utf8')) + // contents = contents.slice(0, contents.indexOf('const data = ')) + 'const data = window.minecraftAssets;' + contents.slice(contents.indexOf('module.exports.versions')) + // return { + // contents, + // loader: 'js', + // resolveDir: dirname(resolvedPath.path), + // } + // }) + } + }, + { + name: 'assets-resolve', + setup (build) { + build.onResolve({ + filter: /.*/, + }, async ({ path, ...rest }) => { + if (['.woff', '.woff2', '.ttf', '.png', '.jpg', '.jpeg', '.gif', '.svg'].some(ext => path.endsWith(ext))) { + return { + path, + namespace: 'assets', + external: true, + } + } + }) + build.onEnd(({ metafile, outputFiles }) => { + // top 5 biggest deps + //@ts-ignore + // const deps = Object.entries(metafile.inputs).sort(([, a], [, b]) => b.bytes - a.bytes).map(([x, { bytes }]) => [x, filesize(bytes)]).slice(0, 5) + // console.log(deps) + }) + }, + }, + { + name: 'prevent-incorrect-linking', + setup (build) { + build.onResolve({ + filter: /.+/, + }, ({ resolveDir }) => { + // disallow imports from outside the root directory to ensure modules are resolved from node_modules of this workspace + if (!resolveDir.startsWith(process.cwd())) { + throw new Error(`Restricted import from outside the root directory: ${resolveDir}`) + } + return undefined + }) + } + }, + { + name: 'watch-notify', + setup (build) { + let count = 0 + let time + build.onStart(() => { + time = Date.now() + }) + build.onEnd(({ errors, outputFiles, metafile, warnings }) => { + const elapsed = Date.now() - time + console.log(`Done in ${elapsed}ms`) + if (count++ === 0) { + return + } + if (errors.length) { + clients.forEach((res) => { + res.write(`data: ${JSON.stringify({ errors: errors.map(error => error.text) })}\n\n`) + res.flush() + }) + return + } + clients.forEach((res) => { + res.write(`data: ${JSON.stringify({ update: { time: elapsed } })}\n\n`) + res.flush() + }) + clients.length = 0 + }) + } + }, + { + name: 'esbuild-readdir', + setup (build) { + build.onResolve({ + filter: /^esbuild-readdir:.+$/, + }, ({ resolveDir, path }) => { + return { + namespace: 'esbuild-readdir', + path, + pluginData: { + resolveDir: join(resolveDir, path.replace(/^esbuild-readdir:/, '')) + }, + } + }) + build.onLoad({ + filter: /.+/, + namespace: 'esbuild-readdir', + }, async ({ pluginData }) => { + const { resolveDir } = pluginData + const files = await fs.promises.readdir(resolveDir) + return { + contents: `module.exports = ${JSON.stringify(files)}`, + resolveDir, + loader: 'js', + } + }) + } + }, + { + name: 'esbuild-import-glob', + setup (build) { + build.onResolve({ + filter: /^esbuild-import-glob\(path:(.+),skipFiles:(.+)\)+$/, + }, ({ resolveDir, path }) => { + return { + namespace: 'esbuild-import-glob', + path, + pluginData: { + resolveDir + }, + } + }) + build.onLoad({ + filter: /.+/, + namespace: 'esbuild-import-glob', + }, async ({ pluginData, path }) => { + const { resolveDir } = pluginData + const [, userPath, skipFiles] = /^esbuild-import-glob\(path:(.+),skipFiles:(.+)\)+$/g.exec(path) + const files = (await fs.promises.readdir(join(resolveDir, userPath))).filter(f => !skipFiles.includes(f)) + return { + contents: `module.exports = { ${files.map(f => `'${f}': require('./${join(userPath, f)}')`).join(',')} }`, + resolveDir, + loader: 'js', + } + }) + } + }, + { + name: 'fix-dynamic-require', + setup (build) { + build.onResolve({ + filter: /1\.14\/chunk/, + }, async ({ resolveDir, path }) => { + if (!resolveDir.includes('prismarine-provider-anvil')) return + return { + namespace: 'fix-dynamic-require', + path, + pluginData: { + resolvedPath: `${join(resolveDir, path)}.js`, + resolveDir + }, + } + }) + build.onLoad({ + filter: /.+/, + namespace: 'fix-dynamic-require', + }, async ({ pluginData: { resolvedPath, resolveDir } }) => { + const resolvedFile = await fs.promises.readFile(resolvedPath, 'utf8') + return { + contents: resolvedFile.replace("require(`prismarine-chunk/src/pc/common/BitArray${noSpan ? 'NoSpan' : ''}`)", "noSpan ? require(`prismarine-chunk/src/pc/common/BitArray`) : require(`prismarine-chunk/src/pc/common/BitArrayNoSpan`)"), + resolveDir, + loader: 'js', + } + }) + } + }, + polyfillNode({ + polyfills: { + fs: false, + crypto: false, + events: false, + http: false, + stream: false, + buffer: false, + perf_hooks: false, + net: false, + }, + }) +] + +export { plugins, clients } diff --git a/scripts/patchPackages.js b/scripts/patchPackages.js index 84bfeb8c..c54e4d62 100644 --- a/scripts/patchPackages.js +++ b/scripts/patchPackages.js @@ -10,11 +10,13 @@ function removeLinesBetween (start, end) { let startIndex = lines.findIndex(line => line === start) if (startIndex === -1) return let endIndex = startIndex + lines.slice(startIndex).findIndex(line => line === end) - const linesToRemove = endIndex - startIndex + 1 - lines.splice(startIndex, linesToRemove) + // insert block comments + lines.splice(startIndex, 0, `/*`) + lines.splice(endIndex + 2, 0, `*/`) } // todo removing bedrock support for now, will optiimze in future instead removeLinesBetween(" 'bedrock': {", ' }') +// fs.writeFileSync(path.join(dataPath, '../dataGlobal.js'), newContents, 'utf8') fs.writeFileSync(dataPath, lines.join('\n'), 'utf8') diff --git a/server.js b/server.js index 6581a6b3..496bbd76 100644 --- a/server.js +++ b/server.js @@ -4,12 +4,13 @@ const express = require('express') const netApi = require('net-browserify') const compression = require('compression') const path = require('path') +const cors = require('cors') +const https = require('https') +const fs = require('fs') // Create our app const app = express() -app.get('/config.json', (_, res) => res.sendFile(path.join(__dirname, 'config.json'))) - app.use(compression()) app.use(netApi({ allowOrigin: '*' })) if (process.argv[3] === 'dev') { @@ -25,10 +26,12 @@ if (process.argv[3] === 'dev') { }) ) } else { - app.use(express.static(path.join(__dirname, './public'))) + app.use(express.static(path.join(__dirname, './dist'))) } // Start the server -const server = app.listen(process.argv[2] === undefined ? 8080 : process.argv[2], function () { +const server = process.argv.includes('--build-only') ? undefined : app.listen(require.main !== module || process.argv[2] === undefined ? 8080 : process.argv[2], function () { console.log('Server listening on port ' + server.address().port) }) + +module.exports = { app } diff --git a/src/crypto.js b/src/crypto.js new file mode 100644 index 00000000..9034a397 --- /dev/null +++ b/src/crypto.js @@ -0,0 +1,2 @@ +export * from 'crypto-browserify' +export function createPublicKey () { } diff --git a/src/globals.d.ts b/src/globals.d.ts index f4719819..db486c6a 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -28,3 +28,11 @@ interface ObjectConstructor { fromEntries(obj: T): Record assign, K extends Record>(target: T, source: K): asserts target is T & K } + +declare module '*.css' { + /** + * @deprecated Use `import style from './style.css?inline'` instead. + */ + const css: string + export default css +} diff --git a/src/index.js b/src/index.js index 20becd66..33f33bfd 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ // soureMapSupport.install({ // environment: 'browser', // }) +require('./styles.css') require('iconify-icon') require('./chat') @@ -37,9 +38,6 @@ require('./botControls') require('./dragndrop') require('./browserfs') -// @ts-ignore -require('crypto').createPublicKey = () => { } - const net = require('net') const Stats = require('stats.js') @@ -64,6 +62,7 @@ const { customCommunication } = require('./customServer') const { default: updateTime } = require('./updateTime') const { options } = require('./optionsStorage') const { subscribeKey } = require('valtio/utils') +const _ = require('lodash') if ('serviceWorker' in navigator && !isCypress()) { window.addEventListener('load', () => { diff --git a/src/shims.js b/src/shims.js new file mode 100644 index 00000000..3a4cf7b6 --- /dev/null +++ b/src/shims.js @@ -0,0 +1,3 @@ +const BrowserFS = require('browserfs') + +export { BrowserFS } diff --git a/webpack.common.js b/webpack.common.js index d0979148..58a6d994 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -8,7 +8,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin') const config = { entry: path.resolve(__dirname, './src/index.js'), output: { - path: path.resolve(__dirname, './public'), + path: path.resolve(__dirname, './dist'), filename: './[name].js', publicPath: './', hotUpdateChunkFilename: 'hot/hot-update.[name].js', @@ -91,7 +91,7 @@ const config = { ), new CopyPlugin({ patterns: [ - { from: path.join(__dirname, 'src/styles.css'), to: './styles.css' }, + { from: path.join(__dirname, 'src/styles.css'), to: './index.css' }, ] }) ]