diff --git a/.erb/configs/webpack.config.base.ts b/.erb/configs/webpack.config.base.ts index 1ee8c1611e..e416556f20 100644 --- a/.erb/configs/webpack.config.base.ts +++ b/.erb/configs/webpack.config.base.ts @@ -7,9 +7,14 @@ import TsconfigPathsPlugins from 'tsconfig-paths-webpack-plugin'; import webpackPaths from './webpack.paths'; import { dependencies as externals } from '../../release/app/package.json'; -const isRenderer = - process.env.npm_lifecycle_script?.includes('webpack.config.renderer') ?? - false; +let processType: string; +if (process.env.npm_lifecycle_script?.includes('webpack.config.renderer')) + processType = 'renderer'; +else if ( + process.env.npm_lifecycle_script?.includes('webpack.config.extension-host') +) + processType = 'extension-host'; +else processType = 'main'; const configuration: webpack.Configuration = { externals: [...Object.keys(externals || {})], @@ -61,16 +66,39 @@ const configuration: webpack.Configuration = { new webpack.IgnorePlugin({ checkResource(resource, context) { // Don't include stuff from the main folder or @main... in renderer and renderer folder in main folder - const exclude = isRenderer - ? resource.startsWith('@main') || resource.includes('main/') - : resource.startsWith('@renderer') || /renderer\//.test(resource); + let exclude = false; + switch (processType) { + case 'renderer': + exclude = + resource.startsWith('@main') || + resource.includes('main/') || + resource.startsWith('@extension-host') || + resource.includes('extension-host/') || + resource.startsWith('@node') || + resource.includes('node/'); + break; + case 'extension-host': + exclude = + resource.startsWith('@main') || + resource.includes('main/') || + resource.startsWith('@renderer') || + resource.includes('renderer/'); + break; + default: // main + exclude = + resource.startsWith('@renderer') || + /renderer\//.test(resource) || + resource.startsWith('@extension-host') || + resource.includes('extension-host/') || + resource.startsWith('@client') || + resource.includes('client/'); + break; + } // Log if a file is excluded just fyi if (!context.includes('node_modules') && exclude) console.log( - `${ - isRenderer ? 'Renderer' : 'Main' - }: Resource ${resource}\n\tat context ${context}: ${ + `${processType}: Resource ${resource}\n\tat context ${context}: ${ exclude ? 'excluded' : 'included' }`, ); diff --git a/.erb/configs/webpack.config.extension-host.prod.ts b/.erb/configs/webpack.config.extension-host.prod.ts new file mode 100644 index 0000000000..232d59947d --- /dev/null +++ b/.erb/configs/webpack.config.extension-host.prod.ts @@ -0,0 +1,34 @@ +/** + * Webpack config for production extension-host process + */ + +import path from 'path'; +import webpack from 'webpack'; +import merge, { mergeWithCustomize } from 'webpack-merge'; +import mainConfig from './webpack.config.main.prod'; +import webpackPaths from './webpack.paths'; +import checkNodeEnv from '../scripts/check-node-env'; +import deleteSourceMaps from '../scripts/delete-source-maps'; + +checkNodeEnv('production'); +deleteSourceMaps(); + +const configuration: webpack.Configuration = { + entry: { + 'extension-host': path.join( + webpackPaths.srcExtensionHostPath, + 'extension-host.ts', + ), + }, + + output: { + path: webpackPaths.distExtensionHostPath, + }, +}; + +export default mergeWithCustomize({ + customizeObject(a, b, key) { + if (key === 'entry') return b; + return merge(a, b); + }, +})(mainConfig, configuration); diff --git a/.erb/configs/webpack.paths.ts b/.erb/configs/webpack.paths.ts index 291a794a87..af84e42a82 100644 --- a/.erb/configs/webpack.paths.ts +++ b/.erb/configs/webpack.paths.ts @@ -6,6 +6,7 @@ const dllPath = path.join(__dirname, '../dll'); const srcPath = path.join(rootPath, 'src'); const srcMainPath = path.join(srcPath, 'main'); +const srcExtensionHostPath = path.join(srcPath, 'extension-host'); const srcRendererPath = path.join(srcPath, 'renderer'); const srcSharedPath = path.join(srcPath, 'shared'); @@ -17,6 +18,7 @@ const srcNodeModulesPath = path.join(srcPath, 'node_modules'); const distPath = path.join(appPath, 'dist'); const distMainPath = path.join(distPath, 'main'); +const distExtensionHostPath = path.join(distPath, 'extension-host'); const distRendererPath = path.join(distPath, 'renderer'); const buildPath = path.join(releasePath, 'build'); @@ -26,6 +28,7 @@ export default { dllPath, srcPath, srcMainPath, + srcExtensionHostPath, srcRendererPath, srcSharedPath, releasePath, @@ -35,6 +38,7 @@ export default { srcNodeModulesPath, distPath, distMainPath, + distExtensionHostPath, distRendererPath, buildPath, }; diff --git a/.eslintrc.js b/.eslintrc.js index 7c64a3f4c4..5ecdc01fbe 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,6 +17,8 @@ module.exports = { 'react/jsx-indent-props': ['warn', 2], 'comma-dangle': ['error', 'always-multiline'], 'prettier/prettier': ['warn', { tabWidth: 2, trailingComma: 'all' }], + // Should always use our logger + 'no-console': 'error', 'react/require-default-props': 'off', 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], 'jsx-a11y/label-has-associated-control': [ diff --git a/.gitignore b/.gitignore index 3e9692bccf..5424390497 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ npm-debug.log.* # Extra VS Code workspaces *.code-workspace + +# Test node localStorage files +local-storage diff --git a/.vscode/launch.json b/.vscode/launch.json index 7e3a093142..7ca499983e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,20 +2,26 @@ "version": "0.2.0", "configurations": [ { - "name": ".NET Core Launch", + "name": "Debug .NET Core", "type": "coreclr", "request": "launch", - "preLaunchTask": "build", + "preLaunchTask": "npm: build:data", "program": "${workspaceFolder}/c-sharp/bin/Debug/net7.0/ParanextDataProvider", "args": [], "cwd": "${workspaceFolder}", "stopAtEntry": false, "windows": { - "program": "${workspaceFolder}/c-sharp/bin/Debug/net7.0/ParanextDataProvider.exe" + "program": "${workspaceFolder}/c-sharp/bin/Debug/net7.0/win-x64/ParanextDataProvider.exe" + }, + "linux": { + "program": "${workspaceFolder}/c-sharp/bin/Debug/net7.0/linux-x64/ParanextDataProvider" + }, + "osx": { + "program": "${workspaceFolder}/c-sharp/bin/Debug/net7.0/osx-x64/ParanextDataProvider" } }, { - "name": ".NET Core Attach", + "name": "Attach to .NET Core", "type": "coreclr", "request": "attach", "processName": "ParanextDataProvider", @@ -24,29 +30,41 @@ } }, { - "name": "Electron: Main", + "name": "Debug Paranext Core Backend", "type": "node", "request": "launch", "protocol": "inspector", "runtimeExecutable": "npm", "runtimeArgs": ["run", "start"], "env": { - "MAIN_ARGS": "--inspect=5858 --remote-debugging-port=9223" + "MAIN_ARGS": "--inspect=5858 --remote-debugging-port=9223", + "IN_VSCODE": "true" } }, { - "name": "Electron: Renderer", + "name": "Attach to Renderer", "type": "chrome", "request": "attach", "port": 9223, "webRoot": "${workspaceFolder}", "timeout": 15000 + }, + { + "name": "Debug Extension Host", + "type": "node", + "request": "launch", + "protocol": "inspector", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "start:extension-host"], + "env": { + "IN_VSCODE": "true" + } } ], "compounds": [ { - "name": "Electron: All", - "configurations": ["Electron: Main", "Electron: Renderer"] + "name": "Debug Paranext Core", + "configurations": ["Debug Paranext Core Backend", "Attach to Renderer"] } ] } diff --git a/README.md b/README.md index 2d274ca452..fb0f70a56b 100644 --- a/README.md +++ b/README.md @@ -56,12 +56,6 @@ cd paranext-core npm install ``` -Build the C# code: - -```bash -npm run build:data -``` - ## Starting Development Start the app in the `dev` environment: @@ -70,7 +64,7 @@ Start the app in the `dev` environment: npm start ``` -After you run `npm start`, you can edit the Electron and frontend files, and they will hot reload. To edit C# files, you must stop the `npm start` process (or only close Paranext), run `npm run build:data`, and restart `npm start` (or if you only closed Paranext, make a trivial edit to `src/main/main.ts`, and save it to launch Paranext again). +After you run `npm start` (or, in VSCode, launch `Debug Paranext Core`), you can edit the code, and the relevant processes will hot reload. ## Packaging for Production diff --git a/package-lock.json b/package-lock.json index 419124f3b9..10e2efe615 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "electron-log": "^5.0.0-beta.19", "electron-updater": "^5.2.3", "electron-window-state": "^5.0.3", - "memoize-one": "^6.0.0", + "node-localstorage": "^2.2.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.4.0", @@ -27,6 +27,7 @@ "@testing-library/react": "^13.3.0", "@types/jest": "^28.1.7", "@types/node": "18.7.6", + "@types/node-localstorage": "^1.3.0", "@types/react": "^18.0.17", "@types/react-dom": "^18.0.6", "@types/react-test-renderer": "^18.0.0", @@ -67,6 +68,7 @@ "jest-environment-jsdom": "^28.1.3", "lint-staged": "^13.0.3", "mini-css-extract-plugin": "^2.6.1", + "nodemon": "^2.0.20", "prettier": "^2.7.1", "react-refresh": "^0.14.0", "react-test-renderer": "^18.2.0", @@ -3762,6 +3764,12 @@ "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==", "dev": true }, + "node_modules/@types/events": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", + "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==", + "dev": true + }, "node_modules/@types/express": { "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", @@ -3959,6 +3967,15 @@ "integrity": "sha512-EdxgKRXgYsNITy5mjjXjVE/CS8YENSdhiagGrLqjG0pvA2owgJ6i4l7wy/PFZGC0B1/H20lWKN7ONVDNYDZm7A==", "dev": true }, + "node_modules/@types/node-localstorage": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@types/node-localstorage/-/node-localstorage-1.3.0.tgz", + "integrity": "sha512-9+s5CWGhkYitklhLgnbf4s5ncCEx0An2jhBuhvw/sh9WNQ+/WvNFkPLyLjXGy+Oeo8CjPl69oz6M2FzZH+KwWA==", + "dev": true, + "dependencies": { + "@types/events": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -10598,6 +10615,12 @@ "node": ">= 4" } }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -10670,7 +10693,6 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true, "engines": { "node": ">=0.8.19" } @@ -13085,11 +13107,6 @@ "node": ">= 4.0.0" } }, - "node_modules/memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", - "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" - }, "node_modules/memory-fs": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz", @@ -13543,12 +13560,90 @@ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "dev": true }, + "node_modules/node-localstorage": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/node-localstorage/-/node-localstorage-2.2.1.tgz", + "integrity": "sha512-vv8fJuOUCCvSPjDjBLlMqYMHob4aGjkmrkaE42/mZr0VT+ZAU10jRF8oTnX9+pgU9/vYJ8P7YT3Vd6ajkmzSCw==", + "dependencies": { + "write-file-atomic": "^1.1.4" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/node-releases": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz", "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==", "dev": true }, + "node_modules/nodemon": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.20.tgz", + "integrity": "sha512-Km2mWHKKY5GzRg6i1j5OxOHQtuvVsgskLfigG25yTtbyfRGn/GNvIbRyOf1PSCKJ2aT/58TiuUsuOU5UToVViw==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^3.2.7", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^5.7.1", + "simple-update-notifier": "^1.0.7", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=8.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/nopt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", @@ -14850,6 +14945,12 @@ "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==", "dev": true }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -15969,6 +16070,14 @@ "node": ">=8" } }, + "node_modules/slide": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz", + "integrity": "sha512-NwrtjCg+lZoqhFU8fOwl4ay2ei8PaqCBOUV3/ektPY9trO1yQ1oXEfmHAhKArUVUr/hOHvy5f6AdP17dCM0zMw==", + "engines": { + "node": "*" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -16058,7 +16167,7 @@ "node_modules/spawn-command": { "version": "0.0.2-1", "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2-1.tgz", - "integrity": "sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A=", + "integrity": "sha512-n98l9E2RMSJ9ON1AKisHzz7V42VDiBQGY6PB1BwRglz99wpVsSuGzQ+jOi6lFXBGVTCrRpltvjm+/XA+tpeJrg==", "dev": true }, "node_modules/spdy": { @@ -16732,6 +16841,33 @@ "node": ">=6" } }, + "node_modules/touch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", + "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, + "dependencies": { + "nopt": "~1.0.10" + }, + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/touch/node_modules/nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "dev": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/tough-cookie": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz", @@ -17062,6 +17198,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -17956,6 +18098,16 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, + "node_modules/write-file-atomic": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-1.3.4.tgz", + "integrity": "sha512-SdrHoC/yVBPpV0Xq/mUZQIpW2sWXAShb/V4pomcJXh92RuaO+f3UTWItiR3Px+pLnV2PvC2/bfn5cwr5X6Vfxw==", + "dependencies": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "slide": "^1.1.5" + } + }, "node_modules/ws": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.0.tgz", @@ -20753,6 +20905,12 @@ "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==", "dev": true }, + "@types/events": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", + "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==", + "dev": true + }, "@types/express": { "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", @@ -20943,6 +21101,15 @@ "integrity": "sha512-EdxgKRXgYsNITy5mjjXjVE/CS8YENSdhiagGrLqjG0pvA2owgJ6i4l7wy/PFZGC0B1/H20lWKN7ONVDNYDZm7A==", "dev": true }, + "@types/node-localstorage": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@types/node-localstorage/-/node-localstorage-1.3.0.tgz", + "integrity": "sha512-9+s5CWGhkYitklhLgnbf4s5ncCEx0An2jhBuhvw/sh9WNQ+/WvNFkPLyLjXGy+Oeo8CjPl69oz6M2FzZH+KwWA==", + "dev": true, + "requires": { + "@types/events": "*" + } + }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -25968,6 +26135,12 @@ "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", "dev": true }, + "ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, "immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -26020,8 +26193,7 @@ "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=" }, "indent-string": { "version": "4.0.0", @@ -27838,11 +28010,6 @@ "fs-monkey": "1.0.3" } }, - "memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", - "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" - }, "memory-fs": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz", @@ -28183,12 +28350,70 @@ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "dev": true }, + "node-localstorage": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/node-localstorage/-/node-localstorage-2.2.1.tgz", + "integrity": "sha512-vv8fJuOUCCvSPjDjBLlMqYMHob4aGjkmrkaE42/mZr0VT+ZAU10jRF8oTnX9+pgU9/vYJ8P7YT3Vd6ajkmzSCw==", + "requires": { + "write-file-atomic": "^1.1.4" + } + }, "node-releases": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz", "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==", "dev": true }, + "nodemon": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.20.tgz", + "integrity": "sha512-Km2mWHKKY5GzRg6i1j5OxOHQtuvVsgskLfigG25yTtbyfRGn/GNvIbRyOf1PSCKJ2aT/58TiuUsuOU5UToVViw==", + "dev": true, + "requires": { + "chokidar": "^3.5.2", + "debug": "^3.2.7", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^5.7.1", + "simple-update-notifier": "^1.0.7", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, "nopt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", @@ -29091,6 +29316,12 @@ "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==", "dev": true }, + "pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -29939,6 +30170,11 @@ "is-fullwidth-code-point": "^3.0.0" } }, + "slide": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz", + "integrity": "sha512-NwrtjCg+lZoqhFU8fOwl4ay2ei8PaqCBOUV3/ektPY9trO1yQ1oXEfmHAhKArUVUr/hOHvy5f6AdP17dCM0zMw==" + }, "smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -30010,7 +30246,7 @@ "spawn-command": { "version": "0.0.2-1", "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2-1.tgz", - "integrity": "sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A=", + "integrity": "sha512-n98l9E2RMSJ9ON1AKisHzz7V42VDiBQGY6PB1BwRglz99wpVsSuGzQ+jOi6lFXBGVTCrRpltvjm+/XA+tpeJrg==", "dev": true }, "spdy": { @@ -30524,6 +30760,26 @@ "integrity": "sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==", "dev": true }, + "touch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", + "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, + "requires": { + "nopt": "~1.0.10" + }, + "dependencies": { + "nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "dev": true, + "requires": { + "abbrev": "1" + } + } + } + }, "tough-cookie": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz", @@ -30750,6 +31006,12 @@ "which-boxed-primitive": "^1.0.2" } }, + "undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, "unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -31396,6 +31658,16 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, + "write-file-atomic": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-1.3.4.tgz", + "integrity": "sha512-SdrHoC/yVBPpV0Xq/mUZQIpW2sWXAShb/V4pomcJXh92RuaO+f3UTWItiR3Px+pLnV2PvC2/bfn5cwr5X6Vfxw==", + "requires": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "slide": "^1.1.5" + } + }, "ws": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.0.tgz", diff --git a/package.json b/package.json index 7b08679437..1213d95fdb 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,9 @@ ], "main": "./src/main/main.ts", "scripts": { - "build": "concurrently \"npm run build:main\" \"npm run build:renderer\"", + "build": "concurrently \"npm run build:main\" \"npm run build:extension-host\" \"npm run build:renderer\"", "build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts", + "build:extension-host": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.extension-host.prod.ts", "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts", "build:data": "dotnet build c-sharp/ParanextDataProvider.sln", "build:data-release": "run-script-os", @@ -56,10 +57,11 @@ "prepare": "husky install", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", "start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer", - "start:main": "cross-env NODE_ENV=development electronmon -r ts-node/register/transpile-only -r tsconfig-paths/register .", + "start:main": "cross-env NODE_ENV=development electronmon -r ts-node/register/transpile-only .", + "start:extension-host": "cross-env NODE_ENV=development nodemon --transpile-only ./src/extension-host/extension-host.ts", "start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts", "start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts", - "start:data": "dotnet run --project c-sharp/ParanextDataProvider.csproj", + "start:data": "dotnet watch --project c-sharp/ParanextDataProvider.csproj", "test": "jest" }, "lint-staged": { @@ -82,7 +84,7 @@ "electron-log": "^5.0.0-beta.19", "electron-updater": "^5.2.3", "electron-window-state": "^5.0.3", - "memoize-one": "^6.0.0", + "node-localstorage": "^2.2.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.4.0", @@ -98,6 +100,7 @@ "@testing-library/react": "^13.3.0", "@types/jest": "^28.1.7", "@types/node": "18.7.6", + "@types/node-localstorage": "^1.3.0", "@types/react": "^18.0.17", "@types/react-dom": "^18.0.6", "@types/react-test-renderer": "^18.0.0", @@ -138,6 +141,7 @@ "jest-environment-jsdom": "^28.1.3", "lint-staged": "^13.0.3", "mini-css-extract-plugin": "^2.6.1", + "nodemon": "^2.0.20", "prettier": "^2.7.1", "react-refresh": "^0.14.0", "react-test-renderer": "^18.2.0", @@ -510,8 +514,17 @@ "patterns": [ "!**/**", "src/main/**", + "src/node/**", "src/shared/**" ], "logLevel": "quiet" + }, + "nodemonConfig": { + "watch": [ + "src/client/**", + "src/extension-host/**", + "src/node/**", + "src/shared/**" + ] } } diff --git a/src/renderer/services/ClientNetworkConnector.ts b/src/client/services/ClientNetworkConnector.ts similarity index 81% rename from src/renderer/services/ClientNetworkConnector.ts rename to src/client/services/ClientNetworkConnector.ts index 7ddd82a628..c2d37a4914 100644 --- a/src/renderer/services/ClientNetworkConnector.ts +++ b/src/client/services/ClientNetworkConnector.ts @@ -14,8 +14,14 @@ import { MessageType, WebSocketRequest, WebSocketResponse, + WEBSOCKET_ATTEMPTS_MAX, + WEBSOCKET_ATTEMPTS_WAIT, WEBSOCKET_PORT, } from '@shared/data/NetworkConnectorTypes'; +import { getErrorMessage } from '@shared/util/Util'; +import logger from '@shared/util/logger'; +import { createWebSocket } from '@client/services/WebSocketFactory'; +import { IWebSocket } from '@client/services/IWebSocket'; // #region local variables @@ -29,6 +35,9 @@ type LiveRequest = { reject: (reason?: unknown) => void; }; +/** localStorage key to store the current clientGuid */ +const CLIENT_GUID_KEY = 'client-network-connector:clientGuid'; + // #endregion /** @@ -46,7 +55,7 @@ export default class ClientNetworkConnector implements INetworkConnector { // #region private members /** The webSocket connected to the server */ - private webSocket?: WebSocket; + private webSocket?: IWebSocket; /** All message subscriptions - arrays of functions that run each time a message with a specific message type comes in */ private messageSubscriptions = new Map< @@ -83,6 +92,9 @@ export default class ClientNetworkConnector implements INetworkConnector { // eslint-disable-next-line @typescript-eslint/no-explicit-any private requests = new Map>(); + /** Unique Guid associated with this connection. Used to verify certain things with server */ + private clientGuid: string | undefined; + // #endregion // #region INetworkConnector methods @@ -118,8 +130,9 @@ export default class ClientNetworkConnector implements INetworkConnector { // Get the client id from the server on new connections this.unsubscribeHandleInitClientMessage = this.subscribe( MessageType.InitClient, - ({ connectorInfo: newConnectorInfo }: InitClient) => { - this.connectorInfo = newConnectorInfo; + ({ connectorInfo: newConnectorInfo, clientGuid }: InitClient) => { + this.connectorInfo = Object.freeze(newConnectorInfo); + this.clientGuid = clientGuid; if (!this.webSocket) { rejectConnect('webSocket is gone!'); @@ -145,23 +158,73 @@ export default class ClientNetworkConnector implements INetworkConnector { }, ); - // Connect the webSocket - this.webSocket = new WebSocket(`ws://localhost:${WEBSOCKET_PORT}`); + // Connect the webSocket - try a few times + let attempts = 0; + const tryConnectWebSocket = async () => { + if (attempts < WEBSOCKET_ATTEMPTS_MAX) { + attempts += 1; + this.webSocket = await createWebSocket( + `ws://localhost:${WEBSOCKET_PORT}`, + ); + + // Attach event listeners + this.webSocket.addEventListener('message', this.onMessage); + this.webSocket.addEventListener('close', this.disconnect); + + // Remove event listeners and try connecting again + const retry = (e: Event) => { + const err = (e as Event & { error?: Error }).error; + logger.warn( + `ClientNetworkConnector WebSocket did not connect on attempt ${attempts}. Trying again. Error: ${getErrorMessage( + err, + )}`, + ); + if (this.webSocket) { + this.webSocket.removeEventListener('message', this.onMessage); + this.webSocket.removeEventListener('close', this.disconnect); + this.webSocket.removeEventListener('error', retry); + } + setTimeout(tryConnectWebSocket, WEBSOCKET_ATTEMPTS_WAIT); + }; + + this.webSocket.addEventListener('error', retry); - // Attach event listeners - this.webSocket.addEventListener('message', this.onMessage); - this.webSocket.addEventListener('close', this.disconnect); + // When we have successfully connected, remove retry-related listeners + const finishConnecting = () => { + if (this.webSocket) { + this.webSocket.removeEventListener('error', retry); + this.webSocket.removeEventListener('open', finishConnecting); + } + }; + + this.webSocket.addEventListener('open', finishConnecting); + } else { + throw new Error( + `ClientNetworkConnector WebSocket was not able to connect after ${attempts} attempts.`, + ); + } + }; + tryConnectWebSocket(); return this.connectPromise; }; notifyClientConnected = async () => { + // Check if this client is reconnecting (such as if the browser refreshed) and tell the server so it can remove all request registrations associated with the old clientId + const reconnectingClientGuid = localStorage.getItem(CLIENT_GUID_KEY); + this.sendMessage({ type: MessageType.ClientConnect, senderId: this.connectorInfo.clientId, + reconnectingClientGuid, }); + + // Save the new clientGuid so we can check it when reconnecting + if (this.clientGuid) localStorage.setItem(CLIENT_GUID_KEY, this.clientGuid); + else localStorage.removeItem(CLIENT_GUID_KEY); + // In webSocket land, we do not receive a response from the server when we notify client connected - // TODO: change the clientconnected into a request that resolves properly + // TODO: change the clientconnected into a request that resolves properly. Then we can also know if we reconnected, I suppose return Promise.resolve(); }; diff --git a/src/client/services/IWebSocket.ts b/src/client/services/IWebSocket.ts new file mode 100644 index 0000000000..a3fd3d0945 --- /dev/null +++ b/src/client/services/IWebSocket.ts @@ -0,0 +1,7 @@ +/** + * Interface that defines the webSocket functionality the extension host and the renderer must implement. + * Used by WebSocketFactory to supply the right kind of WebSocket to ClientNetworkConnector. + * For now, we are just using the browser WebSocket type. We may need specific functionality that don't + * line up between the ws library's implementation and the browser implementation. We can adjust as needed at that point. + */ +export type IWebSocket = WebSocket; diff --git a/src/client/services/WebSocketFactory.ts b/src/client/services/WebSocketFactory.ts new file mode 100644 index 0000000000..e31b44a0c3 --- /dev/null +++ b/src/client/services/WebSocketFactory.ts @@ -0,0 +1,21 @@ +/** + * Creates a WebSocket from the node ws library or from the browser WebSocket depending on if we're in node or browser + */ + +import { isRenderer } from '@shared/util/InternalUtil'; +import { IWebSocket } from './IWebSocket'; + +/** + * Creates a WebSocket for the renderer or extension host depending on where you're running + * @returns WebSocket + */ +// eslint-disable-next-line import/prefer-default-export +export const createWebSocket = async (url: string): Promise => { + if (isRenderer()) { + const Ws = (await import('@renderer/services/RendererWebSocket')).default; + return new Ws(url) as IWebSocket; + } + const Ws = (await import('@extension-host/services/ExtensionHostWebSocket')) + .default; + return new Ws(url) as unknown as IWebSocket; +}; diff --git a/src/extension-host/extension-host.ts b/src/extension-host/extension-host.ts new file mode 100644 index 0000000000..4ebfb1d5dd --- /dev/null +++ b/src/extension-host/extension-host.ts @@ -0,0 +1,49 @@ +import '@extension-host/globalThis'; +import { isClient } from '@shared/util/InternalUtil'; +import * as NetworkService from '@shared/services/NetworkService'; +import papi from '@shared/services/papi'; +import { CommandHandler } from '@shared/util/PapiUtil'; +import logger from '@shared/util/logger'; + +// #region Test logs + +logger.log('Hello from the extension host!'); +logger.log(`Extension host is${isClient() ? '' : ' not'} client`); +logger.log(`Extension host process.type = ${process.type}`); +logger.log(`Extension host process.env.NODE_ENV = ${process.env.NODE_ENV}`); +logger.warn('Extension host example warning'); + +// #endregion + +// #region Services setup + +const commandHandlers: { [commandName: string]: CommandHandler } = { + addMany: async (...nums: number[]) => { + /* const start = performance.now(); */ + /* const result = await papi.commands.sendCommand('addThree', 1, 4, 9); */ + /* logger.log( + `addThree(...) = ${result} took ${performance.now() - start} ms`, + ); */ + logger.log(`Extension host is handling addMany!!`); + return nums.reduce((acc, current) => acc + current, 0); + }, + throwErrorExtensionHost: async (message: string) => { + throw new Error( + `Test Error thrown in throwErrorExtensionHost command: ${message}`, + ); + }, +}; + +NetworkService.initialize() + .then(() => { + // Set up test handlers + Object.entries(commandHandlers).forEach(([commandName, handler]) => { + papi.commands.registerCommand(commandName, handler); + }); + + // TODO: Probably should return Promise.all of these registrations + return undefined; + }) + .catch(logger.error); + +// #endregion diff --git a/src/extension-host/globalThis.ts b/src/extension-host/globalThis.ts new file mode 100644 index 0000000000..213e9e8935 --- /dev/null +++ b/src/extension-host/globalThis.ts @@ -0,0 +1,19 @@ +/** + * Module to set up globalThis and polyfills in the extension host + */ + +import polyfillLocalStorage from '@node/polyfill/LocalStorage'; +import { ProcessType } from '@shared/globalThis'; + +// #region command-line arguments + +const isPackaged = process.argv.includes('--packaged'); + +// #endregion + +// #region globalThis setup + +globalThis.processType = ProcessType.ExtensionHost; +polyfillLocalStorage(isPackaged); + +// #endregion diff --git a/src/extension-host/services/ExtensionHostWebSocket.ts b/src/extension-host/services/ExtensionHostWebSocket.ts new file mode 100644 index 0000000000..a9ea4558ad --- /dev/null +++ b/src/extension-host/services/ExtensionHostWebSocket.ts @@ -0,0 +1,6 @@ +import ws from 'ws'; +/** + * extension-host client uses ws as its WebSocket client, but the renderer can't use it. So we need to exclude it from the renderer webpack bundle like this. + */ + +export default ws; diff --git a/src/main/globalThis.ts b/src/main/globalThis.ts new file mode 100644 index 0000000000..bad48e508a --- /dev/null +++ b/src/main/globalThis.ts @@ -0,0 +1,15 @@ +/** + * Module to set up globalThis and polyfills in main + * + * TODO: consider making this a normal exporting module so it's not using globalThis + * and using NormalModuleReplacementPlugin to make sure the right one gets imported per process. + * Idea from Bergi at https://stackoverflow.com/a/69982121 + * See https://webpack.js.org/plugins/normal-module-replacement-plugin/ + */ + +import polyfillLocalStorage from '@node/polyfill/LocalStorage'; +import { ProcessType } from '@shared/globalThis'; +import { app } from 'electron'; + +globalThis.processType = ProcessType.Main; +polyfillLocalStorage(app.isPackaged); diff --git a/src/main/main.ts b/src/main/main.ts index b31c595939..687cfed775 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -10,13 +10,15 @@ import path from 'path'; import { app, BrowserWindow, shell, ipcMain } from 'electron'; import { autoUpdater } from 'electron-updater'; import windowStateKeeper from 'electron-window-state'; +import '@main/globalThis'; import dotnetDataProvider from '@main/services/dotnet-data-provider.service'; import logger from '@shared/util/logger'; import * as NetworkService from '@shared/services/NetworkService'; import papi from '@shared/services/papi'; import { CommandHandler } from '@shared/util/PapiUtil'; +import { fork, spawn } from 'child_process'; +import { resolveHtmlPath } from '@node/util/util'; import MenuBuilder from './menu'; -import { resolveHtmlPath } from './util'; // #region ELECTRON SETUP @@ -178,6 +180,8 @@ app // dock icon is clicked and there are no other windows open. if (mainWindow === null) createWindow(); }); + + return undefined; }) .catch(logger.log); @@ -187,6 +191,9 @@ app const commandHandlers: { [commandName: string]: CommandHandler } = { echo: async (message: string) => { + return message; + }, + echoRenderer: async (message: string) => { /* const start = performance.now(); */ /* const result = */ await papi.commands.sendCommand('addThree', 1, 4, 9); /* logger.log( @@ -194,6 +201,10 @@ const commandHandlers: { [commandName: string]: CommandHandler } = { ); */ return message; }, + echoExtensionHost: async (message: string) => { + await papi.commands.sendCommand('addMany', 3, 5, 7, 1, 4); + return message; + }, throwError: async (message: string) => { throw new Error(`Test Error thrown in throwError command: ${message}`); }, @@ -216,6 +227,69 @@ const commandHandlers: { [commandName: string]: CommandHandler } = { // Start the dotnet data provider early so its ready when needed once the // WebSocket is up. dotnetDataProvider.start(); + + // TODO: Probably should return Promise.all of these registrations + return undefined; })().catch(logger.error); // #endregion + +// #region Extension Host + +const formatExtensionHostLog = (message: string, tag = '') => { + const messageNoEndLine = message.trimEnd(); + const openTag = `{eh${tag ? ' ' : ''}${tag}}`; + const closeTag = `{/eh${tag ? ' ' : ''}${tag}}`; + if (messageNoEndLine.includes('\n')) + // Multi-line + return `${openTag}\n${messageNoEndLine}\n${closeTag}`; + return `${openTag} ${messageNoEndLine} ${closeTag}`; +}; + +// In production, fork a new process for the extension host +// In development, spawn nodemon to watch the extension-host +const extensionHost = app.isPackaged + ? fork( + path.join(__dirname, '../extension-host/extension-host.js'), + ['--packaged'], + { + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }, + ) + : spawn( + process.platform.includes('win') ? 'npm.cmd' : 'npm', + ['run', 'start:extension-host'], + { + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + +if (!extensionHost.stderr || !extensionHost.stdout) + logger.error( + "Could not connect to extension host's stderr or stdout! You will not see extension host logs here.", + ); +else if (process.env.IN_VSCODE !== 'true') { + // When launched from VSCode, don't re-print the logger stuff because it somehow shows it already + extensionHost.stderr.on('data', (data) => + logger.error(formatExtensionHostLog(data.toString(), 'err')), + ); + extensionHost.stdout.on('data', (data) => + logger.log(formatExtensionHostLog(data.toString())), + ); +} + +extensionHost.on('exit', () => logger.warn('extensionHost just exited!')); + +setTimeout(async () => { + logger.log( + `Add Many (from EH): ${await papi.commands.sendCommand( + 'addMany', + 2, + 5, + 9, + 7, + )}`, + ); +}, 3000); + +// #endregion diff --git a/src/main/services/ServerNetworkConnector.ts b/src/main/services/ServerNetworkConnector.ts index 41928fc443..896b2e6b52 100644 --- a/src/main/services/ServerNetworkConnector.ts +++ b/src/main/services/ServerNetworkConnector.ts @@ -12,12 +12,14 @@ import logger from '@shared/util/logger'; import { Unsubscriber } from '@shared/util/PapiUtil'; import { ClientConnect, + InitClient, Message, MessageType, WebSocketRequest, WebSocketResponse, WEBSOCKET_PORT, } from '@shared/data/NetworkConnectorTypes'; +import { newGuid } from '@shared/util/Util'; // #region local variables @@ -32,9 +34,8 @@ type LiveRequest = { }; /** A WebSocket client and information about its connection */ -type WebSocketClient = { +type WebSocketClient = Omit & { webSocket: WebSocket; - connectorInfo: NetworkConnectorInfo; /** Whether the client has responded to initClient and told us it is ready to receive messages */ connected: boolean; }; @@ -94,6 +95,12 @@ export default class ServerNetworkConnector implements INetworkConnector { */ private requestRouter?: (requestType: string) => number; + /** + * Function to call when a client disconnects. + * Removes request handlers associated with the specified client + */ + private localClientDisconnectHandler?: (clientId: number) => void; + /** All requests that are waiting for a response */ // Disabled no-explicit-any because assigning a request with generic type to LiveRequest gave error // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -106,6 +113,7 @@ export default class ServerNetworkConnector implements INetworkConnector { connect = async ( localRequestHandler: InternalRequestHandler, requestRouter: (requestType: string) => number, + localClientDisconnectHandler: (clientId: number) => void, ) => { // NOTE: This does not protect against sending two different request handlers. See ConnectionService for that // We don't need to run this more than once @@ -114,22 +122,13 @@ export default class ServerNetworkConnector implements INetworkConnector { this.connectionStatus = ConnectionStatus.Connecting; this.localRequestHandler = localRequestHandler; this.requestRouter = requestRouter; + this.localClientDisconnectHandler = localClientDisconnectHandler; // Set up subscriptions that the service needs to work // Mark the connection fully connected and notify that a client was connected this.unsubscribeHandleClientConnectMessage = this.subscribe( MessageType.ClientConnect, - (clientConnect: ClientConnect, clientId) => { - // Verify that the client has the correct clientId. Otherwise nothing will work properly - if (clientId !== clientConnect.senderId) - // TODO: tell the client that they messed up, not throw an exception on the server - throw new Error( - `WebSocket with clientId ${clientId} tried to finalize connection with incorrect senderId ${clientConnect.senderId}`, - ); - // Client finished connecting! - this.getClientSocket(clientId).connected = true; - // TODO: Send an event that the client is fully connected - }, + this.handleClientConnectMessage, ); // Listen for responses from the clients and resolve the request promise @@ -166,6 +165,8 @@ export default class ServerNetworkConnector implements INetworkConnector { disconnect = () => { this.connectionStatus = ConnectionStatus.Disconnected; this.localRequestHandler = undefined; + this.requestRouter = undefined; + this.localClientDisconnectHandler = undefined; this.connectPromise = undefined; // Disconnect all clients - this should clear clientSockets on its own @@ -244,6 +245,25 @@ export default class ServerNetworkConnector implements INetworkConnector { return clientSocket; }; + /** Attempts to get the client socket for a certain clientGuid. Returns undefined if not found. + * This does not throw because it will likely be very common that we do not have a clientId for a certain clientGuid + * as connecting clients will often supply old clientGuids. + */ + private getClientSocketFromGuid = ( + clientGuid: string | undefined | null, + ): WebSocketClient | undefined => { + if (!this.webSocketServer) + throw new Error('Trying to get client socket when not connected!'); + if (!clientGuid) return undefined; + + // Using for...of on iterator here because it is significantly faster (not converting to array first) and cleaner this way in this case + // eslint-disable-next-line no-restricted-syntax + for (const clientSocket of this.clientSockets.values()) + if (clientSocket.clientGuid === clientGuid) return clientSocket; + + return undefined; + }; + /** Get the clientId for a certain webSocket. Throws if not found */ private getClientIdFromSocket = (webSocket: WebSocket): number => { // Using for...of on iterator here because it is significantly faster (not converting to array first) and cleaner this way in this case @@ -374,11 +394,13 @@ export default class ServerNetworkConnector implements INetworkConnector { /** This clientSocket's connector info */ const connectorInfo: NetworkConnectorInfo = { clientId }; + const clientGuid = newGuid(); // Add the client socket to the list this.clientSockets.set(clientId, { webSocket, connectorInfo, + clientGuid, connected: false, }); @@ -388,6 +410,7 @@ export default class ServerNetworkConnector implements INetworkConnector { type: MessageType.InitClient, senderId: this.connectorInfo.clientId, connectorInfo, + clientGuid, }, clientId, ); @@ -406,6 +429,45 @@ export default class ServerNetworkConnector implements INetworkConnector { webSocket.removeEventListener('close', this.onClientDisconnect); webSocket.close(); this.clientSockets.delete(clientId); + + // TODO: Send an event that the client has disconnected and listen for the disconnect in the NetworkService instead of calling the handler here + if (!this.localClientDisconnectHandler) + throw new Error( + `Client disconnected but cannot disconnect it without a localClientDisconnectHandler`, + ); + + this.localClientDisconnectHandler(clientId); + }; + + /** + * Function that handles weboscket messages of type ClientConnect. + * Mark the connection fully connected and notify that a client connected or reconnected + * @param clientConnect message from the client about the connection + * @param connectorId clientId of the client who is sending this ClientConnect message + */ + private handleClientConnectMessage = ( + clientConnect: ClientConnect, + connectorId: number, + ) => { + // Verify that the client has the correct clientId. Otherwise nothing will work properly + if (connectorId !== clientConnect.senderId) + // TODO: tell the client that they messed up, not throw an exception on the server + throw new Error( + `WebSocket with clientId ${connectorId} tried to finalize connection with incorrect senderId ${clientConnect.senderId}`, + ); + + // Client finished connecting! + this.getClientSocket(connectorId).connected = true; + + // Determine if this client is reconnecting so we can unregister request handlers for the old connection + const oldClientSocket = this.getClientSocketFromGuid( + clientConnect.reconnectingClientGuid, + ); + if (oldClientSocket) { + this.disconnectClient(oldClientSocket.webSocket); + } + + // TODO: Send an event that the client is fully connected (mention that it is a reconnect?) }; /** diff --git a/src/main/util.ts b/src/main/util.ts deleted file mode 100644 index 7775eda370..0000000000 --- a/src/main/util.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* eslint import/prefer-default-export: off */ -import { URL } from 'url'; -import path from 'path'; - -export function resolveHtmlPath(htmlFileName: string) { - if (process.env.NODE_ENV === 'development') { - const port = process.env.PORT || 1212; - const url = new URL(`http://localhost:${port}`); - url.pathname = htmlFileName; - return url.href; - } - return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`; -} diff --git a/src/node/polyfill/LocalStorage.ts b/src/node/polyfill/LocalStorage.ts new file mode 100644 index 0000000000..e2bbb7e91b --- /dev/null +++ b/src/node/polyfill/LocalStorage.ts @@ -0,0 +1,22 @@ +import { getUserDir } from '@node/util/util'; +import { LocalStorage } from 'node-localstorage'; +import path from 'path'; + +/** + * Polyfills LocalStorage into node so you can use localstorage just like in browser + * @param isPackaged whether or not the app is packaged for production + */ +const polyfillLocalStorage = (isPackaged: boolean) => { + if (typeof localStorage === 'undefined' || localStorage === null) { + global.localStorage = new LocalStorage( + isPackaged + ? path.join(getUserDir(), `local-storage/${globalThis.processType}/`) + : path.join( + __dirname, + `../../../local-storage/${globalThis.processType}/`, + ), + ); + } +}; + +export default polyfillLocalStorage; diff --git a/src/node/util/util.ts b/src/node/util/util.ts new file mode 100644 index 0000000000..5b38b2ef03 --- /dev/null +++ b/src/node/util/util.ts @@ -0,0 +1,32 @@ +/** + * Utilities useful for node processes + */ +import { URL } from 'url'; +import path from 'path'; + +export function resolveHtmlPath(htmlFileName: string) { + if (process.env.NODE_ENV === 'development') { + const port = process.env.PORT || 1212; + const url = new URL(`http://localhost:${port}`); + url.pathname = htmlFileName; + return url.href; + } + return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`; +} + +/** + * Gets the platform-specific user folder for this application + * Thanks to Luke at https://stackoverflow.com/a/26227660 + */ +export function getUserDir(): string { + return path.join( + process.env.APPDATA || + (process.platform === 'darwin' + ? // Since APPDATA is not defined, we are on a unix-based OS. Therefore HOME will be available + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + path.join(process.env.HOME!, '/Library/Preferences') + : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + path.join(process.env.HOME!, '/.local/share')), + '/paranext-core', + ); +} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 1ac8099805..965523c941 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -28,9 +28,32 @@ const addOne = async (message: number) => const echo = async (message: string) => papi.commands.sendCommand<[string], string>('echo', message); +const echoRenderer = async (message: string) => + papi.commands.sendCommand<[string], string>('echoRenderer', message); + +const echoExtensionHost = async (message: string) => + papi.commands.sendCommand<[string], string>('echoExtensionHost', message); + +const addThree = async (a: number, b: number, c: number) => + papi.commands.sendCommand<[number, number, number], number>( + 'addThree', + a, + b, + c, + ); + +const addMany = async (...nums: number[]) => + papi.commands.sendCommand('addMany', ...nums); + const throwError = async (message: string) => papi.commands.sendCommand<[string], string>('throwError', message); +const throwErrorExtensionHost = async (message: string) => + papi.commands.sendCommand<[string], string>( + 'throwErrorExtensionHost', + message, + ); + const executeMany = async (fn: () => Promise) => { const numRequests = 10000; const requests = new Array>(numRequests); @@ -120,12 +143,88 @@ const Hello = () => { > Echo + + + + +